From 22f925085e6ab48485591a71403de183ab429752 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 21 Oct 2026 17:37:30 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(mac):=20=E9=87=8D=E5=BB=BA=E4=BB=85?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E9=87=87=E9=9B=86=E8=83=BD=E5=8A=9B=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=20Peer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用普通主窗口、系统侧栏工具栏和辅助快捷面板 - 支持想法、明确粘贴、选区及框选截图,五项快捷键独立配置 - 以 SQLite 冻结提交和共享回执恢复保证重试身份,凭据保存在 Keychain - 删除改为原生确认后的直接删除,修正空态布局并接入 Mac 图标 --- .gitignore | 7 + AGENTS.md | 13 + AlertToast README.md | 235 ------- D3LibMin.xcodeproj/project.pbxproj | 579 ------------------ .../contents.xcworkspacedata | 7 - .../xcschemes/xcschememanagement.plist | 14 - D3LibMin/AddDeviceView.swift | 334 ---------- .../AccentColor.colorset/Contents.json | 20 - .../AppIcon.appiconset/Contents.json | 38 -- .../AppIcon.appiconset/appIcon 1.png | Bin 11389 -> 0 bytes .../AppIcon.appiconset/appIcon 2.png | Bin 11389 -> 0 bytes .../AppIcon.appiconset/appicon light.png | Bin 11538 -> 0 bytes D3LibMin/Assets.xcassets/Contents.json | 6 - .../Library.imageset/Contents.json | 21 - .../Library.imageset/Library.png | Bin 287 -> 0 bytes .../Profile.imageset/Contents.json | 21 - .../Profile.imageset/Profile.png | Bin 265 -> 0 bytes D3LibMin/COLOR_SYSTEM_UPDATE.md | 160 ----- D3LibMin/ColorExtension.swift | 29 - D3LibMin/ContentView.swift | 222 ------- D3LibMin/CustomToastView.swift | 54 -- D3LibMin/D3LibMinApp.swift | 22 - D3LibMin/DataManager.swift | 222 ------- D3LibMin/FONT_INTEGRATION_SUMMARY.md | 166 ----- D3LibMin/FontManager.swift | 43 -- D3LibMin/HelpSupportView.swift | 566 ----------------- D3LibMin/HomeView.swift | 262 -------- D3LibMin/IMPLEMENTATION_SUMMARY.md | 168 ----- D3LibMin/ImageWithBorderView.swift | 83 --- D3LibMin/InfoDetailView.swift | 329 ---------- D3LibMin/Models.swift | 156 ----- D3LibMin/NAVIGATION_FIX_SUMMARY.md | 72 --- D3LibMin/NotificationsView.swift | 356 ----------- D3LibMin/PROFILE_SECONDARY_PAGES_SUMMARY.md | 144 ----- D3LibMin/PixelWaveAnimationView.swift | 160 ----- D3LibMin/PrivacyView.swift | 485 --------------- D3LibMin/README_DataStructure.md | 197 ------ D3LibMin/SUBPAGE_COLOR_FIX_SUMMARY.md | 80 --- D3LibMin/SpaceGrotesk-Medium.ttf | Bin 86528 -> 0 bytes D3LibMin/UserProfileView.swift | 284 --------- D3LibMinTests/D3LibMinTests.swift | 17 - D3LibMinUITests/D3LibMinUITests.swift | 41 -- .../D3LibMinUITestsLaunchTests.swift | 33 - Library.png | Bin 287 -> 0 bytes Package.swift | 18 + Profile.png | Bin 265 -> 0 bytes README.md | 113 ++-- Sources/CSQLite/module.modulemap | 5 + Sources/CSQLite/shim.h | 1 + Sources/CaptureCore/CaptureDigest.swift | 45 ++ Sources/CaptureCore/CaptureQueue.swift | 49 ++ Sources/CaptureCore/CaptureStore.swift | 265 ++++++++ Sources/CaptureCore/Credentials.swift | 70 +++ Sources/CaptureCore/Models.swift | 239 ++++++++ Sources/CaptureCore/PeerClient.swift | 235 +++++++ Sources/InKCreApp/AppMain.swift | 348 +++++++++++ Sources/InKCreApp/BrandAssets.swift | 30 + Sources/InKCreApp/CaptureEditor.swift | 108 ++++ Sources/InKCreApp/CaptureModel.swift | 466 ++++++++++++++ Sources/InKCreApp/CaptureRecordsView.swift | 57 ++ Sources/InKCreApp/ClipboardReader.swift | 38 ++ Sources/InKCreApp/ConnectionEditor.swift | 52 ++ Sources/InKCreApp/ConnectionView.swift | 81 +++ Sources/InKCreApp/GlobalShortcuts.swift | 165 +++++ Sources/InKCreApp/KeyboardShortcut.swift | 74 +++ Sources/InKCreApp/PeerMainView.swift | 149 +++++ Sources/InKCreApp/PeerMainWindow.swift | 19 + Sources/InKCreApp/RegionCapture.swift | 248 ++++++++ .../Brand/AppIcon.iconset/icon_128x128.png | Bin 0 -> 1512 bytes .../Brand/AppIcon.iconset/icon_128x128@2x.png | Bin 0 -> 2773 bytes .../Brand/AppIcon.iconset/icon_16x16.png | Bin 0 -> 379 bytes .../Brand/AppIcon.iconset/icon_16x16@2x.png | Bin 0 -> 520 bytes .../Brand/AppIcon.iconset/icon_256x256.png | Bin 0 -> 2773 bytes .../Brand/AppIcon.iconset/icon_256x256@2x.png | Bin 0 -> 5491 bytes .../Brand/AppIcon.iconset/icon_32x32.png | Bin 0 -> 520 bytes .../Brand/AppIcon.iconset/icon_32x32@2x.png | Bin 0 -> 785 bytes .../Brand/AppIcon.iconset/icon_512x512.png | Bin 0 -> 5491 bytes .../Brand/AppIcon.iconset/icon_512x512@2x.png | Bin 0 -> 12582 bytes Sources/InKCreApp/Resources/Brand/AppIcon.svg | 14 + .../Resources/Brand/AppIconSmall.svg | 14 + .../InKCreApp/Resources/Brand/MarkDark.png | Bin 0 -> 360 bytes .../InKCreApp/Resources/Brand/MarkLight.png | Bin 0 -> 380 bytes .../Resources/Brand/MenuTemplate.png | Bin 0 -> 384 bytes .../InKCreApp/Resources/Brand/PROVENANCE.json | 65 ++ Sources/InKCreApp/Resources/Brand/README.md | 9 + Sources/InKCreApp/SelectedText.swift | 145 +++++ Sources/InKCreApp/ShortcutSettingsView.swift | 32 + SpaceGrotesk-Medium.ttf | Bin 86528 -> 0 bytes .../CaptureContractTests.swift | 15 + .../CaptureCoreTests/CaptureStoreTests.swift | 204 ++++++ .../CaptureCoreTests/Fixtures/capture-v1.json | 44 ++ Tests/CaptureCoreTests/LivePeerTests.swift | 64 ++ Tests/CaptureCoreTests/PeerClientTests.swift | 108 ++++ Tests/InKCreAppTests/AppIconTests.swift | 33 + .../CaptureInteractionTests.swift | 179 ++++++ Tests/InKCreAppTests/CaptureLayoutTests.swift | 47 ++ Tests/InKCreAppTests/RegionOverlayTests.swift | 81 +++ Tests/InKCreAppTests/ShortcutTests.swift | 148 +++++ paraflow/.gitkeep | 0 paraflow/7:0.html | 9 - paraflow/8:0.html | 9 - paraflow/9:0.html | 9 - paraflow/project.json | 22 - paraflow/styles.css | 4 - profilehead.png | Bin 439 -> 0 bytes scripts/build-app.sh | 72 +++ scripts/build-macos-icon.sh | 13 + 107 files changed, 4123 insertions(+), 5738 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md delete mode 100644 AlertToast README.md delete mode 100644 D3LibMin.xcodeproj/project.pbxproj delete mode 100644 D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist delete mode 100644 D3LibMin/AddDeviceView.swift delete mode 100644 D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 1.png delete mode 100644 D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 2.png delete mode 100644 D3LibMin/Assets.xcassets/AppIcon.appiconset/appicon light.png delete mode 100644 D3LibMin/Assets.xcassets/Contents.json delete mode 100644 D3LibMin/Assets.xcassets/Library.imageset/Contents.json delete mode 100644 D3LibMin/Assets.xcassets/Library.imageset/Library.png delete mode 100644 D3LibMin/Assets.xcassets/Profile.imageset/Contents.json delete mode 100644 D3LibMin/Assets.xcassets/Profile.imageset/Profile.png delete mode 100644 D3LibMin/COLOR_SYSTEM_UPDATE.md delete mode 100644 D3LibMin/ColorExtension.swift delete mode 100644 D3LibMin/ContentView.swift delete mode 100644 D3LibMin/CustomToastView.swift delete mode 100644 D3LibMin/D3LibMinApp.swift delete mode 100644 D3LibMin/DataManager.swift delete mode 100644 D3LibMin/FONT_INTEGRATION_SUMMARY.md delete mode 100644 D3LibMin/FontManager.swift delete mode 100644 D3LibMin/HelpSupportView.swift delete mode 100644 D3LibMin/HomeView.swift delete mode 100644 D3LibMin/IMPLEMENTATION_SUMMARY.md delete mode 100644 D3LibMin/ImageWithBorderView.swift delete mode 100644 D3LibMin/InfoDetailView.swift delete mode 100644 D3LibMin/Models.swift delete mode 100644 D3LibMin/NAVIGATION_FIX_SUMMARY.md delete mode 100644 D3LibMin/NotificationsView.swift delete mode 100644 D3LibMin/PROFILE_SECONDARY_PAGES_SUMMARY.md delete mode 100644 D3LibMin/PixelWaveAnimationView.swift delete mode 100644 D3LibMin/PrivacyView.swift delete mode 100644 D3LibMin/README_DataStructure.md delete mode 100644 D3LibMin/SUBPAGE_COLOR_FIX_SUMMARY.md delete mode 100644 D3LibMin/SpaceGrotesk-Medium.ttf delete mode 100644 D3LibMin/UserProfileView.swift delete mode 100644 D3LibMinTests/D3LibMinTests.swift delete mode 100644 D3LibMinUITests/D3LibMinUITests.swift delete mode 100644 D3LibMinUITests/D3LibMinUITestsLaunchTests.swift delete mode 100644 Library.png create mode 100644 Package.swift delete mode 100644 Profile.png create mode 100644 Sources/CSQLite/module.modulemap create mode 100644 Sources/CSQLite/shim.h create mode 100644 Sources/CaptureCore/CaptureDigest.swift create mode 100644 Sources/CaptureCore/CaptureQueue.swift create mode 100644 Sources/CaptureCore/CaptureStore.swift create mode 100644 Sources/CaptureCore/Credentials.swift create mode 100644 Sources/CaptureCore/Models.swift create mode 100644 Sources/CaptureCore/PeerClient.swift create mode 100644 Sources/InKCreApp/AppMain.swift create mode 100644 Sources/InKCreApp/BrandAssets.swift create mode 100644 Sources/InKCreApp/CaptureEditor.swift create mode 100644 Sources/InKCreApp/CaptureModel.swift create mode 100644 Sources/InKCreApp/CaptureRecordsView.swift create mode 100644 Sources/InKCreApp/ClipboardReader.swift create mode 100644 Sources/InKCreApp/ConnectionEditor.swift create mode 100644 Sources/InKCreApp/ConnectionView.swift create mode 100644 Sources/InKCreApp/GlobalShortcuts.swift create mode 100644 Sources/InKCreApp/KeyboardShortcut.swift create mode 100644 Sources/InKCreApp/PeerMainView.swift create mode 100644 Sources/InKCreApp/PeerMainWindow.swift create mode 100644 Sources/InKCreApp/RegionCapture.swift create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png create mode 100644 Sources/InKCreApp/Resources/Brand/AppIcon.svg create mode 100644 Sources/InKCreApp/Resources/Brand/AppIconSmall.svg create mode 100644 Sources/InKCreApp/Resources/Brand/MarkDark.png create mode 100644 Sources/InKCreApp/Resources/Brand/MarkLight.png create mode 100644 Sources/InKCreApp/Resources/Brand/MenuTemplate.png create mode 100644 Sources/InKCreApp/Resources/Brand/PROVENANCE.json create mode 100644 Sources/InKCreApp/Resources/Brand/README.md create mode 100644 Sources/InKCreApp/SelectedText.swift create mode 100644 Sources/InKCreApp/ShortcutSettingsView.swift delete mode 100644 SpaceGrotesk-Medium.ttf create mode 100644 Tests/CaptureCoreTests/CaptureContractTests.swift create mode 100644 Tests/CaptureCoreTests/CaptureStoreTests.swift create mode 100644 Tests/CaptureCoreTests/Fixtures/capture-v1.json create mode 100644 Tests/CaptureCoreTests/LivePeerTests.swift create mode 100644 Tests/CaptureCoreTests/PeerClientTests.swift create mode 100644 Tests/InKCreAppTests/AppIconTests.swift create mode 100644 Tests/InKCreAppTests/CaptureInteractionTests.swift create mode 100644 Tests/InKCreAppTests/CaptureLayoutTests.swift create mode 100644 Tests/InKCreAppTests/RegionOverlayTests.swift create mode 100644 Tests/InKCreAppTests/ShortcutTests.swift delete mode 100644 paraflow/.gitkeep delete mode 100644 paraflow/7:0.html delete mode 100644 paraflow/8:0.html delete mode 100644 paraflow/9:0.html delete mode 100644 paraflow/project.json delete mode 100644 paraflow/styles.css delete mode 100644 profilehead.png create mode 100644 scripts/build-app.sh create mode 100644 scripts/build-macos-icon.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc3029c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.build/ +.swiftpm/ +dist/ +.DS_Store +*.xcuserstate +xcuserdata/ +.local-development-signing-identity diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1908d5f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# InKCre Mac Peer + +使用中文与人沟通。未明确要求时不提交或推送。 + +本仓库拥有原生 macOS 采集 Peer。共享状态属于 info-base;Capture 扩展分发由其他 Peer 执行。Mac 不承载扩展宿主,不启动 Python 子进程,不把本机 SQLite 当成共享知识权威。 + +本体是 regular Mac 应用,拥有 Dock 图标、普通主窗口和应用菜单。快捷采集 NSPanel 是辅助入口;不得再把应用整体收窄为 accessory/LSUIElement 菜单栏工具。主窗口导航与快捷采集编辑位置分开,草稿和队列只有一个所有者。 + +`Sources/CaptureCore` 拥有冻结提交、SQLite、Keychain 和 Peer 协议。`Sources/InKCreApp` 拥有系统采集及原生交互。共享请求与图合同见 `InKCre/impl-py` 的 `docs/30-unit-tdd/capture-extension.md`;数据库迁移由 `impl-py` 拥有。 + +同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 + +运行 `swift test` 和 `bash scripts/build-app.sh`。编译不证明系统权限、真实选区、焦点或多屏行为;真实应用验证使用合成内容,按实际结果记录。开发包使用 `dev.inkcre.capture`;发布身份、签名、公证只能使用显式提供的输入。 diff --git a/AlertToast README.md b/AlertToast README.md deleted file mode 100644 index d7ecc6a..0000000 --- a/AlertToast README.md +++ /dev/null @@ -1,235 +0,0 @@ -# AlertToast-SwiftUI [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Get%20over%20170%20free%20design%20blocks%20based%20on%20Bootstrap%204&url=https://froala.com/design-blocks&via=froala&hashtags=bootstrap,design,templates,blocks,developers) -### Present Apple-like alert & toast in SwiftUI - -

- -

- -## 🌄 Example - -

- -

- -

- -

- -## 🔭 Overview - -Currently in SwiftUI, the only way to inform the user about some process that finished for example, is by using `Alert`. Sometimes, you just want to display a message that tells the user that something has completed or that their message was sent. Apple doesn't provide any method other than using Alert, even though they use various types of pop-ups. This results in poor UX, as the user must tap 'OK' or 'Dismiss' for every piece of information they should be notified about - -Alert Toast is an open-source library in Github to use with SwiftUI. It allows you to present popups that don't need any user action to dismiss or to validate. Some great usage examples: `Message Sent`, `Poor Network Connection`, `Profile Updated`, `Logged In/Out`, `Favorited`, `Loading` and so on... - -          - -* Built with pure SwiftUI. -* 3 Display modes: `Alert` (pop at the center), `HUD` (drop from the top) and `Banner` (pop/slide from the bottom). -* `Complete`, `Error` `SystemImage`, `Image`, `Loading`, and `Regular` (Only Text). -* Supports Light & Dark Mode. -* Works with **any** kind of view builder. -* Localization support. -* Font & Background customization. - -#### If you like the project, don't forget to `put star 🌟`. - -       - -₿ Bitcoin donation address: -``` -0xec48bfa813a773fa2394aec23f97da5cb4d5ff02 -``` -- Send only BTC to this deposit address. -- Ensure the network is BNB Smart Chain (BEP20). - -## Navigation - -- [Installation](#-installation) - - [CocoaPods](#cocoapods) - - [Swift Package Manager](#swift-package-manager) - - [Manually](#manually) -- [Usage](#-usage) - - [Usage example with regular alert](#usage-example-with-regular-alert) - - [Complete Modifier Example](#complete-modifier-example) - - [Alert Toast Parameters](#alert-toast-parameters) - - [Article](#-article) - - [Contributing](#-contributing) - - [Author](#-author) - - [License](#-license) - -## 💻 Installation - -### Cocoapods - -[AlertToast Cocapods Website](https://cocoapods.org/pods/AlertToast) - -CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate `AlertToast` into your Xcode project using CocoaPods, specify it in your Podfile: - -```ruby -pod 'AlertToast' -``` - ------- - -### Swift Package Manager - -The [Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies. - -To integrate `AlertToast` into your Xcode project using Xcode 12, specify it in `File > Swift Packages > Add Package Dependency...`: - -```ogdl -https://github.com/elai950/AlertToast.git, :branch="master" -``` - -For Xcode 13, please refer [this article](https://iiroalhonen.medium.com/adding-a-swift-package-dependency-in-xcode-13-937b2caaf218) to install `AlertToast` - ------- - -### Manually - -If you prefer not to use any of dependency managers, you can integrate `AlertToast` into your project manually. Put `Sources/AlertToast` folder in your Xcode project. Make sure to enable `Copy items if needed` and `Create groups`. - -## 🧳 Requirements - -- iOS 13.0+ | macOS 11+ -- Xcode 12.0+ | Swift 5+ - -## 🛠 Usage - -First, add `import AlertToast` on every `swift` file you would like to use `AlertToast`. - -Then, use the `.toast` view modifier: - -**Parameters:** - -- `isPresenting`: (MUST) assign a `Binding` to show or dismiss alert. -- `duration`: default is 2, set 0 to disable auto dismiss. -- `tapToDismiss`: default is `true`, set `false` to disable. -- `alert`: (MUST) expects `AlertToast`. - -#### Usage example with regular alert - -```swift -import AlertToast -import SwiftUI - -struct ContentView: View{ - - @State private var showToast = false - - var body: some View{ - VStack{ - - Button("Show Toast"){ - showToast.toggle() - } - } - .toast(isPresenting: $showToast){ - - // `.alert` is the default displayMode - AlertToast(type: .regular, title: "Message Sent!") - - //Choose .hud to toast alert from the top of the screen - //AlertToast(displayMode: .hud, type: .regular, title: "Message Sent!") - - //Choose .banner to slide/pop alert from the bottom of the screen - //AlertToast(displayMode: .banner(.slide), type: .regular, title: "Message Sent!") - } - } -} -``` - -#### Complete Modifier Example - -```swift -.toast(isPresenting: $showAlert, duration: 2, tapToDismiss: true, alert: { - //AlertToast goes here -}, onTap: { - //onTap would call either if `tapToDismis` is true/false - //If tapToDismiss is true, onTap would call and then dismis the alert -}, completion: { - //Completion block after dismiss -}) -``` - -### Alert Toast Parameters - -```swift -AlertToast(displayMode: DisplayMode, - type: AlertType, - title: Optional(String), - subTitle: Optional(String), - style: Optional(AlertStyle)) - -//This is the available customizations parameters: -AlertStyle(backgroundColor: Color?, - titleColor: Color?, - subTitleColor: Color?, - titleFont: Font?, - subTitleFont: Font?) -``` - -#### Available Alert Types: -- **Regular:** text only (Title and Subtitle). -- **Complete:** animated checkmark. -- **Error:** animated xmark. -- **System Image:** name image from `SFSymbols`. -- **Image:** name image from Assets. -- **Loading:** Activity Indicator (Spinner). - -#### Alert dialog view modifier (with default settings): -```swift -.toast(isPresenting: Binding, duration: Double = 2, tapToDismiss: true, alert: () -> AlertToast , onTap: () -> (), completion: () -> () ) -``` - -#### Simple Text Alert: -```swift -AlertToast(type: .regular, title: Optional(String), subTitle: Optional(String)) -``` - -#### Complete/Error Alert: -```swift -AlertToast(type: .complete(Color)/.error(Color), title: Optional(String), subTitle: Optional(String)) -``` - -#### System Image Alert: -```swift -AlertToast(type: .systemImage(String, Color), title: Optional(String), subTitle: Optional(String)) -``` - -#### Image Alert: -```swift -AlertToast(type: .image(String), title: Optional(String), subTitle: Optional(String)) -``` - -#### Loading Alert: -```swift -//When using loading, duration won't auto dismiss and tapToDismiss is set to false -AlertToast(type: .loading, title: Optional(String), subTitle: Optional(String)) -``` - -You can add many `.toast` on a single view. - -## 📖 Article - -I wrote an article that contains more usage examples. - -[Medium - How to toast an alert in SwiftUI](https://elaizuberman.medium.com/presenting-apples-music-alerts-in-swiftui-7f5c32cebed6) - -## 👨‍💻 Contributors - -All issue reports, feature requests, pull requests and GitHub stars are welcomed and much appreciated. - -- [@barnard-b](https://github.com/barnard-b) - -## ✍️ Author - -Elai Zuberman - -## 📃 License - -`AlertToast` is available under the MIT license. See the [LICENSE](https://github.com/elai950/AlertToast/blob/master/LICENSE.md) file for more info. - ---- - -- [Jump Up](#-overview) diff --git a/D3LibMin.xcodeproj/project.pbxproj b/D3LibMin.xcodeproj/project.pbxproj deleted file mode 100644 index d4a361c..0000000 --- a/D3LibMin.xcodeproj/project.pbxproj +++ /dev/null @@ -1,579 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXContainerItemProxy section */ - 9F3F9F732E34B511004F4170 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 9F3F9F5D2E34B50F004F4170 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 9F3F9F642E34B50F004F4170; - remoteInfo = D3LibMin; - }; - 9F3F9F7D2E34B511004F4170 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 9F3F9F5D2E34B50F004F4170 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 9F3F9F642E34B50F004F4170; - remoteInfo = D3LibMin; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 9F3F9F652E34B50F004F4170 /* D3LibMin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = D3LibMin.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 9F3F9F722E34B511004F4170 /* D3LibMinTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = D3LibMinTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 9F3F9F7C2E34B511004F4170 /* D3LibMinUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = D3LibMinUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 9F3F9F672E34B50F004F4170 /* D3LibMin */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = D3LibMin; - sourceTree = ""; - }; - 9F3F9F752E34B511004F4170 /* D3LibMinTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = D3LibMinTests; - sourceTree = ""; - }; - 9F3F9F7F2E34B511004F4170 /* D3LibMinUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = D3LibMinUITests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 9F3F9F622E34B50F004F4170 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F6F2E34B511004F4170 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F792E34B511004F4170 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 9F3F9F5C2E34B50F004F4170 = { - isa = PBXGroup; - children = ( - 9F3F9F672E34B50F004F4170 /* D3LibMin */, - 9F3F9F752E34B511004F4170 /* D3LibMinTests */, - 9F3F9F7F2E34B511004F4170 /* D3LibMinUITests */, - 9F3F9F662E34B50F004F4170 /* Products */, - ); - sourceTree = ""; - }; - 9F3F9F662E34B50F004F4170 /* Products */ = { - isa = PBXGroup; - children = ( - 9F3F9F652E34B50F004F4170 /* D3LibMin.app */, - 9F3F9F722E34B511004F4170 /* D3LibMinTests.xctest */, - 9F3F9F7C2E34B511004F4170 /* D3LibMinUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 9F3F9F642E34B50F004F4170 /* D3LibMin */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9F3F9F862E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMin" */; - buildPhases = ( - 9F3F9F612E34B50F004F4170 /* Sources */, - 9F3F9F622E34B50F004F4170 /* Frameworks */, - 9F3F9F632E34B50F004F4170 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 9F3F9F672E34B50F004F4170 /* D3LibMin */, - ); - name = D3LibMin; - packageProductDependencies = ( - ); - productName = D3LibMin; - productReference = 9F3F9F652E34B50F004F4170 /* D3LibMin.app */; - productType = "com.apple.product-type.application"; - }; - 9F3F9F712E34B511004F4170 /* D3LibMinTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9F3F9F892E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinTests" */; - buildPhases = ( - 9F3F9F6E2E34B511004F4170 /* Sources */, - 9F3F9F6F2E34B511004F4170 /* Frameworks */, - 9F3F9F702E34B511004F4170 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 9F3F9F742E34B511004F4170 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 9F3F9F752E34B511004F4170 /* D3LibMinTests */, - ); - name = D3LibMinTests; - packageProductDependencies = ( - ); - productName = D3LibMinTests; - productReference = 9F3F9F722E34B511004F4170 /* D3LibMinTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 9F3F9F7B2E34B511004F4170 /* D3LibMinUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9F3F9F8C2E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinUITests" */; - buildPhases = ( - 9F3F9F782E34B511004F4170 /* Sources */, - 9F3F9F792E34B511004F4170 /* Frameworks */, - 9F3F9F7A2E34B511004F4170 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 9F3F9F7E2E34B511004F4170 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 9F3F9F7F2E34B511004F4170 /* D3LibMinUITests */, - ); - name = D3LibMinUITests; - packageProductDependencies = ( - ); - productName = D3LibMinUITests; - productReference = 9F3F9F7C2E34B511004F4170 /* D3LibMinUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 9F3F9F5D2E34B50F004F4170 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1640; - TargetAttributes = { - 9F3F9F642E34B50F004F4170 = { - CreatedOnToolsVersion = 16.4; - }; - 9F3F9F712E34B511004F4170 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = 9F3F9F642E34B50F004F4170; - }; - 9F3F9F7B2E34B511004F4170 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = 9F3F9F642E34B50F004F4170; - }; - }; - }; - buildConfigurationList = 9F3F9F602E34B50F004F4170 /* Build configuration list for PBXProject "D3LibMin" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 9F3F9F5C2E34B50F004F4170; - minimizedProjectReferenceProxies = 1; - packageReferences = ( - 9F3F9FD72E350CF7004F4170 /* XCLocalSwiftPackageReference "../AlertToast-master" */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 9F3F9F662E34B50F004F4170 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 9F3F9F642E34B50F004F4170 /* D3LibMin */, - 9F3F9F712E34B511004F4170 /* D3LibMinTests */, - 9F3F9F7B2E34B511004F4170 /* D3LibMinUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 9F3F9F632E34B50F004F4170 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F702E34B511004F4170 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F7A2E34B511004F4170 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 9F3F9F612E34B50F004F4170 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F6E2E34B511004F4170 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F782E34B511004F4170 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 9F3F9F742E34B511004F4170 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 9F3F9F642E34B50F004F4170 /* D3LibMin */; - targetProxy = 9F3F9F732E34B511004F4170 /* PBXContainerItemProxy */; - }; - 9F3F9F7E2E34B511004F4170 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 9F3F9F642E34B50F004F4170 /* D3LibMin */; - targetProxy = 9F3F9F7D2E34B511004F4170 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 9F3F9F842E34B511004F4170 /* 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; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - 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; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 9F3F9F852E34B511004F4170 /* 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; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - 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; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 9F3F9F872E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMin; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 9F3F9F882E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMin; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 9F3F9F8A2E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/D3LibMin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/D3LibMin"; - }; - name = Debug; - }; - 9F3F9F8B2E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/D3LibMin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/D3LibMin"; - }; - name = Release; - }; - 9F3F9F8D2E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = D3LibMin; - }; - name = Debug; - }; - 9F3F9F8E2E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = D3LibMin; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 9F3F9F602E34B50F004F4170 /* Build configuration list for PBXProject "D3LibMin" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F842E34B511004F4170 /* Debug */, - 9F3F9F852E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 9F3F9F862E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMin" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F872E34B511004F4170 /* Debug */, - 9F3F9F882E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 9F3F9F892E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F8A2E34B511004F4170 /* Debug */, - 9F3F9F8B2E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 9F3F9F8C2E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F8D2E34B511004F4170 /* Debug */, - 9F3F9F8E2E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 9F3F9FD72E350CF7004F4170 /* XCLocalSwiftPackageReference "../AlertToast-master" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "../AlertToast-master"; - }; -/* End XCLocalSwiftPackageReference section */ - }; - rootObject = 9F3F9F5D2E34B50F004F4170 /* Project object */; -} diff --git a/D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist b/D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index ce76cce..0000000 --- a/D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - D3LibMin.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/D3LibMin/AddDeviceView.swift b/D3LibMin/AddDeviceView.swift deleted file mode 100644 index f75bfcf..0000000 --- a/D3LibMin/AddDeviceView.swift +++ /dev/null @@ -1,334 +0,0 @@ -// -// AddDeviceView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/27. -// - -import SwiftUI - -struct AddDeviceView: View { - @Environment(\.presentationMode) var presentationMode - @State private var searchText = "" - @State private var selectedDeviceType: DeviceType = .ar - @State private var isScanning = false - @State private var discoveredDevices: [DiscoveredDevice] = [] - - enum DeviceType: String, CaseIterable { - case ar = "AR Glasses" - case vr = "VR Headset" - case mobile = "Mobile Device" - case desktop = "Desktop Computer" - - var icon: String { - switch self { - case .ar: return "eyeglasses" - case .vr: return "viewfinder" - case .mobile: return "iphone" - case .desktop: return "desktopcomputer" - } - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - - Spacer() - - Text("Add Device") - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - - Spacer() - - // Placeholder for balance - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.clear) - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 20) - - ScrollView { - VStack(spacing: 24) { - // Device Type Selection - VStack(alignment: .leading, spacing: 16) { - Text("Device Type") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 12) { - ForEach(DeviceType.allCases, id: \.self) { deviceType in - DeviceTypeCard( - type: deviceType, - isSelected: selectedDeviceType == deviceType, - action: { - selectedDeviceType = deviceType - } - ) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Search Section - VStack(alignment: .leading, spacing: 16) { - Text("Search for Devices") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - HStack { - Image(systemName: "magnifyingglass") - .font(.system(size: 16)) - .foregroundColor(.secondary) - - TextField("Search for devices...", text: $searchText) - .font(.system(size: 16)) - - if !searchText.isEmpty { - Button(action: { - searchText = "" - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - } - } - } - .padding(12) - .background(Color(.systemGray6)) - .cornerRadius(8) - - // Scan Button - Button(action: { - startScanning() - }) { - HStack { - if isScanning { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 16, weight: .medium)) - } - - Text(isScanning ? "Scanning..." : "Scan for Devices") - .font(.system(size: 16, weight: .medium)) - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(isScanning ? Color.gray : Color.primary) - .cornerRadius(8) - } - .disabled(isScanning) - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Discovered Devices - if !discoveredDevices.isEmpty { - VStack(alignment: .leading, spacing: 16) { - Text("Available Devices") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - ForEach(discoveredDevices) { device in - DiscoveredDeviceRow(device: device) { - connectToDevice(device) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - } - - // Manual Connection - VStack(alignment: .leading, spacing: 16) { - Text("Manual Connection") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - Text("If your device doesn't appear in the list above, you can manually enter the device information or use a QR code.") - .font(.system(size: 14)) - .foregroundColor(.secondary) - - HStack(spacing: 12) { - Button(action: { - // Manual entry action - }) { - HStack { - Image(systemName: "keyboard") - .font(.system(size: 16, weight: .medium)) - Text("Manual Entry") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - Button(action: { - // QR code action - }) { - HStack { - Image(systemName: "qrcode") - .font(.system(size: 16, weight: .medium)) - Text("QR Code") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - } - .padding(.horizontal, 20) - } - } - .background(Color(.systemGroupedBackground)) - .navigationBarHidden(true) - } - - private func startScanning() { - isScanning = true - discoveredDevices = [] - - // Simulate device discovery - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - discoveredDevices = [ - DiscoveredDevice(name: "Rokid AR Pro", type: .ar, signalStrength: 0.9, isPaired: false), - DiscoveredDevice(name: "Even Realities G1", type: .ar, signalStrength: 0.7, isPaired: false), - DiscoveredDevice(name: "Meta Quest 3", type: .vr, signalStrength: 0.8, isPaired: false), - DiscoveredDevice(name: "iPhone 15 Pro", type: .mobile, signalStrength: 0.6, isPaired: true) - ] - isScanning = false - } - } - - private func connectToDevice(_ device: DiscoveredDevice) { - // Handle device connection - print("Connecting to \(device.name)") - } -} - -struct DeviceTypeCard: View { - let type: AddDeviceView.DeviceType - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 12) { - Image(systemName: type.icon) - .font(.system(size: 24)) - .foregroundColor(isSelected ? .white : .primary) - - Text(type.rawValue) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(isSelected ? .white : .primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(isSelected ? Color.primary : Color(.systemGray6)) - .cornerRadius(8) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct DiscoveredDevice: Identifiable { - let id = UUID() - let name: String - let type: AddDeviceView.DeviceType - let signalStrength: Double - let isPaired: Bool -} - -struct DiscoveredDeviceRow: View { - let device: DiscoveredDevice - let onConnect: () -> Void - - var body: some View { - HStack { - Image(systemName: device.type.icon) - .font(.system(size: 20)) - .foregroundColor(.primary) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - HStack(spacing: 8) { - Text(device.type.rawValue) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - - if device.isPaired { - Text("Paired") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.green) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.green.opacity(0.1)) - .cornerRadius(4) - } - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - // Signal strength indicator - HStack(spacing: 2) { - ForEach(0..<4, id: \.self) { index in - Rectangle() - .fill(index < Int(device.signalStrength * 4) ? Color.green : Color(.systemGray4)) - .frame(width: 3, height: CGFloat(index + 1) * 3) - .cornerRadius(1.5) - } - } - - Button(action: onConnect) { - Text(device.isPaired ? "Reconnect" : "Connect") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.primary) - .cornerRadius(6) - } - } - } - .padding(.vertical, 8) - } -} - -#Preview { - AddDeviceView() -} \ No newline at end of file diff --git a/D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json b/D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 8ec08b9..0000000 --- a/D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json b/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a345538..0000000 --- a/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "images" : [ - { - "filename" : "appicon light.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "appIcon 1.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "appIcon 2.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 1.png b/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 1.png deleted file mode 100644 index 7c6f44dbba1c6002c466f4b88a90e1e507db0aa6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11389 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 Z1-o6>u2wzt67MkBv2EwplFu1u{xAJs2uc6| diff --git a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 2.png b/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 2.png deleted file mode 100644 index 7c6f44dbba1c6002c466f4b88a90e1e507db0aa6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11389 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 Z1-o6>u2wzt67MkBv2EwplFu1u{xAJs2uc6| diff --git a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appicon light.png b/D3LibMin/Assets.xcassets/AppIcon.appiconset/appicon light.png deleted file mode 100644 index 10cb4b9895c5f6db1993ce89e22b2c2ed5178edb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11538 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 Void) { - let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) - - switch status { - case .authorized, .limited: - completion(true) - case .denied, .restricted: - completion(false) - case .notDetermined: - PHPhotoLibrary.requestAuthorization(for: .readWrite) { newStatus in - DispatchQueue.main.async { - completion(newStatus == .authorized || newStatus == .limited) - } - } - @unknown default: - completion(false) - } - } - - private func fetchLatestPhoto() { - let fetchOptions = PHFetchOptions() - fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] - fetchOptions.fetchLimit = 1 - - let fetchResult = PHAsset.fetchAssets(with: .image, options: fetchOptions) - - guard let latestAsset = fetchResult.firstObject else { return } - - let imageManager = PHImageManager.default() - let requestOptions = PHImageRequestOptions() - requestOptions.isSynchronous = false - requestOptions.deliveryMode = .highQualityFormat - - imageManager.requestImage( - for: latestAsset, - targetSize: CGSize(width: 1000, height: 1000), - contentMode: .aspectFit, - options: requestOptions - ) { image, _ in - DispatchQueue.main.async { - if let image = image { - // 检查图片是否在5秒内创建 - let now = Date() - let fiveSecondsAgo = now.addingTimeInterval(-5) - - if let creationDate = latestAsset.creationDate, - creationDate > fiveSecondsAgo { - // 图片是在5秒内创建的,播放像素波纹动画 - withAnimation(.easeIn(duration: 0.2)) { - self.showPixelWave = true - } - } else { - // 图片不是最近创建的,显示较小图片并播放中央波纹动画 - self.latestImage = image - - // 图片和波纹动画同时淡入 - withAnimation(.easeIn(duration: 0.2)) { - self.showLatestImage = true - self.showCenterPixelWave = true - } - - // 0.8秒后开始飞出动画,但不立即隐藏view - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - // 先触发飞出动画,但保持showLatestImage为true - // 动画会在ImageWithBorderView内部处理 - } - - // 1.2秒后才真正隐藏图片(给飞出动画0.4秒时间) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { - self.showLatestImage = false - } - - // 1.5秒后波纹动画消失和显示Toast - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - withAnimation(.easeOut(duration: 0.3)) { - self.showCenterPixelWave = false - } - - // 同时显示Toast - withAnimation(.easeInOut(duration: 0.4)) { - self.showToast = true - } - - // 2秒后自动隐藏Toast - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - withAnimation(.easeOut(duration: 0.3)) { - self.showToast = false - } - } - - // 清理图片(在飞出动画完成后) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - self.latestImage = nil - } - } - } - } - } - } - } -} - -#Preview { - ContentView() -} diff --git a/D3LibMin/CustomToastView.swift b/D3LibMin/CustomToastView.swift deleted file mode 100644 index e4da948..0000000 --- a/D3LibMin/CustomToastView.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// CustomToastView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -struct CustomToastView: View { - let message: String - @Binding var isShowing: Bool - - var body: some View { - ZStack { - // 半透明背景 - 只使用透明度变化 - Color.black.opacity(isShowing ? 0.3 : 0.0) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeOut(duration: 0.3)) { - isShowing = false - } - } - - // Toast内容 - 使用透明度和轻微缩放 - VStack(spacing: 16) { - // 云朵图标 - Image(systemName: "cloud.fill") - .font(.system(size: 32, weight: .medium)) - .foregroundColor(.primary) - - // 文字 - Text(message) - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.primary) - .multilineTextAlignment(.center) - } - .padding(.horizontal, 32) - .padding(.vertical, 24) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(.systemBackground)) - .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 4) - ) - .scaleEffect(isShowing ? 1.0 : 0.95) - .opacity(isShowing ? 1.0 : 0.0) - } - .animation(.easeInOut(duration: 0.4), value: isShowing) - } -} - -#Preview { - CustomToastView(message: "云端解析中", isShowing: .constant(true)) -} \ No newline at end of file diff --git a/D3LibMin/D3LibMinApp.swift b/D3LibMin/D3LibMinApp.swift deleted file mode 100644 index 27b8c16..0000000 --- a/D3LibMin/D3LibMinApp.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// D3LibMinApp.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -@main -struct D3LibMinApp: App { - init() { - // Register custom fonts - FontManager.registerFonts() - } - - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/D3LibMin/DataManager.swift b/D3LibMin/DataManager.swift deleted file mode 100644 index 10cd9f9..0000000 --- a/D3LibMin/DataManager.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// DataManager.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import Foundation -import Combine - -class DataManager: ObservableObject { - @Published var infoBlocks: [InfoBlock] = [] - @Published var isLoading = false - @Published var errorMessage: String? - - private var cancellables = Set() - - init() { - loadInitialData() - } - - // MARK: - Data Loading - - private func loadInitialData() { - // 加载初始示例数据 - infoBlocks = [ - InfoBlock( - title: "Room 6836", - subtitle: "Meikai Hotel", - type: .numbers, - needs: [ - UsageNeed(name: "Hotel Check-in", description: "For hotel registration"), - UsageNeed(name: "Room Access", description: "For room key card") - ], - infos: [ - InfoContent(name: "Room Number", content: "6836"), - InfoContent(name: "Hotel Name", content: "Meikai Hotel"), - InfoContent(name: "Check-in Date", content: "2025-01-15"), - InfoContent(name: "Check-out Date", content: "2025-01-18") - ] - ), - InfoBlock( - title: "605-1-3064", - subtitle: "Package pick up", - type: .numbers, - needs: [ - UsageNeed(name: "Package Pickup", description: "For collecting packages") - ], - infos: [ - InfoContent(name: "Pickup Code", content: "605-1-3064"), - InfoContent(name: "Location", content: "Main Post Office"), - InfoContent(name: "Expiry Date", content: "2025-01-20") - ] - ), - InfoBlock( - title: "Moonlight", - subtitle: "Piano scores", - type: .files, - needs: [ - UsageNeed(name: "Music Practice", description: "For piano practice sessions") - ], - infos: [ - InfoContent(name: "File Name", content: "Moonlight_Sonata.pdf", type: .file), - InfoContent(name: "Composer", content: "Ludwig van Beethoven"), - InfoContent(name: "Difficulty", content: "Advanced"), - InfoContent(name: "File Size", content: "2.4 MB") - ] - ), - InfoBlock( - title: "Lanzhi Jiang", - subtitle: "New contact", - type: .profiles, - needs: [ - UsageNeed(name: "Contact Management", description: "For contact information"), - UsageNeed(name: "Communication", description: "For messaging and calls") - ], - infos: [ - InfoContent(name: "Full Name", content: "Lanzhi Jiang"), - InfoContent(name: "Phone", content: "+86 138-1234-5678"), - InfoContent(name: "Email", content: "lanzhi.jiang@example.com"), - InfoContent(name: "Company", content: "Tech Solutions Inc."), - InfoContent(name: "Position", content: "Senior Developer") - ] - ), - InfoBlock( - title: "InkCre README.md", - subtitle: "API definitions", - type: .files, - needs: [ - UsageNeed(name: "Development", description: "For API development reference") - ], - infos: [ - InfoContent(name: "File Name", content: "README.md", type: .file), - InfoContent(name: "Project", content: "InkCre"), - InfoContent(name: "Type", content: "API Documentation"), - InfoContent(name: "Last Updated", content: "2025-01-10") - ] - ), - InfoBlock( - title: "Meeting Notes", - subtitle: "Weekly team sync", - type: .phrases, - needs: [ - UsageNeed(name: "Team Communication", description: "For team meetings"), - UsageNeed(name: "Project Management", description: "For project tracking") - ], - infos: [ - InfoContent(name: "Meeting Type", content: "Weekly Team Sync"), - InfoContent(name: "Date", content: "2025-01-14"), - InfoContent(name: "Duration", content: "1 hour"), - InfoContent(name: "Key Points", content: "Project milestones, resource allocation, next steps"), - InfoContent(name: "Action Items", content: "Follow up on client feedback, prepare demo for next week") - ] - ) - ] - } - - // MARK: - CRUD Operations - - func addInfoBlock(_ infoBlock: InfoBlock) { - infoBlocks.append(infoBlock) - // TODO: 调用API保存到服务器 - saveToAPI(infoBlock) - } - - func updateInfoBlock(_ infoBlock: InfoBlock) { - if let index = infoBlocks.firstIndex(where: { $0.id == infoBlock.id }) { - infoBlocks[index] = infoBlock - // TODO: 调用API更新到服务器 - updateInAPI(infoBlock) - } - } - - func deleteInfoBlock(_ infoBlock: InfoBlock) { - infoBlocks.removeAll { $0.id == infoBlock.id } - // TODO: 调用API从服务器删除 - deleteFromAPI(infoBlock) - } - - func getInfoBlock(by id: UUID) -> InfoBlock? { - return infoBlocks.first { $0.id == id } - } - - func getInfoBlocks(by type: InfoType) -> [InfoBlock] { - return infoBlocks.filter { $0.type == type } - } - - func getInfoBlocks(by need: String) -> [InfoBlock] { - return infoBlocks.filter { infoBlock in - infoBlock.needs.contains { $0.name.localizedCaseInsensitiveContains(need) } - } - } - - // MARK: - API Integration (Future Implementation) - - private func saveToAPI(_ infoBlock: InfoBlock) { - // TODO: 实现API保存逻辑 - print("Saving info block to API: \(infoBlock.title)") - } - - private func updateInAPI(_ infoBlock: InfoBlock) { - // TODO: 实现API更新逻辑 - print("Updating info block in API: \(infoBlock.title)") - } - - private func deleteFromAPI(_ infoBlock: InfoBlock) { - // TODO: 实现API删除逻辑 - print("Deleting info block from API: \(infoBlock.title)") - } - - func refreshFromAPI() { - isLoading = true - errorMessage = nil - - // TODO: 实现从API获取数据的逻辑 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.isLoading = false - // 模拟API调用完成 - } - } - - // MARK: - Search and Filter - - func searchInfoBlocks(query: String) -> [InfoBlock] { - guard !query.isEmpty else { return infoBlocks } - - return infoBlocks.filter { infoBlock in - infoBlock.title.localizedCaseInsensitiveContains(query) || - infoBlock.subtitle.localizedCaseInsensitiveContains(query) || - infoBlock.infos.contains { info in - info.name.localizedCaseInsensitiveContains(query) || - info.content.localizedCaseInsensitiveContains(query) - } || - infoBlock.needs.contains { need in - need.name.localizedCaseInsensitiveContains(query) || - (need.description?.localizedCaseInsensitiveContains(query) ?? false) - } - } - } - - func getExpiredInfoBlocks() -> [InfoBlock] { - let now = Date() - return infoBlocks.filter { infoBlock in - if let expiresAt = infoBlock.expiresAt { - return expiresAt < now - } - return false - } - } - - func getExpiringSoonInfoBlocks(within days: Int = 7) -> [InfoBlock] { - let now = Date() - let futureDate = Calendar.current.date(byAdding: .day, value: days, to: now) ?? now - - return infoBlocks.filter { infoBlock in - if let expiresAt = infoBlock.expiresAt { - return expiresAt > now && expiresAt <= futureDate - } - return false - } - } -} \ No newline at end of file diff --git a/D3LibMin/FONT_INTEGRATION_SUMMARY.md b/D3LibMin/FONT_INTEGRATION_SUMMARY.md deleted file mode 100644 index 5c45478..0000000 --- a/D3LibMin/FONT_INTEGRATION_SUMMARY.md +++ /dev/null @@ -1,166 +0,0 @@ -# D3LibMin 自定义字体集成总结 - -## 概述 - -成功将根目录中的 `SpaceGrotesk-Medium.ttf` 字体文件集成到 D3LibMin 应用中,并应用到指定的页面标题和用户名称。 - -## 完成的字体应用 - -### 1. Library 页面标题 -- **位置**: `HomeView.swift` -- **元素**: "Library" 标题 -- **字体**: SpaceGrotesk-Medium, 24pt -- **修改前**: `.font(.system(size: 24, weight: .medium, design: .default))` -- **修改后**: `.font(.spaceGroteskMedium(size: 24))` - -### 2. Profile 页面标题 -- **位置**: `UserProfileView.swift` -- **元素**: "Profile" 标题 -- **字体**: SpaceGrotesk-Medium, 24pt -- **修改前**: `.font(.system(size: 24, weight: .medium, design: .default))` -- **修改后**: `.font(.spaceGroteskMedium(size: 24))` - -### 3. Profile 页面用户名称 -- **位置**: `UserProfileView.swift` -- **元素**: 用户姓名 (Jackson Guo) -- **字体**: SpaceGrotesk-Medium, 20pt -- **修改前**: `.font(.system(size: 20, weight: .medium))` -- **修改后**: `.font(.spaceGroteskMedium(size: 20))` - -### 4. 卡片标题 -- **位置**: `HomeView.swift` - `InfoCard` 结构体 -- **元素**: 卡片主标题 (如 "Room 6836", "605-1-3064" 等) -- **字体**: SpaceGrotesk-Medium, 20pt -- **修改前**: `.font(.system(size: 20, weight: .medium))` -- **修改后**: `.font(.spaceGroteskMedium(size: 20))` - -### 5. 卡片副标题 -- **位置**: `HomeView.swift` - `InfoCard` 结构体 -- **元素**: 卡片副标题 (如 "Meikai Hotel", "Package pick up" 等) -- **字体**: SpaceGrotesk-Medium, 14pt -- **修改前**: `.font(.system(size: 14, weight: .regular))` -- **修改后**: `.font(.spaceGroteskMedium(size: 14))` - -### 6. 卡片详情页面主标题 -- **位置**: `InfoDetailView.swift` - 主信息卡片区域 -- **元素**: 卡片详情页面的主标题 (与卡片标题相同) -- **字体**: SpaceGrotesk-Medium, 20pt -- **修改前**: `.font(.system(size: 20, weight: .medium))` -- **修改后**: `.font(.spaceGroteskMedium(size: 20))` - -### 7. 卡片详情页面副标题 -- **位置**: `InfoDetailView.swift` - 主信息卡片区域 -- **元素**: 卡片详情页面的副标题 (与卡片副标题相同) -- **字体**: SpaceGrotesk-Medium, 14pt -- **修改前**: `.font(.system(size: 16, weight: .regular))` -- **修改后**: `.font(.spaceGroteskMedium(size: 14))` - -## 技术实现 - -### 1. 字体文件管理 -- **源文件**: `SpaceGrotesk-Medium.ttf` (85KB) -- **目标位置**: `D3LibMin/SpaceGrotesk-Medium.ttf` -- **状态**: ✅ 已复制到项目目录 - -### 2. 字体管理器 (FontManager.swift) -```swift -struct FontManager { - static let spaceGroteskMedium = "SpaceGrotesk-Medium" - - static func registerFonts() { - // 字体注册逻辑 - } -} - -extension Font { - static func spaceGroteskMedium(size: CGFloat) -> Font { - // 尝试使用自定义字体,失败时回退到系统字体 - if let _ = UIFont(name: FontManager.spaceGroteskMedium, size: size) { - return Font.custom(FontManager.spaceGroteskMedium, size: size) - } else { - return Font.system(size: size, weight: .medium) - } - } -} -``` - -### 3. 应用启动时注册 -- **位置**: `D3LibMinApp.swift` -- **实现**: 在 `init()` 方法中调用 `FontManager.registerFonts()` -- **状态**: ✅ 已实现 - -## 字体特性 - -### SpaceGrotesk-Medium 字体 -- **类型**: 几何无衬线字体 -- **风格**: 现代、清晰、易读 -- **适用场景**: 标题、用户界面文本 -- **文件大小**: 85KB -- **许可证**: 开源字体 - -## 兼容性处理 - -### 1. 回退机制 -- 如果自定义字体加载失败,自动回退到系统字体 -- 确保应用在任何情况下都能正常显示 - -### 2. 错误处理 -- 详细的错误日志输出 -- 优雅的失败处理 - -### 3. 性能优化 -- 字体只在应用启动时注册一次 -- 使用懒加载方式创建字体实例 - -## 文件结构 - -``` -D3LibMin/ -├── SpaceGrotesk-Medium.ttf # 字体文件 -├── FontManager.swift # 字体管理器 -├── D3LibMinApp.swift # 应用入口(字体注册) -├── HomeView.swift # Library页面(已更新) -├── UserProfileView.swift # Profile页面(已更新) -└── FONT_INTEGRATION_SUMMARY.md # 本文档 -``` - -## 编译状态 - -✅ **编译成功** - 项目已通过 Xcode 编译验证,字体集成正常工作。 - -## 使用说明 - -### 在其他地方使用自定义字体 -```swift -// 使用 SpaceGrotesk-Medium 字体 -Text("Your Text") - .font(.spaceGroteskMedium(size: 18)) -``` - -### 添加新的字体文件 -1. 将字体文件复制到 `D3LibMin/` 目录 -2. 在 `FontManager.swift` 中添加字体名称常量 -3. 在 `registerFonts()` 方法中添加注册逻辑 -4. 在 `Font` 扩展中添加便捷方法 - -## 效果预览 - -应用现在使用 SpaceGrotesk-Medium 字体显示: -- Library 页面的 "Library" 标题 -- Profile 页面的 "Profile" 标题 -- Profile 页面的用户姓名 "Jackson Guo" -- 所有卡片的主标题 (如 "Room 6836", "605-1-3064" 等) -- 所有卡片的副标题 (如 "Meikai Hotel", "Package pick up" 等) -- 卡片详情页面的主标题和副标题 (与卡片保持一致) - -这些元素现在具有更现代、更专业的视觉效果,与应用的总体设计风格保持一致。 - -## 总结 - -✅ **字体集成完成** -- 成功添加了 SpaceGrotesk-Medium 字体 -- 应用到指定的页面标题和用户名称 -- 实现了完善的错误处理和回退机制 -- 项目编译正常,可以正常运行 - -字体集成提升了应用的专业外观,为用户提供了更好的视觉体验。 \ No newline at end of file diff --git a/D3LibMin/FontManager.swift b/D3LibMin/FontManager.swift deleted file mode 100644 index d11d51d..0000000 --- a/D3LibMin/FontManager.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// FontManager.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -struct FontManager { - static let spaceGroteskMedium = "SpaceGrotesk-Medium" - - static func registerFonts() { - // Try to register the font - if let fontURL = Bundle.main.url(forResource: spaceGroteskMedium, withExtension: "ttf") { - if let fontDataProvider = CGDataProvider(url: fontURL as CFURL), - let font = CGFont(fontDataProvider) { - var error: Unmanaged? - if CTFontManagerRegisterGraphicsFont(font, &error) { - print("Successfully registered font: \(spaceGroteskMedium)") - } else { - print("Failed to register font: \(error?.takeUnretainedValue().localizedDescription ?? "Unknown error")") - } - } else { - print("Could not create font from data provider") - } - } else { - print("Could not find font file: \(spaceGroteskMedium).ttf") - print("Make sure the font file is added to the Xcode project and included in the app bundle") - } - } -} - -extension Font { - static func spaceGroteskMedium(size: CGFloat) -> Font { - // Try to use custom font, fallback to system font if not available - if let _ = UIFont(name: FontManager.spaceGroteskMedium, size: size) { - return Font.custom(FontManager.spaceGroteskMedium, size: size) - } else { - return Font.system(size: size, weight: .medium) - } - } -} \ No newline at end of file diff --git a/D3LibMin/HelpSupportView.swift b/D3LibMin/HelpSupportView.swift deleted file mode 100644 index 48260b5..0000000 --- a/D3LibMin/HelpSupportView.swift +++ /dev/null @@ -1,566 +0,0 @@ -// -// HelpSupportView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/27. -// - -import SwiftUI - -struct HelpSupportView: View { - @Environment(\.presentationMode) var presentationMode - @State private var searchText = "" - @State private var selectedCategory: HelpCategory = .gettingStarted - @State private var showingContactSupport = false - - enum HelpCategory: String, CaseIterable { - case gettingStarted = "Getting Started" - case features = "Features" - case troubleshooting = "Troubleshooting" - case devices = "Devices" - case account = "Account" - - var icon: String { - switch self { - case .gettingStarted: return "play.circle.fill" - case .features: return "star.circle.fill" - case .troubleshooting: return "wrench.and.screwdriver.fill" - case .devices: return "iphone" - case .account: return "person.circle.fill" - } - } - - var color: Color { - switch self { - case .gettingStarted: return .primary - case .features: return .green - case .troubleshooting: return .orange - case .devices: return .purple - case .account: return .red - } - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - - Spacer() - - Text("Help & Support") - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - - Spacer() - - // Placeholder for balance - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.clear) - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 20) - - ScrollView { - VStack(spacing: 24) { - // Search - VStack(alignment: .leading, spacing: 16) { - Text("How can we help?") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - HStack { - Image(systemName: "magnifyingglass") - .font(.system(size: 16)) - .foregroundColor(.secondary) - - TextField("Search for help...", text: $searchText) - .font(.system(size: 16)) - - if !searchText.isEmpty { - Button(action: { - searchText = "" - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - } - } - } - .padding(12) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Categories - VStack(alignment: .leading, spacing: 16) { - Text("Help Categories") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 12) { - ForEach(HelpCategory.allCases, id: \.self) { category in - HelpCategoryCard( - category: category, - isSelected: selectedCategory == category, - action: { - selectedCategory = category - } - ) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Quick Actions - VStack(alignment: .leading, spacing: 16) { - Text("Quick Actions") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - QuickActionRow( - title: "Contact Support", - subtitle: "Get help from our support team", - icon: "message.circle.fill", - color: .primary - ) { - showingContactSupport = true - } - - Divider() - .padding(.leading, 44) - - QuickActionRow( - title: "Report a Bug", - subtitle: "Help us improve by reporting issues", - icon: "exclamationmark.triangle.fill", - color: .orange - ) { - // Report bug action - } - - Divider() - .padding(.leading, 44) - - QuickActionRow( - title: "Feature Request", - subtitle: "Suggest new features", - icon: "lightbulb.fill", - color: .yellow - ) { - // Feature request action - } - - Divider() - .padding(.leading, 44) - - QuickActionRow( - title: "User Guide", - subtitle: "Read the complete user guide", - icon: "book.fill", - color: .green - ) { - // User guide action - } - } - .background(Color.white) - .cornerRadius(12) - } - - // FAQ Section - VStack(alignment: .leading, spacing: 16) { - Text("Frequently Asked Questions") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - ForEach(getFAQs(for: selectedCategory)) { faq in - FAQRow(faq: faq) - - if faq.id != getFAQs(for: selectedCategory).last?.id { - Divider() - .padding(.leading, 44) - } - } - } - .background(Color.white) - .cornerRadius(12) - } - - // App Information - VStack(alignment: .leading, spacing: 16) { - Text("App Information") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - AppInfoRow(title: "Version", value: "1.0.0") - - Divider() - .padding(.leading, 44) - - AppInfoRow(title: "Build", value: "2025.7.27") - - Divider() - .padding(.leading, 44) - - AppInfoRow(title: "Last Updated", value: "July 27, 2025") - } - .background(Color.white) - .cornerRadius(12) - } - } - .padding(.horizontal, 20) - } - } - .background(Color(.systemGroupedBackground)) - .navigationBarHidden(true) - .sheet(isPresented: $showingContactSupport) { - ContactSupportView() - } - } - - private func getFAQs(for category: HelpCategory) -> [FAQItem] { - switch category { - case .gettingStarted: - return [ - FAQItem(question: "How do I add my first information block?", answer: "Tap the + button on the home screen and select the type of information you want to add."), - FAQItem(question: "What are the different information types?", answer: "There are 5 types: Numbers, Profiles, Files, Phrases, and Others. Each type has its own icon and organization."), - FAQItem(question: "How do I edit information?", answer: "Tap on any information card to open the detail view, then tap the edit button to make changes.") - ] - case .features: - return [ - FAQItem(question: "Can I sync data across devices?", answer: "Yes, enable sync in Privacy settings to keep your data synchronized across all your devices."), - FAQItem(question: "How do I search for information?", answer: "Use the search bar at the top of the home screen to find specific information quickly."), - FAQItem(question: "Can I export my data?", answer: "Yes, go to Privacy settings and tap 'Export Data' to download all your information.") - ] - case .troubleshooting: - return [ - FAQItem(question: "The app is not responding", answer: "Try force-closing the app and reopening it. If the problem persists, restart your device."), - FAQItem(question: "I can't add new information", answer: "Check your internet connection and ensure you have sufficient storage space on your device."), - FAQItem(question: "Data is not syncing", answer: "Verify your internet connection and check the sync settings in Privacy preferences.") - ] - case .devices: - return [ - FAQItem(question: "How do I connect AR glasses?", answer: "Go to Profile > Connected Devices > Add Device and follow the pairing instructions."), - FAQItem(question: "My device won't connect", answer: "Ensure Bluetooth is enabled and the device is in pairing mode. Try restarting both devices."), - FAQItem(question: "Can I use multiple devices?", answer: "Yes, you can connect multiple devices and switch between them in the device settings.") - ] - case .account: - return [ - FAQItem(question: "How do I change my username?", answer: "Go to Profile settings and tap on your username to edit it."), - FAQItem(question: "I forgot my password", answer: "Use the 'Forgot Password' option on the login screen to reset your password."), - FAQItem(question: "How do I delete my account?", answer: "Go to Privacy settings and tap 'Delete All Data' to permanently remove your account.") - ] - } - } -} - -struct HelpCategoryCard: View { - let category: HelpSupportView.HelpCategory - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 12) { - Image(systemName: category.icon) - .font(.system(size: 24)) - .foregroundColor(isSelected ? .white : category.color) - - Text(category.rawValue) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(isSelected ? .white : .primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(isSelected ? category.color : Color(.systemGray6)) - .cornerRadius(8) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct QuickActionRow: View { - let title: String - let subtitle: String - let icon: String - let color: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(color) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text(subtitle) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct FAQItem: Identifiable { - let id = UUID() - let question: String - let answer: String -} - -struct FAQRow: View { - let faq: FAQItem - @State private var isExpanded = false - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { - isExpanded.toggle() - } - }) { - HStack { - Image(systemName: "questionmark.circle.fill") - .font(.system(size: 16)) - .foregroundColor(.primary) - .frame(width: 20) - - Text(faq.question) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - - Spacer() - - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - } - } - .buttonStyle(PlainButtonStyle()) - - if isExpanded { - HStack { - Image(systemName: "text.bubble.fill") - .font(.system(size: 16)) - .foregroundColor(.green) - .frame(width: 20) - - Text(faq.answer) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - - Spacer() - } - .padding(.leading, 24) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } -} - -struct AppInfoRow: View { - let title: String - let value: String - - var body: some View { - HStack { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Spacer() - - Text(value) - .font(.system(size: 14, weight: .regular)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } -} - -struct ContactSupportView: View { - @Environment(\.presentationMode) var presentationMode - @State private var subject = "" - @State private var message = "" - @State private var selectedPriority: Priority = .medium - @State private var includeSystemInfo = true - - enum Priority: String, CaseIterable { - case low = "Low" - case medium = "Medium" - case high = "High" - case urgent = "Urgent" - - var color: Color { - switch self { - case .low: return .green - case .medium: return .primary - case .high: return .orange - case .urgent: return .red - } - } - } - - var body: some View { - NavigationView { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 24) { - // Priority Selection - VStack(alignment: .leading, spacing: 16) { - Text("Priority") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - HStack(spacing: 12) { - ForEach(Priority.allCases, id: \.self) { priority in - PriorityButton( - priority: priority, - isSelected: selectedPriority == priority, - action: { - selectedPriority = priority - } - ) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Subject - VStack(alignment: .leading, spacing: 12) { - Text("Subject") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - TextField("Brief description of your issue", text: $subject) - .textFieldStyle(RoundedBorderTextFieldStyle()) - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Message - VStack(alignment: .leading, spacing: 12) { - Text("Message") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - TextEditor(text: $message) - .frame(minHeight: 120) - .padding(8) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Options - VStack(alignment: .leading, spacing: 16) { - Text("Options") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - Toggle("Include system information", isOn: $includeSystemInfo) - .font(.system(size: 14, weight: .medium)) - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Submit Button - Button(action: { - submitSupportRequest() - }) { - Text("Send Message") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(subject.isEmpty || message.isEmpty ? Color.gray : Color.primary) - .cornerRadius(8) - } - .disabled(subject.isEmpty || message.isEmpty) - .padding(.horizontal, 20) - } - .padding(.horizontal, 20) - .padding(.top, 20) - } - } - .background(Color(.systemGroupedBackground)) - .navigationTitle("Contact Support") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: Button("Cancel") { - presentationMode.wrappedValue.dismiss() - }) - } - } - - private func submitSupportRequest() { - // Handle support request submission - print("Support request submitted: \(subject)") - presentationMode.wrappedValue.dismiss() - } -} - -struct PriorityButton: View { - let priority: ContactSupportView.Priority - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(priority.rawValue) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(isSelected ? .white : priority.color) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(isSelected ? priority.color : priority.color.opacity(0.1)) - .cornerRadius(6) - } - .buttonStyle(PlainButtonStyle()) - } -} - -#Preview { - HelpSupportView() -} \ No newline at end of file diff --git a/D3LibMin/HomeView.swift b/D3LibMin/HomeView.swift deleted file mode 100644 index 22d0437..0000000 --- a/D3LibMin/HomeView.swift +++ /dev/null @@ -1,262 +0,0 @@ -// -// HomeView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -struct HomeView: View { - @StateObject private var dataManager = DataManager() - @State private var selectedFilter: InfoType? = nil - @State private var showingAddOptions = false - - // 为了保持向后兼容,将InfoBlock转换为InfoItem用于显示 - var infoItems: [InfoItem] { - return dataManager.infoBlocks.map { InfoItem(from: $0) } - } - - var filteredItems: [InfoItem] { - let items = infoItems - if let selectedFilter = selectedFilter { - return items.filter { $0.type == selectedFilter } - } - return items - } - - var body: some View { - NavigationView { - ZStack { - // Bottom layer: Main content - VStack(spacing: 0) { - // Header - HStack { - HStack(spacing: 12) { - Image("Library") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 24, height: 24) - - Text("Library") - .font(.spaceGroteskMedium(size: 24)) - .foregroundColor(.primary) - } - - Spacer() - - Button(action: { - dataManager.refreshFromAPI() - }) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 20)) - .foregroundColor(.primary) - .rotationEffect(.degrees(dataManager.isLoading ? 360 : 0)) - .animation(dataManager.isLoading ? .linear(duration: 1).repeatForever(autoreverses: false) : .default, value: dataManager.isLoading) - } - .disabled(dataManager.isLoading) - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 20) - - // Filter Buttons - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(InfoType.allCases, id: \.self) { type in - FilterButton( - title: type.rawValue, - icon: type.systemImage, - isSelected: selectedFilter == type, - action: { - if selectedFilter == type { - selectedFilter = nil - } else { - selectedFilter = type - } - } - ) - } - - // Add custom filter button - FilterButton( - title: "", - icon: "plus", - isSelected: false, - isAddButton: true, - action: { - // TODO: Handle add custom filter - } - ) - } - .padding(.horizontal, 20) - } - .padding(.bottom, 20) - - // Info Cards List - ScrollView { - LazyVStack(spacing: 6) { - ForEach(filteredItems) { item in - NavigationLink(destination: InfoDetailView(item: item)) { - InfoCard(item: item) - } - .buttonStyle(PlainButtonStyle()) - } - } - .padding(.horizontal, 20) - } - - Spacer() - } - .background(Color(.systemGroupedBackground)) - - // Middle layer: Dark overlay when options are shown - if showingAddOptions { - Color.black.opacity(0.3) - .ignoresSafeArea() - .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - showingAddOptions = false - } - } - } - - // Top layer: Add Button and Options - VStack { - Spacer() - - VStack { - // Add Options (shown when showingAddOptions is true) - if showingAddOptions { - HStack(spacing: 20) { - AddOptionButton(icon: "textformat", title: "Text") { - // TODO: Handle text option - showingAddOptions = false - } - - AddOptionButton(icon: "photo", title: "Image") { - // TODO: Handle image option - showingAddOptions = false - } - - AddOptionButton(icon: "doc", title: "File") { - // TODO: Handle file option - showingAddOptions = false - } - } - .padding(.bottom, 20) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - - // Main Add Button - Button(action: { - withAnimation(.easeInOut(duration: 0.3)) { - showingAddOptions.toggle() - } - }) { - Image(systemName: showingAddOptions ? "xmark" : "plus") - .font(.system(size: 24, weight: .medium)) - .foregroundColor(.white) - .frame(width: 68, height: 68) - .background(Color.primary) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.2), radius: 6, x: 0, y: 3) - .rotationEffect(.degrees(showingAddOptions ? 45 : 0)) - } - .padding(.bottom, 30) - } - } - } - } - .alert("Error", isPresented: .constant(dataManager.errorMessage != nil)) { - Button("OK") { - dataManager.errorMessage = nil - } - } message: { - if let errorMessage = dataManager.errorMessage { - Text(errorMessage) - } - } - } -} - -struct AddOptionButton: View { - let icon: String - let title: String - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 8) { - Image(systemName: icon) - .font(.system(size: 20)) - .foregroundColor(.primary) - .frame(width: 44, height: 44) - .background(Color.white) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) - - Text(title) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.primary) - } - } - } -} - -struct FilterButton: View { - let title: String - let icon: String - let isSelected: Bool - let isAddButton: Bool - let action: () -> Void - - init(title: String, icon: String = "", isSelected: Bool, isAddButton: Bool = false, action: @escaping () -> Void) { - self.title = title - self.icon = icon - self.isSelected = isSelected - self.isAddButton = isAddButton - self.action = action - } - - var body: some View { - Button(action: action) { - Image(systemName: icon) - .font(.system(size: 22, weight: .medium)) - .foregroundColor(isSelected ? .white : .primary) - .frame(width: 80, height: 80) - .background( - Circle() - .fill(isSelected ? Color.primary : Color.white) - ) - } - .disabled(isAddButton) // 暂时禁用添加按钮 - .opacity(isAddButton ? 0.6 : 1.0) // 添加按钮显示为半透明 - } -} - -struct InfoCard: View { - let item: InfoItem - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - .multilineTextAlignment(.leading) - - Text(item.subtitle) - .font(.spaceGroteskMedium(size: 14)) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(18) - .background(Color.white) - .cornerRadius(8) - } -} - -#Preview { - HomeView() -} \ No newline at end of file diff --git a/D3LibMin/IMPLEMENTATION_SUMMARY.md b/D3LibMin/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index e70e55f..0000000 --- a/D3LibMin/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,168 +0,0 @@ -# D3LibMin 数据结构重构实现总结 - -## 项目概述 - -本次重构为 D3LibMin 个人信息聚合软件建立了规范的数据结构,将原有的简单信息项模型升级为功能完整的信息块系统,为未来的数据库集成和API连接做好了准备。 - -## 完成的工作 - -### 1. 核心数据结构设计 - -#### 新增的数据模型 - -1. **InfoBlock (信息块)** - 核心数据结构 - - 唯一编号 (UUID) - - 生成时间 (createdAt) - - 过期时间 (expiresAt) - 可选 - - 主标题 (title) - - 副标题 (subtitle) - - 内容类别 (type) - InfoType 枚举 - - 使用场景 (needs) - UsageNeed 数组 - - 所有内容 (infos) - InfoContent 数组 - -2. **InfoContent (信息内容)** - 具体内容项 - - 名称 (name) - - 具体内容 (content) - - 内容类型 (type) - ContentType 枚举 (text/file) - -3. **UsageNeed (使用场景)** - 使用目的描述 - - 场景名称 (name) - - 场景描述 (description) - 可选 - -4. **ContentType (内容类型)** - 新增枚举 - - text: 文本内容 - - file: 文件内容(链接形式) - -5. **InfoType 扩展** - 新增 "Others" 类别 - - Numbers: 号码类信息 - - Profiles: 身份类信息 - - Files: 文件类信息 - - Phrases: 碎片类信息 - - Others: 其他类型信息 - -### 2. 数据管理架构 - -#### DataManager 类 -- 实现了完整的 CRUD 操作 -- 支持数据搜索和过滤 -- 为 API 集成预留了接口 -- 包含错误处理和加载状态管理 -- 支持过期信息管理 - -#### 主要功能 -- `addInfoBlock(_:)` - 添加信息块 -- `updateInfoBlock(_:)` - 更新信息块 -- `deleteInfoBlock(_:)` - 删除信息块 -- `searchInfoBlocks(query:)` - 搜索信息块 -- `getExpiredInfoBlocks()` - 获取过期信息块 -- `getExpiringSoonInfoBlocks(within:)` - 获取即将过期的信息块 - -### 3. 用户界面改进 - -#### InfoDetailView 重构 -1. **双输入框设计** - - 第一个输入框:内容名称 - - 第二个输入框:具体内容 - - 并排布局,提升用户体验 - -2. **智能光标跳转** - - 名称输入框回车 → 跳转到内容输入框 - - 内容输入框回车 → 保存当前条目并跳转到下一个名称输入框 - - 实现流畅的数据输入体验 - -3. **数据验证** - - 只有当名称和内容都不为空时才能添加条目 - - 防止无效数据的产生 - -### 4. 向后兼容性 - -#### InfoItem 兼容层 -- 保留了原有的 `InfoItem` 结构 -- 提供了 `InfoItem(from:)` 构造器 -- 提供了 `toInfoBlock()` 转换方法 -- 确保现有功能不受影响 - -#### 数据转换机制 -```swift -// 从 InfoBlock 创建 InfoItem -let infoItem = InfoItem(from: infoBlock) - -// 从 InfoItem 转换为 InfoBlock -let infoBlock = infoItem.toInfoBlock() -``` - -### 5. 示例数据 - -#### 丰富的示例数据 -- 酒店房间信息块(包含房间号、入住日期等) -- 包裹取件信息块(包含取件码、位置等) -- 联系人信息块(包含姓名、电话、邮箱等) -- 文件信息块(包含文件名、项目、类型等) -- 会议笔记信息块(包含会议类型、日期、要点等) - -每个示例都展示了完整的数据结构,包括使用场景和具体内容。 - -## 技术实现亮点 - -### 1. 架构设计 -- **MVVM 模式**: 使用 SwiftUI 的 MVVM 架构 -- **ObservableObject**: DataManager 实现 ObservableObject 协议 -- **Combine 框架**: 使用 Combine 进行数据绑定 - -### 2. 用户体验优化 -- **智能输入**: 自动光标跳转提升输入效率 -- **数据验证**: 防止无效数据输入 -- **加载状态**: 显示数据加载和刷新状态 -- **错误处理**: 完善的错误提示机制 - -### 3. 性能优化 -- **懒加载**: 使用 LazyVStack 优化列表性能 -- **内存管理**: 合理使用 @StateObject 和 @ObservedObject -- **异步处理**: 使用 DispatchQueue 处理耗时操作 - -## 未来扩展准备 - -### 1. API 集成 -- 所有 CRUD 操作都有对应的 API 调用占位符 -- 支持异步数据加载和错误处理 -- 提供了数据刷新机制 - -### 2. 高级功能预留 -- 智能分类和搜索 -- 过期提醒功能 -- 数据导出功能 -- 多设备同步 - -### 3. 数据持久化 -- UserDefaults 本地存储 -- Core Data 复杂关系管理(未来) -- CloudKit 云端同步(未来) - -## 文件结构 - -``` -D3LibMin/ -├── Models.swift # 数据模型定义 -├── DataManager.swift # 数据管理类 -├── HomeView.swift # 主页视图(已更新) -├── InfoDetailView.swift # 详情视图(已重构) -├── README_DataStructure.md # 数据结构文档 -└── IMPLEMENTATION_SUMMARY.md # 实现总结文档 -``` - -## 编译状态 - -✅ **编译成功** - 项目已通过 Xcode 编译验证,所有新功能正常工作。 - -## 总结 - -本次重构成功建立了规范的数据结构,提升了代码的可维护性和扩展性。新的信息块系统为未来的功能扩展和数据库集成奠定了坚实的基础,同时保持了良好的用户体验和向后兼容性。 - -主要成果: -1. 建立了完整的信息块数据结构 -2. 实现了智能的数据输入界面 -3. 创建了可扩展的数据管理架构 -4. 保持了向后兼容性 -5. 为未来 API 集成做好了准备 - -项目现在具备了处理复杂信息管理需求的能力,为后续的功能扩展和数据库集成提供了强有力的支持。 \ No newline at end of file diff --git a/D3LibMin/ImageWithBorderView.swift b/D3LibMin/ImageWithBorderView.swift deleted file mode 100644 index d694052..0000000 --- a/D3LibMin/ImageWithBorderView.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// ImageWithBorderView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -struct ImageWithBorderView: View { - let image: UIImage - @Binding var showLatestImage: Bool - @State private var offsetY: CGFloat = 0 - @State private var opacity: Double = 1 - @State private var scale: CGFloat = 1 - - private var maxWidth: CGFloat { UIScreen.main.bounds.width * 0.6 } - private var maxHeight: CGFloat { UIScreen.main.bounds.height * 0.5 } - - private var imageAspectRatio: CGFloat { image.size.width / image.size.height } - private var containerAspectRatio: CGFloat { maxWidth / maxHeight } - - private var actualSize: CGSize { - if imageAspectRatio > containerAspectRatio { - // 图片更宽,以宽度为准 - let width = maxWidth - let height = maxWidth / imageAspectRatio - return CGSize(width: width, height: height) - } else { - // 图片更高,以高度为准 - let height = maxHeight - let width = maxHeight * imageAspectRatio - return CGSize(width: width, height: height) - } - } - - var body: some View { - VStack { - ZStack { - // 图片本体 - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: actualSize.width, height: actualSize.height) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) - - // 边框 - 使用计算出的实际尺寸 - RoundedRectangle(cornerRadius: 16) - .stroke(Color.white, lineWidth: 4) - .frame(width: actualSize.width, height: actualSize.height) - } - - Spacer() - } - .offset(y: offsetY) - .opacity(opacity) - .scaleEffect(scale) - .onAppear { - // 淡入动画 - opacity = 0 - withAnimation(.easeIn(duration: 0.2)) { - opacity = 1 - } - - // 0.8秒后自动开始飞出动画 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - withAnimation(.easeIn(duration: 0.4)) { - offsetY = -400 - opacity = 0 - scale = 0.8 - } - } - } - } -} - -#Preview { - ImageWithBorderView( - image: UIImage(systemName: "photo") ?? UIImage(), - showLatestImage: .constant(true) - ) -} \ No newline at end of file diff --git a/D3LibMin/InfoDetailView.swift b/D3LibMin/InfoDetailView.swift deleted file mode 100644 index 437a681..0000000 --- a/D3LibMin/InfoDetailView.swift +++ /dev/null @@ -1,329 +0,0 @@ -// -// InfoDetailView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -struct InfoDetailView: View { - @Environment(\.presentationMode) var presentationMode - @State var item: InfoItem - @State private var showingDeleteAlert = false - @State private var editingDetails: [String] = [] - @State private var newDetailName = "" - @State private var newDetailContent = "" - @State private var isEditing = false - @FocusState private var isNameFieldFocused: Bool - @FocusState private var isContentFieldFocused: Bool - - private func addNewDetail() { - if !newDetailName.isEmpty && !newDetailContent.isEmpty { - let detailText = "\(newDetailName): \(newDetailContent)" - editingDetails.append(detailText) - newDetailName = "" - newDetailContent = "" - // 重新聚焦到名称输入框,方便连续添加 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isNameFieldFocused = true - } - } - } - - private func handleNameFieldSubmit() { - // 当名称字段换行时,跳转到内容字段 - if !newDetailName.isEmpty { - isContentFieldFocused = true - } - } - - private func handleContentFieldSubmit() { - // 当内容字段换行时,保存当前条目并跳转到下一个名称字段 - addNewDetail() - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "arrow.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - - Spacer() - - Text(item.type.rawValue) - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.primary) - - Spacer() - - // Menu button - Menu { - Button(action: { - isEditing.toggle() - if isEditing { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isNameFieldFocused = true - } - } - }) { - Label("Edit", systemImage: "pencil") - } - - Button(role: .destructive, action: { - showingDeleteAlert = true - }) { - Label("Delete", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 30) - - ScrollView { - VStack(spacing: 20) { - // Main Info Card - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: item.type.systemImage) - .font(.system(size: 20)) - .foregroundColor(.primary) - - Spacer() - - if isEditing { - Button(action: { - showingDeleteAlert = true - }) { - Image(systemName: "trash") - .font(.system(size: 16)) - .foregroundColor(.red) - } - } - } - - Text(item.title) - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - - Text(item.subtitle) - .font(.spaceGroteskMedium(size: 14)) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Details Section - if !item.details.isEmpty || isEditing { - VStack(alignment: .leading, spacing: 16) { - Text("Essential Information") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.primary) - - ForEach(Array(editingDetails.enumerated()), id: \.offset) { index, detail in - DetailRow( - text: detail, - isEditing: isEditing, - onDelete: { - editingDetails.remove(at: index) - }, - onMove: { from, to in - editingDetails.move(fromOffsets: IndexSet([from]), toOffset: to) - } - ) - } - - if isEditing { - // Add new detail with two input fields - VStack(spacing: 8) { - HStack(spacing: 8) { - TextField("Name", text: $newDetailName) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .focused($isNameFieldFocused) - .onSubmit { - handleNameFieldSubmit() - } - - TextField("Content", text: $newDetailContent) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .focused($isContentFieldFocused) - .onSubmit { - handleContentFieldSubmit() - } - } - - HStack { - Spacer() - - Button(action: { - addNewDetail() - }) { - HStack(spacing: 4) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 16)) - Text("Add") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.primary) - } - .disabled(newDetailName.isEmpty || newDetailContent.isEmpty) - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(20) - .background(Color.white) - .cornerRadius(12) - } - - // Save/Cancel buttons when editing - if isEditing { - HStack(spacing: 12) { - Button(action: { - // Cancel editing - editingDetails = item.details - newDetailName = "" - newDetailContent = "" - isEditing = false - }) { - Text("Cancel") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - Button(action: { - // Save changes - item.details = editingDetails - isEditing = false - }) { - Text("Save") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.primary) - .cornerRadius(8) - } - } - .padding(.horizontal, 20) - } - } - .padding(.horizontal, 20) - } - - Spacer() - - // Floating Add Button (when not editing) - if !isEditing { - HStack { - Spacer() - - Button(action: { - isEditing = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isNameFieldFocused = true - } - }) { - Image(systemName: "plus") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.white) - .frame(width: 56, height: 56) - .background(Color.primary) - .clipShape(Circle()) - .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) - } - .padding(.trailing, 20) - .padding(.bottom, 20) - } - } - } - .background(Color(.systemGroupedBackground)) - .navigationBarHidden(true) - .onAppear { - editingDetails = item.details - } - .alert("Delete Item", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { - presentationMode.wrappedValue.dismiss() - } - } message: { - Text("Are you sure you want to delete this item? This action cannot be undone.") - } - } -} - -struct DetailRow: View { - let text: String - let isEditing: Bool - let onDelete: () -> Void - let onMove: (Int, Int) -> Void - - var body: some View { - HStack { - if isEditing { - Image(systemName: "line.horizontal.3") - .font(.system(size: 14)) - .foregroundColor(.secondary) - } - - HStack { - if text.contains("@") { - // Email indicator - Circle() - .fill(Color.green) - .frame(width: 8, height: 8) - } else { - Circle() - .fill(Color(.systemGray4)) - .frame(width: 8, height: 8) - } - - Text(text) - .font(.system(size: 14, weight: .regular)) - .foregroundColor(.primary) - - Spacer() - } - .padding(.vertical, 12) - .padding(.horizontal, 16) - .background(Color(.systemGray6)) - .cornerRadius(8) - - if isEditing { - Button(action: onDelete) { - Image(systemName: "trash") - .font(.system(size: 14)) - .foregroundColor(.red) - } - } - } - } -} - -#Preview { - InfoDetailView(item: InfoItem( - title: "605-1-3064", - subtitle: "Package pick up", - type: .numbers, - details: ["Complete project proposal", "Schedule team meeting", "Review quarterly reports", "Email client updates"] - )) -} \ No newline at end of file diff --git a/D3LibMin/Models.swift b/D3LibMin/Models.swift deleted file mode 100644 index e389017..0000000 --- a/D3LibMin/Models.swift +++ /dev/null @@ -1,156 +0,0 @@ -// -// Models.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import Foundation - -// 信息类型枚举 -enum InfoType: String, CaseIterable, Codable { - case numbers = "Numbers" - case profiles = "Profiles" - case files = "Files" - case phrases = "Phrases" - case others = "Others" - - var systemImage: String { - switch self { - case .numbers: return "number.circle" - case .profiles: return "person.circle" - case .files: return "doc.circle" - case .phrases: return "quote.bubble" - case .others: return "ellipsis.circle" - } - } -} - -// 内容类型枚举 -enum ContentType: String, CaseIterable, Codable { - case text = "text" - case file = "file" -} - -// 信息内容项 -struct InfoContent: Identifiable, Codable { - let id = UUID() - var name: String - var content: String - var type: ContentType - - init(name: String, content: String, type: ContentType = .text) { - self.name = name - self.content = content - self.type = type - } -} - -// 使用场景 -struct UsageNeed: Identifiable, Codable { - let id = UUID() - var name: String - var description: String? - - init(name: String, description: String? = nil) { - self.name = name - self.description = description - } -} - -// 信息块模型 - 主要数据结构 -struct InfoBlock: Identifiable, Codable { - let id = UUID() - var title: String - var subtitle: String - var type: InfoType - var createdAt: Date - var expiresAt: Date? - var needs: [UsageNeed] - var infos: [InfoContent] - - init(title: String, subtitle: String, type: InfoType, createdAt: Date = Date(), expiresAt: Date? = nil, needs: [UsageNeed] = [], infos: [InfoContent] = []) { - self.title = title - self.subtitle = subtitle - self.type = type - self.createdAt = createdAt - self.expiresAt = expiresAt - self.needs = needs - self.infos = infos - } -} - -// 兼容性模型 - 用于过渡期间 -struct InfoItem: Identifiable, Codable { - let id = UUID() - var title: String - var subtitle: String - var type: InfoType - var details: [String] = [] - var createdAt: Date = Date() - - init(title: String, subtitle: String, type: InfoType, details: [String] = []) { - self.title = title - self.subtitle = subtitle - self.type = type - self.details = details - } - - // 从InfoBlock创建InfoItem的便利构造器 - init(from infoBlock: InfoBlock) { - self.title = infoBlock.title - self.subtitle = infoBlock.subtitle - self.type = infoBlock.type - self.createdAt = infoBlock.createdAt - // 将InfoContent转换为details字符串数组 - self.details = infoBlock.infos.map { "\($0.name): \($0.content)" } - } - - // 转换为InfoBlock的方法 - func toInfoBlock() -> InfoBlock { - let infos = details.map { detail in - let components = detail.split(separator: ":", maxSplits: 1).map(String.init) - if components.count == 2 { - return InfoContent(name: components[0].trimmingCharacters(in: .whitespaces), - content: components[1].trimmingCharacters(in: .whitespaces)) - } else { - return InfoContent(name: "Info", content: detail) - } - } - - return InfoBlock( - title: title, - subtitle: subtitle, - type: type, - createdAt: createdAt, - needs: [], - infos: infos - ) - } -} - -// 用户设备模型 -struct ConnectedDevice: Identifiable { - let id = UUID() - var name: String - var isConnected: Bool - var lastConnected: Date? - var systemImage: String -} - -// 用户模型 -struct UserProfile { - var name: String = "Jackson Guo" - var username: String = "@jacksonguo" - var avatar: String = "person.crop.circle.fill" - var memoryUsed: Double = 2.4 - var memoryTotal: Double = 5.0 - var connectedDevices: [ConnectedDevice] = [ - ConnectedDevice(name: "Rokid AR Pro", isConnected: true, lastConnected: Date(), systemImage: "eyeglasses"), - ConnectedDevice(name: "Even Realities G1", isConnected: false, lastConnected: Calendar.current.date(byAdding: .day, value: -3, to: Date()), systemImage: "eyeglasses") - ] - - var memoryUsagePercentage: Double { - return (memoryUsed / memoryTotal) * 100 - } -} \ No newline at end of file diff --git a/D3LibMin/NAVIGATION_FIX_SUMMARY.md b/D3LibMin/NAVIGATION_FIX_SUMMARY.md deleted file mode 100644 index b4d0fe5..0000000 --- a/D3LibMin/NAVIGATION_FIX_SUMMARY.md +++ /dev/null @@ -1,72 +0,0 @@ -# Profile页面导航修复总结 - -## 问题描述 - -在Profile页面点击"Add Device"、"Notifications"、"Privacy"、"Help & Support"按钮时,无法进入对应的子页面。 - -## 问题原因 - -UserProfileView被直接放在TabView中,但没有被包装在NavigationView中。NavigationLink需要NavigationView才能正常工作。 - -## 修复方案 - -### 修改前 -```swift -UserProfileView() - .tabItem { - Image(systemName: "person") - Text("Profile") - } -``` - -### 修改后 -```swift -NavigationView { - UserProfileView() -} -.navigationViewStyle(StackNavigationViewStyle()) -.tabItem { - Image(systemName: "person") - Text("Profile") -} -``` - -## 技术细节 - -### 1. NavigationView包装 -- 将UserProfileView包装在NavigationView中 -- 使NavigationLink能够正常工作 - -### 2. 导航样式设置 -- 使用`.navigationViewStyle(StackNavigationViewStyle())` -- 确保在iPhone上使用标准的导航栈样式 - -### 3. 保持TabView结构 -- TabView结构保持不变 -- 只修改Profile标签页的内容 - -## 修复效果 - -✅ **Add Device按钮** - 现在可以正常导航到AddDeviceView -✅ **Notifications按钮** - 现在可以正常导航到NotificationsView -✅ **Privacy按钮** - 现在可以正常导航到PrivacyView -✅ **Help & Support按钮** - 现在可以正常导航到HelpSupportView - -## 验证结果 - -- 项目编译成功 -- 导航功能正常工作 -- 所有二级页面都可以正常访问 - -## 相关文件 - -- `ContentView.swift` - 主要修改文件 -- `UserProfileView.swift` - 包含NavigationLink的视图 -- `AddDeviceView.swift` - 目标页面之一 -- `NotificationsView.swift` - 目标页面之一 -- `PrivacyView.swift` - 目标页面之一 -- `HelpSupportView.swift` - 目标页面之一 - -## 总结 - -通过将UserProfileView包装在NavigationView中,成功解决了Profile页面导航问题。现在用户可以正常点击各个按钮进入对应的功能页面,享受完整的应用体验。 \ No newline at end of file diff --git a/D3LibMin/NotificationsView.swift b/D3LibMin/NotificationsView.swift deleted file mode 100644 index ef0fc29..0000000 --- a/D3LibMin/NotificationsView.swift +++ /dev/null @@ -1,356 +0,0 @@ -// -// NotificationsView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/27. -// - -import SwiftUI - -struct NotificationsView: View { - @Environment(\.presentationMode) var presentationMode - @State private var notificationSettings = NotificationSettings() - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - - Spacer() - - Text("Notifications") - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - - Spacer() - - // Placeholder for balance - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.clear) - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 20) - - ScrollView { - VStack(spacing: 24) { - // General Notifications - VStack(alignment: .leading, spacing: 16) { - Text("General") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - NotificationToggleRow( - title: "Push Notifications", - subtitle: "Receive notifications on your device", - icon: "bell.fill", - isOn: $notificationSettings.pushNotifications - ) - - Divider() - .padding(.leading, 44) - - NotificationToggleRow( - title: "Sound", - subtitle: "Play sound for notifications", - icon: "speaker.wave.2.fill", - isOn: $notificationSettings.sound - ) - - Divider() - .padding(.leading, 44) - - NotificationToggleRow( - title: "Vibration", - subtitle: "Vibrate for notifications", - icon: "iphone.radiowaves.left.and.right", - isOn: $notificationSettings.vibration - ) - } - .background(Color.white) - .cornerRadius(12) - } - - // App Notifications - VStack(alignment: .leading, spacing: 16) { - Text("App Notifications") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - NotificationToggleRow( - title: "New Information", - subtitle: "When new information is added", - icon: "plus.circle.fill", - isOn: $notificationSettings.newInformation - ) - - Divider() - .padding(.leading, 44) - - NotificationToggleRow( - title: "Information Updates", - subtitle: "When existing information is updated", - icon: "arrow.clockwise.circle.fill", - isOn: $notificationSettings.informationUpdates - ) - - Divider() - .padding(.leading, 44) - - NotificationToggleRow( - title: "Expiration Reminders", - subtitle: "Before information expires", - icon: "clock.fill", - isOn: $notificationSettings.expirationReminders - ) - - Divider() - .padding(.leading, 44) - - NotificationToggleRow( - title: "Device Connection", - subtitle: "When devices connect or disconnect", - icon: "antenna.radiowaves.left.and.right", - isOn: $notificationSettings.deviceConnection - ) - } - .background(Color.white) - .cornerRadius(12) - } - - // Notification Schedule - VStack(alignment: .leading, spacing: 16) { - Text("Notification Schedule") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - NotificationToggleRow( - title: "Quiet Hours", - subtitle: "Silence notifications during quiet hours", - icon: "moon.fill", - isOn: $notificationSettings.quietHours - ) - - if notificationSettings.quietHours { - VStack(spacing: 12) { - Divider() - .padding(.leading, 44) - - HStack { - Image(systemName: "clock.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text("Quiet Hours") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text("\(notificationSettings.quietStartTime) - \(notificationSettings.quietEndTime)") - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Button(action: { - // Edit quiet hours - }) { - Text("Edit") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.primary) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - } - } - .background(Color.white) - .cornerRadius(12) - } - - // Notification History - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Recent Notifications") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - Spacer() - - Button(action: { - // View all notifications - }) { - Text("View All") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - } - } - - VStack(spacing: 0) { - ForEach(notificationSettings.recentNotifications) { notification in - NotificationHistoryRow(notification: notification) - - if notification.id != notificationSettings.recentNotifications.last?.id { - Divider() - .padding(.leading, 44) - } - } - } - .background(Color.white) - .cornerRadius(12) - } - } - .padding(.horizontal, 20) - } - } - .background(Color(.systemGroupedBackground)) - .navigationBarHidden(true) - } -} - -struct NotificationToggleRow: View { - let title: String - let subtitle: String - let icon: String - @Binding var isOn: Bool - - var body: some View { - HStack { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text(subtitle) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Toggle("", isOn: $isOn) - .toggleStyle(SwitchToggleStyle(tint: .primary)) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } -} - -struct NotificationHistoryRow: View { - let notification: NotificationItem - - var body: some View { - HStack { - Image(systemName: notification.icon) - .font(.system(size: 16)) - .foregroundColor(notification.color) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(notification.title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text(notification.message) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - .lineLimit(2) - - Text(notification.timeAgo) - .font(.system(size: 11, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - if !notification.isRead { - Circle() - .fill(Color.primary) - .frame(width: 8, height: 8) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } -} - -struct NotificationItem: Identifiable { - let id = UUID() - let title: String - let message: String - let icon: String - let color: Color - let timeAgo: String - let isRead: Bool -} - -struct NotificationSettings { - var pushNotifications = true - var sound = true - var vibration = true - var newInformation = true - var informationUpdates = false - var expirationReminders = true - var deviceConnection = true - var quietHours = false - var quietStartTime = "10:00 PM" - var quietEndTime = "8:00 AM" - - var recentNotifications: [NotificationItem] = [ - NotificationItem( - title: "New Information Added", - message: "Room 6836 information has been added to your library", - icon: "plus.circle.fill", - color: .green, - timeAgo: "2 minutes ago", - isRead: false - ), - NotificationItem( - title: "Device Connected", - message: "Rokid AR Pro is now connected", - icon: "antenna.radiowaves.left.and.right", - color: .blue, - timeAgo: "1 hour ago", - isRead: true - ), - NotificationItem( - title: "Information Updated", - message: "605-1-3064 information has been updated", - icon: "arrow.clockwise.circle.fill", - color: .orange, - timeAgo: "3 hours ago", - isRead: true - ), - NotificationItem( - title: "Expiration Reminder", - message: "Package pick up information expires in 2 days", - icon: "clock.fill", - color: .red, - timeAgo: "1 day ago", - isRead: true - ) - ] -} - -#Preview { - NotificationsView() -} \ No newline at end of file diff --git a/D3LibMin/PROFILE_SECONDARY_PAGES_SUMMARY.md b/D3LibMin/PROFILE_SECONDARY_PAGES_SUMMARY.md deleted file mode 100644 index 85d7ba0..0000000 --- a/D3LibMin/PROFILE_SECONDARY_PAGES_SUMMARY.md +++ /dev/null @@ -1,144 +0,0 @@ -# Profile 二级页面实现总结 - -## 概述 - -为 D3LibMin 应用的 Profile 页面创建了四个功能完整的二级页面,每个页面都采用了现代化的设计风格,与主应用保持一致的用户体验。 - -## 页面列表 - -### 1. AddDeviceView - 添加设备页面 - -**功能特性:** -- 设备类型选择(AR眼镜、VR头显、移动设备、桌面电脑) -- 设备搜索和扫描功能 -- 发现的设备列表显示 -- 信号强度指示器 -- 手动连接选项(手动输入、QR码) -- 设备配对状态显示 - -**主要组件:** -- `DeviceTypeCard` - 设备类型选择卡片 -- `DiscoveredDeviceRow` - 发现的设备行 -- 模拟设备发现功能 - -**设计亮点:** -- 网格布局的设备类型选择 -- 实时信号强度可视化 -- 扫描动画效果 - -### 2. NotificationsView - 通知设置页面 - -**功能特性:** -- 通用通知设置(推送、声音、震动) -- 应用特定通知(新信息、更新、过期提醒、设备连接) -- 通知时间安排(静音时段) -- 最近通知历史 -- 通知状态指示器 - -**主要组件:** -- `NotificationToggleRow` - 通知开关行 -- `NotificationHistoryRow` - 通知历史行 -- 静音时段配置 - -**设计亮点:** -- 分类清晰的通知设置 -- 实时通知状态显示 -- 可展开的静音时段配置 - -### 3. PrivacyView - 隐私设置页面 - -**功能特性:** -- 数据收集设置(分析、崩溃报告、设备信息) -- 数据共享设置(跨设备同步、云备份、使用统计) -- 安全设置(生物识别、自动锁定、数据加密) -- 数据管理(导出、清除缓存、删除数据) -- 法律文档链接 - -**主要组件:** -- `PrivacyToggleRow` - 隐私开关行 -- `DataManagementRow` - 数据管理行 -- `PrivacyLinkRow` - 隐私链接行 -- `DataExportView` - 数据导出视图 - -**设计亮点:** -- 全面的隐私控制选项 -- 数据导出进度显示 -- 危险操作的确认对话框 - -### 4. HelpSupportView - 帮助支持页面 - -**功能特性:** -- 帮助搜索功能 -- 分类帮助(入门指南、功能、故障排除、设备、账户) -- 快速操作(联系支持、报告问题、功能请求、用户指南) -- 常见问题解答(FAQ) -- 应用信息显示 -- 联系支持表单 - -**主要组件:** -- `HelpCategoryCard` - 帮助分类卡片 -- `QuickActionRow` - 快速操作行 -- `FAQRow` - FAQ行(可展开) -- `ContactSupportView` - 联系支持表单 -- `PriorityButton` - 优先级按钮 - -**设计亮点:** -- 分类清晰的帮助系统 -- 可展开的FAQ设计 -- 完整的支持联系流程 - -## 技术实现 - -### 导航系统 -- 使用 `NavigationLink` 实现页面导航 -- 统一的返回按钮设计 -- 自定义标题栏布局 - -### 字体一致性 -- 所有页面标题使用 `SpaceGrotesk-Medium` 字体 -- 保持与应用其他部分的字体一致性 - -### 设计系统 -- 统一的卡片设计(白色背景,12pt圆角) -- 一致的颜色方案 -- 标准化的间距和布局 - -### 交互设计 -- 开关控件用于设置项 -- 按钮状态反馈 -- 加载动画和进度指示器 - -## 文件结构 - -``` -D3LibMin/ -├── AddDeviceView.swift # 添加设备页面 -├── NotificationsView.swift # 通知设置页面 -├── PrivacyView.swift # 隐私设置页面 -├── HelpSupportView.swift # 帮助支持页面 -└── UserProfileView.swift # 主Profile页面(已更新) -``` - -## 用户体验特点 - -1. **一致性** - 所有页面遵循相同的设计语言和交互模式 -2. **可访问性** - 清晰的视觉层次和直观的导航 -3. **功能性** - 每个页面都提供完整的功能实现 -4. **现代化** - 采用最新的iOS设计规范 -5. **响应式** - 适配不同屏幕尺寸 - -## 未来扩展 - -这些页面为未来的功能扩展提供了良好的基础: -- 可以轻松添加新的设置选项 -- 支持更复杂的设备管理功能 -- 可扩展的通知系统 -- 增强的隐私控制选项 -- 更丰富的帮助和支持内容 - -## 编译状态 - -✅ 所有页面已成功编译并通过测试 -✅ 与主应用完全集成 -✅ 导航功能正常工作 -✅ 字体和设计保持一致 \ No newline at end of file diff --git a/D3LibMin/PixelWaveAnimationView.swift b/D3LibMin/PixelWaveAnimationView.swift deleted file mode 100644 index 0d04b47..0000000 --- a/D3LibMin/PixelWaveAnimationView.swift +++ /dev/null @@ -1,160 +0,0 @@ -// -// PixelWaveAnimationView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/26. -// - -import SwiftUI - -struct PixelWaveAnimationView: View { - @State private var waveRadius: CGFloat = 30 - @State private var isAnimating = false - @State private var animationTimer: Timer? - - let squareSize: CGFloat = 20 - let originX: CGFloat - let originY: CGFloat - let maxRadius: CGFloat - - init(centerPosition: CGPoint? = nil) { - let screenWidth = UIScreen.main.bounds.width - let screenHeight = UIScreen.main.bounds.height - - let originX: CGFloat - let originY: CGFloat - - if let centerPosition = centerPosition { - // 使用自定义位置 - originX = centerPosition.x - originY = centerPosition.y - } else { - // 默认位置:距左边缘约2.5个单位,距下边缘约4个单位 - originX = 2.5 * squareSize - originY = screenHeight - 4 * squareSize - } - - self.originX = originX - self.originY = originY - - // 计算到屏幕边缘的最大距离 - let corners = [ - CGPoint(x: 0, y: 0), - CGPoint(x: screenWidth, y: 0), - CGPoint(x: 0, y: screenHeight), - CGPoint(x: screenWidth, y: screenHeight) - ] - - let distances = corners.map { corner in - sqrt(pow(corner.x - originX, 2) + pow(corner.y - originY, 2)) - } - - // 增加20%的安全边距,确保波纹完全覆盖屏幕 - self.maxRadius = (distances.max() ?? 1000) * 1.2 - - print("波纹起始点: (\(originX), \(originY))") - print("最大半径: \(self.maxRadius)") - } - - var body: some View { - GeometryReader { geometry in - let screenWidth = geometry.size.width - let screenHeight = geometry.size.height - let cols = Int(ceil(screenWidth / squareSize)) - let rows = Int(ceil(screenHeight / squareSize)) - - ZStack { - Color.clear - .ignoresSafeArea() - - ForEach(0.. Double { - let waveWidth: CGFloat = 100 // 波纹环的宽度(像素) - let distanceFromWave = abs(distance - waveRadius) - - // 移除调试信息以减少控制台输出 - - if distanceFromWave <= waveWidth/2 { - // 在波纹环范围内 - let normalizedDistance = distanceFromWave / (waveWidth/2) - let intensity = max(0.1, 1.0 - normalizedDistance) // 简化的线性衰减 - return intensity * 0.8 - } - - return 0 - } - - private func squareScale(for distance: CGFloat) -> Double { - let waveWidth: CGFloat = 100 - let distanceFromWave = abs(distance - waveRadius) - - if distanceFromWave <= waveWidth/2 { - let normalizedDistance = distanceFromWave / (waveWidth/2) - let intensity = max(0.1, 1.0 - normalizedDistance) - return 0.6 + intensity * 0.4 - } - - return 0.6 - } - - private func startAnimation() { - isAnimating = true - print("开始像素波纹动画,最大半径: \(maxRadius)") - print("初始波纹半径: \(waveRadius)") - - let animationDuration: TimeInterval = 1.25 - let frameRate: TimeInterval = 1.0/60.0 // 60 FPS - let totalFrames = Int(animationDuration / frameRate) - let radiusIncrement = (maxRadius - 30) / CGFloat(totalFrames) - - var currentFrame = 0 - - animationTimer = Timer.scheduledTimer(withTimeInterval: frameRate, repeats: true) { timer in - currentFrame += 1 - self.waveRadius = 30 + CGFloat(currentFrame) * radiusIncrement - - // 每30帧打印一次调试信息 - if currentFrame % 30 == 0 { - print("动画进度: \(currentFrame)/\(totalFrames), 当前半径: \(self.waveRadius)") - } - - if currentFrame >= totalFrames { - timer.invalidate() - self.animationTimer = nil - self.isAnimating = false - print("像素波纹动画结束,最终半径: \(self.waveRadius)") - } - } - } -} - -#Preview { - PixelWaveAnimationView() - .background(Color.black) -} \ No newline at end of file diff --git a/D3LibMin/PrivacyView.swift b/D3LibMin/PrivacyView.swift deleted file mode 100644 index 4337e1a..0000000 --- a/D3LibMin/PrivacyView.swift +++ /dev/null @@ -1,485 +0,0 @@ -// -// PrivacyView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/27. -// - -import SwiftUI - -struct PrivacyView: View { - @Environment(\.presentationMode) var presentationMode - @State private var privacySettings = PrivacySettings() - @State private var showingDataExport = false - @State private var showingDeleteAccount = false - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - - Spacer() - - Text("Privacy") - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - - Spacer() - - // Placeholder for balance - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.clear) - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 20) - - ScrollView { - VStack(spacing: 24) { - // Data Collection - VStack(alignment: .leading, spacing: 16) { - Text("Data Collection") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - PrivacyToggleRow( - title: "Analytics", - subtitle: "Help improve the app by sharing usage data", - icon: "chart.bar.fill", - isOn: $privacySettings.analytics - ) - - Divider() - .padding(.leading, 44) - - PrivacyToggleRow( - title: "Crash Reports", - subtitle: "Send crash reports to help fix issues", - icon: "exclamationmark.triangle.fill", - isOn: $privacySettings.crashReports - ) - - Divider() - .padding(.leading, 44) - - PrivacyToggleRow( - title: "Device Information", - subtitle: "Share device type and OS version", - icon: "iphone", - isOn: $privacySettings.deviceInformation - ) - } - .background(Color.white) - .cornerRadius(12) - } - - // Data Sharing - VStack(alignment: .leading, spacing: 16) { - Text("Data Sharing") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - PrivacyToggleRow( - title: "Sync Across Devices", - subtitle: "Keep your data synchronized", - icon: "icloud.fill", - isOn: $privacySettings.syncAcrossDevices - ) - - Divider() - .padding(.leading, 44) - - PrivacyToggleRow( - title: "Backup to Cloud", - subtitle: "Automatically backup your data", - icon: "externaldrive.fill", - isOn: $privacySettings.backupToCloud - ) - - Divider() - .padding(.leading, 44) - - PrivacyToggleRow( - title: "Share Usage Data", - subtitle: "Share anonymous usage statistics", - icon: "chart.pie.fill", - isOn: $privacySettings.shareUsageData - ) - } - .background(Color.white) - .cornerRadius(12) - } - - // Security - VStack(alignment: .leading, spacing: 16) { - Text("Security") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - PrivacyToggleRow( - title: "Biometric Authentication", - subtitle: "Use Face ID or Touch ID to unlock", - icon: "faceid", - isOn: $privacySettings.biometricAuthentication - ) - - Divider() - .padding(.leading, 44) - - PrivacyToggleRow( - title: "Auto-Lock", - subtitle: "Lock app when not in use", - icon: "lock.fill", - isOn: $privacySettings.autoLock - ) - - if privacySettings.autoLock { - Divider() - .padding(.leading, 44) - - HStack { - Image(systemName: "clock.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 4) { - Text("Auto-Lock Timeout") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text("\(privacySettings.autoLockTimeout) minutes") - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Button(action: { - // Edit timeout - }) { - Text("Edit") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.primary) - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - - Divider() - .padding(.leading, 44) - - PrivacyToggleRow( - title: "Encrypt Local Data", - subtitle: "Encrypt data stored on your device", - icon: "lock.shield.fill", - isOn: $privacySettings.encryptLocalData - ) - } - .background(Color.white) - .cornerRadius(12) - } - - // Data Management - VStack(alignment: .leading, spacing: 16) { - Text("Data Management") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 12) { - DataManagementRow( - title: "Export Data", - subtitle: "Download all your data", - icon: "square.and.arrow.up.fill", - color: .primary - ) { - showingDataExport = true - } - - DataManagementRow( - title: "Clear Cache", - subtitle: "Free up storage space", - icon: "trash.fill", - color: .orange - ) { - // Clear cache action - } - - DataManagementRow( - title: "Delete All Data", - subtitle: "Permanently delete all your data", - icon: "trash.circle.fill", - color: .red - ) { - showingDeleteAccount = true - } - } - .background(Color.white) - .cornerRadius(12) - } - - // Privacy Policy - VStack(alignment: .leading, spacing: 16) { - Text("Legal") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - VStack(spacing: 0) { - PrivacyLinkRow( - title: "Privacy Policy", - subtitle: "Read our privacy policy", - icon: "doc.text.fill" - ) - - Divider() - .padding(.leading, 44) - - PrivacyLinkRow( - title: "Terms of Service", - subtitle: "Read our terms of service", - icon: "doc.plaintext.fill" - ) - - Divider() - .padding(.leading, 44) - - PrivacyLinkRow( - title: "Data Processing", - subtitle: "Learn how we process your data", - icon: "gear.circle.fill" - ) - } - .background(Color.white) - .cornerRadius(12) - } - } - .padding(.horizontal, 20) - } - } - .background(Color(.systemGroupedBackground)) - .navigationBarHidden(true) - .sheet(isPresented: $showingDataExport) { - DataExportView() - } - .alert("Delete All Data", isPresented: $showingDeleteAccount) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { - // Delete account action - } - } message: { - Text("This action cannot be undone. All your data will be permanently deleted.") - } - } -} - -struct PrivacyToggleRow: View { - let title: String - let subtitle: String - let icon: String - @Binding var isOn: Bool - - var body: some View { - HStack { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text(subtitle) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Toggle("", isOn: $isOn) - .toggleStyle(SwitchToggleStyle(tint: .primary)) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } -} - -struct DataManagementRow: View { - let title: String - let subtitle: String - let icon: String - let color: Color - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(color) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text(subtitle) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct PrivacyLinkRow: View { - let title: String - let subtitle: String - let icon: String - - var body: some View { - Button(action: { - // Open privacy policy - }) { - HStack { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(.secondary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text(subtitle) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct DataExportView: View { - @Environment(\.presentationMode) var presentationMode - @State private var exportProgress = 0.0 - @State private var isExporting = false - - var body: some View { - NavigationView { - VStack(spacing: 24) { - Image(systemName: "square.and.arrow.up.fill") - .font(.system(size: 60)) - .foregroundColor(.primary) - - Text("Export Your Data") - .font(.spaceGroteskMedium(size: 24)) - .foregroundColor(.primary) - - Text("This will create a file containing all your information blocks, settings, and preferences.") - .font(.system(size: 16)) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 20) - - if isExporting { - VStack(spacing: 16) { - ProgressView(value: exportProgress) - .progressViewStyle(LinearProgressViewStyle(tint: .primary)) - - Text("\(Int(exportProgress * 100))% Complete") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 20) - } - - Button(action: { - startExport() - }) { - Text(isExporting ? "Exporting..." : "Export Data") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(isExporting ? Color.gray : Color.primary) - .cornerRadius(8) - } - .disabled(isExporting) - .padding(.horizontal, 20) - - Spacer() - } - .padding(.top, 40) - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: Button("Done") { - presentationMode.wrappedValue.dismiss() - }) - } - } - - private func startExport() { - isExporting = true - exportProgress = 0.0 - - // Simulate export progress - Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { timer in - exportProgress += 0.01 - if exportProgress >= 1.0 { - timer.invalidate() - isExporting = false - // Handle export completion - } - } - } -} - -struct PrivacySettings { - var analytics = true - var crashReports = true - var deviceInformation = true - var syncAcrossDevices = true - var backupToCloud = true - var shareUsageData = false - var biometricAuthentication = true - var autoLock = true - var autoLockTimeout = 5 - var encryptLocalData = true -} - -#Preview { - PrivacyView() -} \ No newline at end of file diff --git a/D3LibMin/README_DataStructure.md b/D3LibMin/README_DataStructure.md deleted file mode 100644 index da39963..0000000 --- a/D3LibMin/README_DataStructure.md +++ /dev/null @@ -1,197 +0,0 @@ -# D3LibMin 数据结构设计文档 - -## 概述 - -D3LibMin 是一个个人信息聚合软件,采用"信息块"的概念来组织和管理各种类型的信息。每个信息块都是一个独立的数据单元,包含了完整的信息内容和使用场景。 - -## 核心数据结构 - -### 1. InfoBlock (信息块) - -信息块是系统的核心数据结构,每个主页卡片对应一个信息块。 - -#### 属性说明 - -- **id**: UUID - 唯一标识符 -- **title**: String - 主标题 -- **subtitle**: String - 副标题 -- **type**: InfoType - 内容类别 -- **createdAt**: Date - 生成时间 -- **expiresAt**: Date? - 过期时间(可选) -- **needs**: [UsageNeed] - 使用场景组 -- **infos**: [InfoContent] - 所有内容组 - -### 2. InfoContent (信息内容) - -表示信息块中的具体内容项,采用"名称:内容"的格式。 - -#### 属性说明 - -- **id**: UUID - 唯一标识符 -- **name**: String - 内容名称 -- **content**: String - 具体内容 -- **type**: ContentType - 内容类型(文本/文件) - -### 3. UsageNeed (使用场景) - -描述信息块的使用场景和目的。 - -#### 属性说明 - -- **id**: UUID - 唯一标识符 -- **name**: String - 场景名称 -- **description**: String? - 场景描述(可选) - -### 4. InfoType (内容类别) - -枚举定义了信息块的内容类别: - -- **Numbers**: 号码类信息(房间号、取件码等) -- **Profiles**: 身份类信息(联系人、个人信息等) -- **Files**: 文件类信息(文档、图片等) -- **Phrases**: 碎片类信息(笔记、备忘录等) -- **Others**: 其他类型信息 - -### 5. ContentType (内容类型) - -枚举定义了内容项的类型: - -- **text**: 文本内容 -- **file**: 文件内容(链接形式) - -## 数据管理 - -### DataManager 类 - -`DataManager` 是数据管理的核心类,提供了完整的 CRUD 操作: - -#### 主要方法 - -- `addInfoBlock(_:)` - 添加信息块 -- `updateInfoBlock(_:)` - 更新信息块 -- `deleteInfoBlock(_:)` - 删除信息块 -- `getInfoBlock(by:)` - 根据ID获取信息块 -- `getInfoBlocks(by:)` - 根据类型获取信息块 -- `searchInfoBlocks(query:)` - 搜索信息块 -- `getExpiredInfoBlocks()` - 获取过期信息块 -- `getExpiringSoonInfoBlocks(within:)` - 获取即将过期的信息块 - -#### API 集成准备 - -DataManager 已经为未来的 API 集成做好了准备: - -- 所有 CRUD 操作都有对应的 API 调用占位符 -- 支持异步数据加载和错误处理 -- 提供了数据刷新机制 - -## 用户界面更新 - -### InfoDetailView 改进 - -1. **双输入框设计**: 将单一的输入框改为并排的两个输入框 - - 第一个输入框:内容名称 - - 第二个输入框:具体内容 - -2. **智能光标跳转**: - - 在名称输入框按回车时,光标跳转到内容输入框 - - 在内容输入框按回车时,保存当前条目并跳转到下一个名称输入框 - -3. **数据验证**: 只有当名称和内容都不为空时才能添加条目 - -## 向后兼容性 - -为了确保平滑过渡,系统提供了向后兼容机制: - -### InfoItem 兼容层 - -- 保留了原有的 `InfoItem` 结构 -- 提供了 `InfoItem(from:)` 构造器,从 `InfoBlock` 创建 `InfoItem` -- 提供了 `toInfoBlock()` 方法,将 `InfoItem` 转换为 `InfoBlock` - -### 数据转换 - -```swift -// 从 InfoBlock 创建 InfoItem -let infoItem = InfoItem(from: infoBlock) - -// 从 InfoItem 转换为 InfoBlock -let infoBlock = infoItem.toInfoBlock() -``` - -## 示例数据结构 - -### 酒店房间信息块 - -```swift -InfoBlock( - title: "Room 6836", - subtitle: "Meikai Hotel", - type: .numbers, - needs: [ - UsageNeed(name: "Hotel Check-in", description: "For hotel registration"), - UsageNeed(name: "Room Access", description: "For room key card") - ], - infos: [ - InfoContent(name: "Room Number", content: "6836"), - InfoContent(name: "Hotel Name", content: "Meikai Hotel"), - InfoContent(name: "Check-in Date", content: "2025-01-15"), - InfoContent(name: "Check-out Date", content: "2025-01-18") - ] -) -``` - -### 联系人信息块 - -```swift -InfoBlock( - title: "Lanzhi Jiang", - subtitle: "New contact", - type: .profiles, - needs: [ - UsageNeed(name: "Contact Management", description: "For contact information"), - UsageNeed(name: "Communication", description: "For messaging and calls") - ], - infos: [ - InfoContent(name: "Full Name", content: "Lanzhi Jiang"), - InfoContent(name: "Phone", content: "+86 138-1234-5678"), - InfoContent(name: "Email", content: "lanzhi.jiang@example.com"), - InfoContent(name: "Company", content: "Tech Solutions Inc."), - InfoContent(name: "Position", content: "Senior Developer") - ] -) -``` - -## 未来扩展 - -### API 集成 - -1. **RESTful API**: 设计标准的 REST API 接口 -2. **数据同步**: 实现多设备数据同步 -3. **离线支持**: 支持离线数据存储和同步 - -### 高级功能 - -1. **智能分类**: 基于内容自动分类 -2. **过期提醒**: 自动提醒即将过期的信息 -3. **搜索优化**: 全文搜索和智能推荐 -4. **数据导出**: 支持多种格式的数据导出 - -## 技术实现 - -### 架构模式 - -- **MVVM**: 使用 SwiftUI 的 MVVM 架构 -- **ObservableObject**: 数据管理器实现 ObservableObject 协议 -- **Combine**: 使用 Combine 框架进行数据绑定 - -### 数据持久化 - -- **UserDefaults**: 本地数据存储 -- **Core Data**: 复杂数据关系管理(未来) -- **CloudKit**: 云端数据同步(未来) - -### 性能优化 - -- **懒加载**: 使用 LazyVStack 优化列表性能 -- **内存管理**: 合理使用 @StateObject 和 @ObservedObject -- **异步处理**: 使用 DispatchQueue 处理耗时操作 \ No newline at end of file diff --git a/D3LibMin/SUBPAGE_COLOR_FIX_SUMMARY.md b/D3LibMin/SUBPAGE_COLOR_FIX_SUMMARY.md deleted file mode 100644 index 706742a..0000000 --- a/D3LibMin/SUBPAGE_COLOR_FIX_SUMMARY.md +++ /dev/null @@ -1,80 +0,0 @@ -# 子页面颜色修复总结 - -## 问题描述 - -新增的Profile子页面(AddDeviceView、NotificationsView、PrivacyView、HelpSupportView)中仍然使用蓝色作为主要颜色,与应用的纯黑色主色调不一致。 - -## 问题原因 - -这些子页面中有大量硬编码的蓝色(Color.blue 和 .blue),没有使用我们自定义的primary color系统。 - -## 修复内容 - -### 1. AddDeviceView.swift -**修复的蓝色引用**: -- 扫描按钮背景色:`Color.blue` → `Color.primary` -- 设备类型卡片选中背景:`Color.blue` → `Color.primary` -- 连接按钮背景:`Color.blue` → `Color.primary` - -### 2. NotificationsView.swift -**修复的蓝色引用**: -- "Edit"按钮文字颜色:`.blue` → `.primary` -- "View All"按钮文字颜色:`.blue` → `.primary` -- 开关控件色调:`tint: .blue` → `tint: .primary` -- 未读通知指示器:`Color.blue` → `Color.primary` - -### 3. PrivacyView.swift -**修复的蓝色引用**: -- "Edit"按钮文字颜色:`.blue` → `.primary` -- 数据导出按钮颜色:`color: .blue` → `color: .primary` -- 开关控件色调:`tint: .blue` → `tint: .primary` -- 导出图标颜色:`.blue` → `.primary` -- 进度条色调:`tint: .blue` → `tint: .primary` -- 导出按钮背景:`Color.blue` → `Color.primary` - -### 4. HelpSupportView.swift -**修复的蓝色引用**: -- "Getting Started"分类颜色:`.blue` → `.primary` -- "Contact Support"按钮颜色:`color: .blue` → `color: .primary` -- FAQ问号图标颜色:`.blue` → `.primary` -- "Medium"优先级颜色:`.blue` → `.primary` -- 提交按钮背景:`Color.blue` → `Color.primary` - -## 技术实现 - -### 替换策略 -- 将所有 `Color.blue` 替换为 `Color.primary` -- 将所有 `.blue` 替换为 `.primary` -- 保持其他颜色(如绿色、橙色、红色)不变 - -### 保持的颜色 -- `.green` - 用于成功状态和连接状态 -- `.orange` - 用于警告和中等优先级 -- `.red` - 用于错误和紧急状态 -- `.yellow` - 用于功能请求等 - -## 修复效果 - -✅ **AddDeviceView** - 所有蓝色元素现在使用纯黑色 -✅ **NotificationsView** - 所有蓝色元素现在使用纯黑色 -✅ **PrivacyView** - 所有蓝色元素现在使用纯黑色 -✅ **HelpSupportView** - 所有蓝色元素现在使用纯黑色 - -## 视觉一致性 - -现在所有Profile子页面都使用统一的纯黑色主色调: -- 按钮文字和图标 -- 开关控件 -- 进度条 -- 背景色 -- 指示器 - -## 验证结果 - -- 项目编译成功 -- 所有颜色引用正常工作 -- 视觉一致性得到改善 - -## 总结 - -通过系统性地替换所有硬编码的蓝色引用,现在D3LibMin应用的所有Profile子页面都使用统一的纯黑色主色调,与主应用的设计风格完全一致。这提供了更加专业和统一的用户体验。 \ No newline at end of file diff --git a/D3LibMin/SpaceGrotesk-Medium.ttf b/D3LibMin/SpaceGrotesk-Medium.ttf deleted file mode 100644 index 859f704f32a5fd2bf6a8ba23f283ca5a2699bfcd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86528 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 String { - guard let date = date else { return "Never" } - - let formatter = DateFormatter() - let calendar = Calendar.current - - if calendar.isDateInToday(date) { - formatter.dateFormat = "'Today,' h:mm a" - } else if calendar.isDateInYesterday(date) { - formatter.dateFormat = "'Yesterday,' h:mm a" - } else { - let daysDiff = calendar.dateComponents([.day], from: date, to: Date()).day ?? 0 - if daysDiff <= 7 { - return "\(daysDiff) days ago" - } else { - formatter.dateFormat = "MMM d, h:mm a" - } - } - - return formatter.string(from: date) - } - - var body: some View { - HStack { - Image(systemName: device.systemImage) - .font(.system(size: 16)) - .foregroundColor(.primary) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Text("Last connected: \(formatLastConnected(device.lastConnected))") - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - } - - Spacer() - - HStack(spacing: 4) { - Circle() - .fill(device.isConnected ? Color.green : Color(.systemGray4)) - .frame(width: 8, height: 8) - - Text(device.isConnected ? "Connected" : "Disconnected") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(device.isConnected ? .green : .secondary) - } - } - .padding(.vertical, 8) - } -} - -struct SettingsRow: View { - let title: String - let hasChevron: Bool - - var body: some View { - HStack { - Text(title) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - Spacer() - - if hasChevron { - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.secondary) - } - } - .padding(.vertical, 8) - } -} - -struct DotMatrixProgressView: View { - let progress: Double - let dotsPerRow = 25 - let totalRows = 4 - - var totalDots: Int { - return dotsPerRow * totalRows - } - - var body: some View { - GeometryReader { geometry in - let availableWidth = geometry.size.width - let spacing: CGFloat = 3 - let totalSpacing = spacing * CGFloat(dotsPerRow - 1) - let dotSize = (availableWidth - totalSpacing) / CGFloat(dotsPerRow) - - VStack(spacing: spacing) { - ForEach(0.. diff --git a/Sources/CaptureCore/CaptureDigest.swift b/Sources/CaptureCore/CaptureDigest.swift new file mode 100644 index 0000000..760bcef --- /dev/null +++ b/Sources/CaptureCore/CaptureDigest.swift @@ -0,0 +1,45 @@ +import CryptoKit +import Foundation + +public extension CapturePayload { + /// Matches Capture v1's sorted UTF-8 JSON, including explicit null optionals. + func canonicalDigest() throws -> String { + // The immutable wire payload has millisecond precision. Hash that same representation. + let wire = try CaptureJSON.decode(CapturePayload.self, from: CaptureJSON.encode(self)) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + let null = NSNull() + let origin: Any = wire.origin.map { + ["application_id": $0.applicationID as Any? ?? null, + "title": $0.title as Any? ?? null, "url": $0.url as Any? ?? null] + } ?? null + let materials: [[String: Any]] = try wire.materials.map { material in + switch material.kind { + case .text: + guard let text = material.text else { throw CaptureError.invalid("文字材料格式不正确。") } + return ["kind": "text", "text": text] + case .image: + guard let data = material.data, let mime = material.mimeType else { + throw CaptureError.invalid("图片材料格式不正确。") + } + return ["kind": "image", "mime_type": mime, "filename": material.filename as Any? ?? null, + "data_sha256": Self.sha256(data)] + } + } + let document: [String: Any] = [ + "submission_id": wire.submissionID.uuidString.lowercased(), + "captured_at": formatter.string(from: wire.capturedAt) + "000+00:00", + "entry": wire.entry.rawValue, "origin": origin, "materials": materials, + "annotation": wire.annotation as Any? ?? null, + ] + return Self.sha256(try JSONSerialization.data( + withJSONObject: document, options: [.sortedKeys, .withoutEscapingSlashes] + )) + } + + private static func sha256(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/CaptureCore/CaptureQueue.swift b/Sources/CaptureCore/CaptureQueue.swift new file mode 100644 index 0000000..48c8054 --- /dev/null +++ b/Sources/CaptureCore/CaptureQueue.swift @@ -0,0 +1,49 @@ +import Foundation + +public actor CaptureQueue { + private let store: CaptureStore + private let credentials: CredentialStore + private let client: PeerClient + private var running = false + + public init(store: CaptureStore, credentials: CredentialStore, client: PeerClient = PeerClient()) { + self.store = store + self.credentials = credentials + self.client = client + } + + public func process(force: Bool = false) async throws { + guard !running else { return } + running = true + defer { running = false } + for item in try await store.records() where [.pending, .unknown].contains(item.state) { + let delay = min(300.0, pow(2.0, Double(min(item.attempts, 8))) * 2) + if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { continue } + guard let target = item.target else { continue } + do { + let secret = try credentials.read(for: target.id) + if let result = try await client.recover(item, secret: secret) { + try await store.confirm(item.id, result: result) + continue + } + let dispatched = try await store.markDispatch(item.id) + let result = try await client.submit(dispatched, secret: secret) + try await store.confirm(item.id, result: result) + } catch let error as CaptureError { + let state: CaptureRecord.State + switch error { + case .needsAction, .invalid: state = .needsAction + case .outcomeUnknown: state = .unknown + case .connection: state = item.state == .unknown ? .unknown : .pending + case .storage: throw error + } + try await store.report(item.id, state: state, message: error.localizedDescription) + } catch { + try await store.report( + item.id, state: .unknown, + message: "无法确认响应,原提交已保留。请检查服务版本后重试。" + ) + } + } + } +} diff --git a/Sources/CaptureCore/CaptureStore.swift b/Sources/CaptureCore/CaptureStore.swift new file mode 100644 index 0000000..86f7078 --- /dev/null +++ b/Sources/CaptureCore/CaptureStore.swift @@ -0,0 +1,265 @@ +import CSQLite +import Foundation + +// Only CaptureStore's actor accesses this connection; the wrapper closes it on deallocation. +private final class SQLiteConnection: @unchecked Sendable { + private let handle: OpaquePointer + + init(url: URL) throws { + var database: OpaquePointer? + let status = sqlite3_open_v2( + url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil + ) + guard status == SQLITE_OK, let database else { + if let database { sqlite3_close(database) } + throw CaptureError.storage("无法打开本地采集数据库。") + } + handle = database + sqlite3_busy_timeout(handle, 5000) + } + + deinit { sqlite3_close(handle) } + + func execute(_ sql: String) throws { + guard sqlite3_exec(handle, sql, nil, nil, nil) == SQLITE_OK else { + throw error() + } + } + + func statement(_ sql: String, _ body: (OpaquePointer) throws -> T) throws -> T { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, + let statement else { throw error() } + defer { sqlite3_finalize(statement) } + return try body(statement) + } + + func error() -> CaptureError { + .storage("本地保存失败:\(String(cString: sqlite3_errmsg(handle)))") + } +} + +public actor CaptureStore { + private let database: SQLiteConnection + private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + // Late tasks only live in this process; retain IDs, never deleted records or materials. + private var deletedIDs: Set = [] + + public init(url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + database = try SQLiteConnection(url: url) + let version = try database.statement("PRAGMA user_version") { statement in + guard sqlite3_step(statement) == SQLITE_ROW else { throw database.error() } + return sqlite3_column_int(statement, 0) + } + guard version <= 3 else { + throw CaptureError.storage("本地数据来自较新版本,请更新 InKCre 后再打开。") + } + try database.execute(""" + PRAGMA journal_mode=WAL; + PRAGMA synchronous=FULL; + CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, body BLOB NOT NULL); + CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, body BLOB NOT NULL); + PRAGMA user_version=3; + """) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + public func records() throws -> [CaptureRecord] { + try database.statement("SELECT body FROM records") { statement in + var records: [CaptureRecord] = [] + while true { + let result = sqlite3_step(statement) + if result == SQLITE_DONE { break } + guard result == SQLITE_ROW else { throw database.error() } + records.append(try CaptureJSON.decode(CaptureRecord.self, from: data(statement))) + } + return records.sorted { $0.updatedAt > $1.updatedAt } + } + } + + public func record(_ id: UUID) throws -> CaptureRecord? { + try database.statement("SELECT body FROM records WHERE id=?") { statement in + sqlite3_bind_text(statement, 1, id.uuidString, -1, transient) + let result = sqlite3_step(statement) + if result == SQLITE_DONE { return nil } + guard result == SQLITE_ROW else { throw database.error() } + return try CaptureJSON.decode(CaptureRecord.self, from: data(statement)) + } + } + + public func saveDraft(_ record: CaptureRecord) throws { + guard !deletedIDs.contains(record.id) else { return } + let previous = try self.record(record.id) + guard record.state == .draft, + previous?.state ?? .draft == .draft else { + throw CaptureError.invalid("已提交的材料不能作为草稿覆盖。") + } + try save(record) + } + + public func freeze(_ draft: CaptureRecord, target: ConnectionTarget) throws -> CaptureRecord { + guard !deletedIDs.contains(draft.id), draft.state == .draft, + let payload = draft.payload, payload.submissionID == draft.id else { + throw CaptureError.invalid("草稿状态不正确。") + } + try payload.validate() + try target.validate() + guard try CaptureJSON.encode(payload).count <= 48 * 1024 * 1024 else { + throw CaptureError.invalid("编码后的请求超过 48 MiB。") + } + try database.execute("BEGIN IMMEDIATE") + do { + if let old = try record(draft.id), old.state != .draft { + throw CaptureError.invalid("这份材料已经进入待办,请查看原提交。") + } + var frozen = draft + frozen.target = target + frozen.state = .pending + frozen.message = nil + frozen.updatedAt = Date() + try save(frozen) + try database.execute("COMMIT") + return frozen + } catch { + try? database.execute("ROLLBACK") + throw error + } + } + + public func markDispatch(_ id: UUID) throws -> CaptureRecord { + guard var value = try record(id), [.pending, .unknown].contains(value.state), + value.payload != nil, value.target != nil else { + throw CaptureError.invalid("待办当前不能发送。") + } + // Persist uncertainty before network dispatch, so process death never loses it. + value.state = .unknown + value.attempts += 1 + value.updatedAt = Date() + try save(value) + return value + } + + public func report(_ id: UUID, state: CaptureRecord.State, message: String) throws { + guard !deletedIDs.contains(id) else { return } + guard var value = try record(id) else { throw CaptureError.invalid("找不到待办。") } + // A late failure must not resume an intention the user already cancelled or deleted. + guard value.state != .cancelled else { return } + guard value.state != .draft, value.state != .confirmed, + [.pending, .unknown, .needsAction].contains(state) else { + throw CaptureError.invalid("待办状态不可变更。") + } + value.state = state + value.message = message + value.updatedAt = Date() + try save(value) + } + + public func retry(_ id: UUID) throws { + guard var value = try record(id), + value.state != .draft, value.state != .confirmed else { + throw CaptureError.invalid("这份材料无需重试。") + } + value.state = value.attempts == 0 ? .pending : .unknown + value.message = "使用原提交身份重新确认。" + value.updatedAt = Date() + try save(value) + } + + @discardableResult + public func cancel(_ id: UUID) throws -> CaptureRecord { + guard var value = try record(id), + [.pending, .unknown, .needsAction, .cancelled].contains(value.state) else { + throw CaptureError.invalid("这份材料已经收入或不是待办,请刷新后查看。") + } + value.state = .cancelled + value.message = value.attempts == 0 ? "尚未发送。材料保留在本机,可继续收入。" + : "已停止后续发送;此前请求可能已收入。继续收入时会先确认原回执。" + value.updatedAt = Date() + try save(value) + return value + } + + public func deleteRecord(_ id: UUID) throws { + try deleteRow(id) + deletedIDs.insert(id) + } + + public func confirm(_ id: UUID, result: CaptureResult) throws { + guard !deletedIDs.contains(id) else { return } + guard var value = try record(id), value.state != .draft, + result.submissionID == id else { + throw CaptureError.invalid("提交回执与本机记录不匹配。") + } + value.result = result + value.state = .confirmed + value.message = nil + value.payload = nil + value.updatedAt = Date() + // One row replacement atomically keeps the receipt while discarding local material. + try save(value) + } + + public func discardDraft(_ id: UUID) throws { + guard !deletedIDs.contains(id), let value = try record(id) else { return } + guard value.state == .draft else { + throw CaptureError.invalid("只能丢弃尚未提交的草稿。") + } + try deleteRow(id) + } + + private func deleteRow(_ id: UUID) throws { + try database.statement("DELETE FROM records WHERE id=?") { statement in + sqlite3_bind_text(statement, 1, id.uuidString, -1, transient) + guard sqlite3_step(statement) == SQLITE_DONE else { throw database.error() } + } + } + + public func connection() throws -> ConnectionTarget? { + try database.statement("SELECT body FROM settings WHERE key='connection'") { statement in + let result = sqlite3_step(statement) + if result == SQLITE_DONE { return nil } + guard result == SQLITE_ROW else { throw database.error() } + return try CaptureJSON.decode(ConnectionTarget.self, from: data(statement)) + } + } + + public func saveConnection(_ connection: ConnectionTarget) throws { + try connection.validate() + try write("INSERT OR REPLACE INTO settings(key,body) VALUES ('connection',?)", + data: CaptureJSON.encode(connection)) + } + + private func save(_ record: CaptureRecord) throws { + let encoded = try CaptureJSON.encode(record) + try database.statement("INSERT OR REPLACE INTO records(id,body) VALUES (?,?)") { statement in + sqlite3_bind_text(statement, 1, record.id.uuidString, -1, transient) + let result = encoded.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), transient) + } + guard result == SQLITE_OK, sqlite3_step(statement) == SQLITE_DONE else { + throw database.error() + } + } + } + + private func write(_ sql: String, data: Data) throws { + try database.statement(sql) { statement in + let result = data.withUnsafeBytes { bytes in + sqlite3_bind_blob(statement, 1, bytes.baseAddress, Int32(bytes.count), transient) + } + guard result == SQLITE_OK, sqlite3_step(statement) == SQLITE_DONE else { + throw database.error() + } + } + } + + private func data(_ statement: OpaquePointer) -> Data { + let count = Int(sqlite3_column_bytes(statement, 0)) + guard let pointer = sqlite3_column_blob(statement, 0) else { return Data() } + return Data(bytes: pointer, count: count) + } +} diff --git a/Sources/CaptureCore/Credentials.swift b/Sources/CaptureCore/Credentials.swift new file mode 100644 index 0000000..c555816 --- /dev/null +++ b/Sources/CaptureCore/Credentials.swift @@ -0,0 +1,70 @@ +import CryptoKit +import Foundation +import Security + +public struct CredentialStore: Sendable { + private let service: String + + public init(service: String) { self.service = service } + + public func save(_ secret: String, for connection: UUID) throws { + guard secret.utf8.count >= 32 else { + throw CaptureError.invalid("部署签名凭据至少需要 32 个 UTF-8 字节。") + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + ] + let data = Data(secret.utf8) + var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw CaptureError.storage("Keychain 保存失败(\(status))。") } + } + + public func read(for connection: UUID) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data, + let secret = String(data: data, encoding: .utf8) else { + throw CaptureError.needsAction("无法读取此信息库的凭据,请在连接设置中重新保存。") + } + return secret + } +} + +public enum PeerToken { + public static func create(secret: String, now: Date = Date()) throws -> String { + guard secret.utf8.count >= 32 else { throw CaptureError.invalid("部署签名凭据过短。") } + let header = try JSONSerialization.data(withJSONObject: ["alg": "HS256", "typ": "JWT"]) + let seconds = Int(now.timeIntervalSince1970) + let claims = try JSONSerialization.data(withJSONObject: [ + "role": "authenticated", "iss": "inkcre-peer", "aud": "inkcre-api", + "iat": seconds, "exp": seconds + 3600, + ]) + let content = "\(base64URL(header)).\(base64URL(claims))" + let signature = HMAC.authenticationCode( + for: Data(content.utf8), using: SymmetricKey(data: Data(secret.utf8)) + ) + return "\(content).\(base64URL(Data(signature)))" + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/CaptureCore/Models.swift b/Sources/CaptureCore/Models.swift new file mode 100644 index 0000000..3fed89c --- /dev/null +++ b/Sources/CaptureCore/Models.swift @@ -0,0 +1,239 @@ +import Foundation + +public enum CaptureError: LocalizedError, Sendable { + case invalid(String) + case storage(String) + case connection(String) + case needsAction(String) + case outcomeUnknown + + public var errorDescription: String? { + switch self { + case .invalid(let message), .storage(let message), + .connection(let message), .needsAction(let message): message + case .outcomeUnknown: "请求已发出,结果待确认。原材料已保留,将用同一提交身份恢复。" + } + } +} + +public enum CaptureEntry: String, Codable, Sendable { + case selection, paste, screenshot, manual +} + +public struct CaptureOrigin: Codable, Sendable, Equatable { + public var applicationID: String? + public var title: String? + public var url: String? + + public init(applicationID: String? = nil, title: String? = nil, url: String? = nil) { + self.applicationID = applicationID + self.title = title + self.url = url + } + + enum CodingKeys: String, CodingKey { + case applicationID = "application_id" + case title, url + } +} + +public struct Material: Codable, Sendable, Equatable, Identifiable { + public enum Kind: String, Codable, Sendable { case text, image } + public let kind: Kind + public var text: String? + public let mimeType: String? + public let data: Data? + public let filename: String? + // UI identity is local and is never part of the shared capture contract. + public var id = UUID() + + public init(text: String) { + kind = .text + self.text = text + mimeType = nil + data = nil + filename = nil + } + + public init(image: Data, mimeType: String, filename: String? = nil) { + kind = .image + text = nil + self.mimeType = mimeType + data = image + self.filename = filename + } + + enum CodingKeys: String, CodingKey { + case kind, text, filename + case mimeType = "mime_type" + case data = "data_base64" + } + + public var byteCount: Int { data?.count ?? text?.utf8.count ?? 0 } +} + +public struct CapturePayload: Codable, Sendable { + public let submissionID: UUID + public var capturedAt: Date + public var entry: CaptureEntry + public var origin: CaptureOrigin? + public var materials: [Material] + public var annotation: String? + + public init( + submissionID: UUID = UUID(), capturedAt: Date = Date(), + entry: CaptureEntry = .manual, origin: CaptureOrigin? = nil, + materials: [Material] = [], annotation: String? = nil + ) { + self.submissionID = submissionID + self.capturedAt = capturedAt + self.entry = entry + self.origin = origin + self.materials = materials + self.annotation = annotation + } + + enum CodingKeys: String, CodingKey { + case submissionID = "submission_id" + case capturedAt = "captured_at" + case entry, origin, materials, annotation + } + + public func validate() throws { + guard !materials.isEmpty || !(annotation ?? "").isEmpty else { + throw CaptureError.invalid("先写下想法,或加入一份材料。") + } + guard materials.count <= 128 else { throw CaptureError.invalid("材料数量超过 128 项。") } + let size = materials.reduce(annotation?.utf8.count ?? 0) { $0 + $1.byteCount } + guard size <= 32 * 1024 * 1024 else { + throw CaptureError.invalid("材料累计超过 32 MiB,请拆分后收入。") + } + for material in materials { + switch material.kind { + case .text: + guard let text = material.text, !text.isEmpty, material.data == nil else { + throw CaptureError.invalid("文字材料为空或格式不正确。") + } + case .image: + guard let bytes = material.data, !bytes.isEmpty, + ["image/png", "image/jpeg", "image/gif", "image/webp"].contains(material.mimeType), + material.text == nil else { + throw CaptureError.invalid("图片为空或格式不支持。") + } + } + } + } +} + +public struct ConnectionTarget: Codable, Sendable, Equatable, Identifiable { + public let id: UUID + public let peerID: UUID + public var postgrestURL: URL + public var webURL: URL? + public var name: String + + public init( + id: UUID = UUID(), peerID: UUID = UUID(), postgrestURL: URL, + webURL: URL? = nil, name: String = "我的信息库" + ) { + self.id = id + self.peerID = peerID + self.postgrestURL = postgrestURL + self.webURL = webURL + self.name = name + } + + public func validate() throws { + for url in [postgrestURL, webURL].compactMap({ $0 }) { + guard ["http", "https"].contains(url.scheme), url.host != nil, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { + throw CaptureError.invalid("连接地址须为不含凭据、查询参数或片段的 HTTP(S) 地址。") + } + } + } +} + +public struct CaptureResult: Codable, Sendable, Equatable { + public enum Availability: String, Codable, Sendable { case available, deleted } + public let submissionID: UUID + public let rootBlockID: Int + public let committedAt: Date + public let availability: Availability + + public init(submissionID: UUID, rootBlockID: Int, committedAt: Date, availability: Availability) { + self.submissionID = submissionID + self.rootBlockID = rootBlockID + self.committedAt = committedAt + self.availability = availability + } + + enum CodingKeys: String, CodingKey { + case submissionID = "submission_id" + case rootBlockID = "root_block_id" + case committedAt = "committed_at" + case availability + } +} + +public struct CaptureRecord: Codable, Sendable, Identifiable { + public enum State: String, Codable, Sendable { + case draft, pending, unknown, needsAction, cancelled, confirmed + } + public let id: UUID + public var payload: CapturePayload? + public var target: ConnectionTarget? + public var state: State + public var result: CaptureResult? + public var message: String? + public var attempts: Int + public var updatedAt: Date + + public init(payload: CapturePayload = CapturePayload()) { + id = payload.submissionID + self.payload = payload + state = .draft + attempts = 0 + updatedAt = Date() + } + + public var statusLabel: String { + switch state { + case .draft: "草稿" + case .pending: "本机已保留,待收入" + case .unknown: "结果待确认" + case .needsAction: "需要处理" + case .cancelled: "已取消后续发送" + case .confirmed: result?.availability == .deleted ? "曾收入,内容已删除" : "已收入" + } + } +} + +public enum CaptureJSON { + public static func encode(_ value: T) throws -> Data { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + try container.encode(formatter.string(from: date)) + } + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + return try encoder.encode(value) + } + + public static func decode(_ type: T.Type, from data: Data) throws -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let text = try decoder.singleValueContainer().decode(String.self) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: text) { return date } + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: text) else { + throw CaptureError.invalid("时间格式无效。") + } + return date + } + return try decoder.decode(type, from: data) + } +} diff --git a/Sources/CaptureCore/PeerClient.swift b/Sources/CaptureCore/PeerClient.swift new file mode 100644 index 0000000..eebcf00 --- /dev/null +++ b/Sources/CaptureCore/PeerClient.swift @@ -0,0 +1,235 @@ +import Foundation + +public struct PeerClient: Sendable { + public static let capability = "extensions.capture.submit.v1" + private let session: URLSession + + public init(session: URLSession? = nil) { + if let session { + self.session = session + } else { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 20 + configuration.timeoutIntervalForResource = 90 + configuration.httpCookieStorage = nil + self.session = URLSession(configuration: configuration, delegate: RejectRedirects(), delegateQueue: nil) + } + } + + public func register(_ target: ConnectionTarget, secret: String) async throws -> Date { + try target.validate() + let identity = target.peerID.uuidString.lowercased() + let registration: [String: Any] = [ + "id": identity, "name": Host.current().localizedName ?? "InKCre Mac", + "application_version": "0.1.0", "capabilities": [], + "config_schema": [:], "config": [:], "labels": ["macOS"], + ] + _ = try await database( + target, path: "peers", method: "POST", + query: [.init(name: "on_conflict", value: "id")], + body: JSONSerialization.data(withJSONObject: registration), secret: secret, + prefer: "resolution=ignore-duplicates,return=minimal" + ) + let runtime: [String: Any] = [ + "application_version": "0.1.0", "capabilities": [], "config_schema": [:], + ] + _ = try await database( + target, path: "peers", method: "PATCH", + query: [.init(name: "id", value: "eq.\(identity)")], + body: JSONSerialization.data(withJSONObject: runtime), secret: secret + ) + let lease = try await database( + target, path: "rpc/renew_peer_lease", method: "POST", + body: JSONSerialization.data(withJSONObject: ["peer": identity, "ttl_seconds": 120]), + secret: secret + ) + return try CaptureJSON.decode(Date.self, from: lease).addingTimeInterval(-120) + } + + public func checkConnection(_ target: ConnectionTarget, secret: String) async throws { + let clock = try await register(target, secret: secret) + _ = try await database( + target, path: "capture_receipts", + query: [.init(name: "select", value: "submission_id"), .init(name: "limit", value: "0")], + secret: secret + ) + _ = try await providers(target, secret: secret, databaseTime: clock) + } + + public func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + guard let target = record.target else { throw CaptureError.invalid("待办缺少目标。") } + let data = try await database( + target, path: "capture_receipts", + query: [ + .init(name: "submission_id", value: "eq.\(record.id.uuidString.lowercased())"), + .init(name: "select", value: "submission_id,payload_digest,root_block_id,committed_at"), + ], secret: secret + ) + struct Receipt: Decodable { + let submission_id: UUID + let payload_digest: String + let root_block_id: Int + let committed_at: Date + } + let receipts = try CaptureJSON.decode([Receipt].self, from: data) + guard let receipt = receipts.first else { return nil } + guard receipts.count == 1, receipt.submission_id == record.id else { + throw CaptureError.needsAction("共享回执与待办不一致。") + } + guard let payload = record.payload, try payload.canonicalDigest() == receipt.payload_digest else { + throw CaptureError.needsAction("共享回执的材料指纹与本机不同。原材料已保留,请检查提交身份冲突。") + } + let roots = try await database( + target, path: "blocks", + query: [.init(name: "id", value: "eq.\(receipt.root_block_id)"), + .init(name: "select", value: "id")], secret: secret + ) + struct Root: Decodable { let id: Int } + let exists = try CaptureJSON.decode([Root].self, from: roots).contains { $0.id == receipt.root_block_id } + return CaptureResult( + submissionID: record.id, rootBlockID: receipt.root_block_id, + committedAt: receipt.committed_at, availability: exists ? .available : .deleted + ) + } + + public func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { + guard let target = record.target, let payload = record.payload else { + throw CaptureError.invalid("待办缺少冻结的材料或目标。") + } + let clock = try await register(target, secret: secret) + let candidates = try await providers(target, secret: secret, databaseTime: clock) + let endpoint = candidates[max(0, record.attempts - 1) % candidates.count] + let body = try CaptureJSON.encode(payload) + guard body.count <= 48 * 1024 * 1024 else { + throw CaptureError.needsAction("编码后的请求超过 48 MiB,请保留材料并处理。") + } + let data = try await request( + endpoint, method: "POST", body: body, secret: secret, isCaptureDispatch: true + ) + guard let reported = try? CaptureJSON.decode(CaptureResult.self, from: data), + reported.submissionID == record.id else { throw CaptureError.outcomeUnknown } + // Local materials are released only after the shared receipt validates their fingerprint. + guard let persisted = try await recover(record, secret: secret), + persisted.rootBlockID == reported.rootBlockID else { throw CaptureError.outcomeUnknown } + return persisted + } + + private func providers( + _ target: ConnectionTarget, secret: String, databaseTime: Date + ) async throws -> [URL] { + let formatter = ISO8601DateFormatter() + let data = try await database( + target, path: "peers", + query: [.init(name: "select", value: "id,capabilities"), + .init(name: "lease_expires_at", value: "gt.\(formatter.string(from: databaseTime))"), + .init(name: "order", value: "id")], secret: secret + ) + struct Peer: Decodable { + struct Capability: Decodable { + struct Inbound: Decodable { + struct Parameters: Decodable { let method: String?; let url: String? } + let `protocol`: String + let parameters: Parameters + enum CodingKeys: CodingKey { case `protocol`, parameters } + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + `protocol` = try values.decode(String.self, forKey: .protocol) + parameters = `protocol` == "core.peer.protocol.http.v1" + ? try values.decode(Parameters.self, forKey: .parameters) + : Parameters(method: nil, url: nil) + } + } + let id: String + let inbound: Inbound + } + let id: UUID + let capabilities: [Capability] + } + let peers = try CaptureJSON.decode([Peer].self, from: data) + var endpoints: [URL] = [] + for peer in peers { + for capability in peer.capabilities where capability.id == Self.capability { + let inbound = capability.inbound + guard inbound.protocol == "core.peer.protocol.http.v1", + inbound.parameters.method?.uppercased() == "POST", + let address = inbound.parameters.url, let url = URL(string: address), + ["http", "https"].contains(url.scheme), url.host != nil, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { continue } + if !endpoints.contains(url) { endpoints.append(url) } + } + } + guard !endpoints.isEmpty else { + throw CaptureError.connection("暂时没有在线的 Capture 执行 Peer。请安装并启用配套分发;材料仍保留在本机。") + } + return endpoints + } + + private func database( + _ target: ConnectionTarget, path: String, method: String = "GET", + query: [URLQueryItem] = [], body: Data? = nil, secret: String, prefer: String? = nil + ) async throws -> Data { + var url = target.postgrestURL + for component in path.split(separator: "/") { url.appendPathComponent(String(component)) } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw CaptureError.invalid("连接地址无效。") + } + components.queryItems = query.isEmpty ? nil : query + guard let endpoint = components.url else { throw CaptureError.invalid("连接地址无效。") } + return try await request( + endpoint, method: method, body: body, secret: secret, + profile: "inkcre", prefer: prefer, isCaptureDispatch: false + ) + } + + private func request( + _ url: URL, method: String, body: Data?, secret: String, + profile: String? = nil, prefer: String? = nil, isCaptureDispatch: Bool + ) async throws -> Data { + var request = URLRequest(url: url) + request.httpMethod = method + request.httpBody = body + request.setValue("Bearer \(try PeerToken.create(secret: secret))", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } + if let profile { + request.setValue(profile, forHTTPHeaderField: "Accept-Profile") + request.setValue(profile, forHTTPHeaderField: "Content-Profile") + } + if let prefer { request.setValue(prefer, forHTTPHeaderField: "Prefer") } + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + if isCaptureDispatch { throw CaptureError.outcomeUnknown } + throw CaptureError.connection("暂时无法连接信息库,请检查网络与连接地址。") + } + guard let http = response as? HTTPURLResponse else { + throw CaptureError.outcomeUnknown + } + guard (200..<300).contains(http.statusCode) else { + if isCaptureDispatch, http.statusCode >= 500 || http.statusCode == 429 { throw CaptureError.outcomeUnknown } + switch http.statusCode { + case 401, 403: throw CaptureError.needsAction("凭据被拒绝,请更新连接凭据并检查系统时间。") + case 404: throw CaptureError.needsAction("目标缺少 Capture v1 协议,请更新数据库与配套分发。") + case 409: throw CaptureError.needsAction("提交身份与已保存内容冲突,请保留原待办。") + case 413: throw CaptureError.needsAction("请求超过服务端或代理限额,材料仍保留。") + case 422: throw CaptureError.needsAction("服务端拒绝了材料格式,请保留待办并检查内容。") + case 429, 502, 503, 504: throw CaptureError.connection("服务暂时不可用,稍后自动重试。") + default: throw CaptureError.needsAction("信息库返回错误(HTTP \(http.statusCode)),材料仍保留。") + } + } + return data + } +} + +private final class RejectRedirects: NSObject, URLSessionTaskDelegate { + func urlSession( + _ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + // Credentials and private material go only to the explicitly configured/published endpoint. + completionHandler(nil) + } +} diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift new file mode 100644 index 0000000..a087fd5 --- /dev/null +++ b/Sources/InKCreApp/AppMain.swift @@ -0,0 +1,348 @@ +import AppKit +import CaptureCore +import SwiftUI +import ApplicationServices + +@main +enum InKCreApplication { + @MainActor static func main() { + let application = NSApplication.shared + let delegate = AppDelegate() + application.delegate = delegate + withExtendedLifetime(delegate) { application.run() } + } +} + +@MainActor +final class CapturePanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + // The configurable panel shortcut handles dismissal, including an intentionally disabled Esc. + override func cancelOperation(_ sender: Any?) {} +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSUserInterfaceValidations { + private var statusItem: NSStatusItem? + private var panel: CapturePanel? + private var mainWindow: PeerMainWindow? + private var model: CaptureModel? + private let regionCapture = RegionCapture() + private var previousApplication: NSRunningApplication? + + private var canChangeCaptureContext: Bool { + !regionCapture.isCapturing && NSApp.modalWindow == nil + && mainWindow?.attachedSheet == nil && panel?.attachedSheet == nil + } + + func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + canChangeCaptureContext && model?.submitting != true + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), + let icon = NSImage(contentsOf: url) { NSApp.applicationIconImage = icon } + do { + let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent(identity) + let model = try CaptureModel(directory: directory, identity: identity) + self.model = model + let mainWindow = PeerMainWindow(model: model) + mainWindow.delegate = self + self.mainWindow = mainWindow + let panel = CapturePanel( + contentRect: NSRect(x: 0, y: 0, width: 570, height: 640), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel], + backing: .buffered, defer: false + ) + panel.title = "随手收入 InKCre" + panel.minSize = NSSize(width: 500, height: 450) + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.isReleasedWhenClosed = false + panel.hidesOnDeactivate = false + panel.delegate = self + panel.contentView = NSHostingView(rootView: QuickCaptureView(model: model)) + panel.center() + self.panel = panel + model.presentEditor = { [weak self] in self?.showCaptureSurface() } + model.dismissQuickCapture = { [weak self] in self?.hide() } + model.showMain = { [weak self] in self?.showMain() } + model.showQuick = { [weak self] in self?.showQuick() } + model.takeScreenshot = { [weak self] in self?.screenshot() } + model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in + window === panel || window === mainWindow + } + model.shortcuts.perform = { [weak self, weak model] action in + guard let self, let model, self.canChangeCaptureContext else { return } + switch action { + case .capture: self.showCapture() + case .screenshot: self.quickScreenshot() + case .paste: if model.editorOwner == .quick || model.pane == .capture { model.pasteMaterial() } + case .submit: if model.editorOwner == .quick || model.pane == .capture { model.submit() } + case .hide: if NSApp.keyWindow === self.panel { self.hide() } + } + } + try model.shortcuts.start() + if let message = model.shortcuts.message { model.error = message } + configureMenu() + model.start() + showMain() + } catch { + let alert = NSAlert() + alert.messageText = "InKCre 无法打开本地资料" + alert.informativeText = error.localizedDescription + alert.runModal() + NSApp.terminate(nil) + } + } + + private func configureMenu() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = BrandAssets.menuIcon + let menu = NSMenu() + for (title, selector) in [ + ("显示主窗口", #selector(showMain)), + ("随手收集", #selector(showCapture)), + ("写下想法", #selector(quickThought)), + ("粘贴材料", #selector(quickPaste)), + ("框选截图", #selector(quickScreenshot)), + ("采集记录", #selector(showQueue)), + ("Peer 与连接", #selector(showSettings)), + ] { + let command = NSMenuItem(title: title, action: selector, keyEquivalent: "") + command.target = self + menu.addItem(command) + } + menu.addItem(.separator()) + menu.addItem(withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + item.menu = menu + statusItem = item + + let mainMenu = NSMenu() + let appItem = NSMenuItem(title: "InKCre", action: nil, keyEquivalent: "") + let appMenu = NSMenu(title: "InKCre") + appMenu.addItem(withTitle: "关于 InKCre", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(withTitle: "设置…", action: #selector(showSettings), keyEquivalent: ",").target = self + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let hideOthers = appMenu.addItem(withTitle: "隐藏其他应用", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthers.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + appMenu.addItem(.separator()) + appMenu.addItem(withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appItem.submenu = appMenu + mainMenu.addItem(appItem) + let fileItem = NSMenuItem(title: "文件", action: nil, keyEquivalent: "") + let file = NSMenu(title: "文件") + file.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n").target = self + file.addItem(withTitle: "快捷采集", action: #selector(showQuick), keyEquivalent: "").target = self + file.addItem(.separator()) + file.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + fileItem.submenu = file + mainMenu.addItem(fileItem) + let editItem = NSMenuItem(title: "编辑", action: nil, keyEquivalent: "") + let edit = NSMenu(title: "编辑") + for (title, action, key) in [ + ("撤销", Selector(("undo:")), "z"), + ("剪切", #selector(NSText.cut(_:)), "x"), + ("复制", #selector(NSText.copy(_:)), "c"), + ("粘贴", #selector(NSText.paste(_:)), "v"), + ("全选", #selector(NSText.selectAll(_:)), "a"), + ] { edit.addItem(withTitle: title, action: action, keyEquivalent: key) } + let redo = edit.addItem(withTitle: "重做", action: Selector(("redo:")), keyEquivalent: "z") + redo.keyEquivalentModifierMask = [.command, .shift] + editItem.submenu = edit + mainMenu.addItem(editItem) + let viewItem = NSMenuItem(title: "显示", action: nil, keyEquivalent: "") + let view = NSMenu(title: "显示") + let sidebar = view.addItem(withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") + sidebar.keyEquivalentModifierMask = [.command, .control] + let fullscreen = view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") + fullscreen.keyEquivalentModifierMask = [.command, .control] + viewItem.submenu = view + mainMenu.addItem(viewItem) + let windowItem = NSMenuItem(title: "窗口", action: nil, keyEquivalent: "") + let windows = NSMenu(title: "窗口") + windows.addItem(withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windows.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windows.addItem(withTitle: "显示主窗口", action: #selector(showMain), keyEquivalent: "").target = self + windows.addItem(.separator()) + windows.addItem(withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + windowItem.submenu = windows + mainMenu.addItem(windowItem) + NSApp.windowsMenu = windows + NSApp.mainMenu = mainMenu + } + + @objc private func showCapture() { + guard canChangeCaptureContext, let model, !model.submitting else { return } + prepareQuickCapture() + let request = UUID() + model.selectionRequest = request + guard let application = NSWorkspace.shared.frontmostApplication, + application.processIdentifier != ProcessInfo.processInfo.processIdentifier else { showQuick(); return } + previousApplication = application + guard AXIsProcessTrusted() else { + model.newThought(notice: "尚未允许辅助功能访问。可直接写下想法、明确粘贴,或在设置中开启选区采集。") + return + } + let pid = application.processIdentifier + let bundleID = application.bundleIdentifier + Task { + let snapshot = await Task.detached { SelectedText.read(pid: pid, applicationID: bundleID) }.value + guard model.selectionRequest == request, + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } + var text = snapshot.text + if text == nil, !snapshot.secure { + text = await SelectedText.copyFallback(pid: pid, snapshot: snapshot) { + model.selectionRequest == request + && NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + } + } + guard model.selectionRequest == request, + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } + if let text { + guard await Task.detached(operation: { SelectedText.isCurrent(pid: pid, snapshot: snapshot) }).value, + model.selectionRequest == request, + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } + await model.receive( + CapturePayload(entry: .selection, origin: snapshot.origin, materials: [Material(text: text)]), + request: request + ) + } else { + let notice = snapshot.secure + ? "密码或安全输入区域不会被采集。" + : "未取得可靠选区。可以明确复制后粘贴、框选截图,或直接写想法。" + model.newThought(notice: notice) + } + } + } + @objc private func newThought() { guard canChangeCaptureContext else { return }; showMain(); model?.newThought() } + @objc private func quickThought() { guard canChangeCaptureContext else { return }; prepareQuickCapture(); model?.newThought() } + @objc private func quickPaste() { guard canChangeCaptureContext else { return }; prepareQuickCapture(); model?.pasteMaterial() } + @objc private func quickScreenshot() { guard canChangeCaptureContext else { return }; prepareQuickCapture(); screenshot() } + @objc private func showQueue() { guard canChangeCaptureContext else { return }; model?.pane = .queue; showMain() } + @objc private func showSettings() { guard canChangeCaptureContext else { return }; model?.openSettings() } + @objc private func screenshot() { + guard canChangeCaptureContext, let model, !model.submitting else { return } + model.selectionRequest = UUID() + let request = model.selectionRequest + let owner = model.editorOwner + let restoreMain = mainWindow?.isVisible == true + model.screenCaptureNotice = nil + panel?.orderOut(nil) + mainWindow?.orderOut(nil) + regionCapture.begin { [weak self] result in + guard let self else { return } + model.editorOwner = owner + if restoreMain { self.mainWindow?.orderBack(nil) } + switch result { + case .success(let data): + guard let data else { self.showCaptureSurface(); return } + Task { + await model.receive( + CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]), + request: request + ) + } + case .failure(let error): + if error is ScreenRecordingPermissionError { + model.screenCaptureNotice = error.localizedDescription + if owner == .main { model.pane = .capture } + } else { model.error = error.localizedDescription } + self.showCaptureSurface() + } + } + } + + private func prepareQuickCapture() { + if let current = NSWorkspace.shared.frontmostApplication, + current.processIdentifier != ProcessInfo.processInfo.processIdentifier { + previousApplication = current + } + model?.shortcuts.cancelRecording() + mainWindow?.makeFirstResponder(nil) + model?.editorOwner = .quick + } + + @objc private func showQuick() { + guard canChangeCaptureContext else { return } + prepareQuickCapture() + NSApp.activate(ignoringOtherApps: true) + panel?.makeKeyAndOrderFront(nil) + } + + @objc private func showMain() { + guard !regionCapture.isCapturing else { return } + if mainWindow?.attachedSheet != nil || NSApp.modalWindow != nil { + NSApp.activate(ignoringOtherApps: true) + mainWindow?.makeKeyAndOrderFront(nil) + return + } + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + if mainWindow?.isMiniaturized == true { mainWindow?.deminiaturize(nil) } + NSApp.activate(ignoringOtherApps: true) + mainWindow?.makeKeyAndOrderFront(nil) + } + + private func showCaptureSurface() { + if model?.editorOwner == .quick { showQuick() } + else { showMain() } + } + + private func hide() { + model?.shortcuts.cancelRecording() + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + model?.saveSoon() + if NSWorkspace.shared.frontmostApplication?.processIdentifier == ProcessInfo.processInfo.processIdentifier { + previousApplication?.activate() + } + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + if sender === panel { hide(); return false } + sender.makeFirstResponder(nil) + model?.saveSoon() + return true + } + + func windowDidBecomeKey(_ notification: Notification) { + if notification.object as? NSWindow === mainWindow, panel?.isVisible == true { + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showMain() + return false + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let model else { return .terminateNow } + NSApp.keyWindow?.makeFirstResponder(nil) + Task { + do { + try await model.flush() + model.stop() + model.shortcuts.stop() + sender.reply(toApplicationShouldTerminate: true) + } catch { + model.error = error.localizedDescription + showCaptureSurface() + sender.reply(toApplicationShouldTerminate: false) + } + } + return .terminateLater + } +} diff --git a/Sources/InKCreApp/BrandAssets.swift b/Sources/InKCreApp/BrandAssets.swift new file mode 100644 index 0000000..f7cf0ef --- /dev/null +++ b/Sources/InKCreApp/BrandAssets.swift @@ -0,0 +1,30 @@ +import AppKit + +@MainActor +enum BrandAssets { + static let lightMark = image("MarkLight", size: 24) + static let darkMark = image("MarkDark", size: 24) + static let menuIcon: NSImage = { + let icon = image("MenuTemplate", size: 20) + icon.isTemplate = true + icon.accessibilityDescription = "InKCre" + return icon + }() + + private static let resources: Bundle = { + // SwiftPM's generated accessor searches beside the executable; a macOS app packages resources in Contents/Resources. + if Bundle.main.bundleURL.pathExtension == "app" { + guard let url = Bundle.main.url(forResource: "InKCre_InKCreApp", withExtension: "bundle"), + let bundle = Bundle(url: url) else { preconditionFailure("Missing packaged InKCre resources") } + return bundle + } + return Bundle.module + }() + + private static func image(_ name: String, size: CGFloat) -> NSImage { + guard let url = resources.url(forResource: name, withExtension: "png", subdirectory: "Brand"), + let image = NSImage(contentsOf: url) else { preconditionFailure("Missing brand resource: \(name)") } + image.size = NSSize(width: size, height: size) + return image + } +} diff --git a/Sources/InKCreApp/CaptureEditor.swift b/Sources/InKCreApp/CaptureEditor.swift new file mode 100644 index 0000000..7de1a14 --- /dev/null +++ b/Sources/InKCreApp/CaptureEditor.swift @@ -0,0 +1,108 @@ +import AppKit +import CaptureCore +import SwiftUI + +struct CaptureEditor: View { + @ObservedObject var model: CaptureModel + var isQuickCapture = false + private enum Field: Hashable { case thought, annotation } + @FocusState private var focusedField: Field? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if isQuickCapture { + HStack { + Button("新想法", systemImage: "square.and.pencil") { model.newThought() } + Button("粘贴材料", systemImage: "doc.on.clipboard") { model.pasteMaterial() } + Button("框选截图", systemImage: "viewfinder") { model.takeScreenshot?() } + Spacer() + Button("收起") { model.dismissQuickCapture?() } + } + } + if let notice = model.captureNotice { + Text(notice).font(.callout).foregroundStyle(.secondary) + } + if let notice = model.screenCaptureNotice { + VStack(alignment: .leading, spacing: 6) { + Text(notice).font(.callout) + HStack { + Button("请求屏幕录制权限") { model.requestScreenRecordingAccess() } + Button("打开系统设置") { model.openScreenRecordingSettings() } + } + }.padding(10).background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + } + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(model.draft.payload?.entry == .manual ? "此刻的想法" : "本次材料") + .font(.subheadline).foregroundStyle(.secondary) + if model.draft.payload?.entry == .manual { + TextEditor(text: Binding(get: { model.thought }, set: { model.thought = $0 })) + .focused($focusedField, equals: .thought) + .font(.body).frame(minHeight: 140) + .padding(6) + .background(.background, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel("文字材料") + } + ForEach(Array((model.draft.payload?.materials ?? []).enumerated()), id: \.element.id) { index, material in + if material.kind != .text || model.draft.payload?.entry != .manual { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("材料 \(index + 1)").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("移除", systemImage: "xmark") { model.requestRemoveMaterial(material.id) } + .accessibilityLabel("移除第 \(index + 1) 项材料") + } + if material.kind == .text { + Text(material.text ?? "").textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + } else if let data = material.data, let image = NSImage(data: data) { + Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 220) + .accessibilityLabel("本次采集的图片") + } else { + Text("图片原始字节已保留;系统无法预览此格式。").font(.caption) + } + } + } + } + if let origin = model.draft.payload?.origin { + VStack(alignment: .leading, spacing: 3) { + if let title = origin.title { Text(title) } + if let url = origin.url { Text(url).textSelection(.enabled) } + if let application = origin.applicationID { Text(application) } + }.font(.caption).foregroundStyle(.secondary) + } + Text("补充想法").font(.subheadline).foregroundStyle(.secondary) + TextEditor(text: Binding(get: { model.annotation }, set: { model.annotation = $0 })) + .focused($focusedField, equals: .annotation) + .frame(minHeight: 64).padding(6) + .background(.background, in: RoundedRectangle(cornerRadius: 8)) + .accessibilityLabel("用户附注,与原文分别保存") + } + } + Divider() + Text(model.saveStatus).font(.caption).foregroundStyle(.secondary) + .accessibilityLabel(model.saveStatus) + HStack { + if isQuickCapture { + Button("取消本次") { model.cancelCapture() } + } else { + Button("删除草稿", systemImage: "trash", role: .destructive) { model.requestDiscard(model.draft) } + .disabled(!model.hasContent) + } + Spacer() + Button("导出材料") { model.export(model.draft) } + .disabled(!model.hasContent) + Button("收入", systemImage: "tray.and.arrow.down") { model.submit() } + .buttonStyle(.borderedProminent) + .disabled(!model.hasContent || model.submitting) + } + }.padding().disabled(model.submitting) + .onAppear { focusEditor() } + .onChange(of: model.draft.id) { focusEditor() } + .onChange(of: model.submitting) { _, busy in if !busy { focusEditor() } } + } + + private func focusEditor() { + guard !model.submitting else { return } + focusedField = model.draft.payload?.entry == .manual ? .thought : .annotation + } +} diff --git a/Sources/InKCreApp/CaptureModel.swift b/Sources/InKCreApp/CaptureModel.swift new file mode 100644 index 0000000..3da02d8 --- /dev/null +++ b/Sources/InKCreApp/CaptureModel.swift @@ -0,0 +1,466 @@ +import AppKit +import CaptureCore +import Combine +import Foundation + +@MainActor +final class CaptureModel: ObservableObject { + enum Pane: Hashable { case capture, queue, settings } + enum EditorOwner { case main, quick } + enum RemovalRequest { + case material(draftID: UUID, materialID: UUID) + case record(CaptureRecord, close: Bool) + + var title: String { + switch self { + case .material: return "移除这项材料?" + case .record(let record, let close): + if close { return "取消本次采集?" } + return record.state == .draft ? "删除这份草稿?" : "删除本机记录?" + } + } + + var actionTitle: String { + switch self { + case .material: "移除" + case .record(_, let close): close ? "丢弃并关闭" : "删除" + } + } + + var message: String { + switch self { + case .material: "移除后无法撤销,补充想法会保留。" + case .record(let record, _): + switch record.state { + case .draft: "这份草稿和材料将从本机删除,无法恢复。" + case .confirmed: "仅删除此 Mac 的记录,信息库中的内容保持不变。删除后无法恢复。" + default: "停止后续发送并删除本机材料,无法恢复。已发出的请求可能已经收入,无法撤回。" + } + } + } + } + @Published var pane = Pane.capture + @Published var editorOwner = EditorOwner.main + @Published var draft = CaptureRecord() + @Published var records: [CaptureRecord] = [] + @Published var connection: ConnectionTarget? + @Published var editingConnection: ConnectionTarget? + @Published var showingConnectionEditor = false + @Published var error: String? + @Published var saveStatus = "随时写下想法,或加入材料" + @Published var submitting = false + @Published var lastContactAt: Date? + @Published var peerConnectionIssue: String? + @Published private(set) var checkingConnection = false + @Published var captureNotice: String? + @Published var screenCaptureNotice: String? + @Published var removalRequest: RemovalRequest? + let shortcuts: GlobalShortcuts + var takeScreenshot: (() -> Void)? + var selectionRequest = UUID() + var presentEditor: (() -> Void)? + var dismissQuickCapture: (() -> Void)? + var showMain: (() -> Void)? + var showQuick: (() -> Void)? + let store: CaptureStore + let credentials: CredentialStore + private let queue: CaptureQueue + private var saveTask: Task? + private var heartbeat: Task? + private var connectionCheck: Task? + private var connectionRevision = UUID() + private var submissionStatusID: UUID? + private var editRevision = 0 + + init(directory: URL, identity: String) throws { + shortcuts = try GlobalShortcuts() + store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + credentials = CredentialStore(service: "\(identity).connections") + queue = CaptureQueue(store: store, credentials: credentials) + } + + var pendingCount: Int { + records.filter { [.pending, .unknown, .needsAction].contains($0.state) }.count + } + var hasContent: Bool { + !(draft.payload?.materials.isEmpty ?? true) || !(draft.payload?.annotation ?? "").isEmpty + } + var thought: String { + get { draft.payload?.materials.first(where: { $0.kind == .text })?.text ?? "" } + set { + guard var payload = draft.payload else { return } + if let index = payload.materials.firstIndex(where: { $0.kind == .text }) { + if newValue.isEmpty { payload.materials.remove(at: index) } + else { payload.materials[index].text = newValue } + } else if !newValue.isEmpty { + payload.materials.insert(Material(text: newValue), at: 0) + } + draft.payload = payload + saveSoon() + } + } + var annotation: String { + get { draft.payload?.annotation ?? "" } + set { draft.payload?.annotation = newValue.isEmpty ? nil : newValue; saveSoon() } + } + + func start() { + submitting = true + heartbeat = Task { + do { + connection = try await store.connection() + try await refresh() + if let latest = records.first(where: { $0.state == .draft }) { + draft = latest + saveStatus = "已恢复本机草稿" + } + } catch { self.error = error.localizedDescription } + submitting = false + var cycle = 0 + while !Task.isCancelled { + await sendPending() + if cycle % 4 == 0, !checkingConnection, let connection { + let revision = connectionRevision + do { + try await PeerClient().checkConnection( + connection, secret: credentials.read(for: connection.id) + ) + if connectionRevision == revision && !checkingConnection { + lastContactAt = Date() + peerConnectionIssue = nil + } + } catch { + if connectionRevision == revision && !checkingConnection { + peerConnectionIssue = error.localizedDescription + } + } + } + cycle += 1 + do { try await Task.sleep(for: .seconds(10)) } catch { break } + } + } + } + + func stop() { heartbeat?.cancel(); saveTask?.cancel(); connectionCheck?.cancel() } + + func saveSoon() { + submissionStatusID = nil + editRevision += 1 + saveTask?.cancel() + saveStatus = "正在保留草稿…" + saveTask = Task { + do { + try await Task.sleep(for: .milliseconds(250)) + try Task.checkCancellation() + try await flush() + } catch is CancellationError { + } catch { + guard !Task.isCancelled else { return } + self.error = error.localizedDescription + saveStatus = "尚未保留,请勿退出" + } + } + } + + func flush() async throws { + guard draft.state == .draft else { return } + if !hasContent { + let id = draft.id + let revision = editRevision + if try await store.record(id) != nil { try await store.discardDraft(id) } + if draft.id == id, revision == editRevision { saveStatus = "随时写下想法,或加入材料" } + try await refresh() + return + } + draft.updatedAt = Date() + let value = draft + let revision = editRevision + saveStatus = "正在保留草稿…" + do { try await store.saveDraft(value) } + catch { + if draft.id == value.id, revision == editRevision { saveStatus = "尚未保留,请勿退出" } + throw error + } + if draft.id == value.id, revision == editRevision { saveStatus = "草稿已保留在本机" } + try await refresh() + } + + func newThought(notice: String? = nil) { + guard !submitting else { return } + submitting = true + selectionRequest = UUID() + Task { + defer { submitting = false } + do { + saveTask?.cancel() + try await flush() + draft = CaptureRecord() + submissionStatusID = nil + captureNotice = notice + saveStatus = "写下此刻的想法" + revealEditor() + } catch { self.error = error.localizedDescription } + } + } + + func pasteMaterial() { + guard !submitting else { return } + submitting = true + selectionRequest = UUID() + Task { + defer { submitting = false } + do { + let materials = try ClipboardReader.materials() + guard !materials.isEmpty else { throw CaptureError.invalid("剪贴板没有可用的文字、链接或图片。") } + saveTask?.cancel() + try await flush() + draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: materials)) + submissionStatusID = nil + captureNotice = nil + try await flush() + revealEditor() + } catch { self.error = error.localizedDescription; presentEditor?() } + } + } + + func receive(_ payload: CapturePayload, request: UUID? = nil, notice: String? = nil) async { + if let request, request != selectionRequest { return } + guard !submitting else { + error = "上一份材料正在保留,请稍后重新采集。" + presentEditor?() + return + } + submitting = true + defer { submitting = false } + do { + try payload.validate() + saveTask?.cancel() + try await flush() + if let request, request != selectionRequest { return } + draft = CaptureRecord(payload: payload) + submissionStatusID = nil + captureNotice = notice + try await flush() + revealEditor() + } catch { self.error = error.localizedDescription; presentEditor?() } + } + + func submit() { + guard !submitting, hasContent else { return } + guard let connection else { openSettings(); showingConnectionEditor = true; return } + // An input method owns Return until its marked text is committed. + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.hasMarkedText() { return } + submitting = true + saveTask?.cancel() + Task { + do { + let frozen = try await store.freeze(draft, target: connection) + draft = CaptureRecord() + submissionStatusID = frozen.id + saveStatus = frozen.statusLabel + try await refresh() + submitting = false + await sendPending(force: true) + } catch { submitting = false; self.error = error.localizedDescription } + } + } + + func sendPending(force: Bool = false) async { + do { + try await queue.process(force: force) + try await refresh() + } + catch { self.error = error.localizedDescription } + } + + func refresh() async throws { + records = try await store.records() + if !hasContent, let id = submissionStatusID, let record = records.first(where: { $0.id == id }) { + saveStatus = record.statusLabel + } + } + + func resume(_ record: CaptureRecord) { + guard record.state == .draft, !submitting else { return } + submitting = true + selectionRequest = UUID() + Task { + defer { submitting = false } + do { + saveTask?.cancel() + try await flush() + guard let current = try await store.record(record.id), + current.state == .draft else { + throw CaptureError.invalid("这份草稿已变更,请刷新记录后再打开。") + } + draft = current + submissionStatusID = nil + saveStatus = "已恢复本机草稿" + pane = .capture + } catch { self.error = error.localizedDescription } + } + } + + func retry(_ record: CaptureRecord) { + Task { + do { try await store.retry(record.id); await sendPending(force: true) } + catch { self.error = error.localizedDescription } + } + } + + func requestRemoveMaterial(_ id: UUID) { + guard !submitting, let materials = draft.payload?.materials, + materials.contains(where: { $0.id == id }) else { return } + removalRequest = .material(draftID: draft.id, materialID: id) + } + + func requestDiscard(_ record: CaptureRecord, close: Bool = false) { + guard !submitting else { return } + if record.id == draft.id && !hasContent { + if close { dismissQuickCapture?() } + return + } + removalRequest = .record(record, close: close) + } + + func confirmRemoval(_ request: RemovalRequest) { + removalRequest = nil + guard !submitting else { return } + switch request { + case .material(let draftID, let materialID): + guard draft.id == draftID else { return } + draft.payload?.materials.removeAll { $0.id == materialID } + saveSoon() + case .record(let record, let close): discard(record, close: close) + } + } + + func cancelCapture(close: Bool = true) { requestDiscard(draft, close: close) } + + func cancel(_ record: CaptureRecord) { + Task { + do { try await store.cancel(record.id); try await refresh() } + catch { self.error = error.localizedDescription } + } + } + + private func discard(_ record: CaptureRecord, close: Bool) { + guard !submitting else { return } + submitting = true + selectionRequest = UUID() + saveTask?.cancel() + Task { + defer { submitting = false } + do { + if draft.id != record.id { try await flush() } + try await store.deleteRecord(record.id) + if draft.id == record.id { + draft = CaptureRecord() + submissionStatusID = nil + captureNotice = nil + saveStatus = "本机草稿已删除" + } + try await refresh() + if close { dismissQuickCapture?() } + } catch { self.error = error.localizedDescription } + } + } + + func openSettings(target: ConnectionTarget? = nil) { + editingConnection = target ?? connection + pane = .settings + showMain?() + if target != nil { showingConnectionEditor = true } + } + + func continueInMain() { + pane = .capture + showMain?() + } + + private func revealEditor() { + if editorOwner == .main { pane = .capture } + presentEditor?() + } + + func requestScreenRecordingAccess() { + if CGRequestScreenCaptureAccess() { screenCaptureNotice = nil } + else { screenCaptureNotice = "授权后请完全退出并重新打开 InKCre。若系统开关已开启,也请检查当前应用是否需要重新授权。" } + } + + func openScreenRecordingSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } + + func saveConnection(server: String, web: String, secret: String) async throws { + guard let address = URL(string: server.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CaptureError.invalid("请输入信息库 PostgREST 地址。") + } + let web = web.trimmingCharacters(in: .whitespacesAndNewlines) + let webAddress = web.isEmpty ? nil : URL(string: web) + if !web.isEmpty && webAddress == nil { throw CaptureError.invalid("Web 地址无效。") } + let previous = editingConnection + let sameTarget = previous?.postgrestURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + == address.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let target = ConnectionTarget( + id: sameTarget ? previous!.id : UUID(), peerID: sameTarget ? previous!.peerID : UUID(), + postgrestURL: address, webURL: webAddress, name: address.host ?? "我的信息库" + ) + try target.validate() + if !secret.isEmpty { try credentials.save(secret, for: target.id) } + let savedSecret = try credentials.read(for: target.id) + try await store.saveConnection(target) + connection = target + editingConnection = target + connectionRevision = UUID() + lastContactAt = nil + peerConnectionIssue = nil + checkConnection(target, secret: savedSecret) + } + + func checkConnection() { + guard let target = connection else { return } + do { checkConnection(target, secret: try credentials.read(for: target.id)) } + catch { peerConnectionIssue = error.localizedDescription } + } + + private func checkConnection(_ target: ConnectionTarget, secret: String) { + connectionCheck?.cancel() + checkingConnection = true + let revision = connectionRevision + connectionCheck = Task { + defer { if !Task.isCancelled && connectionRevision == revision { checkingConnection = false } } + do { + try await PeerClient().checkConnection(target, secret: secret) + guard !Task.isCancelled, connectionRevision == revision else { return } + lastContactAt = Date() + peerConnectionIssue = nil + } catch { + guard !Task.isCancelled, connectionRevision == revision else { return } + peerConnectionIssue = error.localizedDescription + } + } + } + + func openResult(_ record: CaptureRecord) { + guard let result = record.result, result.availability == .available, + var url = record.target?.webURL else { + error = "未配置 Web 地址;提交引用仍保留在此。" + return + } + for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { url.appendPathComponent(part) } + NSWorkspace.shared.open(url) + } + + func export(_ record: CaptureRecord) { + guard record.payload != nil else { return } + let panel = NSSavePanel() + panel.nameFieldStringValue = "capture-\(record.id.uuidString).json" + if panel.runModal() == .OK, let url = panel.url { + do { try CaptureJSON.encode(record).write(to: url, options: .atomic) } + catch { self.error = error.localizedDescription } + } + } +} diff --git a/Sources/InKCreApp/CaptureRecordsView.swift b/Sources/InKCreApp/CaptureRecordsView.swift new file mode 100644 index 0000000..4f81d71 --- /dev/null +++ b/Sources/InKCreApp/CaptureRecordsView.swift @@ -0,0 +1,57 @@ +import AppKit +import CaptureCore +import SwiftUI + +struct CaptureRecordsView: View { + @ObservedObject var model: CaptureModel + var body: some View { + Group { + if model.records.isEmpty { + ContentUnavailableView("还没有采集记录", systemImage: "tray", + description: Text("草稿、待收入和已收入的记录会显示在这里。")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(model.records) { record in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(record.statusLabel).font(.headline) + Spacer() + Text(record.updatedAt, style: .time).font(.caption).foregroundStyle(.secondary) + } + if let text = record.payload?.materials.first(where: { $0.kind == .text })?.text { + Text(text).lineLimit(3) + } else if let material = record.payload?.materials.first(where: { $0.kind == .image }), + let data = material.data, let image = NSImage(data: data) { + Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 100) + .accessibilityLabel("记录中的图片") + } + if let message = record.message { Text(message).font(.caption).foregroundStyle(.secondary) } + if let target = record.target { Text(target.name).font(.caption).foregroundStyle(.secondary) } + if let result = record.result { Text("Block \(result.rootBlockID)").font(.caption).textSelection(.enabled) } + HStack { + if record.state == .draft { + Button("继续编辑") { model.resume(record) } + } else if record.state == .confirmed { + Button("打开") { model.openResult(record) } + .disabled(record.result?.availability == .deleted) + } else { + Button(record.state == .cancelled ? "继续收入" : "立即重试") { model.retry(record) } + if record.state != .cancelled { + Button("取消收入") { model.cancel(record) } + } + Button("修复连接") { model.openSettings(target: record.target) } + } + Button("删除记录", role: .destructive) { model.requestDiscard(record) } + if record.payload != nil { Button("导出材料") { model.export(record) } } + } + } + Divider() + } + }.padding() + } + } + }.disabled(model.submitting) + } +} diff --git a/Sources/InKCreApp/ClipboardReader.swift b/Sources/InKCreApp/ClipboardReader.swift new file mode 100644 index 0000000..699207d --- /dev/null +++ b/Sources/InKCreApp/ClipboardReader.swift @@ -0,0 +1,38 @@ +import AppKit +import CaptureCore +import UniformTypeIdentifiers + +@MainActor +enum ClipboardReader { + static func materials() throws -> [Material] { + var result: [Material] = [] + let supported: [(NSPasteboard.PasteboardType, String)] = [ + (.png, "image/png"), (.init(UTType.jpeg.identifier), "image/jpeg"), + (.init(UTType.gif.identifier), "image/gif"), (.init(UTType.webP.identifier), "image/webp"), + ] + for item in NSPasteboard.general.pasteboardItems ?? [] { + var image: Material? + for (type, mime) in supported { + if let data = item.data(forType: type) { + image = Material(image: data, mimeType: mime) + break + } + } + if let image { result.append(image); continue } + if let data = item.data(forType: .tiff), + let bitmap = NSBitmapImageRep(data: data), + let png = bitmap.representation(using: .png, properties: [:]) { + result.append(Material(image: png, mimeType: "image/png")) + continue + } + if item.types.contains(.fileURL) { + throw CaptureError.invalid("首版不导入文件。请粘贴文字、链接或图片本身。") + } + if let text = item.string(forType: .string) ?? item.string(forType: .URL), !text.isEmpty { + result.append(Material(text: text)) + } + } + try CapturePayload(entry: .paste, materials: result).validate() + return result + } +} diff --git a/Sources/InKCreApp/ConnectionEditor.swift b/Sources/InKCreApp/ConnectionEditor.swift new file mode 100644 index 0000000..826787e --- /dev/null +++ b/Sources/InKCreApp/ConnectionEditor.swift @@ -0,0 +1,52 @@ +import CaptureCore +import SwiftUI + +struct ConnectionEditor: View { + @ObservedObject var model: CaptureModel + @Environment(\.dismiss) private var dismiss + @State private var server: String + @State private var web: String + @State private var secret = "" + @State private var saving = false + @State private var issue: String? + @FocusState private var addressFocused: Bool + + init(model: CaptureModel, target: ConnectionTarget?) { + self.model = model + _server = State(initialValue: target?.postgrestURL.absoluteString ?? "") + _web = State(initialValue: target?.webURL?.absoluteString ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("连接共享信息库").font(.title2).bold() + Form { + TextField("PostgREST 地址", text: $server).focused($addressFocused) + TextField("Web 地址(可选)", text: $web) + SecureField("部署签名凭据", text: $secret) + }.textFieldStyle(.roundedBorder).disabled(saving) + Text("凭据保存在本机 Keychain。已有连接留空可沿用原凭据;更换地址会建立新连接,旧待办仍属于原信息库。") + .font(.caption).foregroundStyle(.secondary) + if let issue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + HStack { + Spacer() + Button("取消") { dismiss() }.keyboardShortcut(.cancelAction).disabled(saving) + Button(saving ? "正在保存…" : "保存并检查") { + saving = true + issue = nil + Task { + defer { saving = false } + do { + try await model.saveConnection(server: server, web: web, secret: secret) + secret = "" + dismiss() + } catch { issue = error.localizedDescription } + } + }.keyboardShortcut(.defaultAction) + .disabled(saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(20).frame(width: 520) + .interactiveDismissDisabled(saving) + .onAppear { addressFocused = true } + } +} diff --git a/Sources/InKCreApp/ConnectionView.swift b/Sources/InKCreApp/ConnectionView.swift new file mode 100644 index 0000000..cf82f4b --- /dev/null +++ b/Sources/InKCreApp/ConnectionView.swift @@ -0,0 +1,81 @@ +import AppKit +import SwiftUI +@preconcurrency import ApplicationServices + +struct ConnectionView: View { + @ObservedObject var model: CaptureModel + @State private var selectionAllowed = AXIsProcessTrusted() + @State private var screenAllowed = CGPreflightScreenCaptureAccess() + + var body: some View { + Form { + Section("此 Mac Peer") { + LabeledContent("当前能力", value: "收集:选区、粘贴、截图、想法") + LabeledContent("信息库", value: model.connection?.name ?? "尚未配置") + if let connection = model.connection { + LabeledContent("Peer 身份") { Text(connection.peerID.uuidString).textSelection(.enabled) } + if model.checkingConnection { + HStack { ProgressView().controlSize(.small); Text("正在检查连接…") } + } else if let issue = model.peerConnectionIssue { + Text("最近检查未通过:\(issue)").foregroundStyle(.secondary) + } else if let date = model.lastContactAt { + LabeledContent("最近检查通过") { Text(date, format: .dateTime.month().day().hour().minute().second()) } + Text("Capture 执行能力可用").font(.caption).foregroundStyle(.secondary) + } else { Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) } + } + LabeledContent("本机待收入", value: "\(model.pendingCount) 项") + Text("此 Mac 与其他 Peer 共享同一个信息库。关闭主窗口后仍会保留草稿、处理待办;退出 InKCre 才停止运行。") + .font(.caption).foregroundStyle(.secondary) + } + Section("连接共享信息库") { + if let target = model.connection { + LabeledContent("PostgREST 地址") { Text(target.postgrestURL.absoluteString).textSelection(.enabled) } + if let web = target.webURL { + LabeledContent("Web 地址") { Text(web.absoluteString).textSelection(.enabled) } + } + } else { + Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") + .foregroundStyle(.secondary) + } + Button(model.connection == nil ? "连接信息库…" : "编辑连接…") { + model.editingConnection = model.connection + model.showingConnectionEditor = true + } + if model.connection != nil { + Button("重新检查连接") { model.checkConnection() }.disabled(model.checkingConnection) + } + } + Section("快捷键") { + ShortcutSettingsView(shortcuts: model.shortcuts) + } + Section("系统权限") { + LabeledContent("读取选区", value: selectionAllowed ? "已允许" : "尚未允许") + if !selectionAllowed { + Button("允许读取选区(辅助功能)") { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + } + LabeledContent("屏幕录制", value: screenAllowed ? "已允许" : "尚未允许") + if !screenAllowed { + Button("请求屏幕录制权限") { model.requestScreenRecordingAccess(); refreshPermissions() } + } + Button("打开屏幕录制权限设置") { model.openScreenRecordingSettings() } + Text("选区与截图分别需要系统授权;输入和明确粘贴不需要这两项权限。") + .font(.caption).foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .sheet(isPresented: $model.showingConnectionEditor) { + ConnectionEditor(model: model, target: model.editingConnection) + } + .onDisappear { model.shortcuts.cancelRecording() } + .onAppear { refreshPermissions() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshPermissions() } + } + + private func refreshPermissions() { + selectionAllowed = AXIsProcessTrusted() + screenAllowed = CGPreflightScreenCaptureAccess() + } +} diff --git a/Sources/InKCreApp/GlobalShortcuts.swift b/Sources/InKCreApp/GlobalShortcuts.swift new file mode 100644 index 0000000..c4d5489 --- /dev/null +++ b/Sources/InKCreApp/GlobalShortcuts.swift @@ -0,0 +1,165 @@ +import AppKit +import Carbon.HIToolbox +import CaptureCore +import Combine + +@MainActor +final class GlobalShortcuts: ObservableObject { + @Published private(set) var bindings: [ShortcutAction: RecordedShortcut] + @Published private(set) var recording: ShortcutAction? + @Published private(set) var message: String? + var perform: ((ShortcutAction) -> Void)? + var isCaptureWindow: ((NSWindow?) -> Bool)? + private let defaults: UserDefaults + private var keys: [ShortcutAction: EventHotKeyRef] = [:] + private var handler: EventHandlerRef? + private var monitor: Any? + private var resignObserver: NSObjectProtocol? + static let settingsKey = "individualShortcuts.v1" + + init(defaults: UserDefaults = .standard) throws { + self.defaults = defaults + if let data = defaults.data(forKey: Self.settingsKey) { + bindings = try JSONDecoder().decode([ShortcutAction: RecordedShortcut].self, from: data) + } else { + bindings = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) + // Preserve an existing preset, including deliberate disabling, on first upgrade. + switch defaults.string(forKey: "shortcutPreset") { + case "commandShift": + for action in ShortcutAction.allCases where action.isGlobal { + let old = action.defaultShortcut + bindings[action] = RecordedShortcut(keyCode: old.keyCode, modifiers: [.command, .shift], keyLabel: old.keyLabel) + } + case "disabled": bindings[.capture] = nil; bindings[.screenshot] = nil + default: break + } + } + for (action, shortcut) in bindings { try validate(shortcut, for: action) } + } + + func start() throws { + guard handler == nil else { return } + var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let status = InstallEventHandler(GetApplicationEventTarget(), { _, event, context in + guard let event, let context else { return OSStatus(eventNotHandledErr) } + var identity = EventHotKeyID() + let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, + MemoryLayout.size, nil, &identity) + guard status == noErr, identity.signature == 0x494e4b43, + let action = ShortcutAction(rawValue: identity.id) else { return OSStatus(eventNotHandledErr) } + let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() + MainActor.assumeIsolated { if owner.recording == nil { owner.perform?(action) } } + return noErr + }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) + guard status == noErr else { throw CaptureError.needsAction("无法启用快捷键事件,请重新打开 InKCre。") } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + let consumed = MainActor.assumeIsolated { + guard let self else { return false } + return self.handle(event) == nil + } + return consumed ? nil : event + } + resignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in MainActor.assumeIsolated { self?.cancelRecording() } } + restoreGlobals() + } + + func stop() { + for key in keys.values { UnregisterEventHotKey(key) } + keys.removeAll() + if let handler { RemoveEventHandler(handler); self.handler = nil } + if let monitor { NSEvent.removeMonitor(monitor); self.monitor = nil } + if let resignObserver { NotificationCenter.default.removeObserver(resignObserver); self.resignObserver = nil } + recording = nil + } + + func set(_ shortcut: RecordedShortcut?, for action: ShortcutAction) throws { + if let shortcut { try validate(shortcut, for: action) } + var changed = bindings + changed[action] = shortcut + let data = try JSONEncoder().encode(changed) + if handler != nil, recording == nil, action.isGlobal, + shortcut != bindings[action] || keys[action] == nil { + if let shortcut { try register(shortcut, for: action) } + else if let old = keys.removeValue(forKey: action) { UnregisterEventHotKey(old) } + } + defaults.set(data, forKey: Self.settingsKey) + bindings = changed + message = nil + } + + func update(_ shortcut: RecordedShortcut?, for action: ShortcutAction) { + cancelRecording() + do { try set(shortcut, for: action) } + catch { message = error.localizedDescription } + } + + func beginRecording(_ action: ShortcutAction) { + for key in keys.values { UnregisterEventHotKey(key) } + keys.removeAll() + recording = action + message = nil + } + + func cancelRecording() { + guard recording != nil else { return } + recording = nil + restoreGlobals() + } + + func handle(_ event: NSEvent) -> NSEvent? { + if let action = recording { + guard !event.isARepeat else { return nil } + let shortcut = RecordedShortcut(event: event) + if event.keyCode == kVK_Escape, shortcut.modifierBits == 0 { cancelRecording(); return nil } + do { + try validate(shortcut, for: action) + cancelRecording() + try set(shortcut, for: action) + } catch { message = error.localizedDescription } + return nil + } + guard isCaptureWindow?(event.window) == true, + NSApp.modalWindow == nil, + event.window?.attachedSheet == nil, + (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() != true else { return event } + let modifiers = event.modifierFlags.intersection(RecordedShortcut.allowedModifiers).rawValue + guard let action = ShortcutAction.allCases.first(where: { + !$0.isGlobal && bindings[$0]?.keyCode == event.keyCode && bindings[$0]?.modifierBits == modifiers + }) else { return event } + if !event.isARepeat { perform?(action) } + return nil + } + + private func validate(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { + try shortcut.validate(for: action) + if let other = ShortcutAction.allCases.first(where: { $0 != action && bindings[$0] == shortcut }) { + throw CaptureError.invalid("\(shortcut.label) 已用于“\(other.title)”,请换一个组合。原设置未更改。") + } + } + + private func register(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { + var reference: EventHotKeyRef? + let status = RegisterEventHotKey(UInt32(shortcut.keyCode), shortcut.carbonModifiers, + EventHotKeyID(signature: 0x494e4b43, id: action.rawValue), + GetApplicationEventTarget(), 0, &reference) + guard status == noErr, let reference else { + throw CaptureError.needsAction("“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") + } + // Register the replacement first; failure must not remove the working shortcut. + if let old = keys.updateValue(reference, forKey: action) { UnregisterEventHotKey(old) } + } + + private func restoreGlobals() { + guard handler != nil else { return } + var failures: [String] = [] + for action in ShortcutAction.allCases where action.isGlobal && keys[action] == nil { + guard let shortcut = bindings[action] else { continue } + do { try register(shortcut, for: action) } + catch { failures.append(error.localizedDescription) } + } + if !failures.isEmpty { message = failures.joined(separator: "\n") } + } +} diff --git a/Sources/InKCreApp/KeyboardShortcut.swift b/Sources/InKCreApp/KeyboardShortcut.swift new file mode 100644 index 0000000..825fa9f --- /dev/null +++ b/Sources/InKCreApp/KeyboardShortcut.swift @@ -0,0 +1,74 @@ +import AppKit +import Carbon.HIToolbox +import CaptureCore + +enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable { + case capture = 1, screenshot, paste, submit, hide + var id: UInt32 { rawValue } + var isGlobal: Bool { self == .capture || self == .screenshot } + var title: String { + switch self { + case .capture: "随手收集" + case .screenshot: "框选截图" + case .paste: "粘贴材料" + case .submit: "收入" + case .hide: "收起面板" + } + } + var defaultShortcut: RecordedShortcut { + switch self { + case .capture: RecordedShortcut(keyCode: UInt16(kVK_ANSI_I), modifiers: [.control, .option], keyLabel: "I") + case .screenshot: RecordedShortcut(keyCode: UInt16(kVK_ANSI_S), modifiers: [.control, .option], keyLabel: "S") + case .paste: RecordedShortcut(keyCode: UInt16(kVK_ANSI_V), modifiers: [.command, .shift], keyLabel: "V") + case .submit: RecordedShortcut(keyCode: UInt16(kVK_Return), modifiers: .command, keyLabel: "↩") + case .hide: RecordedShortcut(keyCode: UInt16(kVK_Escape), modifiers: [], keyLabel: "Esc") + } + } +} + +struct RecordedShortcut: Codable, Equatable, Sendable { + let keyCode: UInt16 + let modifierBits: UInt + let keyLabel: String + static let allowedModifiers: NSEvent.ModifierFlags = [.control, .option, .shift, .command] + + init(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, keyLabel: String) { + self.keyCode = keyCode + modifierBits = modifiers.intersection(Self.allowedModifiers).rawValue + self.keyLabel = keyLabel + } + + init(event: NSEvent) { + let special: [Int: String] = [kVK_Return: "↩", kVK_Escape: "Esc", kVK_Tab: "⇥", kVK_Space: "空格", + kVK_Delete: "⌫", kVK_ForwardDelete: "⌦", kVK_LeftArrow: "←", kVK_RightArrow: "→", + kVK_UpArrow: "↑", kVK_DownArrow: "↓", kVK_Home: "↖", kVK_End: "↘", + kVK_PageUp: "⇞", kVK_PageDown: "⇟"] + let functionKeys = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10, + kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20] + let label = special[Int(event.keyCode)] + ?? functionKeys.firstIndex(of: Int(event.keyCode)).map { "F\($0 + 1)" } + ?? event.characters(byApplyingModifiers: [])?.uppercased() ?? "按键 \(event.keyCode)" + self.init(keyCode: event.keyCode, modifiers: event.modifierFlags, keyLabel: label) + } + + var modifiers: NSEvent.ModifierFlags { .init(rawValue: modifierBits) } + var label: String { + [(NSEvent.ModifierFlags.control, "⌃"), (.option, "⌥"), (.shift, "⇧"), (.command, "⌘")] + .filter { modifiers.contains($0.0) }.map(\.1).joined() + keyLabel + } + var carbonModifiers: UInt32 { + [(NSEvent.ModifierFlags.control, controlKey), (.option, optionKey), (.shift, shiftKey), (.command, cmdKey)] + .reduce(0) { $0 | (modifiers.contains($1.0) ? UInt32($1.1) : 0) } + } + static func == (left: Self, right: Self) -> Bool { + left.keyCode == right.keyCode && left.modifierBits == right.modifierBits + } + func validate(for action: ShortcutAction) throws { + let plainEscape = action == .hide && keyCode == kVK_Escape && modifierBits == 0 + guard keyCode <= 127, !keyLabel.isEmpty, + modifierBits & ~Self.allowedModifiers.rawValue == 0, + plainEscape || !modifiers.intersection([.command, .control, .option]).isEmpty else { + throw CaptureError.invalid("请同时按住 ⌘、⌃ 或 ⌥,避免影响正常输入。") + } + } +} diff --git a/Sources/InKCreApp/PeerMainView.swift b/Sources/InKCreApp/PeerMainView.swift new file mode 100644 index 0000000..1dfa224 --- /dev/null +++ b/Sources/InKCreApp/PeerMainView.swift @@ -0,0 +1,149 @@ +import SwiftUI + +struct PeerMainView: View { + @ObservedObject var model: CaptureModel + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + NavigationSplitView { + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark) + .resizable().frame(width: 32, height: 32).accessibilityHidden(true) + VStack(alignment: .leading) { + Text("InKCre").font(.headline) + Text("Mac Peer").font(.caption).foregroundStyle(.secondary) + } + Spacer() + }.padding() + List(selection: Binding(get: { Optional(model.pane) }, set: { + guard let value = $0 else { return } + if value == .settings { model.openSettings() } + else { model.pane = value } + })) { + Label("采集", systemImage: "square.and.pencil").tag(CaptureModel.Pane.capture) + Label("记录", systemImage: "tray.full").tag(CaptureModel.Pane.queue) + Label("Peer 与连接", systemImage: "network").tag(CaptureModel.Pane.settings) + }.listStyle(.sidebar) + VStack(alignment: .leading, spacing: 4) { + Text(model.connection?.name ?? "未连接信息库") + Text("\(model.pendingCount) 项待收入").foregroundStyle(.secondary) + }.font(.caption).frame(maxWidth: .infinity, alignment: .leading).padding() + }.navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 260) + } detail: { + Group { + switch model.pane { + case .capture: + if model.editorOwner == .quick { + ContentUnavailableView { + Label("正在快捷采集", systemImage: "rectangle.on.rectangle") + } description: { + Text("当前草稿正在快捷面板中编辑。") + } actions: { + Button("在主窗口继续") { model.continueInMain() } + } + } else { CaptureEditor(model: model) } + case .queue: CaptureRecordsView(model: model) + case .settings: ConnectionView(model: model) + } + }.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(title) + } + .toolbar { + ToolbarItemGroup(placement: .automatic) { + if model.pane == .capture && model.editorOwner == .main { + Button("新想法", systemImage: "square.and.pencil") { model.newThought() } + .help("新想法(⌘N)") + .disabled(model.submitting) + Button("粘贴材料", systemImage: "doc.on.clipboard") { model.pasteMaterial() } + .help("粘贴材料") + .disabled(model.submitting) + Button("框选截图", systemImage: "viewfinder") { model.takeScreenshot?() } + .help("框选截图") + .disabled(model.submitting) + } + Button("快捷采集", systemImage: "rectangle.and.pencil.and.ellipsis") { model.showQuick?() } + .help("打开快捷采集面板") + } + } + .modifier(CaptureAlerts(model: model, owner: .main)) + } + + private var title: String { + switch model.pane { + case .capture: "采集" + case .queue: "记录" + case .settings: "Peer 与连接" + } + } +} + +struct QuickCaptureView: View { + @ObservedObject var model: CaptureModel + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 0) { + HStack { + Image(nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark) + .resizable().frame(width: 24, height: 24).accessibilityHidden(true) + Text("随手收入 InKCre").font(.headline) + Spacer() + Button("在主窗口继续") { model.continueInMain() } + }.padding() + Divider() + if model.editorOwner == .quick { CaptureEditor(model: model, isQuickCapture: true) } + } + .frame(minWidth: 500, minHeight: 400) + .modifier(CaptureAlerts(model: model, owner: .quick)) + } +} + +private struct CaptureAlerts: ViewModifier { + @ObservedObject var model: CaptureModel + let owner: CaptureModel.EditorOwner + + private enum PresentedAlert { + case error(String) + case removal(CaptureModel.RemovalRequest) + + var title: String { + switch self { + case .error: "需要处理" + case .removal(let request): request.title + } + } + + var message: String { + switch self { + case .error(let message): message + case .removal(let request): request.message + } + } + } + + private var presentedAlert: PresentedAlert? { + if let error = model.error { return .error(error) } + return model.removalRequest.map(PresentedAlert.removal) + } + + func body(content: Content) -> some View { + content.alert(presentedAlert?.title ?? "需要处理", isPresented: Binding( + get: { presentedAlert != nil && model.editorOwner == owner }, + set: { + if !$0 && model.editorOwner == owner { + model.error = nil + model.removalRequest = nil + } + } + ), presenting: presentedAlert) { alert in + switch alert { + case .error: + Button("知道了", role: .cancel) { model.error = nil } + case .removal(let request): + Button(request.actionTitle, role: .destructive) { model.confirmRemoval(request) } + Button("取消", role: .cancel) { model.removalRequest = nil } + } + } message: { alert in Text(alert.message) } + } +} diff --git a/Sources/InKCreApp/PeerMainWindow.swift b/Sources/InKCreApp/PeerMainWindow.swift new file mode 100644 index 0000000..69e7a8d --- /dev/null +++ b/Sources/InKCreApp/PeerMainWindow.swift @@ -0,0 +1,19 @@ +import AppKit +import SwiftUI + +@MainActor +final class PeerMainWindow: NSWindow { + init(model: CaptureModel) { + super.init(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + title = "InKCre" + toolbarStyle = .unified + minSize = NSSize(width: 760, height: 520) + isReleasedWhenClosed = false + collectionBehavior = [.fullScreenPrimary] + contentViewController = NSHostingController(rootView: PeerMainView(model: model)) + if !setFrameUsingName("InKCreMainWindow") { center() } + setFrameAutosaveName("InKCreMainWindow") + } +} diff --git a/Sources/InKCreApp/RegionCapture.swift b/Sources/InKCreApp/RegionCapture.swift new file mode 100644 index 0000000..b0206e8 --- /dev/null +++ b/Sources/InKCreApp/RegionCapture.swift @@ -0,0 +1,248 @@ +import AppKit +import CaptureCore +import ScreenCaptureKit + +struct ScreenRecordingPermissionError: LocalizedError { + var errorDescription: String? { + "系统尚未允许当前 InKCre 读取屏幕。请在设置中授权,随后完全退出并重新打开应用。" + } +} + +@MainActor +final class RegionCapture { + private var windows: [NSWindow] = [] + private var completion: ((Result) -> Void)? + private let hasPermission: () -> Bool + var isCapturing: Bool { completion != nil } + + init(hasPermission: @escaping () -> Bool = CGPreflightScreenCaptureAccess) { + self.hasPermission = hasPermission + } + + func begin(completion: @escaping (Result) -> Void) { + guard self.completion == nil else { return } + // Checking is silent; requesting access is an explicit settings action, never a retry side effect. + guard hasPermission() else { + completion(.failure(ScreenRecordingPermissionError())) + return + } + self.completion = completion + guard !NSScreen.screens.isEmpty else { + finish(.failure(CaptureError.invalid("没有可用的屏幕,请重新截图。"))) + return + } + NSApp.activate(ignoringOtherApps: true) + for screen in NSScreen.screens { + let window = RegionWindow(contentRect: screen.frame, styleMask: .borderless, + backing: .buffered, defer: false) + window.level = .screenSaver + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.title = "框选截图" + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.isReleasedWhenClosed = false + let overlay = RegionOverlay(frame: NSRect(origin: .zero, size: screen.frame.size)) + overlay.didBeginSelection = { [weak self, weak overlay] in + for window in self?.windows ?? [] { + if let other = window.contentView as? RegionOverlay, other !== overlay { other.resetSelection() } + } + } + overlay.complete = { [weak self] rect in + guard let self, !self.windows.isEmpty else { return } + if let rect { self.capture(screen: screen, rect: rect) } + else { self.finish(.success(nil)) } + } + window.contentView = overlay + window.makeFirstResponder(overlay) + windows.append(window) + window.orderFrontRegardless() + if screen.frame.contains(NSEvent.mouseLocation) { window.makeKey() } + } + NSCursor.crosshair.push() + } + + private func hide() { + for window in windows { window.orderOut(nil) } + windows.removeAll() + NSCursor.pop() + } + + private func finish(_ result: Result) { + if !windows.isEmpty { hide() } + let callback = completion + completion = nil + callback?(result) + } + + private func capture(screen: NSScreen, rect: CGRect) { + hide() + Task { + do { + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { + throw CaptureError.invalid("无法识别所选屏幕。") + } + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + guard let display = content.displays.first(where: { $0.displayID == number.uint32Value }) else { + throw CaptureError.invalid("所选屏幕已断开,请重新截图。") + } + let own = content.applications.filter { $0.processID == ProcessInfo.processInfo.processIdentifier } + let filter = SCContentFilter(display: display, excludingApplications: own, exceptingWindows: []) + let configuration = SCStreamConfiguration() + // Overlay coordinates are local, top-left points; SCK maps them to this display. + configuration.sourceRect = rect + configuration.width = max(1, Int((rect.width * CGFloat(filter.pointPixelScale)).rounded())) + configuration.height = max(1, Int((rect.height * CGFloat(filter.pointPixelScale)).rounded())) + configuration.showsCursor = false + configuration.captureResolution = .best + let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) + guard let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else { + throw CaptureError.invalid("截图编码失败,请重试。") + } + try CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]).validate() + finish(.success(data)) + } catch { + let failure = error as NSError + if failure.domain == SCStreamErrorDomain, failure.code == SCStreamError.Code.userDeclined.rawValue { + finish(.failure(ScreenRecordingPermissionError())) + } else { finish(.failure(error)) } + } + } + } +} + +private final class RegionWindow: NSWindow { + override var canBecomeKey: Bool { true } +} + +@MainActor +final class RegionOverlay: NSView { + var complete: ((CGRect?) -> Void)? + var didBeginSelection: (() -> Void)? + private var start: CGPoint? + private(set) var selection = CGRect.zero + private let hint = NSTextField(labelWithString: "拖动框选本屏区域 · Esc 或右键取消") + private let accept = NSButton(title: "截取 ↩", target: nil, action: nil) + private let reset = NSButton(title: "重新框选", target: nil, action: nil) + private let cancel = NSButton(title: "取消", target: nil, action: nil) + override var isFlipped: Bool { true } + override var acceptsFirstResponder: Bool { true } + // A global shortcut can open this view while another app still owns the first click. + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + hint.textColor = .white + hint.font = .systemFont(ofSize: 15, weight: .medium) + for (button, action) in [(accept, #selector(confirmSelection)), (reset, #selector(resetSelection)), (cancel, #selector(cancelSelection))] { + button.target = self + button.action = action + button.bezelStyle = .rounded + addSubview(button) + } + addSubview(hint) + accept.isEnabled = false + reset.isEnabled = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func layout() { + super.layout() + hint.frame = CGRect(x: 24, y: 22, width: max(200, bounds.width - 370), height: 25) + cancel.frame = CGRect(x: bounds.maxX - 110, y: 20, width: 86, height: 32) + accept.frame = CGRect(x: bounds.maxX - 212, y: 20, width: 94, height: 32) + reset.frame = CGRect(x: bounds.maxX - 314, y: 20, width: 94, height: 32) + } + + override func resetCursorRects() { addCursorRect(bounds, cursor: .crosshair) } + + override func draw(_ dirtyRect: NSRect) { + NSGraphicsContext.current?.cgContext.clear(bounds) + let shade = NSBezierPath(rect: bounds) + if !selection.isEmpty { shade.appendRect(selection) } + shade.windingRule = .evenOdd + NSColor.black.withAlphaComponent(0.35).setFill() + shade.fill() + if !selection.isEmpty { + let border = NSBezierPath(rect: selection) + NSColor.black.setStroke() + border.lineWidth = 4 + border.stroke() + NSColor.white.setStroke() + border.lineWidth = 2 + border.stroke() + for point in [CGPoint(x: selection.minX, y: selection.minY), CGPoint(x: selection.maxX, y: selection.minY), + CGPoint(x: selection.minX, y: selection.maxY), CGPoint(x: selection.maxX, y: selection.maxY)] { + let handle = NSBezierPath(rect: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)) + NSColor.white.setFill() + handle.fill() + NSColor.black.setStroke() + handle.lineWidth = 1 + handle.stroke() + } + } + NSColor.black.withAlphaComponent(0.8).setFill() + NSBezierPath(roundedRect: CGRect(x: 12, y: 12, width: bounds.width - 24, height: 50), xRadius: 10, yRadius: 10).fill() + } + + override func mouseDown(with event: NSEvent) { + window?.makeKey() + window?.makeFirstResponder(self) + didBeginSelection?() + start = convert(event.locationInWindow, from: nil) + selection = .zero + accept.isEnabled = false + reset.isEnabled = false + hint.stringValue = "拖动框选本屏区域 · Esc 或右键取消" + needsDisplay = true + } + override func mouseDragged(with event: NSEvent) { + guard let start else { return } + let end = convert(event.locationInWindow, from: nil) + selection = CGRect(x: min(start.x, end.x), y: min(start.y, end.y), + width: abs(start.x - end.x), height: abs(start.y - end.y)).intersection(bounds) + hint.stringValue = "已选 \(Int(selection.width)) × \(Int(selection.height)) · 松手后确认截取" + display() + } + override func mouseUp(with event: NSEvent) { + mouseDragged(with: event) + start = nil + let valid = selection.width >= 2 && selection.height >= 2 + accept.isEnabled = valid + reset.isEnabled = !selection.isEmpty + hint.stringValue = valid ? "已选 \(Int(selection.width)) × \(Int(selection.height)) · 点击截取或按回车" + : "请拖动框选区域 · Esc 或右键取消" + } + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 53: cancelSelection() + case 36, 76: confirmSelection() + default: super.keyDown(with: event) + } + } + override func cancelOperation(_ sender: Any?) { cancelSelection() } + override func rightMouseDown(with event: NSEvent) { cancelSelection() } + + @objc func resetSelection() { + selection = .zero + start = nil + accept.isEnabled = false + reset.isEnabled = false + hint.stringValue = "拖动框选本屏区域 · Esc 或右键取消" + needsDisplay = true + } + @objc func confirmSelection() { + guard accept.isEnabled else { return } + accept.isEnabled = false + finishSelection(selection) + } + @objc func cancelSelection() { finishSelection(nil) } + + private func finishSelection(_ rect: CGRect?) { + let callback = complete + complete = nil + callback?(rect) + } +} diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..e5bfc1be6422601580be3491a3930c65dffc6443 GIT binary patch literal 1512 zcmZWp2{hDu6#mUHV;#~XB9$V^-dMvlG-6UJ`&I@SX|Xifd2fa%ON!LUGRQt9gDAw~ zPf{8UQHhc{_TBKt@*2zIoOjOaym#-p_nvd_J?A_3yZ79LbJi9pVOe1S019u3v)PK| zZ4*Rp`BxYZ=2k#@T3O)0=C(fiqvSpSh-^I0)Gm}ZJ8sO?_)ULf`>V!R|J;kWhu#}#GojLsgF?ZU@($fr{h_y^1i!`>} z64J_(?axaY_B`2*I~CDsvcj6Bjn~XJ%$u}_jnV4%p%D`Q$|NyuyqQLlBB)m&=`gBs zvO58dm9G_c)2qs85Tk_w8mW{bK``QoWpZOXKnZ0S5+?@hGh@7Wf$?u30IW)eYL^b* ztUJU}*YY6tRvBP9z+gO_+(|jl^%M6-rh`VkDhmZU0Th;Z;WyDx)l2SSQ)(3lN3QHp z1FX6lN-pJ7UidrofB@!bRgXd@LWoTAry&P52Y;XJlJ_Jsbr3nH#>WH-8w2oD#0u&W&XoRh#AUYjAj*di+Th=($%clBD5ah_2_WE!~3l^b0_S|dx|Lv z==w=p8WCYYKGYoseNh$5Tb#RoGq(J2CR9oc*7VJr7v>0Mt{s?!VU|_6C`hmK zSUW2?H^0>t%S-d)o82@=qn;kEBBmb5-J)x&(FG~0q`oKw$tKITzmda>M8Y|V7I#Jp z(yAe4pc(ej67ys`d2X40FH$ilOZu6Nx&GAj^l)>GK=ThO(|a!_Qs!x{CW3H}=;IT% zwi;LxC*js#PkGj=0`A#@10?`w4U4D2cNik}-#G6sU=X&bc;{q6&#MVKov*@w5ToH% zTS4z#UJf1)xtK2oK@eD9U!UoJezm#xZc@^SR{ol~Jo#!b16JY9OcP5kByknAZU|#X zB*|}oMsQPL@a-`UO;J9Z!#~~I)AK6FBqDl=&#BVWyzAG|8`?Q#>NwEW))tNNcPw*= z5nz8Vb2a5j#l9fFmGVTSUmbWEWLKKdG>9$u!0s-x+MPtNm(l=nV)CA$p$35i3BM?R zX$T`B4^H{@!?%ZG1h_(OOX1n;yzxz`>h^ZL1H&z2IR53?JU%U5-Pa!qEXLP)tE;u} zl+1p5YPL}bi?=e$d^oP3u3tiyT`D5(&MJ?b=Wq;j)Y9T&FrcXIK~cDsI?Wxi8DKqMWYq=o#tB2blM-G%E-&3K6g4Qz$-%LzgvrY zn7${XLqXyrnSzwN=l`>@{}cVsDt|6G##^+cMcX`<`iQe~pW&SCgHRC=z*>UUP&k7vlKaruri=kAmx<@_ z^u7yhqgS)*~xwC@sMc0c(4!deLC-JN?g-)=o z=itqDWDn>>wSIMGN1-D|zV5dN>`8|JB)w=>Dd8JTFSGBx&KaA*``7#tu9T*p1D~NI@<(K4Tqr&1^+zF+gCotAiDLdDqP2-BvTYj9}$VC-uh(b|j5d4)cP1CKB_?4+Y4iTauov7-t~Z$60j`De{mW z+l1i!`HkC9F~zoeu4e5raiz-GX$+RmH05Uvx5hEd2+@B7)ET}J literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7a7111a454244187ab1342336529ad0a4cbb74e2 GIT binary patch literal 2773 zcmb7Gdpy(o8~<)=lo@9^hOvImbQMJ|hvga>Q|in;OC97I#U^U$e0Ox2Tyj}Ngfbe+ zL|QKOEm2Ba&7}IVmSiECqoQ1Prq}uB*Xwm&=lovZ@9Xt>UZ2bJc|Y&x`8>~)w$qD* zR>P?Q0D$&zcclOTOew;Er7FsSns7N>IjDqql3W4B;#byIl?MQ7r#)PqeUgjE2X<%d z_SNg0R0I(tPn~Y^TRRU#6IQutp|Rk$#{OX!*9op?{(MPW8F~*gcAbk_XyLjo5r^Zww!4(|L#X2BSz2_%Ldxhwzi=gKb9K`xuJYl#VL1At9Zlpu$q_k{_D2vJ6Q49Yd9|Mg;eBRBYkNkWTjN61IAibWmFh+GJ7$TJG*|^ zx@d_t5A{msU!nnc5(Zy!;OvE(7MRZM(M0UQpe~?8pd@F{#VOb_?x7gM;KK6M^dFtb zQ7ZBsbAF{urVMAqPKOfDg*tEzr z3-E(!%}KaOn?B+H+f^iee5@k zV>bYPVa2Pi2R1q82VRBIOGUHPtUyYsjUyqrc=V^60w6!E$mmnLa7Z_|2Eaxv$|fu;gSuJ;RL$ zh`mEYLo+fn&t{ct4Pl+C&mj$cyW!c!;V^gY2B6|}AVBFb0k|Pp(8nD0&&x?ms>LVK z`p<9DB7|o)J2*(DLZe3mfU`IpE@W!>vAyF&>*1**$F^<9-%u*HLtGa3-=qcJI%*3v zL5uAYbp4S}qj_In ztSLR3%keDuY?EVY8wdMe7j1+$J(!uX8GCkRtgC(n<9+=F1JZantkIpEw*9=d(#Z~k zi#JCArpqx(Qk&M?f7uYBIXnF!;>ERnCP7WLYwr8Z1^}j;VqaQX$MpvgPkv>6Pjxz2 zCC&BR@H&UI!j@awCclef6uTAKDKT~zS*5>yR8WxhQ;FrGf4>i$B-mMVNkOXTU?k>!m zk5+^|zF^-%KfWyXWn)@SPL9YtD`xLraO`T!)o;Pw+}bSBH!?EH+z5{J%wKnWX!`G> z_-`WSU%r9=|8!-6M%lt9Gc)Mp$m50UI9=Vf_;T~Vh#`%x!I-}t>rq0lry|WREWZ4# zZ^=T^ZAlu{IhCN^*X3%wpmoJStKyF*YglM#sdr9Afd;bUjcJ zK3W5xVN}{uZa+U8>Yfj0v1Cl0gznt0Py=Q%B+J>>QEl#EXCUSLzM%D3P;9rEhJN@* ztdlZ#pJJv!8B_}nmZ)B=g{hP2=8vlz5+uNc&}v6C_sUJIxV)GI+!~P2g zD%dKZjQPb@MmC+?#9K>{bXl2=={dQ!qWg1cm14rrGU{CR7j-~c7~N$i(%Sw8j>={y zS)C$MQ2ye;vS;&}k|M@c@xdEI!E-yM-}WJ9ROOS)r#L4MV`qF^4FvZ52iu*ELeyu1 z(_T;zyZ3}ly4-1jlkTNLZJc3Gbo>t2{p+n-A7G`I*`gQpTc0d%glTZI{lQh3`Z^!* zq7cbq`GW_tN3ahlsTqhqHg)a^sG90}&r9mhp}7~%+Yy=yG=8C@4*HOza6G-4UCJ7% zk_3B|!5Y-;j&g-5ZS+!|Ksfjl(=AUNg4Y-gUJ*H$IU|8=3Q=pug4amLMybtd&+K=# z9JbZ#sDl}-C%A!UA(zD(+@uxIThXD3UJrmQnjtH^^X$dTetiBUq)7eiq?gcyjRemrr@7)Du!Y^cg~8R(0F;)rKt611H*hJgyt`{y3dBUfy#Krl`W Wgls+GrmE~y0gtU-u2;A0{ryisp5lW5 literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..2a45c297dc055bd3beb5ab576b911b2e7d0c255f GIT binary patch literal 379 zcmV->0fhdEP)t7}s@KtyX`M&gV1x{oeYX=TWcM0WbiPRMlm> z-LhCLtUnwM>2x|6V@$20?j_v-r#EU(a9<$=zRNO8{$Od0`QZv^Uk~KfV_Hi3dSmKG Z{Q~Wn5+$DIYLox~002ovPDHLkV1lRvpVt5Y literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..57e7d2e94e60e6b75870acc842f418e98ea8eed0 GIT binary patch literal 520 zcmV+j0{8uiP)$oBALSzWHX}Z@&4H0B3E9o`WE?K7z~fc&in{%yi9JQ$vcv7Bo{dvFG#+UyiHxlf2VJgC#Uv8D;=5u0000< KMNUMnLSTX#P2phx literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..7a7111a454244187ab1342336529ad0a4cbb74e2 GIT binary patch literal 2773 zcmb7Gdpy(o8~<)=lo@9^hOvImbQMJ|hvga>Q|in;OC97I#U^U$e0Ox2Tyj}Ngfbe+ zL|QKOEm2Ba&7}IVmSiECqoQ1Prq}uB*Xwm&=lovZ@9Xt>UZ2bJc|Y&x`8>~)w$qD* zR>P?Q0D$&zcclOTOew;Er7FsSns7N>IjDqql3W4B;#byIl?MQ7r#)PqeUgjE2X<%d z_SNg0R0I(tPn~Y^TRRU#6IQutp|Rk$#{OX!*9op?{(MPW8F~*gcAbk_XyLjo5r^Zww!4(|L#X2BSz2_%Ldxhwzi=gKb9K`xuJYl#VL1At9Zlpu$q_k{_D2vJ6Q49Yd9|Mg;eBRBYkNkWTjN61IAibWmFh+GJ7$TJG*|^ zx@d_t5A{msU!nnc5(Zy!;OvE(7MRZM(M0UQpe~?8pd@F{#VOb_?x7gM;KK6M^dFtb zQ7ZBsbAF{urVMAqPKOfDg*tEzr z3-E(!%}KaOn?B+H+f^iee5@k zV>bYPVa2Pi2R1q82VRBIOGUHPtUyYsjUyqrc=V^60w6!E$mmnLa7Z_|2Eaxv$|fu;gSuJ;RL$ zh`mEYLo+fn&t{ct4Pl+C&mj$cyW!c!;V^gY2B6|}AVBFb0k|Pp(8nD0&&x?ms>LVK z`p<9DB7|o)J2*(DLZe3mfU`IpE@W!>vAyF&>*1**$F^<9-%u*HLtGa3-=qcJI%*3v zL5uAYbp4S}qj_In ztSLR3%keDuY?EVY8wdMe7j1+$J(!uX8GCkRtgC(n<9+=F1JZantkIpEw*9=d(#Z~k zi#JCArpqx(Qk&M?f7uYBIXnF!;>ERnCP7WLYwr8Z1^}j;VqaQX$MpvgPkv>6Pjxz2 zCC&BR@H&UI!j@awCclef6uTAKDKT~zS*5>yR8WxhQ;FrGf4>i$B-mMVNkOXTU?k>!m zk5+^|zF^-%KfWyXWn)@SPL9YtD`xLraO`T!)o;Pw+}bSBH!?EH+z5{J%wKnWX!`G> z_-`WSU%r9=|8!-6M%lt9Gc)Mp$m50UI9=Vf_;T~Vh#`%x!I-}t>rq0lry|WREWZ4# zZ^=T^ZAlu{IhCN^*X3%wpmoJStKyF*YglM#sdr9Afd;bUjcJ zK3W5xVN}{uZa+U8>Yfj0v1Cl0gznt0Py=Q%B+J>>QEl#EXCUSLzM%D3P;9rEhJN@* ztdlZ#pJJv!8B_}nmZ)B=g{hP2=8vlz5+uNc&}v6C_sUJIxV)GI+!~P2g zD%dKZjQPb@MmC+?#9K>{bXl2=={dQ!qWg1cm14rrGU{CR7j-~c7~N$i(%Sw8j>={y zS)C$MQ2ye;vS;&}k|M@c@xdEI!E-yM-}WJ9ROOS)r#L4MV`qF^4FvZ52iu*ELeyu1 z(_T;zyZ3}ly4-1jlkTNLZJc3Gbo>t2{p+n-A7G`I*`gQpTc0d%glTZI{lQh3`Z^!* zq7cbq`GW_tN3ahlsTqhqHg)a^sG90}&r9mhp}7~%+Yy=yG=8C@4*HOza6G-4UCJ7% zk_3B|!5Y-;j&g-5ZS+!|Ksfjl(=AUNg4Y-gUJ*H$IU|8=3Q=pug4amLMybtd&+K=# z9JbZ#sDl}-C%A!UA(zD(+@uxIThXD3UJrmQnjtH^^X$dTetiBUq)7eiq?gcyjRemrr@7)Du!Y^cg~8R(0F;)rKt611H*hJgyt`{y3dBUfy#Krl`W Wgls+GrmE~y0gtU-u2;A0{ryisp5lW5 literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c55a5e6248e12b8caf6a6bf396fb97980cbe5d51 GIT binary patch literal 5491 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 literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..57e7d2e94e60e6b75870acc842f418e98ea8eed0 GIT binary patch literal 520 zcmV+j0{8uiP)$oBALSzWHX}Z@&4H0B3E9o`WE?K7z~fc&in{%yi9JQ$vcv7Bo{dvFG#+UyiHxlf2VJgC#Uv8D;=5u0000< KMNUMnLSTX#P2phx literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c221809eb99164792a708a4deeafaa379af56c6e GIT binary patch literal 785 zcmV+s1Md8ZP)(*1K~#90?VB-=5r_E2DAa?!-7g~tCv_y_z1 zN)s>WX`-GMKy9L&tSTF3Zf~?w8QKnPuO6nSC>x%tApyL7tJQ z9aYr>o&XKt25=3yyrI0}G6T+lQ{WTuQbbNt`Daz#Q`OhHeRNfIch&fo&Hz=t2fP5T zref1R=fHCjd9$d)LIJ9J034B>X`YlBa9>2;n;MM*RCN#d3|!e@Vm28Aw?*Vfuwjva zBjCZNvsy*HUQcvgzuz~7yCU)^7z_ze)qUVgqC2OINaRIo4plXVf&Jj>aLLf|aiY6s z9|1x$z$Bot`COcFHyAJpxMd*`SKJH+Of_IiMwY&mYXHDZL`oL}rWufl!EcDr*h~U) zMeYTZR(IJfNuTqya4w))tx_tLD3{Bjw!vUvKOx)itbo>PwS{d-ek<-bNA7Dt?ghlE z0ZHG^06fp5-EPOarq}BwzF$;g39wz1))5|+*pEQ1Rzp=|{jyB%Ye4P=ti2KHbUKVi zqq$hUUbpi6ra>wKOvN!b0-`dROfo6OntSk}h0&4;)l}aX;#I*%@o)?Pk?Ch-j zx7Ju&K-}$Z*2w)nAol`dKLW$yFq6w!Qeq1jkH?u@&XN*KfbC_h)oLZWuGwrx?wk7> zPzZ3HfNKf?t`l%gA;5J4t|@#4$#Ff zBHMxjhxkSB?Xr$>KAWrJe@oE7KXrYb?3^o}0e^sB_@B)g3tmA%K|zpz+m|Oot;*;f P00000NkvXXu0mjfSLtOV literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..c55a5e6248e12b8caf6a6bf396fb97980cbe5d51 GIT binary patch literal 5491 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 literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png b/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7a47cc62ea8f8793bc5e10c910a1e695e52b9dc3 GIT binary patch literal 12582 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 literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.svg b/Sources/InKCreApp/Resources/Brand/AppIcon.svg new file mode 100644 index 0000000..e6836fe --- /dev/null +++ b/Sources/InKCreApp/Resources/Brand/AppIcon.svg @@ -0,0 +1,14 @@ + +InKCre macOS application icon +Original standard-256 mark on an 824px white tile with transparent outer margins. + + + + + + + + + + + diff --git a/Sources/InKCreApp/Resources/Brand/AppIconSmall.svg b/Sources/InKCreApp/Resources/Brand/AppIconSmall.svg new file mode 100644 index 0000000..3445213 --- /dev/null +++ b/Sources/InKCreApp/Resources/Brand/AppIconSmall.svg @@ -0,0 +1,14 @@ + +InKCre macOS application icon, small sizes +Original optical-32 mark on a 28px white tile with transparent outer margins. + + + + + + + + + + + diff --git a/Sources/InKCreApp/Resources/Brand/MarkDark.png b/Sources/InKCreApp/Resources/Brand/MarkDark.png new file mode 100644 index 0000000000000000000000000000000000000000..6c314f081420230d718f3267a57b933fe4c91b8b GIT binary patch literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3TAX~PbvH$7ERG^?_ ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0vtJzX3_G8*5`Jji=kfycFe z72hS_S-w_W8zk>8nSH_8Vu_b)gG61%UzK>Jl!`+STxL3PNX+D}W^@l^Gz))pCoJiY zi_MXx!v6Q=_~MqN$vLYqW? z*7V54xc=kSY`i7)b>`!o)PMZnyiO#p+&gE1!5_&V+Kk;#Uq!RazH}>VzVPtO($)jc z7U@EnjQj~==VmT(Ij}wIh@cm1&Fs`G#(zwAZD%&Ny05kL4ddWoJSI@t z!@u1sQ!ypVreX9=AO`1(Q3Sl$4NjJIu$w#E-feF;1^^yF8kns^u0g}=)7wZC! z;sVxOtp)}#YWkC0><_C=3_vw7Ni4N@zl2=C8W*spdx7TnfcCi5@{Nn^DI%{a=7cKQ zNj41;p@@tt?b)%G|9AI*_N_9JNT$rbu^|5kTYI2%ji#JVYmYN6drK&iL{8KwriAYI z!2bvU7y)n|Eep$~_C=H5OIC) z%rmJ;&dtLtk%7xBfko=u!5o1qF1Lhc@5zT)P8WX4Nmh|R8I)xCZKAz-=LDCNYwC0j zTt7CwmTB(rkiOKns{@Pci4x>Kz|B-v#CU3#e&&o+Bpr%75}StrbhTvQ~l}r;{(ld-QyQ=#`*7 a52X9vY?i)|`JfgUdJLYfelF{r5}E*tvY5mG literal 0 HcmV?d00001 diff --git a/Sources/InKCreApp/Resources/Brand/PROVENANCE.json b/Sources/InKCreApp/Resources/Brand/PROVENANCE.json new file mode 100644 index 0000000..c67995a --- /dev/null +++ b/Sources/InKCreApp/Resources/Brand/PROVENANCE.json @@ -0,0 +1,65 @@ +{ + "checked_on": "2026-10-21", + "purpose": "Preserve current InKCre mark and package native vector assets; no redesign.", + "sources": [ + { + "local_path": "sources/web-logo-256.svg", + "repository": "InKCre/impl-ts", + "path": "apps/client-web/public/logo/256.svg", + "commit": "2d260e3f194ba34858243d41d19afa889a2d4060", + "git_blob_sha": "8ebc4f5bea14cbd550a32fe6ec7e0c8bbbd17837", + "url": "https://github.com/InKCre/impl-ts/blob/2d260e3f194ba34858243d41d19afa889a2d4060/apps/client-web/public/logo/256.svg", + "role": "Standard geometry master, 256 viewBox. Used at logical sizes 48 and above in this pack." + }, + { + "local_path": "sources/ui-logo-32.svg", + "repository": "InKCre/ui", + "path": "packages/web/src/logo.svg", + "commit": "cd434bf11eaeae0fe59e230008cb15c8a9f950f6", + "git_blob_sha": "2727331578969757f6ac17c069e2793f908a3f6f", + "url": "https://github.com/InKCre/ui/blob/cd434bf11eaeae0fe59e230008cb15c8a9f950f6/packages/web/src/logo.svg", + "same_blob_url": "https://github.com/InKCre/impl-ts/blob/2d260e3f194ba34858243d41d19afa889a2d4060/apps/client-web/public/logo/32.svg", + "role": "Existing 32-viewBox small-size geometry, shared by design system and current web app. Used at logical sizes up to 32 in this pack." + }, + { + "local_path": "sources/website-mark-32.svg", + "repository": "InKCre/docs", + "path": "website/content/public/images/inkcre-mark.svg", + "commit": "7449135fefdd6056fecba387e59e3005aed5eafd", + "git_blob_sha": "51b8c832f4708c10e612f9c8702e51346e4cf248", + "url": "https://github.com/InKCre/docs/blob/7449135fefdd6056fecba387e59e3005aed5eafd/website/content/public/images/inkcre-mark.svg", + "role": "Corroborating current website logo; not the rendering master because its connector path differs from UI source line encoding." + } + ], + "additional_evidence": [ + { + "url": "https://github.com/InKCre/impl-ts/blob/2d260e3f194ba34858243d41d19afa889a2d4060/apps/client-web/src/App.vue", + "finding": "Current app header references /logo/32.svg." + }, + { + "url": "https://github.com/InKCre/ui/blob/cd434bf11eaeae0fe59e230008cb15c8a9f950f6/packages/web/histoire.config.ts", + "finding": "Current UI design-system logo points to src/logo.svg." + }, + { + "url": "https://github.com/InKCre/ui/blob/cd434bf11eaeae0fe59e230008cb15c8a9f950f6/docs/design/visual-language.md", + "finding": "Current visual language describes a neutral black/white/gray framework, rectilinear shapes and a flat appearance." + }, + { + "url": "https://github.com/InKCre/impl-swift/blob/main/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json", + "finding": "Swift app includes light and dark/tinted raster versions of the same mark. Inspected for corroboration only, not traced or used as source." + } + ], + "excluded_reference": { + "url": "https://github.com/InKCre/impl-ts/blob/2d260e3f194ba34858243d41d19afa889a2d4060/apps/client-webext/assets/inkcre.svg", + "git_blob_sha": "70ec5a6c03584be8317dbb4bf3fd23e1ff8be2e8", + "reason": "Current extension still contains a traced-looking 200px asset with near-white rounded background, off-black tones and a detached gray speck; also identical to asset in archived client-webext. Excluded in favor of clean native web/UI sources. Not a claim that the extension stopped using it." + }, + "derived_changes": [ + "Preserve all source foreground coordinates, connector strokes and viewBox proportions.", + "Replace white nested-square knockout with a true even-odd transparent counter.", + "Provide original black/gray, reverse white/gray, solid black and solid white colorways.", + "Provide transparent canvases plus four contrasting white/black background combinations.", + "Select existing 32 geometry for logical sizes <=32 and existing 256 geometry for >=48. This threshold is a pack convention, not an independently verified official brand rule.", + "Retina exports select geometry by logical display size, not physical raster size." + ] +} diff --git a/Sources/InKCreApp/Resources/Brand/README.md b/Sources/InKCreApp/Resources/Brand/README.md new file mode 100644 index 0000000..341425c --- /dev/null +++ b/Sources/InKCreApp/Resources/Brand/README.md @@ -0,0 +1,9 @@ +# InKCre Mac 标识资源 + +来自用户提供的 `InKCre-logo-pack-20261021`。来源与固定仓库版本见同目录 `PROVENANCE.json`;面板与菜单 PNG 保持资产包原字节,应用图标另做 macOS 平台画布适配。 + +`MarkLight.png` 和 `MarkDark.png` 分别取自原色透明、反白透明的 24 px @2x 版本,用于采集面板并按界面深浅切换。`MenuTemplate.png` 取自纯黑透明的 20 px @2x 版本,由 macOS 作为 template 图像适配菜单栏。 + +`AppIcon.svg` 与 `AppIconSmall.svg` 保留资产包的标准 256 和小尺寸 32 几何与配色,在白色圆角底板外围加入透明留白。沿用 `nearby-audio` 已修复的 legacy ICNS 光学尺度:常规底板占 824/1024,小尺寸占 28/32;圆角半径为底板宽度的 18.75%。不能把通用满版白底 PNG 直接打包成 Mac 图标,ICNS 不会自动补这层留白。 + +16、32 逻辑尺寸(包括对应 @2x)使用小尺寸母版,其余使用标准母版。更改 SVG 后运行 `bash scripts/build-macos-icon.sh`,通过本机已有的 `rsvg-convert` 生成整套 PNG;不要放大小尺寸位图。普通应用构建只用系统 `iconutil` 打包已生成的 iconset,无需额外安装渲染器。SwiftPM 资源 bundle 与 ICNS 在签名前装入应用。 diff --git a/Sources/InKCreApp/SelectedText.swift b/Sources/InKCreApp/SelectedText.swift new file mode 100644 index 0000000..902713f --- /dev/null +++ b/Sources/InKCreApp/SelectedText.swift @@ -0,0 +1,145 @@ +import AppKit +import ApplicationServices +import CaptureCore +import Carbon.HIToolbox + +struct SelectionSnapshot: Sendable { + var text: String? + var origin: CaptureOrigin + var rangeLength: Int = 0 + var secure = false + var focus: SelectionFocus? +} + +// AX references are immutable identities; IPC remains on a worker thread. +struct SelectionFocus: @unchecked Sendable { + let element: AXUIElement + let window: AXUIElement? + let range: CFRange? +} + +/// AX calls run off the main thread and each IPC has a bounded timeout. +enum SelectedText { + static func read(pid: pid_t, applicationID: String?) -> SelectionSnapshot { + var snapshot = SelectionSnapshot(origin: CaptureOrigin(applicationID: applicationID)) + let application = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(application, 0.12) + guard let focused = element(application, kAXFocusedUIElementAttribute) else { return snapshot } + AXUIElementSetMessagingTimeout(focused, 0.12) + snapshot.secure = string(focused, kAXSubroleAttribute) == kAXSecureTextFieldSubrole + guard !snapshot.secure else { return snapshot } + let window = element(application, kAXFocusedWindowAttribute) + if let window { + AXUIElementSetMessagingTimeout(window, 0.12) + snapshot.origin.title = string(window, kAXTitleAttribute) + snapshot.origin.url = string(window, kAXDocumentAttribute) + } + snapshot.text = string(focused, kAXSelectedTextAttribute).flatMap { $0.isEmpty ? nil : $0 } + var selectedRange: CFRange? + if let value = attribute(focused, kAXSelectedTextRangeAttribute), + CFGetTypeID(value) == AXValueGetTypeID() { + var range = CFRange() + if AXValueGetValue(unsafeDowncast(value, to: AXValue.self), .cfRange, &range) { + snapshot.rangeLength = range.length + selectedRange = range + } + } + // A delayed AX reply must not be attached to a different focused element. + guard let current = element(application, kAXFocusedUIElementAttribute), + CFEqual(current, focused) else { + return SelectionSnapshot(origin: CaptureOrigin(applicationID: applicationID)) + } + snapshot.focus = SelectionFocus(element: focused, window: window, range: selectedRange) + return snapshot + } + + static func isCurrent(pid: pid_t, snapshot: SelectionSnapshot) -> Bool { + guard let focus = snapshot.focus else { return false } + let application = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(application, 0.12) + guard let current = element(application, kAXFocusedUIElementAttribute), + CFEqual(current, focus.element) else { return false } + AXUIElementSetMessagingTimeout(current, 0.12) + if let window = focus.window { + guard let currentWindow = element(application, kAXFocusedWindowAttribute), + CFEqual(window, currentWindow) else { return false } + } + if let expected = focus.range { + guard let value = attribute(current, kAXSelectedTextRangeAttribute), + CFGetTypeID(value) == AXValueGetTypeID() else { return false } + var actual = CFRange() + guard AXValueGetValue(unsafeDowncast(value, to: AXValue.self), .cfRange, &actual), + actual.location == expected.location, actual.length == expected.length else { return false } + } + if let text = snapshot.text, string(current, kAXSelectedTextAttribute) != text { return false } + return string(current, kAXSubroleAttribute) != kAXSecureTextFieldSubrole + } + + private static func attribute(_ element: AXUIElement, _ name: String) -> CFTypeRef? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil } + return value + } + + private static func string(_ element: AXUIElement, _ name: String) -> String? { + let value = attribute(element, name) + if let url = value as? URL { return url.absoluteString } + return value as? String + } + + private static func element(_ parent: AXUIElement, _ name: String) -> AXUIElement? { + guard let value = attribute(parent, name), CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } + return unsafeDowncast(value, to: AXUIElement.self) + } + + @MainActor + static func copyFallback(pid: pid_t, snapshot selection: SelectionSnapshot, isActive: () -> Bool) async -> String? { + let expectedLength = selection.rangeLength + guard expectedLength > 0, !IsSecureEventInputEnabled(), + isActive(), await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, + isActive() else { return nil } + let board = NSPasteboard.general + let originalCount = board.changeCount + var snapshot: [[NSPasteboard.PasteboardType: Data]] = [] + var size = 0 + for item in board.pasteboardItems ?? [] { + var representations: [NSPasteboard.PasteboardType: Data] = [:] + for type in item.types { + guard let data = item.data(forType: type) else { return nil } + size += data.count + guard size <= 32 * 1024 * 1024 else { return nil } + representations[type] = data + } + snapshot.append(representations) + } + guard board.changeCount == originalCount, isActive(), + let down = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true), + let up = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false) else { return nil } + down.flags = .maskCommand + up.flags = .maskCommand + down.postToPid(pid) + up.postToPid(pid) + for _ in 0..<12 { + do { try await Task.sleep(for: .milliseconds(25)) } catch { return nil } + guard isActive() else { return nil } + if board.changeCount != originalCount { + let copiedCount = board.changeCount + guard let text = board.string(forType: .string), text.utf16.count == expectedLength else { return nil } + // Restore only the copy we observed; a later user copy always wins. + if board.changeCount == copiedCount { + let items = snapshot.map { representations in + let item = NSPasteboardItem() + for (type, data) in representations { item.setData(data, forType: type) } + return item + } + board.clearContents() + if !items.isEmpty { board.writeObjects(items) } + } + guard await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, + isActive() else { return nil } + return text + } + } + return nil + } +} diff --git a/Sources/InKCreApp/ShortcutSettingsView.swift b/Sources/InKCreApp/ShortcutSettingsView.swift new file mode 100644 index 0000000..839abf1 --- /dev/null +++ b/Sources/InKCreApp/ShortcutSettingsView.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct ShortcutSettingsView: View { + @ObservedObject var shortcuts: GlobalShortcuts + + var body: some View { + ForEach(ShortcutAction.allCases) { action in + HStack { + VStack(alignment: .leading) { + Text(action.title) + Text(action.isGlobal ? "全局" : action == .hide ? "快捷面板" : "采集编辑器") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button(shortcuts.recording == action ? "按下快捷键…" : shortcuts.bindings[action]?.label ?? "未设置") { + shortcuts.beginRecording(action) + } + .frame(minWidth: 110) + .accessibilityLabel("\(action.title)快捷键") + .accessibilityValue(shortcuts.recording == action ? "正在录入" : shortcuts.bindings[action]?.label ?? "未设置") + Button("清除") { shortcuts.update(nil, for: action) } + .disabled(shortcuts.bindings[action] == nil) + .accessibilityLabel("清除\(action.title)快捷键") + Button("默认") { shortcuts.update(action.defaultShortcut, for: action) } + .accessibilityLabel("恢复\(action.title)默认快捷键") + } + } + Text("点击后按下组合键;Esc 取消录入。每项立即生效并保留。") + .font(.caption).foregroundStyle(.secondary) + if let message = shortcuts.message { Text(message).font(.callout).foregroundStyle(.red) } + } +} diff --git a/SpaceGrotesk-Medium.ttf b/SpaceGrotesk-Medium.ttf deleted file mode 100644 index 859f704f32a5fd2bf6a8ba23f283ca5a2699bfcd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86528 zcmcG%2Yg(`wLgC6uC%MVT3K2ZOO|%E+NLh8R;w=Us&~n%wq+SfmL=PgtK3a7hDWmv zrV~o&;gP(AB#;1cD27mzA0<2>B#;n7AcZ^v2_?Z-zwep3TO=Fu^7;M$2CcN`&YU?j zbLPyMbIzPAF-ejP_z07vmd2*$E6@GfHc5K-9!X-awDk7$r!Bk0CrR%el%zEmwDb@9 zhD)RWBC!Tk(!bo(UszJQaotZP>4Coia900-cVOR@n+{2=?-hK{nVcKn)^mArk0kwI zK$0RZo*v%`+>!zKZ^iG3>Dhf7;_lh~9@>hLq?>;*Gc~?``OAOPO48kX@w{vXfQYqW ze+B**@jZKHZhr5t9{cWnc#fYq)9lvCaa*_YvLxN|J4w=AIXAv{o8}kNllYDw$+TsB zZffrRYf~iY5j+pe+qQM*{1X>vd@4y#fQGCqw(XeOHlsaqA)fyV<#iZ?v4x8?X_{Oq zPRf*WB~ad!Wpl@umbuI&Nyhj@t;K3CDFeV}F=uH3Nb=x09^+}E){ehCp0YAG9^eN5E8$#FS-d~$O9)bGY8`@D6sT<7)Hol@$&S7fH9noOywnb&2Qa`oD{ z%nY;D;Yds=@Mc?Tax}IQQ?xdGnITn@C8H$C6Y>f4G+L5&Cb$wTcC#+QrLh>FdF1J> z1TjM!;)Kyzkdh{&z#aX#MWl=@7SvrH(Qd<&L zxiW8}v>>vebkdax@T}=O>?|{-w})i^=byaij|T>`b0em=?IaZ za^#w8j&yae85vpAO#~mi{P5*RCq~y?I=UKcE42ZS4tQuhByd4a8z zPfR>J@v{k0mVJ)0ks)Q{+t_`|36`V0igM3QfR{y|`_X4R@3Req%K3|TdQq=2510#! zUS`!DyBpE>i#KOAy3zT*-u4sBzTOaN@#?+jtic#uvNJp)x5+_+Fgem3yH>foCBd=W z--8x^4_xu)c+4+8+h&<qKB2tA@=i6f0!|zHD?NbH8tQ`!rHENTb2jFw5O8o9 ztr#~ZgwG7|iqV3F!y-2xoUN%WFWb*bySM9iZLY2^Z}3&HizgmPLln{4977%V1 zb1`ON7BpgD;YXDxS<7+dZ{SYlO=eNPq1?=z%4`05TGR^{^~^@l-Uqtwo=~4^%y=5d zpMvM9e-;h?i*I}W2Ez@{Z!}#0gX@XukI3_<8s%-LuAm=vZZUmgZmAw>Qmq#sF>J<9 zpvJCKmw}^*Q#WDOVx_djyb8IXt_+e1iAk*Y(Dm0JI&{Mghx%8q?(Z8N6Vv&Wqn=Mx8)Q01C`Of9)m$H%zOVAX(r{*Ycm)AV6031E5y%kr8b-0ULXhME;Yv2Okz?}ax!EU1TpjU>~Gqz>NKmN;KdFlZe}Jr> z{ypBgm7Sd{XY2RstGD;DpDM-ey}j-11*Noado?hS{2o->(10T$x?g`{+r6>>x$Pvl z@nU%|UyEjlZ7M|^FG}%}#Pb(V&kh)7e>`jGpPl~k7Q-BB9hG`YCZW5r-gZImS|%RzK>f{rfGog`)RIka0K+aMP>0b)a3veq}Yb|o05rVTL;Us3H$ zZQb-}!|+g}Z+KW=YHEfk`%9uVrFutCT<`Yk)kD3JJwro1k-b<$Oe#fNR`fZ`&*Q2t zk*q6_(J?McNW^-QA^(e8b~ZHZY-w3v6S+RzRG9}+?`X>PHb$C@ndi1>mx{2InwdIur^uTVZJ3@i zBy{EayH<H?C(#b&55SEj!iNY$^@ymS`&ly-Fp?fSn@PmeFWV^7lt$=8KjUbZo1u z-P+c&xmvkU?ys$^G7Re7LqBKnVXc*wZTh;MoiSZI>*}_3*u7n)S$XNTIm}aDS0}ib zeGPqD1`fk&1gn5wz#T>-KI8AY^wP5YbH`R3JI1aZp3N^C&R9LHY!YKpE|-G770~{q zJ*pf@c0uWZW&l1d;oJo-vxP4b>ZNrvwXI#t4C~zm%Ka>Fg(2GEEw0^I%a}Sae~ath zQeBqWa_ZapWver8US>-xb+0NHg9E0y6nLi2YCHDr{G(veP+jh_coc zmJgRq*O%9W5tTnBWG9!bsaIZSruv$J-p>~nqzdfNPs=B?Hu-J*rIq(bNec@Le_1G( zp5@Tr3n(^O(P9!>^zXPHmoD4_rba_2FZrCYi=U_McKc)hqx|e=YI*7VD6j7RozM%p z!~x}+c=mV6N~7&Ey13SxEPg?4_IDuoN9UA5U+eZX#2ezh)kW=j)5ZBkmHdN!P`TWZ zZ^4T$B%L+#!^dQD@F$JDp^@HGt|G?a8_hxZcb65!{Q!sZP5=5 zCOeA~Z5bKqk=n?t>cZx(j{3BGYi4>zx=tHusxE0?iPo{3v76;9NDho+Yv7^)3`!~~ z={Mo|?u##;zUr#<>9Tz08ouL>Vdd8u!>HG?@Tt6IsOaCT`SAw=kwun|U>R1_ zbwU2=TO&(G>zPCOb$!iX@8?QFdj{7DaPpKRfQ57dbwhmQ?zzK;!*h4D50rK6HYI^Z z2&MjrQfJt&&mWksH4J@Y&`>))@Lz+5LDU*y_wb(t_6=nfYGZ$p3s9T(YmeQP?7|{W z#v;~bop^BHA;Y2l51lxG7C-;ncfb3$&mVYz_pO}wZ3XtyP>HsvlMr_ovV?R)q_ws% z&{KE8GpjSM2aA-9)>)cpgHsOnG zTwR&COm8m8jv6D%9O!K^D1(F)yPjWT<@%|`?cqw0&Pcm1KTorwuDuN#hg(~FZS{_G zKT$4bfBaLfd#tJ+is`1dWd>hYt0&T4l)Ue_e(cy0y<=rvOv9uHyZUW$RmFnl@IBb=F7Z z)@5hcD%Ix7Vn2=@JE%C4EX%PG zX(TH~t`@zpN-oJ8(hrXtrx8^ai;30wEhdY1nZ?@yE9u+UV6R9`Ej4@RyE^B+=91;h z%WVz&dOL>)ySs;myIT5IHn*(o(-*C&i7xS4wFaHFCO5i#WkJEp^61EB)bWI6tTSDokgKQGZ z>)uwq8Kd57i0^ELO6c9y*|oFYyQ`z1(bnJ8)NgIl^Fi-zqCuZ(j%l8$tlHQ@lfg!4 z*u5PcUYY`oedmIKwaI_Mo=h4(x4F^I0vj6y_1$)t%YMf>X=&$t=(1~a?5>fFQ~Hb% znzMM+i&N{t@`Q@q0wt_<@#m(coqLDF<#K#Dk|BSTG2*g+@v0qKFT;!(*nOyH;j`th z7TT`XVxjdYC}6RU66e&Ev&3=7xoJ*IbhO2pb}oov)s6`Vt0sz+Ut97G`4;RTBPv}t z3H-x?E;_MbQ2kA*hL==XsBgQDMj z)xbzu<}!j+ai~mY2WPJA=(uub$JVVoK5V^VFU#9|L+c1T_d?}Y7oN*Nod&gA{iB9i zg^Fo+lkB0BsAhi4midjxJ35YkNOf2?>Wl!3@(QqE>=r&-C}$U9oj?z4ft4nL*`}2@ zp3z+J1=4>HmZQ7WRz56G-TOJk#*!)6;AVdr)aTbrXF8 z|0lqIfxzEHR-km{jTvU+4M4*!XWy27AwQ?pNl=^RG67+?$j^n@1qAYcQ2L4g36%|t zlLlG5?0XEl@K@9?M5$M)6ePf+@?VC1VXyQH>1wT(+9k{jd)Y10)nPXJE~vn7?2QW_ zNxzb=p}moK+ix$v)KXYz!H>QGHiZI9HWUFW%W;y%!uf`?jA{W)v~*=T<17UQ7EAu* z#Z^vIj>(dnpFK0l+eRDgpIVy)TRYmuCz}#9akyPTB+$wPtslwXl&%4-RGyx`FY2)u z@mm%&^ErSt+=F@2g-|}Yu~en~CH5!9`0GFis9rg>huKFxntu(qjT7USmIdoQ!`@L= z$WNeR0sT;mHb|pnNTY1B57605wWX`FK4?t`iwa|UC`rGz<|h@EW!E|!4USC>J6c<| z)a5qXl9t7lW)&7@p_P|%imHuK#d@30LXrs>-gS_^61K6dUhEzDi5p zf>U0BUT|9Y`r@hr$v?6+^X4G|p~|WdZD=={No_qLZgX})C|NbsT@dtI>6)s_&SFD! zL`rg|rKrjnZFCn|ikGK*a?;b9#!KawgChf5fTU+vvfq+rWy&gv}{DVN55r0Zr7IY?D;nFS-bQS z+rbKHmF|K4rJXrm8XFUEIVu$XSOBi641voZ0uKI4lzt}tkZlVC$1VI)`Xw+_N@p`4 zJ52MxhmIoRBti}n?IYHz?5rBAtvbt6b9Pp>)mD{duD0QmC~8^QAzK$-;Bo^LwXnC2 ztz8SgXk94RJShJJed`a|SzUfr$wI6}HlIZsmN>H~p%mY;;Fbv3Ok756WFh;QY`E-a z+h(8s592u}W>yUrHckKNT+jSIt4V9Lr}(xu$2V{F8nY6-%F(#EmM+(ZE4zDdo;U2g zrEe`u9qfqi7#wPkX&*$-U`3LBSYugaZP=-fmB;P3Z}Ec_aT)9n{LEsf4TNl3A62h) zn>?l>pJ%jk_f$!v*=^c2y>8ua_U$WaZp|C0%B#YWq9*S04pm(fRz>D&Fry2ftJ3$mL&x$!v#>F0v|z*k|#8~ zAiu&$4#_8_<6Nc+_@y?@>!@HHX&)p@;lXdH32Nv@ZbtY)cU8 zr$Z}uZwo^2^HZVeknRywEUc2T69(XE>?tliL5F4;MmU-%8V~UdnHfe_gC89`hh2_i zQH1gtbDq+$uqnf(UprB)x{Kr!Rm#Qd??R6}3kBo>Lkn6yF5}XDyk+hg)6lpGcrMl+ zQMwq8aoKPt=vWAp#zo*bAi<#m9N-J`jscE+TGDEEeh`527CBTNTNQw!tXA5?>%v1M z>-aoIsPlMU2%P8@@LcJkGr&P_6JZrt{H3+>SO}EpB^+9LcW1CH&7**R#%Fgi9hgVX z2?FZJppy?s4+ttSkMQI9snevfRt~4p?B{)s;w?^Yq!tB~c!Z!jxm!9MQkt*}_yK4K zehOPS7H9yVdvb!H_$Ob%+vHHqj+Y1uc`Tje;|Hr8%AS0Mpg1Z?|Ku11G&dH|g=Zi( z%R-=&HxTqCK=U=U26$P`;oAv{U7Zzbh&F*?_iY6I4WJns8an~~?7M){SzdZ7r0ju6 zg0?QKl3wHV3cP8lgyFZqkRC#B_xGtR`2#ySWw1+NgeIl$@R6V+AM{JKhus0aXS9Zx&oAP1I3oyL?dk9*U=Nzi+y|ktm{>4{c*VGbkUc@Rgh*oIF5jE;KcN`5+t|I1_}@*ivc8mmLHp ze%Qpv09qTDU3YUF0#3aZ@LUNdu|Ro09Q?2;2qoN98o1|!P?|Rmm3N19i)-1*L*kLKDui|_l8~SIJfh0hD7A!{0OIj<|PQnIJXmBZJ7CX zKJ!$XX8tEMCh})|<#7KW$Caew`Z33~7>;pmBV0?NV=A<-` zTO6w=9309yltVQ;8wkohdVJnfI2|XYJ2?&kr}-6dKTj=&gFenv0!s5vrTsi5pfv9s zD(~j|kbu&>3(TKMheBGUc^7cML=xp`-{NrWTMwVH{Oi&UDts0BC=Mm*%}s$n2C=mVvFO}%yZmk)wcK*_%$#MoAGm|cJ?>zKZ_Z<_RoB2gX}-g!9UCZ z_r`J_$>6j2IOk#keGv0PP#xPK-OD*)FB@7sPVH0`RK%ftR&z)#1VI_&S;KxG)wq3OptF9miWuY^6{f7TOR z6Ld*hiJye>oy?E2OWe=NWOdie3Bq$J?{Ar{s+wtNUhh$^)pXUASH<-tly)gCwVlWF z$|~$bTW1Hat15F>$Bf37t*WW-O?m2(K~vZ=zX{$E9oe zEO2kKOryo&m>scB7sD|-T%%eF9Swn!Y!JBjODE8V25tUTE|tH)7~1I^)1+11PabkV z!Pf&se`4GHJYnC7nOuHV_6-((+bgpaMjb!n-d)z9O^ zw6&I5(!2L$^<^FY(e8EUH>|ODxWc=Khr7eOM%eS#M{L9qMD=gw6R>DNTA}Z2<>o-& z&*A*a;hK3S2jD3jo`v#S`SE>zEGc}X2uge`Bqm`G!ZA`@BV7s|4S^CL3(UJ#1~C&K z3+Sh#K`70PfOd@u=qee0auuorzhWO$`xvBIC-0Z;SK;tmbEi_(jsUFQU$ zG>=r4aJ)h|NSl;1xg3U<0yrkWMx~*Tl2;m~IaD)G`sjj!5|0s7^)f&H0+p?UE+`Wf z0?IZRsO)oqmIa~m#Ek^S{=w`rB!DQZIsa~gz6PjM#>lC7ciltKw*k$RF>)&O)9(Q) zRX`toJwStvJl)tmg;x3wYNUs>*>ykR`6=v*i-Sc#~! z2Rnm?PFZ`Pxso-s)WjN0?@m8ncSoIa6W@ze?6-tq-!J`ExlX__H^e(wcbr#Ga&7=M zA#klM)~|q)%%#$hxg$h*EuZyNKUA*L5|sEF(M*8m@%}uno^S-5TI6u7jweW6tQJPw$(r9sY5I3z34wZMU2|{Ug2SD?t!0hXytKin&heZNS=b7Rvk^dhucBPk!iPc z(^Vh@8qNsBe(*p9ZvLYP=)^!6-t9HRuE?>-%EG*oRA-vQVYg(a=T+rrw-!#reOEK; zHn}pQqgP})8ytlrdS{~}J-;wNJJpmD6=x{Q$jVJzR%FR`TKk%mR(AjJ*}j~%f}EP% zWxCji_~=-Jv#zMPBZugv7yv}7h?s@YSfKceW@pRt&mLXjj{-XN5RCap^q1 zf&`p6ox`!R9zO#d^To9vQJPj3mBz{%4&tVj#i8=9RY53miGY4O8HD=VgZ{+Vo4`x@ z6Q=_D(@kfj;eGG-8k6hnq zfd;;u_5z7YYt`PAV-|kSap087aa_r9FNTAI(}_X>B{{}RW3M}cS|UQH6FiYjyBc{Z zLVem~NIpG6XF4v%i6Fk|TKr$+bH>CWM2Cm+!7mjW$CqMFP(E24HzV51&y9Kv*TG<~`>@3cUsXI5qOZOAL5URGdVdxq~1HX0Mj&hQKHI*g!3eh?ZF z_Cs*;yDFUI@&T-CtxQ%7Mv>-nDt5qSM4KA14X-O1)0i+uV3F^SW!`kA2u*PJTg8$GCc0Bt*EH0tEj*xi1=K5d;*?; z5EU_l!sz~oyY8C7b%<+5UU*?d*&I?X0p;8zPw0o2B_q8N<#~;}?s|CSYawRs zX*S}Z9)#5N&?$qwfOLc;2NK9s#U{6{P}G(Pl2+rElW}}a)QK~!2JR+a+)iEqJo#*5 z$7pqRQAu(2@S2#Il%hV@;Ov@|{7id^*EKwt6cs&z14U6)k|SPMQZ_9RP|-4YewR$pR?wrCLfodL3Ff5Y6#t@gXj0)6%%28^3W)%uF*5z2pL^< zL2K&;tKwH((9&|js<_z6xw*;M^>cGueBC|u&CM&*GSbaiY3VCgHpMhwG&w(cQM3B1 zZ|m0GTk#Jc9X;)vI(mER;**z~%^4T3FlDYV8tS`ob}VH%>~+j&9LAxLB#n28w~Hb? z{5Y90B?a*YLwo_t zagWv1jJd_96NRYYn##AbQxjH1L@;Aqb&YZ5Zey%Ifv=3Wn1We~<0}JR9t4KLk%Oox zidrU~1+Ol26-E?vIU)<|3L+owc%+@Z_0~fV;eY(IS|0+IeD((WOnwYl&_D7Og<>K1 zYbX~BHum!`0voW`6Z2|64{7G>_Ck9qpvg&5=Ioqo1bRAcj!08(Ua;*jyEQU0Avr;> z*N4fvbX;h%WJg6=(^GY_Zn-(r3c8?8%RT5_8bxp}T7@8pdPYC!r7<4dc*`#Q8#{09 zZv0z&-g)OevFd_VPvD>U#nLYzuFjJNG2Rs&Tw+>^ zjj?;k_Y0+mI~L)koAXvD;a|14I-|v5Z_U6Ti76?G^u5*YXvsL5-byy3qaqUzk;?jjl zGh#M++MApAY}l}8rl!2CZre6CH1_*`S@OmqD4Lm z@p!u$KdFl+HH)iS(z5pse$(*HgYPZFO_y&gE7`YMVg$Pt7lo>ovm%r~pEo#oba0SH z&x6sk%j>aAVSfxg0U-AD^k{lMR;8^J^xN99zB+P!n5in)A5kB%KDuW8{Mc8;)Nhbq zKjpxfBrg12-X=ed-bllCmfTe#TPmUp{3!P>>5Iuu_EWXxk2PCQYxxox8Xo$T4&0mRsSLfG`m~4WxvbgLfRy1(auYzygXCL54HP3o+&dwKhu=Q*3&0_gIt)B-U5wp0WahYQ5h~M zRmMQ=d1GU@^N5)B()n_PTuEgp`o@Te8aYB4Wp^qkS?&2Zo`2)?oM8_A3Mse8je_oQ z<9b%7JkIVspGr*Mcs|LZ_4p9jXarS!)NT+b;r8SG!|K(i)$(I!sHL{RtEjCg?}$26 zsHl^(xC~u1SJaw1Uy8zRzww%}tFIor=0-6)t?V8-8DFD_4cpi~ zbkpmlm-vj#19uA_IqHW!A(^P*ayf1Esx`Q}aXXFTdt_bN{msRbV|-+kJ3JY zxfffjxb_1k5c?|MQFZ$&HzzpLR%GjA<6@%|qI)Yh(O0WJCQcu{Eap_l%DAMo#AWf( zk+tH_1VeNrMz>D7LC%vczz>Pq|CKFhzVhi;x8JK$7Z-CT>eyKwD-FiwMk23{$XjkC z@|1sYa8mqYFs&8o1YaxRw5H57JKs_sVz-~zIYwh$AU!G9vd_>bCu~@uF;xKr7DB5? zB~cAmew|YsE`-zKCwYx_dt+X8- z{A(mTBO^QX2V|E06nqDZe$-S=cZsWSQE)0~rI#t%F0Xer2Vafr{poUAPD zQD&~|AX*Z(?3dtAfZz$;t&?2^GFh7f-UPq)g|kKybG7ZLuh`I7>`RC<^ltOTKk{_- z&b|Vl)$Ys7YqVM$^}d~LG0=BbEH7=#)r}>+^T?_T+sB=qrG+gHXKO)ZQLCL+8Y~ua zIru-EyKcF5? z)WhTw4o{73_yEbnh_qupn*K)cQt?B zF!Q5k{Wmv1#!A*I4`>pz6V|wLYUWT*>C-z3GJ0vy{&SzZJ@S-q3HU zAD)iFjy+Sd^YY6{(_v%ME0}Se23j(s*v>TYd9VB|AQ!5TWY)*}d6@$Q!FtKZ=hro3 zz!wLMO9ra0+n!=Jr{G7d7n2+4w3JzKgJbB4Wy+f&S3?&<8RWerjSLdauDN_wGZ zEz_6f=jKM&EbC}2?ao=(T~MTXOfG(TyerF5X->^FR~GiWBYM`?7hA2x#B)Y4#xm{; z+YTNObj|m=Z1I1+5HR4o--6NQL0EU;Z%2d+E3hLG&vRchk z^5lbMH8o`qK3Gy)<9bj&@s(LhKn=Vvlp_VyU>L&T2#52S zPCk!X!0nJr_tA6c#J{8}yccBMh@`c#6R95f{hN@?ByI0rcI~y7?b}ajN%qSpLXwgQ zYQk>O%){yn9S%Pyjau+7|l%_(N7vS^bi9wE4( z7;wlTj-?vjGMnbdm7eNXr zDveswY7TRcu84dkX%fXpZA`?&Du0rZu||dJg6)J`zx$# z9*U?5I0?;0?jQ8qtk~UaW;Pew?YYQ+lw&hx%wK13&Ykn^?qb@nN5v+_*|RFM$Muu_ zIk^s#*%E79R$;Mt(l+VM9bTo6eRE`g-6dB>EsNF1=?wau`l9x;s8@o1ik~C;b+SKx zu5BStZsGF)tqA)J z?-!Q@xA9TP<(@(Tg(PPJ+RZZxJcBU^jXfT_fiPqMdNc&X*#DdfI!Vxlhfp>=ByxDi zrBpVK#+IOJYdd~JP|`>)q=;k{y8BbY@FJjvVGhh@4CuAVh-v0rA1giJuR~9H9X$P1 zJe}lEMX!Pw5AgW#@&%Xl6z2oNsFe@$$mc@%*)iXN)(`TsOULD8E>W6{dJ7s_xdOwB z?-Nv%1yrT~MT&P@cn9>S@!gHe>g3^t`}t}XrLoF+X~4m)w8{k>+^b^wOviCAtO})0 zleU$h4&XjaQQ)Gi=J4kLa4*!`oFC7`KmHs|vm!xLkPU$k{wukV%T1g=Xp~tImq-M+ z^BKo{a(G1m&UFG|40(#3ah~stf>zQL=_z(b9uFkykcKDf zAnqNsQk*-jZBgfo0Idf&t>QUp%DgXpN21y!xv4KA*8<(scJecn8`}_0P^#aAoO3uu zxkK^+z^;&NEZAUoRc-6*+g@4X*zk6Kp`oj}xyw+PzcwyqRmYKmvzwaE9yroDk`lLe zM~thh?9j;A*vO%>E>}!Qq&~?5LCK41JR$YqdW|svf0g}^uWeR5=YDe=@kUG^YQmn zMVf_pPMXEy=R}2gzKZgW`SIe!4Kc*)(Y*uWwcD5U5;rNr{G(zc>eMl~EzlaBj;J;N z)7{bzDezSD@sg*+G4zz=4sw#=DRyGuob&6iSc&a?7a};gk4Kr)&L3Ku-#;KMyflrI zz%mTm9oN60*9OZ&ip)_j#mxikG5n5!9WpZkCj_m0fbXmPX&6P`!K04Vhj3UW zT@OyBk)_OsW_710Yma(4U)cA+sj7<&dPLKC$B;+o75GBo2i4ndsTkiN9OKJl7fHWd z#knX2*dgV39H4-bJQ2_xJnE1`xvoG^Xam~>XZ?!HlQTaZgRg?5jd)6tg`sunwny+O zNvhDNq*;omuFxA57@=SAkr7nhuhyay?}NDaF4}rSms3LfG(>Xr{FYo?djDB$t8p18`a|%T;V*w-n`RMg{yN!>)<) z(A9DKgvzJ$@&}|x1sq(2OmqSGK|h?ACp@D3N$JuMJTw;q{-SgXp9_JXP74G_+%CU0 zDWG&R;!w?D&WlB8Gl91W-5h;ADE%jXi;QQ8wL;k(zLEnwj<}($hFP5{#~S5}Q!_NB z=L{oXT|#exny3ls0eM0wFnj9iPkVSJjRRS67B=8QbYQ zV>^8zWxpPvsXwGFpejxMA!Vo4vNpO60z1t4@CTjnkA((Lc^y36E1quTPsN=Pii8w> zALTbxmX6b?8kdjAxj^q=>*i3x(4@%^w2nJ2;D9{nM%x3ehPzx@uzthuQQFN zxF_>vqZw@1d0M+qui=FlOv7s1if%G;g9%}eKQJNF|9DB?*uUGO_>7Mzdq=P}MS`?PMs%^U~# znBcrL!NJF(MNL1v4L0d{DvtyDNXWW^%u&lfE?wy_kNN#b9V?yuf~uhlY5-Tuzp&`O z)Q{BiT6s9g^)jF;?Zcew1&*Tu=%>2`<*Vd#gOua$$m5_CnME+Zk7`o6y`6`i!TREN zoEsA@&1ptMRE7;1)vRk|C#(~R+36WJySaA+z4qr^(NcVRV1(cHQx8wfcY?juxc$A= zv_g*zlqb08E#N2*=~%UJqvjS(%KvY<9oXj=KfQJH20d~+OdM(Nx^|mk$IZy? zki^qE3?jF~P+)|<>7N_T`5S2tuz#`31doqXW}6Jx#-MwFMxwO!25 zC}dso-(dV|8+HP0P?WOzidtB*OHxwYR?oWHryee=srEelu&cJ#_3$H;lm9a5_Nfxh zon&un11$rfMOe}i2=u?Ur4t=(tSr=?{?kV(@AspRQr_=JAD_fY_A5>5f(FVYv>us+ zMo1!1jCR}fO`5-b0aJ;NQ+Ft2q}<4RS@Um>7iD2cfDbJhKlFFox^>7_9^wV~+EGsp zvXvhrFTk<*9{P8PfBx%1eDX@ovMaB=@{7Mf2ncFBL&mZge*B7mXGE7$`W4=a#9sP$ zh=2a8VGgB|AtTmLkP+)sL_iALkVvG(W3tdbQm4YbC^BO4RJ&))h*jlsyo)4N?>bzo zGM?k9tDeiCEQQN~`!0c-d&jxolFJ{rKVO%czn3?&{MH-HsL$MnzKMjDk7B!m64ffw;kx!~=Gls^gU^G$*Bq=|9-*x8Du z{|c0cXH&)hBJBUSsO1l7CJ}Yk6-T}h@z;k-qaAduM$H-|Sxa<$HQAr;%X)kZ>6=pczF>WwU>rRJADvU*F=| zJF5-V^ZTGxH8<0JZGI=1>+2@;le}fu;y0ZLPU5$SjwMN-8C?0nO9zs}ZI}AUI$IJuyjcliddk^waqgaNX9$w~?z{mv3oU?BpV?+98QxoSr zryQxVf>S4GM4+)(OB*@-6Q*c>2KXe0=L7B)<=JZ?<)2_-f$}eflz)=N`pe5JG@}9h zFEOpZyqpwf4&Z-TTJV==H}Z1|rw1z}il0!RGqYR5R`|=qavB5wINg~zB9E&xXZK6( zFQ2<`h8g1Ly z)W}UEQ)6xV=mS^qX;fpq3za`fKfrC_L~!isdZgA;14S|>BiovhVJpa2{^W{Jh{{S& zhfwr6bH(gAq#tOsygv37gnE$Bh0G6SXs5+2Y-7FE7h+mX#~R4o{iW>8TJQ!)wc& z^gYKdFZVc{W%L)V4w6EME&_J{3dJhDXk^aY)I8-TZoB+)Ug}eJ05W<}sblN5w={N7 zd*n~a!3N6dG>1&h9nDUXS!xM=l<)4`*v1SZ@^`QKrZ^Zh9NzeUgg&W>pg{R>>JDm5RfzvaLCarl2@ z&XF;4BfDN>!T6{dBP*trr>@gH{6(`k9jpr(7g+apk_KA3aG{KQ`>>8?!<&`2*n0A% zx#@Lq#!@IM`_dfsH?v~@MC1K+sVeT=fQXf;q=(2jNC za~XbV+M}98>Nf0fB6S;E_JQy0lsMgbU0ll9z7KRC_|CD0=gHbr|Huosp5yy~bKyr} z{oI$txe!qmhtxA#K}d!J+9#mAl3gcY`55H3hAcoKF?m!)9=lkz7q}MHJt#fM>!Ors zQ8t}c_X*Y&ur;jVmWWWyFR`Y;(-dxN2zd(0*em&M4WU^XwpWVwKn*|BQyn_HgBq2N z6n+*L@E0h@9C*^S@G%=@n-K|Ur`tcIMZznjCgKyqi66bhl?;D)EnQ#m|NUrZzcC{= z-bq<+osfRUm7TWST$?pF*J^P%vhWjIRgi9878V6Gm3GdQQ0sad)E>i6>tI2B2uWGH5RzNWK5BG7guu%{SP} zQd5e}bz56nx7L}9Q&P)p4fE~Ig99zi1A|T8p5FSp-X49ys`{v6pG_MdVXe)NEbq(D z?<4fF#1ltD5LN4 z5z)_uTsA7*A^N#cjuK0H4hUI2QpgrU(VF1T;Q)W8XjZFGn~dxlT*rA>y4_z#Gq(6C zG~9i#_8=D~D?x6{%Sk%XEr50?+$Bgo$w&DH_;m)LgIeg8EM#-Qek*b!BgBwjViDKC zC=gkP{fE_e?rm<~+bKSaI`Z;5@G7soyiWHBGmYrIJ3Awh`9%G-Hm9{Pwy@RdY%7Q@ zY;*p=CSOD04n!3)aR#Yk6b%fjgiSe<+mvxG=QIVJm}WYs;np2uzdbGp40>ZlDZ*{A z&*~^=7t86`T3x-hqhm|8a#3PjTC=01H@(a}`#g(Cj7x2CI$KiX^xj?FJ-fW#9i7RE ztCz>6Ew?z!tJw0yq2+PQ)6(!Ulo(u1uyBZZ(461v-;MWkzm=7|thAfaV&XAus`f5& zhtcI9Ixz+A4eUF3rFY-J!3*bFJKe3;-jzK~bBB}y)~npddi7TxxoP!zjhF0Nrf*qQ z(C7Zv%86Ai@y7W}nS@(j0|P`)Ik@3h;H4G`Qc^xqFFi(MMbS-fK~^rKo8D)P?L94w z)*pzwb?cj3wzvyUdr?Sea4z;Kw4nKh+5(Tz$DP=vk+}%FJ{`0;sF?kM8skyL%Aop! zlC4{1vl?OSaD+Cy=s13gr((5?pqz0VHm?c!kXFYlpD_irhx99F+GavoG;9De*=I(F>M zX>Y`ob!i9qQIF(-^pc##LNfNgu4d+ioX8556R&eQffb>~)FXxy!Vnm6?~C+CamtxLNPtQn<{F%2e z(wJ6itDsn8@+lOOocg96!S30Z4E2_7?TE4C#uDYYUIkKT6F(Zy08u@u#rqR8j zHKt{AC1|JwP1VqLK$D(m>Hr1R>;vpL_weuO*m3%$Yq8^iM!w@LrCKJ7IopGAsGj2 z!`>$&HdfRg57rj%dDxHK(CjhPzVltFyDNM67=SiD21}?# z9rgYPcnRU<%8%)thEK5#$OZZEgmQ8xF5-q^ z_jj{X*tasE&6guCmNYF=wNy>Z<4&doF_g49B;Xk;79A23lM+Jot>lo6IGLt`-wJx% z-C(lC8&+71t%j5pN!P6$8d`bmI#YJGi9S~4mY3(!Px*+;HS9^wG5qIXRP6NgCcG0X zFG3&XFk5mWOFKq=IbLZu`>1V*%TNzf_{93ihjI)WiYvfaLyO&_X6NOl2`*E6 z1Q(trfrUI!0=}Paj0Pxymx$v*exL)vHpzD+sQ$DJZ0Vn6t_k6sd-c?w4!PjiG5+LJ z`0eO~%k5N`pS8b<&b9oBlkSc9pPbt}QxoF1ds6k=0o#s-sUq^*fqTeu(GSao$QY+i zWwMh&R{w9}|59m2-Y3`mJ9uXN_5HsIPZQ|5G(b-v zXS+G@7aM=}SuNh8^QQ8a`bX>{Vt`EGnUOCI&=aWrRd{Aj$3wa1XV{f;HTo4YI)PmC zGsqs#v&duKn^5u&QS$U$^D{gnJu=f1uXQ52IwXgj+{q&fJ7En8Lg|$D82FiZ(8QAE zkH{7Ri>QCm2k!M?i%#CFwWg+~8B?9hl8lv^CXdBs)5xZ7L)~aC7f!F`n!;+#p@WEf~UH=h9&i3!{yRaSGUIgB_OLt4p!CQB= zN0jH7_X_1>!&7oB-URq_md{_j>r#HkJOcy3T@73C6C5}6YK+bt-mq9fWuVs#u*CP@_w5fFs`f{|*Z96~AM99Tuw(6`5zmu`QF{~Fyr%wD z?cw(usJ33aR;#Dl^ajyS<-hT^;p>6T3~w^P@dxibfceZA?#Bm3f~b*WI4$?w1C!Z) z-}{P5B~Ab$y#cy>?>D};m{7_iGmMsqPP8=j|J2fOun#`;XmO{=3TR;8!aIDxJxvU+ z`C_;YT(7*#(q@%cS|+7fAP=?%?ZF5Hk?3L2?C3i11$58t0~lBd^as#!B&y z^wNpab-pP5+RHj^id76LDBtZ1y#_?Mttmtt|Z0`U84zsmpbrTO%KMdvFY65k#m}6*9;7-K6{eg zKXTCLJ2;|rvL6nOjt(hp^ZY2x`{AN@b%I{RBa&^DZnmO*%6Gj6(gaaZ6l>vk`SI6p z!82p02u1pBF}6+DmDjdS(>ru?8KPjSvy1cwyr)umtEO&c*8&A3W@klamnO%)1H7djEGjQF51F5jhg3T-olOGAQ_*+TnS#S{ zXm*2#eLdQqFq|)P^g5@#tZ$^LX=J3ynu8>3IXSV_dD{4N$BJ}aQdLD|Q&aWU+V;oH z%hSx}wB=?{qP~F*yPKC&OlQv_e&brkq|p89GMrZnac0V*9Gr{J%u8Q+1C1F5KT?_1 z0C8ksD{k3bqn!zFYG~;-L_6#AtEX$dJKH<9)z@$D@b&lm8u|xbEgG(bL3d?qx51p` znQn?{!AsgUwfvD@($?GUi|}@Ldn0^23at=l=@M?e^~xmYi$EJ<=Wvrg{2%n4&eB6< zz14vFz*#z^KYZ-VHLwvNevZ!x{L#~5t`OlJoJ074W{4~8$f0BcS3>H;ug9gfIT^QwDd+fJG34XxC?dne0AL?TUc3IT=b|* z`R@I>ylxZPtrhJyG5!jicta3M%hhEwPc-Pz=6#=amyUuC>E2i9+Mo;}_K*W`z!DuY^MSY2 z+u8H)w$FRL^X=j_wv7V=jra*&NMmEqRB!E$=_bQ4|1~<$sqGx#ADA(+w}Vw+=_COPjL*_m-BLs@M^tjSC5OaFasyu~O;%t&oP;lkj~>Mhc91DoQk9U54QJRb1=rc4W&r z{wusqSC)K``SnUpoMc(#DdK>J-1j?77yuj!$54w2y zQUee_tXwC@vpcQ?@{tiv6W(w43famiO9q#5rFgeM2!G&?(=X6dk(=~K$W6*4|KU3h zI70qK7+m}mxmnimSTFI^ zpIh4|>hcwI6J~`X@Q#Z}I@+Ox!zb{LnBz0YJ3Ei_cTrZg-LU7pz1O#`I`=0T`jco%2_PaYeKpk0eVN!_+@oiY-k2tL5Uj^Y0-=@|PCl?{zlXi*~-SQdp7P*?#pFX+oM&TX*W zCet}IB(~RLiATZ-(MJmfQ=H3;zUj*94c`B}rK-89`j)5VugfQ(k;nSZaw-30)3s$$ zp4vOPeV4GF3}D6iXAxfH;ta)0+TFPf@8PYk-Q>IVn%a)G`fHw+Z#gv~-y)wV8*kJ% zOu9c~t9=zwmEND%;xbGvMo85rar*y)O9=8ftbj%mC#`{B`NU(iLTi*i(L2h)$=EmP zOd3Z}+}C3vdDZ3Zzb9xVZgz*3r3~s!DhZqx)w3exfqGU{&xWV|A#26yaPe`F*AAQj z)$`w1)LwkVquMbz)m2b^Afz8!-o%F z=gH0W5Z+qgOr#%3PS8skp1{du0}SE&P)e^}f=ym=I*Au?ztdog|pJbjz-5|oyp-av0s~N@_o&$CCg;aB3?qBr_?$mJD{7pMGCVL zv%_JoA8<6VU$b?%)v)f`f;n!K(|u#hEgcS3mha?J~6Hc3{oX zodJ695xi5_zx+1_C}11j5%O61*IxGO)vH-*{X|8@`r7~c!yjg6@9_A%<#*gNP(H|N z3P);UYlaHS2X49LZBKDj3B6Ig1}%)Dg$(lZk5dQGgy@NI8uIRl#Kw#^+PM{(e|yx| z(&Bqm&b;4buPU?^DC_0SpQL3@)YW_IMhD{(YD%ly;tZ@P_Z9X7hz|5wyqAE^(O_-l zyW9YI8-~@pzdrC(bC&wlCXbr1cR^FTT)Go=iy#FOaSn--vgB6w0ha;=kOIMX7m(HK zYf=CcKd)0e0*8RR7L`A!zB#7=5&{_${B%Lf;?c9bZ5n?yHVbP2I!QlHxOnTl=FP;J zoG)}UCUMfG(=Mn~GjG9zHjI|BL*Szx`Vjfg=w-KPHinrx1(i-~+D#Y(R**N@CAwq(ol1gZ(#kf5}u@^_zOCTT9I<~>nHbYW0VQ+^*C$zOi{ zBmKH_lpOx@b0!s|O?OC}p*3R@n51l!!<4_oM4{1Ab7-T~16Wn+xi*C!aBXU7pO9l7 zUh8!sr*f_(;N3zxoA48f*TXM4m7i=HS=H#p$y|fv(K)dZ4!frpr*fy$il5l#DxAal z(cGSq9$}=Tx!0VQW-_Iv(W#v7UgMqg&_iqaI9qWlU&l}7lYE?^WuinCEKh+`xzKsA z?1E&~%G^#v+6Bq2kW+b0`}X?!9i6S4YQty3ni_F7k8ybOt2fkoceK%|+&2(-X?@X1 zb)*Z2^Ff0t$uq@YTfYj2a$h(90IlTTuHk3$S`G08-Mr=BH5<5Z*NQXwI({Yx?R5WHw%enw9sFiOR!h!rWQk=ShEE7D-7R#kR-P3H`nCL2%*tAnXX%uz zeAh*5rwO$wCnnXlBlQkU$%wkbr^~pPn**4q85Lk2z%iZ9^vu(uJV$4IsttRJ{FqGH zONFgXKF{9B)=_RAtH>% zv0L$2A08pVt!J*&K`cDqjn{VqTR=!n>}0(7PtQTT^7Rv$Sn%2;ZRX z_vP1RW!GBw{!Y1xz4<#++1kpQxmU~9)x`Ol&6TzYd+GA6*Xc*jzD}PzSQk@!7DW=0 ztB>J z69ZopVq*% zmU8HIpIprO@v5uRr(vhQEtC_9O5&$pNZ@pJZ{UgVDWVWALUInk&W=~IyNqeR#^OSp zF!JP{@FAafpsl{pA^*l&*%%j{nVPa9Gdmpne_2_XJ}Nbd?o(g~mUFqBg~o&LOY%*k zWeUO9!}@7fTMoQhNM~2YE2OKAtS)AwduF7rw7#jlq7Tu}jT^n*>8g^EiZ(|nn=J2H z6BiZfusfYaRq<8bc3XRSS+gxNMw^v6h<2;d3Sw!Y$yUY?9qssT;daf7jhrs*QoUS; zUPzpQ5{uu~qm@TP-pWHKZ&($uO1IZjZC;jJtOyFP<0^6PewuEPNOdz2eRJKTW? zaIhz2jp1`gy;jGTEGmUaDtz(6NwM1?v)n4>ugJSi63HoDEdL`2mz^)Y1Rg6u2mVNN zMEQ#e4};7K!08=I9FCE?KLk!%t$;r+{Sx)KM@DlW*8+vNiS7%D|BwOMqVaqF9^9~b zz%cvcSwsKqhDWx?@g2sG zP4AsT8jP+!+R6|XumnHrSL}0lPKY{Py-pwLS)|yh_-L|$AKE!KGrBn#DPvo{9l;Dt zvO2nZvvXc#zfm)ai)V})HM6K_<|vQZ!rWu;X4(AcnFR$iM~|LSa7wR{Uawj4&%Kvv zSxw)tJ@o^BrAH{akYf8a3@bGMl6PoY^xpcm{ie$nZTqEohV^Xry%RG>c~d&Sl+eCn zW!v@{qkP>=Ui2be1y-)*|JLkTzha-gxc9em?u9+Zz3F+y9usFbZ>a}Orx&}+0h~F| z+@|=pm4&mfyKeT`*Ijq^(MRolC1&(EIUwf90i%wKx$>$GI z-W0#d_OdsM{aJFJyi>l&2}j?t17K%(f2F!lt|SjDCU}&+hP{PJRY+w=CzpjV?K}hCT4rg zp4f=kHnE4r_KMAmEr=Z%J0^B)?9|vZV=s!mCib@2`(qc!J|Fu=>?g55#Wuu+#>K@Y z#`TEHjvE*^EbiF2)8i`R&WyV#?(w)~aj(U#iQ5qOeO!Gj*{Ve==3-lAwd&WZq}7qF zPH8o%Rc)&|t*&ZyYpeTPEo!yA)$6T3Z1q*^(AH_K$F#n_^_`ZnLU*%>e6TgJDK?-t)XzHfYS{E_je#7~T`i9av? z^7tF$?}>jj{+ald@$bZc62B$BF8+_Ut=e{K+p}$6+YxQYv>n&Bs_j{AFK#=p?VW8O zYWq~%6>Z;ayQb~>w%;WbB#cZLlTe;;M#4D>a}%yl_`aQL7t_w$Za}+X?T&4CTD!^Z zX0)5r?y7dTw7aj}qIS=>d%fL_GZZr`~_=e*7(osa80zVr0X7k0j` z^8=lq>ikaU-;<(}l9IehBa%0t zl6-aYP04p9Kal)*@-xXRlHX4LDEaf`&B@;<|C;yPVPGye@OQ%0NWX4(@tH*OR(dbUm}{+^#oweW2^&t}k?bv+LTf8@na)|LE*)Mcu}B ztLiqp+a=wu?si+Z2f8ip_I$TDx~=K9x!W(@Mfce5N!>HM=XWpeeq{Gkx=-moyZa^G z=XJlM`$OHgci)o|p3)|zXUeFQ^HZ)!xhZ9S%EFXqQ(jJ4o$_hQ)|C2Gks6(vklH0R zBehRzQR)$?C#9CBPD{Ny^|sUpQlCs+k@{BZ$EllAe@boW5z-^BN2eY=d*t*O++$>q z6MBsAQQhOb9#{9cv&X_7%X_Tqv9`yS9@~4S^~~*A&~sGJ6ML5TJfr71JumBdYtM&! zF6p_l=lea^_x!16Lt02$Tw15J9%*@LC26&3bJDI#yEX0Mv?XaP(^jW_nzlLZr?frk z(dix2`3rA)LHell6VuDn&qzNv{j&5M)9+7zI{l^echWyi|2n-ceRr?$Uafm|?v>W7 zyw@4M&gnI`*Y&*?^jh5O`ChO0`mooRy?*G$|0XlyG7>Y=Gx}x>%@~t0A!A0y#Thqb z+?(-a#)}#6WUR~hI^*YzhRo2+R+*hMSzeS`kU27QOy=0ks?4i0Z_J#Z`B>)C-YLC3 zy$AKar1u-W*Jky~Ixp+7tk1J{dRlvWdxm=^cxpW7d9Lu>?77$TglD+`Gp6jrVt-_Qm=VeLZ~HzCpebzA?V*eNXv5@_n8incXQnEBo;5 z)3Yzlek}X>?ALQ5a}LYN%^8t%OioSCc{x|-+?lg5XL-(BIqP!1&Z*1Uom-qcGWWRL z#kos!SLVKz`%&&!xj*La&Wp$^%{wOV=DY=Y>+`q+K_@FZeojd4}LC+2Pf@|BL9fSOX^9K(e zJZ$i_gKrsp&)|m#KRI~C;5P?vEeI*-UQk?cMZrx4_ZK`}@Jhjl1se-~DEOl=sxYC@ zS2(b6c;WGdV+*SaXBS>lIIr-Y!Y2w}Dtx2xgThUP4MnYrIu@lCjV-DwI=ASmqC1L~ z7Og4zxj4EwrFc;B(Be~zYm4tG{-Agr*XH7%ig%UhlCC8MC1Xmal-yGCYRPw{v88FH zMWv%lPb|HnbVcdv(l1KCDgCAN&moaRIt=MOByY$OL&gj_eaN{(ZXdFA$jd|C9;$~% z4~-vs;?T086+>%=o;~#Pp|=cuVCdsRpBcJ#=#F7xSj%A@hm9OIX4tr4)x&0UJv405 zu%*L(9=3DX-r?cH^M_XqzjF9sPk2q{Z`iQPV4;nWKh^9CmC? zRCAywijgFzuT=h@RS!##}w1`#5^UYI5Pc>6?R%7_~MBt;-#zIN>lUBqZbk;q^iAP1Dl=f!Y!j2Nyj zVua)!5iidX{TLA)qW6fQ#J4-Ai)GsKFlBNO=zh4H^4b@s`~ooLcqo`M4uGgG%;qB%fPY+SH2xO#L_nbP0Y;-3h?d zpc0;kvZQPKFx!Y$3Vg*4hnhOHB4;a17G!||9G zi+J^?NTSb@r2B|gIv<=X+Ub8`!c+7x%xihRQgqV&`F~KQD0eX_h#n6e4C800>plq6 zz}WTXKOJ2$^Fa={2=oM|?HWJ5L844%R>a_K7mG0s?ga2xnFbzUMa7SprIzY?j|f_7vL=yQqHbCF)~Fi9~Z2(_+ z6En#GdKqRcNQM5um;gWU)<*x#hB)ti9k6rFo+^dbJurS3{sj&dPJjmnn*m;UC1D2WfEnP80rL>NQLQ7t z4}_ri4F3$Ek%tC&X}~nR)U@LO3=cK*-!=b%U(+T6XbeB-eSO1UeA|ti_}!7s!ST}{ zi^I*cykB%=tU3!`Vr9L0lJcgF(0)@uM>!sTI9_xNeE)Bo=nd#9+Li531;1xy2yHLe z?oc%CrP069L5L?azfQgwohU#XQ(oiWW$ak@+}wVy$cD$Hgj|N(p=6cO%gzzWMo+Tl zRFTKKh7aeNXKNP}hS6n^ZFxqo0Z#Z2!{d$qvm0K#u?d#f{b2dsaKbYXmOoaBPVj;Z zY0Oi_0PGEK9buj==885(kCVonNZcwdKkj0lEs`n6(X{gf;%Q)H?Lp|^JTo-)p^tiz zcN~;pmIQP*lhb_D%G!P3GikTFoLe3NccbSu!jbPUI`SPI*XX&z$a9f^-Pfcg(dfMa z-B;bi_^N;};!h`wj9|#6l*t_Q*As~EQRpy6KCyb#=mvpr+w{CBimm=!P2K)XWV4Er zZIGJMVbUXKlh;NMHs%?&jI#*OW1bE4Bv*D<@SRgZg(yK6C<*zE`h5^RBiPNx#rQq% zFAx+C>1}27c57aX-d8CKnUD9GXA`DegH90S-`d?rIy%zE4ek;xFk2cJ-J1RL6T}+Q zY;=onm>(w}sFT9#;}?km=s;~jKl0aSK;E_>Z(A^)oh2)p_zrS975I!_c@etrwS;#b zbu^$q4TT>DXxF4pW=ra%JObT=K8H06tj=Kc2CsRxILG!y`p~9QD1$=WOua8Q&ld2g zK)D9$6Xjsb!r05#&1!9ScaCv%Hlw!%V9pOPdIV+H)0#tVn=!gdrqwUpI&1x-bC~{; z(LLa^x%lZ1hTG?0-R?m20(8U!A>bFjLs%(Bk7WKd%fRT2jrf%GWTE>7!@O&r|E`I? ziH_FY0R6@2HmpK%pKGam29=_h3ooWO*iXPOdFhOWuiN`2{~!6u|Nft+g*+{&g26wu z&JfnJ{KiPR;2-0rA3ee`sXIQ|E!gh@`q)Q`~b2GDIVPY(GJc~8{gT+bWbTM0;C+Em(<@Is_>*cz<6#dv(6PQ9q(r#tce|Oo~lv zm()3_OHxWwucWM`!;^+39g|d%RGoBo(j`gPCf$&9OVZs*4aw2Tamj6yJ0>S3cTG-B z_9SN~_e(BLK05h~`xoodsV+nC0Pq!vl> zNu83Cle#CRC-qLsODbeU`J|-Eq%)J|Bwdqqy-nl%mSP-%Z-gc$-hd7KVSZH;h(4f782~|!1R_) zn@!$|&2I8YB_|xX3M!R*J-m$*yS2ry*^rpF+Hg5W3W7Nj*jf*!`Y}m44 zy$~B%^tGXww~5b&-WyUjsP*5j-}uEP#y>w(b2kDe!(CyUFeLOQqm72P)&oj_-jFWS>6gK)&eU-jO&(qgavN!Ab`U(Al{#VV0ZUY_}oM}Kpe;a?T=-=GiIFl3q&O#2K zkNlm(8B!N;g2{#A3vsUaR9wVaUKfk?>~Oz9n%#LXrLN2so5Yo@T)tXt6Z6D(%%^`V zu3;tnO4egsC%%^})Hd-0`?dTiSE}#C4M^sj#ddL%s1vt{U*sFgFK!jTir>UbvXi(+ z{J~m;Jz_p>=RW$5_Y1$=std#eRLh5?5)Vr)9+jcu5g8(X)icFH86h5%kzz6bJb6mS zvOhtbSRz}AXJl*ftZc(SOVBE00_RUXFWZY1Xc{lb4l-PwDA(!E?00*kyokN&&u2#T z74@53CEwOjayl#E9#mh-)%<68r@l(=*4N2h`Wo4wua$r3d7SvXOm5Pd@?2J9PUT-v zn{}2viyfZMQGNIq!=>_OmR3HZzLHDS*YYFXPOjJ8ztf}T@A_&LqHmW!>*M4O zJxi|9?d3PxC)er@Dn>u42dZ;bwd$`&>5=jTd7_@L?$;-&XY@VtU#w0zRgRS>u`2gv z_81zgFI2y)QEIlDq2AZS)u-wdHAYpbb5yOG$_}}Sx>ncdsk)LAr%#h})pU8eI#b@N zZjiUA>*dvIj=WpFBO8MdCpz<@fpsS*MSe+w~avgFagRsE?7q=o92``Yc(mFIHjtZWXHU zV8`M6*!_BeiqQ9}DE)|v)6dA+$}7)i6?LuZB4?@;c8cmD+5cJ2U>#Z&Yul%>O7aX= zbJeI$vRWm`8`WHSle$dau5Ojrs0-vgbrJu-x|p3?E|J%&3*}R4vs|P$%8zwNb}a5B z-_@<<`#N5Jpxeq1b%K0Pw~?ReB>9C-mS5^F@+;j{ey$Ibd-V14Pkn>*>zkC+wB(i&U(BTHU7y>Dg+vegpmKbWx_(u%puv;%LT#juOYnZnC== zr}pR*^(r}8Em4o@$$G4srz3T)&X-g4t2#qpsV`7ZtA)Bkm#J&vr8&BfdRG0S{?z^1 zTjw_QtsbCO>7&%IszD#Ae$aR7Vs?c6N#CVo&;+LGGt^CLi#(dY%Ad?XyXthQOcF1v zJLEVyUfrqgUMl^UEDpKaFuhi^V~Q{&68bgZ&}P;9Yc8$$SI3Votwla<&+!uyK=V@zkHOGhy7Q{&Q+L3FejEX~SG^W7FfbW7P)e&E{2(0BROrQ<|L-P5I8i59xh zp=s|e^k|pX*1iZ{IStNK1Mi#&2dzcd@S$xOB*V8S!K;(SG59HCHK3t~;I|rov@Q6SE3pTSfYs!ufmemo`ghGG7i(sVRZryqaLYQp@*xA2f#o`DjY40^iA` z$;8i;=P^(dpk~=GnKE>IRAQPkt-`;_rDXD^3e%)78UKN@ABHqfrbSM(euG0E*(`(% zeC?Y%UgB-w)TIM`HxQdLWMvuQnbH}{-NbVi&n6zp*qQGZz!3~xOHE3~zIk3vCpD)2 zOvB#fmnkDgBT0P?<(@?fN=eDT_f}Kc?%M)_>xMbSFVK#ZVHfD7cTz(R@T2!6B&?Cv z*q!yJsiFrhC5^o_dWj5?NvyM|n_k|{7CEe5&SUf|pPo)X>S=%eNma~07mS8A6y0e! zb!a4Y)NoD1Gmoakk45i7;}R#Jm7RpfbqZ%Qw4wLZmeGKAqCNle>d2bBPW&%rus9X2 z8x3D+ji&Z3{|JhrNB1oK(r^^j5}A?i3FgjhTPnOczCHqU+E}KNHuWy?%klx&aM#J(}uD@iJOV zt(+levbW`#tcIH{&z9%NbLDyReEQ=T$P3X2FP4|cOXXa7nY>(HA+MBI$*bix^ke7A z>*%lFAa9g6$(!XZ@>Y49yj|YGdb+#h-SQqepT7LP@;-ULd_X=ZACeEtN93b&p?pj} zE}x(WyjVUdpOR0@CGr{htb9%`mCHCi<9YdlT!H@il3XcYmaoWH=_#+0ugf>&oANFB zwtPpfmhZ~<~+*l zF@K{{RS(sZGr-dMuR{jo8ok*g)x-Z{e2j49u$k&KHR&6(@Rs^ir0=(;B`N^+7q8NK&k>^gdy zI$f2iv1*(eugcW~HBn7c6`aF5MOC5?SD{r;Q)lq6(dp>NGt^8qOPz_nHJg*|&Ozrs zPo1yks0-AE>LPWqxv%r0WLqOIM| zzX9%4ck%b_d(?cjK;6q~%KOy=>Ou98dRRT89#sp~W9o7Bgj%E)t0&b{=zdGoGiZFz zsikU}TCSdFq-O==ATROnte5#G`K#i0^_p76xX2soP4$*~TfM_L(7WnA^*(*@57kHF z5wy-n)ml!;`~|FSl#ui0bh8?{w!m7-I14@Rxhbh_?^ z?wiTjPL}pChUL@QXui2RPxoOwtFP{-57+(m06h@>day3gg}O)=>k?h6hv=bt7$XiN z^hkO|qgkbTq&`X?t&d^c;W$Pf#^@8!w@+dlt5fv9^r`wZeY!4VJYt+4ugmoWJyB0W z*Pg7WFhVg^SLte_L(?;wPXB0zo~dW)Gxb?|wmw^*qt9jR;(W#`F3=b1i}c0%5=JfN z>dW-y`U>>+tMt{3U|h=>#&wKh+`u@-O^jsRqHoo=>D%=kXzF*ddiEa1HWui6^?mw& z{eXT@KcpXKwC+*8P(P+0XVhbnUQ9plDOTAo(a-2-^>ccuUZ$7p=NS)Kp6Q9r z{R(}B*Yqm=x_(2yso&CX>v!~O{jPpbzpp>gABK!RZsbT+baZ&Z)Ut6k)m32yV{6K1 zmWLNqPpqygpAua(uA*k#jHweU%g>A|8ed&oHf~&bRc%=DxH99pm{-bb!%FN^c!?WC zi47vEq|tkLi5uFGpwNaiI!6y}vJM$KwyY*>SZzh+`10^!PJrRVoY%vK*?^;mH+c^m zSvGD)ZF$&8Ck8XBD!e7x`5_|-J8ZP$G}>`G!U?P85tC+AO)RUKF}1R6Ms3&;HpH-_ z9j`|Rg_2toc8s-+J|^gM$66mD$C^kU=S1>2H`T}4phAzUsi>OR^0?rygdgW7e@s(I z(I+<1VJF!SMV-``Bf6w&q8c_leAsa75?&x z+$kRfAqwlU?w(MPinFbnPd`N;Uu`i2{63EEtLuzaP;IR?=7ZGtSK+A zsw}G-UokGM(#hP)pv=wg8&b)K!m1pnD(e(oRW_}9dTmYhv`OXR)y_v+RtINfShf9B zSdH^qO;Cur#bMK}ZS?e}c!X9~S52HAR%`u*)S5KRaMCct&6F87z|a}C&}IaGD}08V z$+Mcmi$1G~4x4R17d5*v!*n@?RxvreVzPA!r!sncxn5)XygrX(=4QKQP9*KCykg>{ z+DR>HCy`waJw19t#moTRVmeK+%5|{eWf$~y%)U91_ABG6r;fEYoC-*V7koL{Eo`3UWH(xROFa2u#f^_1Z&8l* zgJ~_J9Yz$O4L54^%)3R#voV7$OI*v6MoWUtb%V`qwDgvE-4qtOIaK7Pw8%|Sk()zB zZv35ekYjF66b5p_ea{;(b0SJ=Ck1_|uqbRuV_o)meQvmY-S0Ye$6MmF;bG>44Kr`& z!XIoXEnGffAEJlZoV6aJ9ggXEHhJqUDs>ZDYAxf2HPz@QUp`O1^@V9IV}^~dD6c7> zUNODV53z9b(P#59H+EQKLkRLzRN!QGQGvCL9yYP2Y-V||2m2*&Ns;p)ES0>$6IV0skfxqty;zI zM~dC5Q0%^6?7Z*u*sAIAIaR{r%MMgYYY)3|t6EV>=n<2uYpR@B6uVI_354oa@M5>3 zmDo_oclSBh^_T0|)8?a(m|j^neNqtn$Z^BSvF{KgCuZJUH>O@UX1Q)-@CL$kzmeyr zEYHoEe9NXhKChbtdG7mp?t6J|jyccxbJLXP#v{*(hqu_WKiu7tDDLBwb+N-Hy~VZ! z$a%MfO5AvqOj-(4c53b>At8 zd^h!_IbkOTS(mz*mhC3C)ct&4Us#!`lHTaD#>Ne8g_pH=LM^b3n_{w!+pSWCwo=2D zY^4rZdYyJz=nZbPzD#Eb{HYpdtBM{@}|errdNzFZ`0^%AFUT# z3w$1%ermdt{+O~rDFuJZ&7%T0kIKqzaM5LrJS-4%vdz6^2+iFPT;ZjvBu~s_MJHMJf*z0(L*cOqRETR>K3RcVEZsAjfR$=tRg%=G0VADX$7n zzQ>#Ii5@$nva;Msp7HAQd0j=zQ)DGdHabLEWv!czqQ)>$F04oL&N32Dw$EWS*&d(k z*%ur;>V+$p@UU;9jYS~QmfrWxK%$LfvqT$rSyoKJRcsW|zg&&8(24|f1e>#%Zo4b8ES_g4=ShS4 zCalVq2xeiF)t!5fud1FJ;gFRRT)V0nQ)?m}yXxr^T-VxwYb|!R9(eLC3-)>XIC2NV z`G_}Q=0=xK9gj@3>5C|zI<5As>E*T2%5C=`_`rk9Xr&98xl)raHF#%yE@v zQ%$<A984-AFIpH*?A7ve*CR9wEQB!U{6x&2L6^X}* ztjAZ>H_~(~Y(|7zs&ay3W3nN_+L#nuqSkc^$^)CRBt18BLiLOqHyA^iV2tSmV<;Pp zAxtpVY|2VodkDs)PlEE!Ab>KFh>?k(PIx$`{i}+5BpHbR#FOnUbl97>(BsK-RIhB0m9embLhCVl(pmJ#ojU5o*6T5>jgTC!>=cKi zy28}l*5@mYtms`?J+AWDqemDYv{N^6Mb-yC9oFOX&g3)-t5BqNdDAmeXJJdA*M6 z=yl@BfRc@;Cp+7TTeipPgJL!X>nU_2mgBsPI0&0sQN`Gl6Lx{ac0EpS*yD9N0^X9w z4nm*Es){PNg<6WHWlh_(EM*#!C1_fX+0?XbmeRDGto0TJV&m9*`&u@PP;=O@jgz;p z70XyUV%f2DG+FdwR|ReCnb?r%p}0&ppf|gEwa?=ekk6^X6rjy43f-~K_PMHTQE6y( zlYZ@J;og#xK&aL?UvXJ}iCY~!?z1y|;w{PbMj+KJBMLJ_e*u(={+gHM-l_O#TwbsodN9r-`lHcl# z8Ajq%S2cO=SXoBjzE{mE%IEOxiAAa7X#8e&z0tRZme^is_iOf-jX~w6?twrd40pC z!z-)C+r;{c3Zm@jnIrri65;OMp_;1!`s(Me0FwTOEW?` zg?c-_QH9FR&W)%&t=6gpPDwj?Lg&}zxP5Ip$TK&OJ$<9>n4^<94oT)Xgv}g>G@0X2 zWR6QTr7b(sb~>yyZKN6_&(}&LYG+kDxn>A*%@{V<3}JH3nBlP%jGa#g|Wn~G9`UNXEgDc5|?w8x)r9x&6En^7*z$=2r1iw?kcOJJi`>XW4H>J4VpUrEYh+)OM%s%P?fOLz->X zr9g;oAG_4{v0cl)*^#r$Yn+0MX{34^D%?I+PH5qp*3$89iq-LLDC-bmeNn6}14K|D zwQeA_6M_OUjM@prP(cnR04G!PUF9R+;{;}Nx{qtwC)mn6(jSaDRtOn$>bl zcTZrAf@bcY6aAR^zmWR^xq$mJxr_xbtyC-Sy;LuunS&m~{S*UUCCHERl$jnsMa-X4R2xVp_p&!r>kd_42vJ7P%O@>_?*3Z%>U>|8$0|w1Z z^QL4)jbbHC7+-r0DvVr|@?4(Q;}!xv$2D(vP4WPLtiuEGhBmY7m`SWY?PcfW$B-kZ z+Bx?sJGXA;)y<5$na4J>)n;~@`DHtMYi3_f+$Ev4W;KV|UDd}LqW(e^mySGE46H1x ztzwOjo>E>@C32>g)l3onrcRkUg;hUhjXb+b6PpMyI;1zTkHf~4GrPc9SLbd*H?fvI zKIxe*!qQr#Jpp}1kI6#zytD@^->FxuoOk?WKWajjKlskcOv*SfV{5PPGmh`Iz1N;z zdotGb+L`IG)YgpCGqz@2mT`Snzl=LGkIgL1+@A4Z#&4M{YT)`PV|hk0*Yb?z<~hTc z8JBft#(NpBWej(&UR!!?$rxC4Qr@uxe#=-KbWP6S>b-~iKyzg#HNUc6BsN>k)$9BH zuIe_bVpO#NWOdd?&Qa>~(#Hy|Vh5^z46`^qGq^a(Qny zdBOGDzL%SRCl^%a-JUJ`*qGcPS4{3?E_E)Kx;vxRy103^?|T_Ob7jUkm*>>p^Db7y0&%WkfcV)CW;1n%n$)oZ6QP5u&p z>V$c6b2<4yIc;;DMK6t4(D$vIQx4{CN@A;19;SXd*M4Qfrdo}cEiuZ-T(+DVOKZOp z3X~XmXWSM$bv9%1zWI&&@Qj#@m`3}HZH-ROygTDau6VAF%`Q`_PMx*o>iS-4P2*;+ z;r3nHMPsOeni6DYq&B%`WH{yCSoSJV12XctobUz`YB6ogT<;y=GHuSp+1}wR+fDl( zRW+n1>kbDP2C?tY|FdJ`uXD{U#CI_2-?9yU?0kg@a zi3;R>lg+ zd};4ry8qh_Cbj+@!LR%IuNQWE{NFgX{;fRy9_)kXznSKyx0;t|ll?)c!w%y1A3HU# zOQw9yFHnmQ)GnOd|8JCm^KE-i52F8??C{r4f9Gzx3?fm{J2l3ZCoFMT2fy}0_+W&vz#s8E0HS>Q^;s4Wla_#nu zW3cO?X)AXE63MO6fw%T)h@89eH9=LjldBddHnj4_60Jr$>^RKfu+wA>a>};*G-VS;*`S|bs{M$wH-~Z13 z-w2kz`?>~F8{pOf_vWbz=w!rVX~4%nGxz6|)GymM$#d$N{aJq0DAgKE+5h-~zvbuO z$Mjkc@$*x5&3}Qk9-KQJNM1D$=TJ`pesCavjj0TBbaFW$FUjkFs1cy8?xRx#1%6P{ z4)hWKM*nvIX2Rd>|H|C=^JD+h?tkCE-tNL8{I3P#v7gU_S{$H; z-Q)k-a<50sfBj9(^{+<4{NjJY|GW*VpSi=)7WyBt)O`PY{w4d>aQ2Kp_|N}Vu)BXT zVb;@cXujjXc5L^$dlm>r$oTm`ZhFtZmgi<~G`anIR6vpj>ao?g?>AwY7+UjJC(r!f z{&l_azvuXM-&kwgT(Fg;{a+Ajp#RpapMm{8QZw+fBenli-Os$=_+@`%*s}waYrnX2ao%=SK#zWGc>;UCo!W`M26~^B$B9v%!o% z1gFp~U-rAi{ub+JzPa;cH4{sj{$;avXXE@4wqFIe^(I~yY-d>(WPQ!uP{K0aydFYmB(zkL# z4AL>1YHlj78v}2#lWxktsIP~V`Uo~Sw z{y*R_PMjRgfVLg@`Cr2w?ctyOf;oos=3m!Tw~u_&xHs`tzugF=QK#>2=JJ|K$?aAD{RsR|e42U} z=so^*{rUS*@qgajZ}XNA>}L7`Myveq{F+MTFKU=OqV(VS`rivRP=o%S@4xcZq`&)D z1mvWtgH|6{ZQimJEgZ_q-Tk#(DO3q^Nd|nO>G>zYV!JTQUSdRZThXI@c9r&;-KWc=@FD$WdKKO2L!envYjwUn{DhUU7G<8ttOU?jrv zW#WN0^sSNbfs%kSb>??2On%AcX2u)>`EPm+e=RkP@Be>(PF=Db&Kc=!-e>$TMAU8D zl%DhRe}`WAk4N(YZ~qh1Z8eR(CjVCdH%+B_=#U!qBJ0)c;`vYBK6Lp18z29xn%PvQ z&bVPf!Zz9ex6|wY)9@=>w~e{m?L+(*KO0D6;1mD5v^rl5PNDg>Q{yo=y6#qo3d9ck zX1x^u7YC}(|B)1q@yz4Qt~)uk>tW9ATEfX)D>=JswdW(xI*(aL7-QE3237?I)&rU~ zfX>>#|8(tOPrLTe*%8QDedz4@V|Ms4yZbnM`#8J$1Xdw_Y}X;$oo-lRX?Bt^YY}T$ zRq4dZ$10&vRuqP_x-iP_F4mG&r?IRsY{g2&z%FDRSz~H;7CQ|2&;_~Jot@B{c0mj5 zfRzsOg4=DSTlV&ML=UgztLKa)GoFKeAAxshIsXJFvTQ|uYuNFV7r9@kR>&yMN2)U(So zw#m*jDL{LwJhk|rZJs@I0=CfCd+v6gJeTrxRlvn_z4wFwjlIn?^Gx`zf9U%?k9rn6 z`RjSX^Gd+K=S`0tZMFW_dOmNo-{slp+3NYx+W#7`wPEfJJb6RBQH^v?fTHZEwSk{E z!Q08u-majhm%3}fJ=lLg?_g|7t^G)IcYfZZO}d=4c~1@4Vl&=5x$#M|SD{%MK9_cE z?lIhNVno2uiQI=W3u*4pu>RTWXH7BpXl!~g>MGf*kQL_GC9_s(heotZBz|+&%X~q_A!jEyS6CSK2&)C}eqpIZsCTWu215D4QagFWggk^k-cb5Jn)|!NJ<7&HV_%QWcRVHFHU#~8 z1WzXu5A#%q+jeZW+b0uW6LJGQQKM~}bd4a6Co>vvY_?#t3!63iaKBlNuWbs~6Lw2t z_C0*myfu{hml5Q(iT_Q^5QMSkpI~Qe!H#^L*t<{2Mc^r(*-;jqyaz}FrZh&d_v25{ zVXV05kIhzWhOjGSs_eVqA`fg9nG^wJ~W5F@m_6oLEG> z!im>vd8z+1c^kNkGa{DzpCiW%x49CS)?>!b=7AfD>w}z5@CfE(UQ~ae* z6Q(C9%;r8@keaUSmD!c_Ws$x#cFOF?UYQ-)Ewdy0Wp-qxe@FJr?5O&KYUmhSBAxsX z2TDaOgscfSVTf*-N_ z3H%Ix6X8^q4%C+p?0y}a&AX8=DR$<2R3jv%tk;M)T0jU2OrK(@Zsz?9)RK=Ms;C zg?#HV@EqS-%D0w*<^FndVh1^~gPhnwPV68jc90W0$cY`~#13*|2RX5WoY+B5>>ww0 zkP|z|i5=v`4sv3LWTZ!;56GVZ^J@})Nivc{4s9WawvapYl;-6a@*}}R8xSmFaB(pPcSN8JlMBVOzZk^75 z!@9sVy*!&;h^)F<=RmhlYxK3m5-Y>=Jw1w}*W7Pbg*m1ayYboa!gmJz=gp3gH%Xk?tKHzkP zPU1t!QO05o|H2w4*0Kj~1v~ssm2<>8dIeXoL+;g_pjFSFvGZl9-7z+TePN&Cgob78 z>>4dURdKQ>yR62`vFx*&Ajh%OYI}Bf@5o;G{~vm5Cj@&R@tmGVKe52<{F9Y~|)LiQc)D4$@T z(N1zH`-`T?W$Z25Q@+5yqM339JBwz^7uh?sNWN5hFU|oNtH*K@ z0DFRR7QkdZnUfc)IICkVdw!nFi2-xho|&YocORrFX2ptrTTeJMR-NO!g&bn)hWMY&*2cXrIys$cJS5?APIdd z8SSbI=nA@nRP@RoAOpHL@X#J7@m>X(45oleFcnmRYA_9)0p`NduOs}0g#8$Jh45bm zuYpzIb?^pw6TAi92Je8?eCJ*89(W&o06qjCfi++)_!xWwJ_YN*XW(=21^5zt1=fQN zU=uQ9Gimr5Y@wZe1Ga)~;9Kmz!~7o9p<&kJe<%1I>;eto53n2T0e^zM{_V(-_Q;X; zoU7I#(Jav<8e}Lq%>N5KY7abW4?JoQJZcX-Y7abW4?JoQJZcX-Y7abW4?JoQJZcX- zY7abW4?JoQJZcX-YL8sz|3)qc&x04huOde70Kb8H#x7d6l&?ArlQLFapebY34U=+KDVV94JurJ>reUUI_QuS@^Z+lk4>KDx z2QwEl53>(uK4xFg4-5bUu^WV008Zo#h0`#ngEPVTU=FwdTnH`#7lTW{rC=_&3|t4U z2RDFQ!7}gycnN&TIn!T(^4n9S_JKodC!m-5!uX zx+CZW$Qzvm4g=(krv7X4Mt1|`jZOiXfPB#8gZ2XQ0X?8SdO&-9w7)?g1CGV+ILzb0 z7;pmhAqH8{mVQ72@}MoFHi`TXHW8gXfzg{pdP|A4i$t{4M8DRgA_YXrDX{FRp2*JXiR`SN$T>fW?6024?&*o_p`Iw_08ZUzcl1Pd zGf!YI^aS=qPhh9?L|S_ydz>e-=XoN#q$hG>P9poAC$iUhBIg4ou&;Uod!Hw;yLtlq zt0!=NPJ);Z7Jz%fec*olJ^&sB3rXo?;Bn&g1Xu(XgD1gL;AyY~JOiEu&yhDvN%u0a z96V1Ry#Q8#7YXkru#vpk#CX_d+`k6j0yF`nq(DOyXo#jq5l+hvm*F4+M1n-@$6<~K z;l5g^MK)xY^6C`;i@8NkqSU{Nfg8RVz-~sRuco;ki76S53l5dhcljNBs zza)7j$tzAqZ!4FA6@*3JNb*Lm1TTYEz^mXjunN2m-T-fcw}3eX@Ex$4_`D0=1Mh8XapMZ)z=FbkXoE~cKHD8d-~hypDcWoeBW4-%l;F)q*^S=E8D zi;j%)bmBRQv5sKb-HmmSMt=98_Y^FyTq-QDqmx1Nr71H%8cnz!quY)(ho8T?*Hh2g8U+WBY=sAB^7uZ(^2=}j|_q>YU z^C}tT|3OBB7N8}F0kI$sv;wU`8xZf`DcfQufOeoWNCJoXchUP^Melo+yukmRqzoiw zAjyA84_ng1MpyV9UEz0hh2PN?ewV9pdl$S1-UlCm55Y%Z4Ok042A_g;;4|#L{J?j91V2Ilj9CY^gI~a}{zdeo7SWGdL_cbg+({gN2fIK6 z_yg<)d%&MyFYx>86!YCm0tGY(0ihrag!|Xi7h6wXY(0IkMfAlM(HC1pUu+S5F;2V! z&2^$(u1>Ve)roewI?*myC)(xeM7x4?qFt^|w9D0rcDXvyE>|boVe z)roewI?=8J=tR33b)x?ZedrGVDn?^gF&eXq(U?_?#;jsAW>v7B^gq#q4x;}sGPH`3 zp;e3wtzu+omA=aVgT5ME1Fi-0z;)nya09pz+yrh0w}4y0ZQyor2e=z7@bA?3g8RS& z{_h!G`kv9H?-^bCUO$5SqnHaZAH#ec^9jsFn2Rx=#C!_#Y0M@5?>Q4D1TGlH=mI@_ zIPH2kZ9SZ}9!|R#uG#=st)oX&M~|qEak2)uY8_m)2VAv|9#Ng`5qYuqfowRg!&MtZ zKH>IdG^8J6=lvOT9LV!vPymLY=}p3{0F%KKPzk1jDo_okfipl2ahVQk!3;1HTm&u# zmw@Yt^G$sFcIZ36o!~BTH@FAP2MfTx;6CsW>3SGE0v-jg@b0VNHLwc24&DH7g15li z;2p4#0nXb1=WT%VHo$os z;Jj$!AQr@dR-iR#102rV0OxIh^ESYF8{oXAC)WVyZGiJO(4*T)k8USDx}EgscG9EU zNsn$PJ-VIr=yuYh+ewdZCq25I^yqfdquWW3ZYMpuo%HB-(xcl6_pXO~*TcOTPXWvP z>*3n#;o9rr+Uw!k^>FQaxOP2UyB@Ax57(}TYuCfI>*3nRWlaP4}y_FlO5UbyyNxb|MS zXB|DtI(n3K^e7wPo^|vn8{nRG^e7wPo^|vn8{nRG^e7wPo^|vn8{nRG^eF4-Q8vIm z>*!H7z&-2eQ8vIm>*!H7z&-2eQ8vIm>*!H7z&-2eQP$C;Y=C>#(W7jDd)CpTY=C># z(W7jDd)CpTtfNO+M~||O9%TdEvyL8R9r{;WdX#l)Iye)Y59WXiz=hxo$5Nw>%k4+R&WR5-U;pkcY}MteDH+-3pi;VJ*!_H(aWr(msv+IvyNV79lgvtdYN_fGVADN z*3rwXqnBByd-yr66O;U*r&&i&vyPr-oo?c=b^G=(^?2ejliubma2B{2TuWtT*f}liELS%$X1A5F;_o&vBz;fn;8Kuu_K_RjDXJNgtjY~qpDzzY6T~a zy~L>J9rW+&=>Pp9L&aN+bViExcBFG7C!Tc@n>gF-Fit5m`)O@sjPp*;PP?1at~%J$ zuF^T}>SN9{`&6aNBN&hD#|U44HApU21*(AaQO&sHGj`nZSv&6d9H%o)mrFUB=}gv6 zoUg8s%Q=1ND)~C6FI~$?vDc{^<(r(nbhBJ-$0Of0`)tYgIBn@cxt8;m9+n?-($W+1 z6V6&%%va4wAJQng|v(x+OR^Nm!3 z8IM$qXy_wUq8X1=ojIrI6xE%RicVE2np28YDyI}pP(3-Ns6wTg6N*&2IhROfa4r$& z%5pBzIVy{jiOy3V&Lp~6c{!EnQsv`ZqRUh^rx4wya?Hr3>SsnS)c`YcsS3=17%~?ZgBqNuvsnP6%7EQegVLT<2D;zV7xK~k4sbU`w$m17m&ovQeVE%k1La!e>h?H=jv+b2-2AW7{r$c zb0u@WTmdmE;yR4hQDVn@jw1C((`Gu@Hq*hjnRq*E5XQ*QO!DMRu5dVk^jI=B#$DXI{wdbU~cgV5ToLwhr zfg8z_P3|bm*W}U`Bzr4HTE2z;4(Z;C6PAAJ}?3sqzw)Rll z+M{Ug=ZY}S%)5XZbP*#q;hdQ_m;2?6(u8wv-j&=r9S_&L8D~*;Z@q9c-yuTG8F{!q z$h>;2IVlhN5oXO}&3Spyk1@|4YfjCB=F~jyPczaI&dGU8uz!Y8mT=}uUSP~(HKQSl zv+$TBGAG@M7MykW6ZFr_J+~*~Kxc*-Et$(#*4n zP)@XCeTbQDfo`YTK{Mw9-9fPq#LT=vCn{EqnE4mz&Z;vsvoO${dIuyp4RpFnhi0Y*niKM%nXiHNDRwV3vo_E$yTC8*VFnhTCFuow@;t+*V|BvfbmNo8h^ltKqpK zhx6^8#sTA5SrM6}&6F#=A#8TxCqg;s`NLz^8GBMjFj9Jn@jxHflYk+5eLNknj-of4_c zC25gjIJn3&92^@sIIeJTR`|fdxu@z>(Np)(^hP+hE=}~|+`0^G;Ow}<*|~FOo#+i` zFW}um#+srzt*!`qG-FQDoKSZJG$+)FmYh*{EdGzvCqTpZMVjIJA|Jj#PV{0nYP?7{ z++XxH+#eg}q(nSt)Umq5$N&*#WB_+$fXFa1K=d;*02^e0$TTuQ9L~vfcd#eq-TH3s zNCMHxNCFYVxpR+TgFJv(;c-k7~U+w#;TbvhD8dpN0)Is>iL>1L(Q zpdhJ}Xr)e==)x6k<&DQm8p@scMIvc>B4yIJqFs4oq)U8|bV(9TawXNum0?z*Bv^@( zW@SlVD@*c8S$`za04}eUD0x<*LOBukTqMVNa*pU>rA|9{&sI)sMOIzHynr;PwIUU+W~@Jy(^+rB=61&WL#-TY zZ{<*Xb5bkv!|e1LWu=j_(kLoO8bt+3qo^Qh6lJARl$AzN<{Vd~(X&V+GjqR;7O-3{ zXB7-5x-c$t z*?@3PeSI50?=T|}ZY7*V!hK3w)-k?+7-zkjb%$FSw{H_9^JI|B>&W@8e-heWrQzuz z$UkM}U$-FnmuTgmw(>8`%0F%8UznAD+RDE$EB~~We_>YsX)FK2to+kf{)Jijr>*=8 zv+_?{`4?v8pSJQZ%*sD)oPwCio9-C!&2vaFm- zv2rd233rF^m~&l`B6l+f5zUH+d!Xkd`O-~qLwK$HE3lH!XC+^um3f`5wCil;TxToc zI$H@BVI^EAE8$YCgzID_TuP9Hi?p&WGDx;XTB+8{O0{USHi9_RBio|)lWjRxwq;w{ zR%~Tk4zevuux`RixX!w(=wf;#$d3YKRlMnuaCf9slYGjx@~K1{`P9eCr#vg4O09hA zqm6v(WaU$el}|}lJ|$WClyBwJ5G$XOtbFQg<uC(TM7^eC}fJ!0;9BKL39b=4lcQ&C<76`WZ_Kq+Wm-R1%Q4uGvi2jb|3UUES6lyP{#(kS z)^(^u+tALp^i9^jkDEPc48vn)Np(y5j)fqPS4*22e98EONojjae`4=mdoQr}bnDvN(r4TIFq@*0 z*1m(W5p!&4tWzeOJbTZ!uGyBJXX!!K=O^~v3#@&pwQu3rtGR?@R>w~6yBYi#IUm~Vv`8!^YZ z+jlRteiH26sX1l^bEVOs%--9Q!(MBvWRysgdFsgj*V(zpc2NXzd~bH|3bhJC)2fhK zDwdZ)Q9xQ0T7inZs--oiN|hFVt>A-L3mO#^L|Z9Hj3GviQ6d$JN*j-jan1kQ(hn$eR?UnS@V?O1mq@p zc!{QG)$9Gs@XwQytJEQgRH^qNS)gC=mqpOAvIII#mew~^*UO#t4NK}}<*j;cqkf+r z-`?~SS8n5L$6<1PSd<*6;yBt*{ysd>*lDaS1S9sb<0)$n&xn(Na8JsskyT7l?y{gw9h^kh%_ zcsjsSKeEv@G>@X30osDnp`M=YX}+g^9I(3Av;90h)zchL^Ps9Zu>S;vNWXob=2Mxh z$8|@pwt({BV*jTc{i6I~8?g+umR{?yf#?f*RW@Vkbo7uQ8mu~RlrPk0l#4zaMEzMG z2kEUoB~cc2*n!>}oK#{Yx+S75(tk59N{=$5oM<>adQR|ztq6WW|C1<>qai__{y19B zeutOw8Q+e__zqSXUHk_;Us^N2xF=$PL3$QHwGh&_jFc08!%uhPC9{q*T$Sn;5?FWtdXz`op>kO z`)Yd!sKh2#Fjq?xTEB!7IMcyS(fjRP>{ERPukiC^jdh%<1jlHSP4XQ0Kt3MiYw!ZU z+V14cdir7!y4*!*ZUty^lhE=C(fTA@$K4M5b#FzVejeR<2U@a!Zg-FzLql4FCxi26 z|FQqF|40~_4AwU1;BWpOu#~N=yXMP-tmwwt3yrvMIJ=h}#dYlXeNmpZi)^*5XLsy4 z5D6_U+t)~MZ|y@(|5JZG-Zg#y$2IBy;~K>P{^{AoePQ~er)@_++Z?n9TbXB`4_*#7 zff%-e4z{6(y+oaSh90yWUGNn&zSYc6oqkXC-NC&Wqud{XbZu@}kBKHAucY+x;g4DOkX4cJB(rm-y=e8fByNr@Ka=DaAxm<}KjT%`f zwQ{{I#*TF@!!d|pJIfBiFTU3lr{q0 zBP{UYX4QuXt5gdjv}-^n1RzgJnY$elj!;c%1F^ni8D^YY?>4x{se9+1a8J8d*GB4D zx5Kr&U8Fi(m+NuwlHZwDYzKa}OMTjb`sDa`B>~us-glU%s?h}885)c+eO#y8c@pN1!$`B-V+i6@*xc)QWregbm49SPmZSnERe?WH_P$}VA7N4hVk8I8LQ z{Zxl-XvUmz)| zlSNpk|MeMk{gQ!YWD`i<8f;D0nswMlu4h%f1Kkr%AfH>_6_R16~A!nwnMb>OVHf={bMaZX-NT_R(QME{^709V3Bo%e6d8yw5 zX0(aSa2GSXO=h+$1`j!29UnjMT1whZDI+PNmb*7uU7==cPx()EpeSv(dEM+Wdrgmd z!@OzUGH(Y%z@@Ulpw3{GnFF?T7C6!{Fr(4nL*qb!CQ(+YuNj>~a_E=ilNvgvKI1w& zwv4zZ$GwNuRd1ahQU6K(p5R%u{^JqxmtI%_-a7@fcbd_DJe{}eGuT01X|BP#s0zII zIxLT>O^sQ2f*Nb~E&CL$qpjPf?MrX&kvHkl@6o3Z%D3{p{7BypeYe!skM@R@j3Mn2@wj6bE)qbIlfay68yF#Dq?EO4O0EDs+%0pV`=r8% zT`8B79wlYaZYhWEmn-3~k_A403eIk+gYJ_>KL0|}qofwvE!RW$%VNz>NsduE$9s@^ zj1tcZYM}XGZ9*^NZf1l$3H_e^z?L & \u000b\t\"" + } + ], + "annotation": null + }, + "digest": "ba75764323c460566d33947277f12b71582409404ed0b6778a026887174eda91" + }, + { + "payload": { + "submission_id": "00000000-0000-4000-8000-000000000002", + "captured_at": "2026-10-21T01:02:03.123Z", + "entry": "paste", + "origin": { + "application_id": "test.fixture", + "title": "原文", + "url": "https://example.test/a?b=c" + }, + "materials": [ + { + "kind": "text", + "text": "第一份原文" + }, + { + "kind": "image", + "mime_type": "image/png", + "data_base64": "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAHCAIAAABPxRC5AAAAE0lEQVR4nGPkUbJgQAJMyBwa8AEvKAB0sOhdcgAAAABJRU5ErkJggg==", + "filename": "截图.png" + } + ], + "annotation": "自己的想法" + }, + "digest": "2b9846cb9cc1aba40a968745b9a858c1611ca84a87721bea67c6f55b9c74b997" + } +] diff --git a/Tests/CaptureCoreTests/LivePeerTests.swift b/Tests/CaptureCoreTests/LivePeerTests.swift new file mode 100644 index 0000000..628feb0 --- /dev/null +++ b/Tests/CaptureCoreTests/LivePeerTests.swift @@ -0,0 +1,64 @@ +import CaptureCore +import Foundation +import Security +import Testing + +@Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_CAPTURE_TEST_POSTGREST"] != nil)) +func liveQueueConfirmsOriginalImageAndRecoversReceiptAfterRestart() async throws { + let environment = ProcessInfo.processInfo.environment + let address = try #require(environment["INKCRE_CAPTURE_TEST_POSTGREST"]) + let target = ConnectionTarget(postgrestURL: try #require(URL(string: address))) + let secret = try #require(environment["INKCRE_CAPTURE_TEST_SECRET"]) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "dev.inkcre.capture.acceptance.\(UUID().uuidString)" + let credentials = CredentialStore(service: service) + try credentials.save(secret, for: target.id) + defer { + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: target.id.uuidString, + ] as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + struct Fixture: Decodable { let payload: CapturePayload } + let fixtureURL = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] + let payload = CapturePayload(entry: .paste, origin: fixture.payload.origin, + materials: fixture.payload.materials, annotation: fixture.payload.annotation) + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + let frozen = try await store.freeze(CaptureRecord(payload: payload), target: target) + let queue = CaptureQueue(store: store, credentials: credentials) + try await queue.process(force: true) + if environment["INKCRE_CAPTURE_TEST_DROP_RESPONSE"] == "1" { + let uncertain = try #require(try await store.record(frozen.id)) + #expect(uncertain.state == .unknown) + #expect(uncertain.payload?.materials[1].data == payload.materials[1].data) + let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + try await CaptureQueue(store: reopened, credentials: credentials).process(force: true) + } + let confirmed = try #require(try await store.record(frozen.id)) + #expect(confirmed.state == .confirmed, Comment(rawValue: confirmed.message ?? "")) + #expect(confirmed.payload == nil) + let result = try #require(confirmed.result) + + // A second local database represents a process that lost its success response. + let recoveredStore = try CaptureStore(url: directory.appendingPathComponent("recovered.sqlite")) + _ = try await recoveredStore.freeze(CaptureRecord(payload: payload), target: target) + _ = try await recoveredStore.markDispatch(frozen.id) + try await CaptureQueue(store: recoveredStore, credentials: credentials).process(force: true) + let recovered = try #require(try await recoveredStore.record(frozen.id)) + #expect(recovered.result == result) + #expect(recovered.payload == nil) + var conflictingPayload = payload + conflictingPayload.annotation = "同一身份,错误的其他材料" + let conflictStore = try CaptureStore(url: directory.appendingPathComponent("conflict.sqlite")) + _ = try await conflictStore.freeze(CaptureRecord(payload: conflictingPayload), target: target) + try await CaptureQueue(store: conflictStore, credentials: credentials).process(force: true) + let conflict = try #require(try await conflictStore.record(frozen.id)) + #expect(conflict.state == .needsAction) + #expect(conflict.payload?.annotation == conflictingPayload.annotation) + if let resultPath = environment["INKCRE_CAPTURE_TEST_RESULT"] { + try CaptureJSON.encode(result).write(to: URL(fileURLWithPath: resultPath), options: .atomic) + } +} diff --git a/Tests/CaptureCoreTests/PeerClientTests.swift b/Tests/CaptureCoreTests/PeerClientTests.swift new file mode 100644 index 0000000..6091fb3 --- /dev/null +++ b/Tests/CaptureCoreTests/PeerClientTests.swift @@ -0,0 +1,108 @@ +import CaptureCore +import Foundation +import Synchronization +import Testing + +private final class StubTransport: URLProtocol, @unchecked Sendable { + static let handler = Mutex<(@Sendable (URLRequest) throws -> Data)?>(nil) + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + do { + let callback = Self.handler.withLock { $0 } + let unwrapped = try #require(callback) + let data = try unwrapped(request) + let response = try #require(HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { client?.urlProtocol(self, didFailWithError: error) } + } + override func stopLoading() {} +} + +@Suite(.serialized) +struct PeerClientTests { + private func client() -> PeerClient { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [StubTransport.self] + return PeerClient(session: URLSession(configuration: configuration)) + } + + @Test func recoveryRejectsSameIdentityWithDifferentMaterials() async throws { + var record = CaptureRecord(payload: CapturePayload(materials: [Material(text: "本机原材料")])) + record.target = ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!) + let identity = record.id.uuidString + StubTransport.handler.withLock { handler in + handler = { request in + #expect(request.url?.path == "/capture_receipts") + return Data(""" + [{"submission_id":"\(identity)","payload_digest":"\(String(repeating: "0", count: 64))", + "root_block_id":42,"committed_at":"2026-10-21T01:02:03Z"}] + """.utf8) + } + } + defer { StubTransport.handler.withLock { $0 = nil } } + do { + _ = try await client().recover(record, secret: String(repeating: "s", count: 32)) + Issue.record("冲突回执不能确认本机材料") + } catch CaptureError.needsAction(let message) { + #expect(message.contains("指纹")) + } + } + + @Test func anotherExecutorCanAcceptTheSameFrozenIntentAfterUnknownOutcome() async throws { + var record = CaptureRecord(payload: CapturePayload(materials: [Material(text: "保持同一份材料")])) + record.target = ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!) + let identity = record.id.uuidString + let received = Mutex<[String]>([]) + let receiptPublished = Mutex(false) + let payload = try #require(record.payload) + let digest = try payload.canonicalDigest() + StubTransport.handler.withLock { handler in + handler = { request in + switch request.url?.path { + case "/rpc/renew_peer_lease": return Data("\"2026-10-21T01:04:03Z\"".utf8) + case "/peers" where request.httpMethod == "GET": + return Data(""" + [{"id":"00000000-0000-4000-8000-000000000001","capabilities":[ + {"id":"unrelated","inbound":{"protocol":"other.v1","parameters":{"method":42}}}, + {"id":"extensions.capture.submit.v1","inbound":{"protocol":"core.peer.protocol.http.v1","parameters":{"method":"POST","url":"https://first.test/capture"}}}]}, + {"id":"00000000-0000-4000-8000-000000000002","capabilities":[ + {"id":"extensions.capture.submit.v1","inbound":{"protocol":"core.peer.protocol.http.v1","parameters":{"method":"POST","url":"https://second.test/capture"}}}]}] + """.utf8) + case "/capture": + let host = request.url!.host! + received.withLock { $0.append(host) } + if host == "first.test" { throw URLError(.networkConnectionLost) } + return Data(""" + {"submission_id":"\(identity)","root_block_id":42,"committed_at":"2026-10-21T01:02:03Z","availability":"available"} + """.utf8) + case "/capture_receipts": + guard receiptPublished.withLock({ $0 }) else { return Data("[]".utf8) } + return Data(""" + [{"submission_id":"\(identity)","payload_digest":"\(digest)","root_block_id":42, + "committed_at":"2026-10-21T01:02:03Z"}] + """.utf8) + case "/blocks": return Data("[{\"id\":42}]".utf8) + default: return Data() + } + } + } + defer { StubTransport.handler.withLock { $0 = nil } } + let peer = client() + record.attempts = 1 + await #expect(throws: CaptureError.self) { + _ = try await peer.submit(record, secret: String(repeating: "s", count: 32)) + } + record.attempts = 2 + // An HTTP success without its durable shared receipt must not release local material. + await #expect(throws: CaptureError.self) { + _ = try await peer.submit(record, secret: String(repeating: "s", count: 32)) + } + receiptPublished.withLock { $0 = true } + let result = try await peer.submit(record, secret: String(repeating: "s", count: 32)) + #expect(result.submissionID == record.id) + #expect(received.withLock { $0 } == ["first.test", "second.test", "second.test"]) + } +} diff --git a/Tests/InKCreAppTests/AppIconTests.swift b/Tests/InKCreAppTests/AppIconTests.swift new file mode 100644 index 0000000..d33ea7f --- /dev/null +++ b/Tests/InKCreAppTests/AppIconTests.swift @@ -0,0 +1,33 @@ +import AppKit +import Testing + +struct AppIconTests { + @Test func iconsetHasTransparentMacMarginsAtEveryScale() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let folder = root.appendingPathComponent("Sources/InKCreApp/Resources/Brand/AppIcon.iconset") + for (name, pixels, fraction) in [ + ("16x16", 16, 0.875), ("16x16@2x", 32, 0.875), + ("32x32", 32, 0.875), ("32x32@2x", 64, 0.875), + ("128x128", 128, 0.8046875), ("128x128@2x", 256, 0.8046875), + ("256x256", 256, 0.8046875), ("256x256@2x", 512, 0.8046875), + ("512x512", 512, 0.8046875), ("512x512@2x", 1024, 0.8046875), + ] { + let bitmap = try #require(NSBitmapImageRep(data: Data(contentsOf: folder.appendingPathComponent("icon_\(name).png")))) + #expect(bitmap.pixelsWide == pixels && bitmap.pixelsHigh == pixels) + let inset = Double(pixels) * (1 - fraction) / 2 + for vertical in [false, true] { + let visible = (0.. 0.05 + } + let first = try #require(visible.first) + let last = try #require(visible.last) + #expect(abs(Double(first) - inset) <= 1) + #expect(abs(Double(pixels - last - 1) - inset) <= 1) + } + #expect(try #require(bitmap.colorAt(x: 0, y: 0)).alphaComponent == 0) + #expect(try #require(bitmap.colorAt(x: pixels / 2, y: pixels / 2)).alphaComponent == 1) + } + } +} diff --git a/Tests/InKCreAppTests/CaptureInteractionTests.swift b/Tests/InKCreAppTests/CaptureInteractionTests.swift new file mode 100644 index 0000000..f3f1b84 --- /dev/null +++ b/Tests/InKCreAppTests/CaptureInteractionTests.swift @@ -0,0 +1,179 @@ +import AppKit +import CaptureCore +import Testing +@testable import InKCreApp + +@Suite(.serialized) @MainActor +struct CaptureInteractionTests { + @Test func emptyCaptureShortcutDoesNotOpenConnectionOrStartSubmission() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + var mainOpened = false + model.showMain = { mainOpened = true } + model.submit() + #expect(!model.hasContent) + #expect(!model.submitting) + #expect(!mainOpened) + #expect(!model.showingConnectionEditor) + #expect(model.pane == .capture) + #expect(model.error == nil) + } + + @Test func backgroundQueueRefreshCannotReplaceNewDraftFeedbackWithAnOldRecord() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + let old = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧的已收入记录")])) + try await model.store.saveDraft(old) + _ = try await model.store.freeze(old, target: ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!)) + try await model.store.confirm(old.id, result: CaptureResult( + submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available + )) + model.newThought() + for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } + let newDraftID = model.draft.id + let feedback = model.saveStatus + await model.sendPending() + #expect(model.draft.id == newDraftID) + #expect(model.saveStatus == feedback) + #expect(model.error == nil) + } + + @Test func quickCaptureDoesNotChangeMainWindowNavigationAndUsesTheSameDraft() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + model.thought = "主窗口中未提交的草稿" + let previousID = model.draft.id + model.pane = .settings + model.editorOwner = .quick + var presented: CaptureModel.EditorOwner? + model.presentEditor = { presented = model.editorOwner } + let payload = CapturePayload(entry: .selection, materials: [Material(text: "快捷采集的新材料")]) + await model.receive(payload) + #expect(model.pane == .settings) + #expect(presented == .quick) + #expect(model.draft.id == payload.submissionID) + #expect(try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿") + let currentID = model.draft.id + model.editorOwner = .main + #expect(model.draft.id == currentID) + #expect(model.thought == "快捷采集的新材料") + var mainOpened = false + model.showMain = { mainOpened = true } + model.continueInMain() + #expect(mainOpened) + #expect(model.pane == .capture) + #expect(model.draft.id == currentID) + } + + @Test func mainCaptureRevealsEditorAndMissingConnectionOpensMainSettings() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + model.pane = .queue + await model.receive(CapturePayload(materials: [Material(text: "回到主窗口采集")])) + #expect(model.pane == .capture) + model.editorOwner = .quick + var mainOpened = false + model.showMain = { mainOpened = true; model.editorOwner = .main } + model.submit() + #expect(mainOpened) + #expect(model.editorOwner == .main) + #expect(model.pane == .settings) + #expect(model.hasContent) + #expect(model.lastContactAt == nil) + } + + @Test func materialRemovalRequiresConfirmationAndPreservesOtherContent() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + let material = Material(text: "误采的材料") + let kept = Material(text: "保留的材料") + model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [material, kept], annotation: "附注")) + try await model.flush() + model.requestRemoveMaterial(material.id) + #expect(model.draft.payload?.materials == [material, kept]) + model.removalRequest = nil + #expect(model.draft.payload?.materials == [material, kept]) + model.requestRemoveMaterial(material.id) + model.confirmRemoval(try #require(model.removalRequest)) + try await model.flush() + let saved = try #require(try await model.store.record(model.draft.id)) + #expect(saved.payload?.materials.map(\.text) == [kept.text]) + #expect(saved.payload?.annotation == "附注") + } + + @Test func deletingBeforeDebounceRequiresConfirmationAndDoesNotReappear() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + model.thought = "尚未等到自动保存的中文 👩🏽‍💻" + model.annotation = "最后一次附注" + let id = model.draft.id + let request = model.selectionRequest + model.requestDiscard(model.draft) + #expect(model.draft.id == id) + #expect(model.thought == "尚未等到自动保存的中文 👩🏽‍💻") + model.removalRequest = nil + #expect(model.draft.id == id) + model.requestDiscard(model.draft) + model.confirmRemoval(try #require(model.removalRequest)) + for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } + #expect(!model.submitting) + #expect(model.error == nil) + #expect(model.selectionRequest != request) + #expect(model.draft.id != id) + let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + #expect(try await reopened.record(id) == nil) + #expect(try await reopened.records().isEmpty) + } + + @Test func cancellingQuickCaptureOnlyDiscardsContentAfterConfirmation() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + var closed = false + model.dismissQuickCapture = { closed = true } + model.cancelCapture() + #expect(closed && model.removalRequest == nil) + closed = false + model.thought = "尚未确认丢弃的想法" + model.cancelCapture() + #expect(!closed && model.hasContent) + model.removalRequest = nil + #expect(model.thought == "尚未确认丢弃的想法") + model.cancelCapture() + model.confirmRemoval(try #require(model.removalRequest)) + for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } + #expect(closed && !model.hasContent) + #expect(model.error == nil) + } + + @Test func deletingAnotherRecordKeepsTheCurrentUnsavedDraft() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + defer { model.stop() } + let other = CaptureRecord(payload: CapturePayload(materials: [Material(text: "另一份草稿")])) + try await model.store.saveDraft(other) + model.thought = "当前尚未等到自动保存的内容" + let currentID = model.draft.id + model.requestDiscard(other) + model.confirmRemoval(try #require(model.removalRequest)) + for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } + #expect(model.draft.id == currentID) + #expect(try await model.store.record(currentID)?.payload?.materials.first?.text == "当前尚未等到自动保存的内容") + #expect(try await model.store.record(other.id) == nil) + #expect(model.error == nil) + } +} diff --git a/Tests/InKCreAppTests/CaptureLayoutTests.swift b/Tests/InKCreAppTests/CaptureLayoutTests.swift new file mode 100644 index 0000000..e804c9a --- /dev/null +++ b/Tests/InKCreAppTests/CaptureLayoutTests.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI +import Testing +@testable import InKCreApp + +@Suite(.serialized) @MainActor +struct CaptureLayoutTests { + @Test func emptyRecordsContentIsCenteredAcrossDetailWidths() throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") + defer { model.stop() } + for width in [520, 550, 790] { + let renderer = ImageRenderer(content: CaptureRecordsView(model: model) + .environment(\.colorScheme, .light) + .frame(width: CGFloat(width), height: 517) + .background(.white)) + renderer.scale = 1 + let image = try #require(renderer.cgImage) + let bitmap = NSBitmapImageRep(cgImage: image) + var minX = width + var maxX = -1 + // The system symbol is symmetric; punctuation makes text ink bounds asymmetric. + var foundSymbol = false + for y in 0..= minX) + #expect(abs(Double(minX + maxX) / 2 - Double(width - 1) / 2) < 1) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + let output = URL(fileURLWithPath: path).appendingPathComponent("records-empty-\(width).png") + try #require(bitmap.representation(using: .png, properties: [:])).write(to: output) + } + } + } +} diff --git a/Tests/InKCreAppTests/RegionOverlayTests.swift b/Tests/InKCreAppTests/RegionOverlayTests.swift new file mode 100644 index 0000000..33feb48 --- /dev/null +++ b/Tests/InKCreAppTests/RegionOverlayTests.swift @@ -0,0 +1,81 @@ +import AppKit +import Testing +@testable import InKCreApp + +@Suite(.serialized) @MainActor +struct RegionOverlayTests { + @Test func deniedAccessReturnsAnInlineIssueWithoutOpeningAnOverlayOrRequestingPermission() { + _ = NSApplication.shared + let windows = NSApp.windows.count + var checks = 0 + let capture = RegionCapture(hasPermission: { checks += 1; return false }) + var denied = 0 + for _ in 0..<3 { + capture.begin { result in + if case .failure(let error) = result, error is ScreenRecordingPermissionError { denied += 1 } + else { Issue.record("拒绝访问不得进入框选或截取。") } + } + } + #expect(checks == 3) + #expect(denied == 3) + #expect(!capture.isCapturing) + #expect(NSApp.windows.count == windows) + } + + private func event(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws -> NSEvent { + try #require(NSEvent.mouseEvent(with: type, location: view.convert(point, to: nil), modifierFlags: [], + timestamp: 0, windowNumber: view.window!.windowNumber, + context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + } + + @Test func firstDragKeepsVisibleSelectionUntilExplicitConfirmation() throws { + _ = NSApplication.shared + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 600, height: 400), styleMask: .borderless, + backing: .buffered, defer: false) + let view = RegionOverlay(frame: CGRect(x: 0, y: 0, width: 600, height: 400)) + window.contentView = view + var completed = false + var calls = 0 + var result: CGRect? + view.complete = { result = $0; completed = true; calls += 1 } + #expect(view.acceptsFirstMouse(for: nil)) + view.mouseDown(with: try event(.leftMouseDown, at: CGPoint(x: 400, y: 300), in: view)) + view.mouseDragged(with: try event(.leftMouseDragged, at: CGPoint(x: 100, y: 100), in: view)) + view.mouseUp(with: try event(.leftMouseUp, at: CGPoint(x: 100, y: 100), in: view)) + #expect(view.selection == CGRect(x: 100, y: 100, width: 300, height: 200)) + #expect(!completed) + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + let scale = CGFloat(bitmap.pixelsWide) / view.bounds.width + let edge = try #require(bitmap.colorAt(x: Int(100 * scale), y: Int(200 * scale))) + let inside = try #require(bitmap.colorAt(x: Int(200 * scale), y: Int(200 * scale))) + let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(200 * scale))) + #expect(edge.alphaComponent > 0.95) + #expect(inside.alphaComponent < 0.05) + #expect(outside.alphaComponent > 0.25 && outside.alphaComponent < 0.5) + view.confirmSelection() + view.confirmSelection() + #expect(completed) + #expect(calls == 1) + #expect(result == view.selection) + } + + @Test func resettingOrCancellingNeverCapturesAccidentalTinySelection() throws { + _ = NSApplication.shared + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 600, height: 400), styleMask: .borderless, + backing: .buffered, defer: false) + let view = RegionOverlay(frame: CGRect(x: 0, y: 0, width: 600, height: 400)) + window.contentView = view + var calls = 0 + view.complete = { rect in #expect(rect == nil); calls += 1 } + view.mouseDown(with: try event(.leftMouseDown, at: CGPoint(x: 200, y: 200), in: view)) + view.mouseUp(with: try event(.leftMouseUp, at: CGPoint(x: 201, y: 201), in: view)) + view.confirmSelection() + #expect(calls == 0) + view.resetSelection() + #expect(view.selection.isEmpty) + view.cancelSelection() + view.cancelSelection() + #expect(calls == 1) + } +} diff --git a/Tests/InKCreAppTests/ShortcutTests.swift b/Tests/InKCreAppTests/ShortcutTests.swift new file mode 100644 index 0000000..d289e70 --- /dev/null +++ b/Tests/InKCreAppTests/ShortcutTests.swift @@ -0,0 +1,148 @@ +import AppKit +import Carbon.HIToolbox +import CaptureCore +import Testing +@testable import InKCreApp + +@Suite(.serialized) @MainActor +struct ShortcutTests { + @Test func captureShortcutDoesNotConsumeKeysWhileAnAttachedSheetOwnsInput() throws { + _ = NSApplication.shared + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 500, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + let sheet = NSWindow(contentRect: .init(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) + window.beginSheet(sheet) + defer { window.endSheet(sheet); window.orderOut(nil) } + shortcuts.isCaptureWindow = { $0 === window } + var performed = false + shortcuts.perform = { _ in performed = true } + let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\r", + charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) + #expect(window.attachedSheet === sheet) + #expect(shortcuts.handle(event) === event) + #expect(!performed) + } + + @Test func ordinaryUnicodeInputPassesThroughWithoutKeyboardLayoutTranslation() throws { + _ = NSApplication.shared + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + shortcuts.isCaptureWindow = { _ in true } + let text = "InKCre UI Audit 中文 👩🏽‍💻" + let cg = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)) + let characters = Array(text.utf16) + characters.withUnsafeBufferPointer { buffer in + cg.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: buffer.baseAddress) + } + let event = try #require(NSEvent(cgEvent: cg)) + let original = try #require(event.characters) + #expect(original == text) + #expect(shortcuts.handle(event) === event) + #expect(event.characters == original) + var count = 0 + var result = [UniChar](repeating: 0, count: 256) + cg.keyboardGetUnicodeString(maxStringLength: result.count, actualStringLength: &count, unicodeString: &result) + #expect(String(utf16CodeUnits: result, count: count) == original) + } + + private func defaults() -> (UserDefaults, String) { + let name = "dev.inkcre.shortcut-tests.\(UUID().uuidString)" + return (UserDefaults(suiteName: name)!, name) + } + + @Test func individualBindingsAndDisablingSurviveRestart() throws { + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + let replacement = RecordedShortcut(keyCode: UInt16(kVK_ANSI_K), modifiers: [.control, .option], keyLabel: "K") + try shortcuts.set(replacement, for: .capture) + try shortcuts.set(nil, for: .screenshot) + let reopened = try GlobalShortcuts(defaults: defaults) + #expect(reopened.bindings[.capture] == replacement) + #expect(reopened.bindings[.screenshot] == nil) + #expect(reopened.bindings[.submit] == ShortcutAction.submit.defaultShortcut) + #expect(reopened.bindings[.paste] == ShortcutAction.paste.defaultShortcut) + try reopened.set(ShortcutAction.screenshot.defaultShortcut, for: .screenshot) + #expect(reopened.bindings[.capture] == replacement) + } + + @Test func legacyPresetsPreserveExistingIntent() throws { + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + defaults.set("disabled", forKey: "shortcutPreset") + let disabled = try GlobalShortcuts(defaults: defaults) + #expect(disabled.bindings[.capture] == nil && disabled.bindings[.screenshot] == nil) + #expect(disabled.bindings[.submit] != nil) + defaults.set("commandShift", forKey: "shortcutPreset") + let migrated = try GlobalShortcuts(defaults: defaults) + #expect(migrated.bindings[.capture]?.modifiers == [.command, .shift]) + #expect(migrated.bindings[.screenshot]?.modifiers == [.command, .shift]) + } + + @Test func duplicateAndPlainTypingCannotReplaceOldSettings() throws { + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + #expect(throws: CaptureError.self) { try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) } + #expect(throws: CaptureError.self) { + try shortcuts.set(RecordedShortcut(keyCode: UInt16(kVK_ANSI_A), modifiers: .shift, keyLabel: "A"), for: .submit) + } + #expect(shortcuts.bindings[.capture] == ShortcutAction.capture.defaultShortcut) + #expect(shortcuts.bindings[.submit] == ShortcutAction.submit.defaultShortcut) + #expect(defaults.data(forKey: GlobalShortcuts.settingsKey) == nil) + } + + @Test func failedSystemRegistrationPreservesOtherKeysAndSavedBinding() throws { + _ = NSApplication.shared + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + let old = RecordedShortcut(keyCode: UInt16(kVK_F17), modifiers: [.control, .option, .command], keyLabel: "F17") + let blocked = RecordedShortcut(keyCode: UInt16(kVK_F18), modifiers: [.control, .option, .command], keyLabel: "F18") + try shortcuts.set(old, for: .capture) + try shortcuts.set(nil, for: .screenshot) + try shortcuts.start() + defer { shortcuts.stop() } + #expect(shortcuts.message == nil) + var occupied: EventHotKeyRef? + #expect(RegisterEventHotKey(UInt32(blocked.keyCode), blocked.carbonModifiers, + EventHotKeyID(signature: 0x54455354, id: 77), GetApplicationEventTarget(), 0, &occupied) == noErr) + defer { if let occupied { UnregisterEventHotKey(occupied) } } + #expect(throws: CaptureError.self) { try shortcuts.set(blocked, for: .capture) } + #expect(shortcuts.bindings[.capture] == old) + #expect(try GlobalShortcuts(defaults: defaults).bindings[.capture] == old) + var probe: EventHotKeyRef? + let result = RegisterEventHotKey(UInt32(old.keyCode), old.carbonModifiers, + EventHotKeyID(signature: 0x54455354, id: 78), GetApplicationEventTarget(), 0, &probe) + defer { if let probe { UnregisterEventHotKey(probe) } } + #expect(result != noErr) + } + + @Test func recordingConsumesKeyWithoutExecutingActionAndEscapeCancels() throws { + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + var performed = false + shortcuts.perform = { _ in performed = true } + shortcuts.beginRecording(.paste) + let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .option], + timestamp: 0, windowNumber: 0, context: nil, characters: "p", charactersIgnoringModifiers: "p", + isARepeat: false, keyCode: UInt16(kVK_ANSI_P))) + #expect(shortcuts.handle(event) == nil) + #expect(shortcuts.bindings[.paste]?.keyCode == UInt16(kVK_ANSI_P)) + #expect(!performed && shortcuts.recording == nil) + shortcuts.beginRecording(.submit) + let escape = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: 0, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, keyCode: UInt16(kVK_Escape))) + #expect(shortcuts.handle(escape) == nil) + #expect(shortcuts.recording == nil) + #expect(shortcuts.bindings[.submit] == ShortcutAction.submit.defaultShortcut) + } +} diff --git a/paraflow/.gitkeep b/paraflow/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/paraflow/7:0.html b/paraflow/7:0.html deleted file mode 100644 index 71dca0c..0000000 --- a/paraflow/7:0.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - -
9:41
Essentiali

605-1-3064

Package pick up

Moonlight

Piano scores

John Smith

Business contact

Project Alpha

Design files

Meeting Notes

Weekly team sync

- \ No newline at end of file diff --git a/paraflow/8:0.html b/paraflow/8:0.html deleted file mode 100644 index 5970385..0000000 --- a/paraflow/8:0.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - -
9:41
Professional headshot of user with neutral expression, light background

Alex Johnson

@alexjohnson

Memory Usage

2.4 GB / 5.0 GB

48% of storage used

Connected Devices

Glasses

Last connected: Today, 3:45 PM

Connected

App Settings

Notifications
Privacy
Help & Support
- \ No newline at end of file diff --git a/paraflow/9:0.html b/paraflow/9:0.html deleted file mode 100644 index 9e683d3..0000000 --- a/paraflow/9:0.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - -
9:41

Complete project proposal

Schedule team meeting

Review quarterly reports

Prepare presentation slides

Email client updates

- \ No newline at end of file diff --git a/paraflow/project.json b/paraflow/project.json deleted file mode 100644 index e65bac2..0000000 --- a/paraflow/project.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Minimal Knowledge App", - "created_at": "2025-07-26T07:13:42.791846+00:00", - "paraflow_version": "1.0", - "screens": [ - { - "id": "7:0", - "title": "Home Page", - "filename": "7:0.html" - }, - { - "id": "8:0", - "title": "User Profile", - "filename": "8:0.html" - }, - { - "id": "9:0", - "title": "Information Detail", - "filename": "9:0.html" - } - ] -} \ No newline at end of file diff --git a/paraflow/styles.css b/paraflow/styles.css deleted file mode 100644 index ba6495e..0000000 --- a/paraflow/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -.__paraflow-fa-fas {font-family: "Font Awesome 6 Free"; display: inline-block; line-height: 1; font-weight: 900; font-style: normal} -.list-item-container {background-color: rgba(255, 255, 255, 1); margin-bottom: 6px; padding-top: 18px; padding-right: 18px; padding-bottom: 18px; padding-left: 18px; border-top-left-radius: 8px; border-top-right-radius: 8px; border-bottom-right-radius: 8px; border-bottom-left-radius: 8px} -.filter-button-inactive {background-color: rgba(244, 246, 248, 1); color: rgba(7, 11, 17, 1)} -.filter-button-active {background-color: rgba(33, 37, 40, 1); color: rgba(255, 255, 255, 1)} \ No newline at end of file diff --git a/profilehead.png b/profilehead.png deleted file mode 100644 index 157aec742a6c484ea786ea957ae948a9ab7d2063..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 439 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#P1|%(0%q{^b&H|6fVg?3oVGw3ym^DWND9BhG zWippfY}r;Iv5#$QH}3YIUrF>xP& P;mqLa>gTe~DWM4fxqGDh diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100644 index 0000000..26e6516 --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,72 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." + +configuration="${CONFIGURATION:-release}" +bundle_id="${BUNDLE_ID:-dev.inkcre.capture}" +development_identity="${DEVELOPMENT_SIGNING_IDENTITY:-}" +if [[ -z "$development_identity" && -z "${SIGNING_IDENTITY:-}" && -f .local-development-signing-identity ]]; then + development_identity="$(< .local-development-signing-identity)" +fi +version="0.1.0" +case "$bundle_id" in *[!a-zA-Z0-9.-]*|"") echo "Invalid BUNDLE_ID" >&2; exit 1;; esac +if [[ -n "${SIGNING_IDENTITY:-}" && "$bundle_id" == dev.* ]]; then + echo "Signed release builds require an explicit stable BUNDLE_ID outside dev.*." >&2 + exit 1 +fi +if [[ -n "${SIGNING_IDENTITY:-}" && -n "$development_identity" ]]; then + echo "Choose either development signing or release signing, not both." >&2 + exit 1 +fi + +swift build -c "$configuration" --arch arm64 --arch x86_64 +bin_path="$(swift build -c "$configuration" --arch arm64 --arch x86_64 --show-bin-path)" +app="dist/InKCre.app" +mkdir -p "$app/Contents/MacOS" +mkdir -p "$app/Contents/Resources" +cp "$bin_path/InKCre" "$app/Contents/MacOS/InKCre" +cp -R "$bin_path/InKCre_InKCreApp.bundle" "$app/Contents/Resources/" +iconutil -c icns Sources/InKCreApp/Resources/Brand/AppIcon.iconset -o "$app/Contents/Resources/AppIcon.icns" +cat > "$app/Contents/Info.plist" < + + +CFBundleExecutableInKCre +CFBundleIdentifier$bundle_id +CFBundleNameInKCre +CFBundleDisplayNameInKCre +CFBundleIconFileAppIcon.icns +CFBundlePackageTypeAPPL +CFBundleShortVersionString$version +CFBundleVersion7 +LSMinimumSystemVersion15.0 +NSHighResolutionCapable +NSPrincipalClassNSApplication +NSAccessibilityUsageDescription仅在你唤起采集时读取当前选区与可获得的出处。 +NSScreenCaptureUsageDescription保存你明确框选的屏幕区域。 + +PLIST +touch "$app" +if [[ -n "${SIGNING_IDENTITY:-}" ]]; then + codesign --force --options runtime --timestamp --sign "$SIGNING_IDENTITY" "$app" +elif [[ -n "$development_identity" ]]; then + codesign --force --sign "$development_identity" "$app" +else + echo "Ad-hoc signing: screen-recording grants may reset after code changes. Use DEVELOPMENT_SIGNING_IDENTITY for a stable local build." >&2 + codesign --force --sign - "$app" +fi +codesign --verify --strict "$app" + +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +cp -R "$app" "$staging/InKCre.app" +ln -s /Applications "$staging/Applications" +dmg="dist/InKCre-$version-universal.dmg" +hdiutil create -quiet -volname InKCre -srcfolder "$staging" -format UDZO -ov "$dmg" +if [[ -n "${NOTARY_PROFILE:-}" ]]; then + [[ -n "${SIGNING_IDENTITY:-}" ]] || { echo "Notarization requires SIGNING_IDENTITY" >&2; exit 1; } + xcrun notarytool submit "$dmg" --keychain-profile "$NOTARY_PROFILE" --wait + xcrun stapler staple "$dmg" +fi +echo "$PWD/$app" +echo "$PWD/$dmg" diff --git a/scripts/build-macos-icon.sh b/scripts/build-macos-icon.sh new file mode 100644 index 0000000..376a222 --- /dev/null +++ b/scripts/build-macos-icon.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +command -v rsvg-convert >/dev/null || { echo 'Regenerating icons requires librsvg (rsvg-convert).' >&2; exit 1; } +brand=Sources/InKCreApp/Resources/Brand +for entry in '16x16:16:small' '16x16@2x:32:small' '32x32:32:small' '32x32@2x:64:small' \ + '128x128:128:regular' '128x128@2x:256:regular' '256x256:256:regular' \ + '256x256@2x:512:regular' '512x512:512:regular' '512x512@2x:1024:regular'; do + IFS=: read -r name pixels geometry <<< "$entry" + source="$brand/AppIcon.svg" + if [[ "$geometry" == small ]]; then source="$brand/AppIconSmall.svg"; fi + rsvg-convert -w "$pixels" -h "$pixels" "$source" -o "$brand/AppIcon.iconset/icon_$name.png" +done From a2872bba23f2fa73e3b826ccbd1a6ccb5b029160 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 21 Oct 2026 20:30:12 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix(capture):=20=E7=94=B1=20Mac=20?= =?UTF-8?q?=E6=9E=84=E5=9B=BE=E5=B9=B6=E7=9B=B4=E6=8E=A5=E5=86=99=E5=85=A5?= =?UTF-8?q?=20info-base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除执行 Peer 发现和委托,使用既有 PostgREST 原子提交。 - 在原生侧验证图片和材料边界,保持待办身份与回执恢复。 - 验证无 Python Peer 时提交与丢响应恢复,更新开发构建为 8。 --- AGENTS.md | 4 +- README.md | 8 +- Sources/CaptureCore/CaptureGraph.swift | 80 +++++++++++++++++++ Sources/CaptureCore/Models.swift | 19 +++++ Sources/CaptureCore/PeerClient.swift | 73 +++-------------- Sources/InKCreApp/ConnectionView.swift | 2 +- .../CaptureContractTests.swift | 45 ++++++++++- .../CaptureCoreTests/CaptureStoreTests.swift | 2 +- Tests/CaptureCoreTests/PeerClientTests.swift | 32 +++----- scripts/build-app.sh | 2 +- 10 files changed, 173 insertions(+), 94 deletions(-) create mode 100644 Sources/CaptureCore/CaptureGraph.swift diff --git a/AGENTS.md b/AGENTS.md index 1908d5f..997b487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,11 @@ 使用中文与人沟通。未明确要求时不提交或推送。 -本仓库拥有原生 macOS 采集 Peer。共享状态属于 info-base;Capture 扩展分发由其他 Peer 执行。Mac 不承载扩展宿主,不启动 Python 子进程,不把本机 SQLite 当成共享知识权威。 +本仓库拥有原生 macOS 采集 Peer。共享状态属于 info-base;Mac 自己校验材料、构图并通过共享数据库协议原子写入,不依赖其他 Peer 执行采集。Mac 不承载扩展宿主,不启动 Python 子进程,不把本机 SQLite 当成共享知识权威。 本体是 regular Mac 应用,拥有 Dock 图标、普通主窗口和应用菜单。快捷采集 NSPanel 是辅助入口;不得再把应用整体收窄为 accessory/LSUIElement 菜单栏工具。主窗口导航与快捷采集编辑位置分开,草稿和队列只有一个所有者。 -`Sources/CaptureCore` 拥有冻结提交、SQLite、Keychain 和 Peer 协议。`Sources/InKCreApp` 拥有系统采集及原生交互。共享请求与图合同见 `InKCre/impl-py` 的 `docs/30-unit-tdd/capture-extension.md`;数据库迁移由 `impl-py` 拥有。 +`Sources/CaptureCore` 拥有冻结提交、SQLite、Keychain、采集构图和数据库协议。`Sources/InKCreApp` 拥有系统采集及原生交互。共享图与提交合同见 `InKCre/docs` 的 `20-product-tdd/capture-contract.md`;数据库迁移由 `impl-py` 拥有。 同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 diff --git a/README.md b/README.md index ee881f4..4bdee1a 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,11 @@ open dist/InKCre.app 连接设置需要共享信息库的 PostgREST HTTP(S) 地址和部署签名凭据,可选填 Web Peer 地址。凭据仅存于 Keychain。该身份具有部署 Peer 的完整权限,应使用你有权访问的信息库。 -“连接信息库”或“编辑连接”打开原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后网络检查在后台进行,不阻塞采集。最近检查须同时通过 Peer 注册、回执合同访问与 Capture 执行者发现;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。 +“连接信息库”或“编辑连接”打开原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后网络检查在后台进行,不阻塞采集。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。 -部署需要包含 `capture_receipts` 的数据库合同,以及在线、已启用 `inkcre/capture` Python 分发的 Peer(Host SDK ≥0.3.4、<0.4.0)。Mac 从共享 `peers` 公告发现 `extensions.capture.submit.v1`,不要求固定 Python 地址。Web 读取者另需启用同产品的 Module Federation 分发(Host SDK ≥0.4.0、<0.5.0)。发布与安装由各分发既有的 Registry 流程承担。 +部署只需要具备 `capture_receipts`、`graph_digest` 和 `commit_capture` 的共享 PostgreSQL/PostgREST 数据库协议。Mac 自己构图并原子写入,不发现或调用 Python 执行者;Python Peer 停止不影响收入。Web 或 Python Peer 若要解释这些内容,可各自按需启用 Capture 读取分发;它们不是写入的前置条件。共享合同见 [主动采集持久化契约](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 -“草稿已保留在本机”表示 SQLite 已成功落盘;“本机已保留,待收入”表示冻结材料和目标已经保存;“结果待确认”表示请求可能已经成功;只有共享回执通过材料指纹核对后才显示“已收入”。网络中断、退出或切换连接不会给旧提交换身份或改目标。后台恢复先查回执,必要时复用原材料与原 UUID,并轮换在线执行者。共享根被删除后重试也不会重建。 +“草稿已保留在本机”表示 SQLite 已成功落盘;“本机已保留,待收入”表示冻结材料和目标已经保存;“结果待确认”表示请求可能已经成功;只有共享回执通过材料指纹核对后才显示“已收入”。网络中断、退出或切换连接不会给旧提交换身份或改目标。后台恢复先查回执,必要时复用原材料、图与原 UUID 向同一信息库重试。共享根被删除后重试也不会重建。 材料累计最多 32 MiB,编码后的请求最多 48 MiB;图片保留原始字节,支持 PNG/JPEG/GIF/WebP。实际代理必须允许同等请求体。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。 @@ -63,4 +63,4 @@ BUNDLE_ID=你的稳定BundleID SIGNING_IDENTITY='Developer ID Application: …' NOTARY_PROFILE=你的Profile bash scripts/build-app.sh ``` -Swift 测试覆盖冻结包重开、目标绑定、原子确认与 Python/Swift 指纹样例。原生权限、多应用选区、多屏、Intel 实机与公证安装还需对应环境验证,不能由 universal 编译结果推断通过。 +Swift 测试覆盖冻结包重开、目标绑定、原子确认、原生构图、直接数据库提交与已有冻结材料指纹兼容。原生权限、多应用选区、多屏、Intel 实机与公证安装还需对应环境验证,不能由 universal 编译结果推断通过。 diff --git a/Sources/CaptureCore/CaptureGraph.swift b/Sources/CaptureCore/CaptureGraph.swift new file mode 100644 index 0000000..a9d69e5 --- /dev/null +++ b/Sources/CaptureCore/CaptureGraph.swift @@ -0,0 +1,80 @@ +import Foundation + +/// The native Peer owns Capture semantics; the database only commits this prepared graph. +struct CaptureCommit: Encodable { + struct Block: Encodable { + let id: Int + let resolver: String + let content: String + } + + struct Relation: Encodable { + let from_: Int + let to_: Int + let content: String + } + + struct Blob: Encodable { + let block_id: Int + let data_base64: Data + } + + struct Graph: Encodable { + let root_local_id = -1 + var blocks: [Block] + var relations: [Relation] = [] + var blobs: [Blob] = [] + } + + let submission_id: UUID + let payload_digest: String + let graph: Graph + + init(_ payload: CapturePayload) throws { + try payload.validate() + struct Metadata: Encodable { + let captured_at: Date + let entry: CaptureEntry + let origin: CaptureOrigin? + } + struct Attachment: Encodable { + let mime_type: String + let filename: String? + } + func json(_ value: T) throws -> String { + String(decoding: try CaptureJSON.encode(value), as: UTF8.self) + } + var graph = Graph(blocks: [Block( + id: -1, resolver: "extensions.capture.capture.v1", + content: try json(Metadata(captured_at: payload.capturedAt, entry: payload.entry, origin: payload.origin)) + )]) + for (index, material) in payload.materials.enumerated() { + let id = -(graph.blocks.count + 1) + switch material.kind { + case .text: + guard let text = material.text else { throw CaptureError.invalid("文字材料格式不正确。") } + graph.blocks.append(Block(id: id, resolver: "core.text.v1", content: text)) + case .image: + guard let bytes = material.data, let mime = material.mimeType else { + throw CaptureError.invalid("图片材料格式不正确。") + } + graph.blocks.append(Block( + id: id, resolver: "extensions.capture.attachment.v1", + content: try json(Attachment(mime_type: mime, filename: material.filename)) + )) + graph.blocks.append(Block(id: id - 1, resolver: "core.image.v1", content: "")) + graph.blobs.append(Blob(block_id: id - 1, data_base64: bytes)) + graph.relations.append(Relation(from_: id, to_: id - 1, content: "content")) + } + graph.relations.append(Relation(from_: -1, to_: id, content: "material:\(index)")) + } + if let annotation = payload.annotation, !annotation.isEmpty { + let id = -(graph.blocks.count + 1) + graph.blocks.append(Block(id: id, resolver: "core.text.v1", content: annotation)) + graph.relations.append(Relation(from_: -1, to_: id, content: "annotation")) + } + submission_id = payload.submissionID + payload_digest = try payload.canonicalDigest() + self.graph = graph + } +} diff --git a/Sources/CaptureCore/Models.swift b/Sources/CaptureCore/Models.swift index 3fed89c..470a319 100644 --- a/Sources/CaptureCore/Models.swift +++ b/Sources/CaptureCore/Models.swift @@ -1,4 +1,6 @@ import Foundation +import ImageIO +import UniformTypeIdentifiers public enum CaptureError: LocalizedError, Sendable { case invalid(String) @@ -104,6 +106,11 @@ public struct CapturePayload: Codable, Sendable { throw CaptureError.invalid("先写下想法,或加入一份材料。") } guard materials.count <= 128 else { throw CaptureError.invalid("材料数量超过 128 项。") } + for (value, limit) in [(origin?.applicationID, 1024), (origin?.title, 4096), (origin?.url, 8192)] { + guard (value?.unicodeScalars.count ?? 0) <= limit else { + throw CaptureError.invalid("来源信息过长,请缩短后收入。") + } + } let size = materials.reduce(annotation?.utf8.count ?? 0) { $0 + $1.byteCount } guard size <= 32 * 1024 * 1024 else { throw CaptureError.invalid("材料累计超过 32 MiB,请拆分后收入。") @@ -120,6 +127,18 @@ public struct CapturePayload: Codable, Sendable { material.text == nil else { throw CaptureError.invalid("图片为空或格式不支持。") } + guard (material.filename?.unicodeScalars.count ?? 0) <= 512, + let source = CGImageSourceCreateWithData(bytes as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + CGImageSourceGetStatus(source) == .statusComplete, + let identifier = CGImageSourceGetType(source) as String?, + UTType(identifier)?.preferredMIMEType == material.mimeType, + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0, width <= 89_478_485 / height, + CGImageSourceCreateImageAtIndex(source, 0, [kCGImageSourceShouldCache: false] as CFDictionary) != nil else { + throw CaptureError.invalid("图片内容与格式不符、文件损坏或解码尺寸过大。") + } } } } diff --git a/Sources/CaptureCore/PeerClient.swift b/Sources/CaptureCore/PeerClient.swift index eebcf00..d7d47e3 100644 --- a/Sources/CaptureCore/PeerClient.swift +++ b/Sources/CaptureCore/PeerClient.swift @@ -1,7 +1,6 @@ import Foundation public struct PeerClient: Sendable { - public static let capability = "extensions.capture.submit.v1" private let session: URLSession public init(session: URLSession? = nil) { @@ -47,13 +46,12 @@ public struct PeerClient: Sendable { } public func checkConnection(_ target: ConnectionTarget, secret: String) async throws { - let clock = try await register(target, secret: secret) + _ = try await register(target, secret: secret) _ = try await database( target, path: "capture_receipts", - query: [.init(name: "select", value: "submission_id"), .init(name: "limit", value: "0")], + query: [.init(name: "select", value: "submission_id,graph_digest"), .init(name: "limit", value: "0")], secret: secret ) - _ = try await providers(target, secret: secret, databaseTime: clock) } public func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { @@ -96,15 +94,13 @@ public struct PeerClient: Sendable { guard let target = record.target, let payload = record.payload else { throw CaptureError.invalid("待办缺少冻结的材料或目标。") } - let clock = try await register(target, secret: secret) - let candidates = try await providers(target, secret: secret, databaseTime: clock) - let endpoint = candidates[max(0, record.attempts - 1) % candidates.count] - let body = try CaptureJSON.encode(payload) + let body = try CaptureJSON.encode(CaptureCommit(payload)) guard body.count <= 48 * 1024 * 1024 else { throw CaptureError.needsAction("编码后的请求超过 48 MiB,请保留材料并处理。") } - let data = try await request( - endpoint, method: "POST", body: body, secret: secret, isCaptureDispatch: true + let data = try await database( + target, path: "rpc/commit_capture", method: "POST", body: body, + secret: secret, isCaptureDispatch: true ) guard let reported = try? CaptureJSON.decode(CaptureResult.self, from: data), reported.submissionID == record.id else { throw CaptureError.outcomeUnknown } @@ -114,59 +110,10 @@ public struct PeerClient: Sendable { return persisted } - private func providers( - _ target: ConnectionTarget, secret: String, databaseTime: Date - ) async throws -> [URL] { - let formatter = ISO8601DateFormatter() - let data = try await database( - target, path: "peers", - query: [.init(name: "select", value: "id,capabilities"), - .init(name: "lease_expires_at", value: "gt.\(formatter.string(from: databaseTime))"), - .init(name: "order", value: "id")], secret: secret - ) - struct Peer: Decodable { - struct Capability: Decodable { - struct Inbound: Decodable { - struct Parameters: Decodable { let method: String?; let url: String? } - let `protocol`: String - let parameters: Parameters - enum CodingKeys: CodingKey { case `protocol`, parameters } - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - `protocol` = try values.decode(String.self, forKey: .protocol) - parameters = `protocol` == "core.peer.protocol.http.v1" - ? try values.decode(Parameters.self, forKey: .parameters) - : Parameters(method: nil, url: nil) - } - } - let id: String - let inbound: Inbound - } - let id: UUID - let capabilities: [Capability] - } - let peers = try CaptureJSON.decode([Peer].self, from: data) - var endpoints: [URL] = [] - for peer in peers { - for capability in peer.capabilities where capability.id == Self.capability { - let inbound = capability.inbound - guard inbound.protocol == "core.peer.protocol.http.v1", - inbound.parameters.method?.uppercased() == "POST", - let address = inbound.parameters.url, let url = URL(string: address), - ["http", "https"].contains(url.scheme), url.host != nil, - url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { continue } - if !endpoints.contains(url) { endpoints.append(url) } - } - } - guard !endpoints.isEmpty else { - throw CaptureError.connection("暂时没有在线的 Capture 执行 Peer。请安装并启用配套分发;材料仍保留在本机。") - } - return endpoints - } - private func database( _ target: ConnectionTarget, path: String, method: String = "GET", - query: [URLQueryItem] = [], body: Data? = nil, secret: String, prefer: String? = nil + query: [URLQueryItem] = [], body: Data? = nil, secret: String, prefer: String? = nil, + isCaptureDispatch: Bool = false ) async throws -> Data { var url = target.postgrestURL for component in path.split(separator: "/") { url.appendPathComponent(String(component)) } @@ -177,7 +124,7 @@ public struct PeerClient: Sendable { guard let endpoint = components.url else { throw CaptureError.invalid("连接地址无效。") } return try await request( endpoint, method: method, body: body, secret: secret, - profile: "inkcre", prefer: prefer, isCaptureDispatch: false + profile: "inkcre", prefer: prefer, isCaptureDispatch: isCaptureDispatch ) } @@ -211,7 +158,7 @@ public struct PeerClient: Sendable { if isCaptureDispatch, http.statusCode >= 500 || http.statusCode == 429 { throw CaptureError.outcomeUnknown } switch http.statusCode { case 401, 403: throw CaptureError.needsAction("凭据被拒绝,请更新连接凭据并检查系统时间。") - case 404: throw CaptureError.needsAction("目标缺少 Capture v1 协议,请更新数据库与配套分发。") + case 404: throw CaptureError.needsAction("目标缺少 Capture 写入协议,请更新共享数据库。") case 409: throw CaptureError.needsAction("提交身份与已保存内容冲突,请保留原待办。") case 413: throw CaptureError.needsAction("请求超过服务端或代理限额,材料仍保留。") case 422: throw CaptureError.needsAction("服务端拒绝了材料格式,请保留待办并检查内容。") diff --git a/Sources/InKCreApp/ConnectionView.swift b/Sources/InKCreApp/ConnectionView.swift index cf82f4b..b0dc228 100644 --- a/Sources/InKCreApp/ConnectionView.swift +++ b/Sources/InKCreApp/ConnectionView.swift @@ -20,7 +20,7 @@ struct ConnectionView: View { Text("最近检查未通过:\(issue)").foregroundStyle(.secondary) } else if let date = model.lastContactAt { LabeledContent("最近检查通过") { Text(date, format: .dateTime.month().day().hour().minute().second()) } - Text("Capture 执行能力可用").font(.caption).foregroundStyle(.secondary) + Text("信息库写入连接可用").font(.caption).foregroundStyle(.secondary) } else { Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) } } LabeledContent("本机待收入", value: "\(model.pendingCount) 项") diff --git a/Tests/CaptureCoreTests/CaptureContractTests.swift b/Tests/CaptureCoreTests/CaptureContractTests.swift index f780668..a57321b 100644 --- a/Tests/CaptureCoreTests/CaptureContractTests.swift +++ b/Tests/CaptureCoreTests/CaptureContractTests.swift @@ -1,8 +1,8 @@ -import CaptureCore +@testable import CaptureCore import Foundation import Testing -@Test func pythonAndSwiftAgreeOnFrozenPayloadDigest() throws { +@Test func preservesExistingFrozenPayloadDigests() throws { struct Fixture: Decodable { let payload: CapturePayload; let digest: String } let url = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixtures = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url)) @@ -13,3 +13,44 @@ import Testing #expect(try changed.canonicalDigest() != fixture.digest) } } + +@Test func nativeGraphSeparatesOriginalsImageSourceAndAnnotation() throws { + struct Fixture: Decodable { let payload: CapturePayload; let digest: String } + let url = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1] + let data = try CaptureJSON.encode(CaptureCommit(fixture.payload)) + #expect(data == (try CaptureJSON.encode(CaptureCommit(fixture.payload)))) + let document = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(document["payload_digest"] as? String == fixture.digest) + let graph = try #require(document["graph"] as? [String: Any]) + let blocks = try #require(graph["blocks"] as? [[String: Any]]) + let relations = try #require(graph["relations"] as? [[String: Any]]) + let blobs = try #require(graph["blobs"] as? [[String: Any]]) + #expect(blocks.map { $0["id"] as? Int } == [-1, -2, -3, -4, -5]) + #expect(blocks.map { $0["resolver"] as? String } == [ + "extensions.capture.capture.v1", "core.text.v1", + "extensions.capture.attachment.v1", "core.image.v1", "core.text.v1", + ]) + #expect(blocks[1]["content"] as? String == fixture.payload.materials[0].text) + #expect(blocks[4]["content"] as? String == fixture.payload.annotation) + #expect(relations.map { $0["content"] as? String } == ["material:0", "content", "material:1", "annotation"]) + #expect(blobs.count == 1 && blobs[0]["block_id"] as? Int == -4) + #expect(Data(base64Encoded: try #require(blobs[0]["data_base64"] as? String)) == fixture.payload.materials[1].data) +} + +@Test func nativeValidationRejectsMismatchedImagesAndCountsUTF8Bytes() throws { + struct Fixture: Decodable { let payload: CapturePayload } + let url = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let image = try #require(CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data) + #expect(throws: CaptureError.self) { + try CapturePayload(materials: [Material(image: image, mimeType: "image/jpeg")]).validate() + } + #expect(throws: CaptureError.self) { + try CapturePayload(materials: [Material(image: Data("broken".utf8), mimeType: "image/png")]).validate() + } + let text = String(repeating: "x", count: 32 * 1024 * 1024 - 3) + try CapturePayload(materials: [Material(text: text)], annotation: "中").validate() + #expect(throws: CaptureError.self) { + try CapturePayload(materials: [Material(text: text)], annotation: "中文").validate() + } +} diff --git a/Tests/CaptureCoreTests/CaptureStoreTests.swift b/Tests/CaptureCoreTests/CaptureStoreTests.swift index ca4d29c..29825b1 100644 --- a/Tests/CaptureCoreTests/CaptureStoreTests.swift +++ b/Tests/CaptureCoreTests/CaptureStoreTests.swift @@ -144,7 +144,7 @@ struct CaptureStoreTests { let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) let target = ConnectionTarget(postgrestURL: URL(string: "https://one.example/db")!) - let image = Data([0, 1, 2, 255]) + let image = try #require(Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAHCAIAAABPxRC5AAAAE0lEQVR4nGPkUbJgQAJMyBwa8AEvKAB0sOhdcgAAAABJRU5ErkJggg==")) let draft = CaptureRecord(payload: CapturePayload( entry: .paste, materials: [Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), Material(image: image, mimeType: "image/png")], diff --git a/Tests/CaptureCoreTests/PeerClientTests.swift b/Tests/CaptureCoreTests/PeerClientTests.swift index 6091fb3..df0d546 100644 --- a/Tests/CaptureCoreTests/PeerClientTests.swift +++ b/Tests/CaptureCoreTests/PeerClientTests.swift @@ -51,30 +51,22 @@ struct PeerClientTests { } } - @Test func anotherExecutorCanAcceptTheSameFrozenIntentAfterUnknownOutcome() async throws { + @Test func writesDirectlyToDatabaseAndRetriesIdenticalGraphAfterUnknownOutcome() async throws { var record = CaptureRecord(payload: CapturePayload(materials: [Material(text: "保持同一份材料")])) record.target = ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!) let identity = record.id.uuidString let received = Mutex<[String]>([]) let receiptPublished = Mutex(false) - let payload = try #require(record.payload) - let digest = try payload.canonicalDigest() + let digest = try #require(record.payload).canonicalDigest() StubTransport.handler.withLock { handler in handler = { request in + #expect(request.url?.host == "fixture.test") + #expect(request.value(forHTTPHeaderField: "Accept-Profile") == "inkcre") switch request.url?.path { - case "/rpc/renew_peer_lease": return Data("\"2026-10-21T01:04:03Z\"".utf8) - case "/peers" where request.httpMethod == "GET": - return Data(""" - [{"id":"00000000-0000-4000-8000-000000000001","capabilities":[ - {"id":"unrelated","inbound":{"protocol":"other.v1","parameters":{"method":42}}}, - {"id":"extensions.capture.submit.v1","inbound":{"protocol":"core.peer.protocol.http.v1","parameters":{"method":"POST","url":"https://first.test/capture"}}}]}, - {"id":"00000000-0000-4000-8000-000000000002","capabilities":[ - {"id":"extensions.capture.submit.v1","inbound":{"protocol":"core.peer.protocol.http.v1","parameters":{"method":"POST","url":"https://second.test/capture"}}}]}] - """.utf8) - case "/capture": - let host = request.url!.host! - received.withLock { $0.append(host) } - if host == "first.test" { throw URLError(.networkConnectionLost) } + case "/rpc/commit_capture": + #expect(request.httpMethod == "POST") + let attempt = received.withLock { $0.append(request.url!.path); return $0.count } + if attempt == 1 { throw URLError(.networkConnectionLost) } return Data(""" {"submission_id":"\(identity)","root_block_id":42,"committed_at":"2026-10-21T01:02:03Z","availability":"available"} """.utf8) @@ -85,17 +77,17 @@ struct PeerClientTests { "committed_at":"2026-10-21T01:02:03Z"}] """.utf8) case "/blocks": return Data("[{\"id\":42}]".utf8) - default: return Data() + default: + Issue.record("写入不能发现或调用其他 Peer:\(request.url?.path ?? "")") + throw URLError(.unsupportedURL) } } } defer { StubTransport.handler.withLock { $0 = nil } } let peer = client() - record.attempts = 1 await #expect(throws: CaptureError.self) { _ = try await peer.submit(record, secret: String(repeating: "s", count: 32)) } - record.attempts = 2 // An HTTP success without its durable shared receipt must not release local material. await #expect(throws: CaptureError.self) { _ = try await peer.submit(record, secret: String(repeating: "s", count: 32)) @@ -103,6 +95,6 @@ struct PeerClientTests { receiptPublished.withLock { $0 = true } let result = try await peer.submit(record, secret: String(repeating: "s", count: 32)) #expect(result.submissionID == record.id) - #expect(received.withLock { $0 } == ["first.test", "second.test", "second.test"]) + #expect(received.withLock { $0.count == 3 }) } } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 26e6516..4275b3d 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -38,7 +38,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion7 +CFBundleVersion8 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From ff1439e75e031db3bbff85ea0eb13d0a35b186e1 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Wed, 21 Oct 2026 22:55:33 +0800 Subject: [PATCH 03/14] =?UTF-8?q?ref(mac):=20=E5=8E=9F=E7=94=9F=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E=E6=95=B0=E6=8D=AE=E5=BA=93=E5=B9=B6=E5=88=86=E7=A6=BB?= =?UTF-8?q?=20Capture=20=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分通用 PeerCore、应用 Host 与独立 Capture Swift 分发 - 原子保存图和原图,以共享回执恢复并遵守参与启停 - 删除未发布实现兼容层,补齐 Cida 与依赖许可 --- .gitignore | 2 + AGENTS.md | 6 +- LICENSE | 21 + Package.resolved | 123 +++++ Package.swift | 35 +- Packages/PeerCore/LICENSE | 21 + Packages/PeerCore/Package.swift | 15 + .../PeerCore/BundledExtensionRuntime.swift | 48 ++ .../PeerCore/BundledParticipation.swift | 83 +++ .../Sources/PeerCore/ConnectionSettings.swift | 53 ++ .../Sources/PeerCore/ConnectionTarget.swift | 49 ++ .../Sources/PeerCore/Credentials.swift | 46 ++ .../Sources/PeerCore/DatabaseError.swift | 22 + .../PeerCore/Sources/PeerCore/InfoBase.swift | 59 +++ .../Sources/PeerCore/NativeExtension.swift | 21 + .../Sources/PeerCore/PeerDatabase.swift | 61 +++ .../PeerCore/Sources/PeerCore/PeerError.swift | 18 + .../PeerMacUI/PeerConnectionEditor.swift | 85 +++ README.md | 23 +- Sources/CaptureCore/CaptureGraph.swift | 80 --- Sources/CaptureCore/Credentials.swift | 70 --- Sources/CaptureCore/PeerClient.swift | 182 ------- Sources/InKCreApp/AppMain.swift | 482 +++++++----------- Sources/InKCreApp/ConnectionEditor.swift | 52 -- THIRD_PARTY_NOTICES.md | 9 + Tests/CaptureCoreTests/LivePeerTests.swift | 64 --- Tests/CaptureCoreTests/PeerClientTests.swift | 100 ---- .../AppIconTests.swift | 2 +- .../CaptureInteractionTests.swift | 23 +- .../CaptureLayoutTests.swift | 29 +- .../RegionOverlayTests.swift | 2 +- .../ShortcutTests.swift | 24 +- .../CaptureContractTests.swift | 53 +- .../CaptureTests/CaptureLifecycleTests.swift | 63 +++ .../CaptureStoreTests.swift | 124 ++--- .../Fixtures/capture-v1.json | 0 Tests/CaptureTests/LivePeerTests.swift | 136 +++++ Tests/CaptureTests/NativeExtensionTests.swift | 37 ++ extensions/capture/LICENSE | 21 + extensions/capture/Legal/Cida/ATTRIBUTION.txt | 9 + extensions/capture/Legal/Cida/LICENSE | 202 ++++++++ extensions/capture/Package.swift | 23 + extensions/capture/README.md | 7 + .../capture/Sources}/CSQLite/module.modulemap | 0 .../capture/Sources}/CSQLite/shim.h | 0 .../Sources/Capture/CaptureClient.swift | 130 +++++ .../Sources/Capture}/CaptureDigest.swift | 33 +- .../Sources/Capture/CaptureGraph.swift | 73 +++ .../Sources/Capture}/CaptureQueue.swift | 28 +- .../Sources/Capture}/CaptureStore.swift | 92 ++-- .../capture/Sources/Capture}/Models.swift | 91 +--- .../Sources/Capture/Resources/extension.json | 1 + .../Sources/CaptureMac}/BrandAssets.swift | 2 +- .../Sources/CaptureMac}/CaptureEditor.swift | 3 +- .../Sources/CaptureMac/CaptureExtension.swift | 369 ++++++++++++++ .../Sources/CaptureMac}/CaptureModel.swift | 189 ++++--- .../CaptureMac}/CaptureRecordsView.swift | 3 +- .../Sources/CaptureMac}/ClipboardReader.swift | 5 +- .../Sources/CaptureMac}/ConnectionView.swift | 6 +- .../Sources/CaptureMac}/GlobalShortcuts.swift | 20 +- .../CaptureMac}/KeyboardShortcut.swift | 5 +- .../Sources/CaptureMac}/PeerMainView.swift | 0 .../Sources/CaptureMac}/PeerMainWindow.swift | 0 .../Sources/CaptureMac}/RegionCapture.swift | 100 +++- .../Brand/AppIcon.iconset/icon_128x128.png | Bin .../Brand/AppIcon.iconset/icon_128x128@2x.png | Bin .../Brand/AppIcon.iconset/icon_16x16.png | Bin .../Brand/AppIcon.iconset/icon_16x16@2x.png | Bin .../Brand/AppIcon.iconset/icon_256x256.png | Bin .../Brand/AppIcon.iconset/icon_256x256@2x.png | Bin .../Brand/AppIcon.iconset/icon_32x32.png | Bin .../Brand/AppIcon.iconset/icon_32x32@2x.png | Bin .../Brand/AppIcon.iconset/icon_512x512.png | Bin .../Brand/AppIcon.iconset/icon_512x512@2x.png | Bin .../CaptureMac}/Resources/Brand/AppIcon.svg | 0 .../Resources/Brand/AppIconSmall.svg | 0 .../CaptureMac}/Resources/Brand/MarkDark.png | Bin .../CaptureMac}/Resources/Brand/MarkLight.png | Bin .../Resources/Brand/MenuTemplate.png | Bin .../Resources/Brand/PROVENANCE.json | 0 .../CaptureMac}/Resources/Brand/README.md | 0 .../Sources/CaptureMac}/SelectedText.swift | 6 +- .../CaptureMac}/ShortcutSettingsView.swift | 0 scripts/build-app.sh | 14 +- scripts/build-macos-icon.sh | 2 +- scripts/collect-licenses.py | 22 + 86 files changed, 2538 insertions(+), 1241 deletions(-) create mode 100644 LICENSE create mode 100644 Package.resolved create mode 100644 Packages/PeerCore/LICENSE create mode 100644 Packages/PeerCore/Package.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/Credentials.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/DatabaseError.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/InfoBase.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/NativeExtension.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift create mode 100644 Packages/PeerCore/Sources/PeerCore/PeerError.swift create mode 100644 Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift delete mode 100644 Sources/CaptureCore/CaptureGraph.swift delete mode 100644 Sources/CaptureCore/Credentials.swift delete mode 100644 Sources/CaptureCore/PeerClient.swift delete mode 100644 Sources/InKCreApp/ConnectionEditor.swift create mode 100644 THIRD_PARTY_NOTICES.md delete mode 100644 Tests/CaptureCoreTests/LivePeerTests.swift delete mode 100644 Tests/CaptureCoreTests/PeerClientTests.swift rename Tests/{InKCreAppTests => CaptureMacTests}/AppIconTests.swift (93%) rename Tests/{InKCreAppTests => CaptureMacTests}/CaptureInteractionTests.swift (95%) rename Tests/{InKCreAppTests => CaptureMacTests}/CaptureLayoutTests.swift (56%) rename Tests/{InKCreAppTests => CaptureMacTests}/RegionOverlayTests.swift (99%) rename Tests/{InKCreAppTests => CaptureMacTests}/ShortcutTests.swift (87%) rename Tests/{CaptureCoreTests => CaptureTests}/CaptureContractTests.swift (57%) create mode 100644 Tests/CaptureTests/CaptureLifecycleTests.swift rename Tests/{CaptureCoreTests => CaptureTests}/CaptureStoreTests.swift (60%) rename Tests/{CaptureCoreTests => CaptureTests}/Fixtures/capture-v1.json (100%) create mode 100644 Tests/CaptureTests/LivePeerTests.swift create mode 100644 Tests/CaptureTests/NativeExtensionTests.swift create mode 100644 extensions/capture/LICENSE create mode 100644 extensions/capture/Legal/Cida/ATTRIBUTION.txt create mode 100644 extensions/capture/Legal/Cida/LICENSE create mode 100644 extensions/capture/Package.swift create mode 100644 extensions/capture/README.md rename {Sources => extensions/capture/Sources}/CSQLite/module.modulemap (100%) rename {Sources => extensions/capture/Sources}/CSQLite/shim.h (100%) create mode 100644 extensions/capture/Sources/Capture/CaptureClient.swift rename {Sources/CaptureCore => extensions/capture/Sources/Capture}/CaptureDigest.swift (59%) create mode 100644 extensions/capture/Sources/Capture/CaptureGraph.swift rename {Sources/CaptureCore => extensions/capture/Sources/Capture}/CaptureQueue.swift (66%) rename {Sources/CaptureCore => extensions/capture/Sources/Capture}/CaptureStore.swift (71%) rename {Sources/CaptureCore => extensions/capture/Sources/Capture}/Models.swift (66%) create mode 100644 extensions/capture/Sources/Capture/Resources/extension.json rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/BrandAssets.swift (96%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/CaptureEditor.swift (99%) create mode 100644 extensions/capture/Sources/CaptureMac/CaptureExtension.swift rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/CaptureModel.swift (76%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/CaptureRecordsView.swift (99%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/ClipboardReader.swift (91%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/ConnectionView.swift (91%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/GlobalShortcuts.swift (87%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/KeyboardShortcut.swift (96%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/PeerMainView.swift (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/PeerMainWindow.swift (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/RegionCapture.swift (72%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_128x128.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_16x16.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_256x256.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_32x32.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_512x512.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIcon.svg (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/AppIconSmall.svg (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/MarkDark.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/MarkLight.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/MenuTemplate.png (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/PROVENANCE.json (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/Resources/Brand/README.md (100%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/SelectedText.swift (96%) rename {Sources/InKCreApp => extensions/capture/Sources/CaptureMac}/ShortcutSettingsView.swift (100%) create mode 100644 scripts/collect-licenses.py diff --git a/.gitignore b/.gitignore index fc3029c..a9c2649 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist/ *.xcuserstate xcuserdata/ .local-development-signing-identity +**/Package.resolved +!/Package.resolved diff --git a/AGENTS.md b/AGENTS.md index 997b487..4036584 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,13 @@ 使用中文与人沟通。未明确要求时不提交或推送。 -本仓库拥有原生 macOS 采集 Peer。共享状态属于 info-base;Mac 自己校验材料、构图并通过共享数据库协议原子写入,不依赖其他 Peer 执行采集。Mac 不承载扩展宿主,不启动 Python 子进程,不把本机 SQLite 当成共享知识权威。 +本仓库拥有原生 macOS 采集 Peer。共享状态属于 info-base;Mac 自己校验材料、构图并通过共享数据库协议原子写入,不依赖其他 Peer 执行采集。Mac 承载随签名应用交付的原生扩展,不启动 Python 子进程,不把本机 SQLite 当成共享知识权威。 本体是 regular Mac 应用,拥有 Dock 图标、普通主窗口和应用菜单。快捷采集 NSPanel 是辅助入口;不得再把应用整体收窄为 accessory/LSUIElement 菜单栏工具。主窗口导航与快捷采集编辑位置分开,草稿和队列只有一个所有者。 -`Sources/CaptureCore` 拥有冻结提交、SQLite、Keychain、采集构图和数据库协议。`Sources/InKCreApp` 拥有系统采集及原生交互。共享图与提交合同见 `InKCre/docs` 的 `20-product-tdd/capture-contract.md`;数据库迁移由 `impl-py` 拥有。 +`Packages/PeerCore` 拥有通用 PostgreSQL 原生连接、事务、图/Storage、凭据、Host 配置和原生扩展生命周期。`extensions/capture` 是 first-party Swift 分发,拥有材料、系统采集、界面、SQLite 草稿/待办、构图与收入恢复。`Sources/InKCreApp` 只负责应用 Host。跨平台业务合同属于 Capture Extension,数据库 schema/迁移由 impl-py 交付。 + +Capture 和此次 Mac 分发尚未发布,直接修订候选 schema,不增加旧 HTTP、旧回执或旧本机格式兼容。保留原开发数据文件,不自动导入或清空。第三方引用必须保留许可证、固定来源、修改说明和署名;打包必须包含完整依赖许可。 同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..d1ee13f --- /dev/null +++ b/Package.resolved @@ -0,0 +1,123 @@ +{ + "originHash" : "6367858f5c09eed9f54900a15f2366791d9b770535a83ee4a1cad1d8f354711a", + "pins" : [ + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "39ce38a93408937bcd27f521f3cdf88266136b80", + "version" : "1.33.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "13713a4ffdee8abd929f92568ee9462a46ae26e0", + "version" : "1.1.7" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", + "version" : "4.5.2" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "4038b6a4f74a1426f47eb1dd5b4bb5628a15d597", + "version" : "1.16.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "9467f9cc3c32b4386fad1ecc4618328887ef5758", + "version" : "2.12.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "6db9d9abd92ad7399b8a564cafbb87d3507245fa", + "version" : "2.104.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "a18ee16b8039866fd919bdb4e8e906882d4ecc75", + "version" : "2.13.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index a697971..752a233 100644 --- a/Package.swift +++ b/Package.swift @@ -2,17 +2,30 @@ import PackageDescription let package = Package( - name: "InKCre", - platforms: [.macOS(.v15)], - products: [ - .executable(name: "InKCre", targets: ["InKCreApp"]), - .library(name: "CaptureCore", targets: ["CaptureCore"]), - ], + name: "InKCre", platforms: [.macOS(.v15)], + products: [.executable(name: "InKCre", targets: ["InKCreApp"])], + dependencies: [.package(path: "Packages/PeerCore"), .package(path: "extensions/capture")], targets: [ - .systemLibrary(name: "CSQLite"), - .target(name: "CaptureCore", dependencies: ["CSQLite"]), - .executableTarget(name: "InKCreApp", dependencies: ["CaptureCore"], resources: [.copy("Resources/Brand")]), - .testTarget(name: "CaptureCoreTests", dependencies: ["CaptureCore"], resources: [.copy("Fixtures")]), - .testTarget(name: "InKCreAppTests", dependencies: ["InKCreApp"]), + .executableTarget( + name: "InKCreApp", + dependencies: [ + .product(name: "PeerCore", package: "PeerCore"), + .product(name: "PeerMacUI", package: "PeerCore"), + .product(name: "CaptureMac", package: "capture"), + ]), + .testTarget( + name: "CaptureTests", + dependencies: [ + .product(name: "Capture", package: "capture"), + .product(name: "PeerCore", package: "PeerCore"), + ], path: "Tests/CaptureTests", resources: [.copy("Fixtures")]), + .testTarget( + name: "CaptureMacTests", + dependencies: [ + .product(name: "CaptureMac", package: "capture"), + .product(name: "Capture", package: "capture"), + .product(name: "PeerCore", package: "PeerCore"), + .product(name: "PeerMacUI", package: "PeerCore"), + ], path: "Tests/CaptureMacTests"), ] ) diff --git a/Packages/PeerCore/LICENSE b/Packages/PeerCore/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/Packages/PeerCore/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Packages/PeerCore/Package.swift b/Packages/PeerCore/Package.swift new file mode 100644 index 0000000..0b53b6b --- /dev/null +++ b/Packages/PeerCore/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "PeerCore", platforms: [.macOS(.v15)], + products: [ + .library(name: "PeerCore", targets: ["PeerCore"]), + .library(name: "PeerMacUI", targets: ["PeerMacUI"]), + ], + dependencies: [.package(url: "https://github.com/vapor/postgres-nio.git", from: "1.32.0")], + targets: [ + .target(name: "PeerCore", dependencies: [.product(name: "PostgresNIO", package: "postgres-nio")]), + .target(name: "PeerMacUI", dependencies: ["PeerCore"]), + ] +) diff --git a/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift b/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift new file mode 100644 index 0000000..e394a51 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift @@ -0,0 +1,48 @@ +import Foundation + +@MainActor +public final class BundledExtensionRuntime { + public let module: any NativeExtension + public private(set) var running = false + public private(set) var applying = false + public private(set) var syncIssue: String? + private var idleWaiters: [CheckedContinuation] = [] + private let defaults: UserDefaults + private var key: String { "extensions.\(module.descriptor.name).enabled" } + public var enabled: Bool { defaults.object(forKey: key) as? Bool != false } + + public init(module: any NativeExtension, defaults: UserDefaults = .standard) { + self.module = module + self.defaults = defaults + } + public func startIfEnabled() throws { + if enabled && !running { + try module.start() + running = true + } + } + public func setEnabled(_ enabled: Bool) async throws { + guard !applying else { throw PeerError.needsAction("扩展正在应用上一次操作。") } + applying = true + defer { + applying = false + for waiter in idleWaiters { waiter.resume() } + idleWaiters.removeAll() + } + syncIssue = nil + do { try await module.setEnabled(enabled) } catch PeerError.connection(let message) { + syncIssue = message + } + defaults.set(enabled, forKey: key) + if enabled { try startIfEnabled() } else { try await stop() } + } + public func waitUntilIdle() async { + if applying { await withCheckedContinuation { idleWaiters.append($0) } } + } + + /// Process shutdown changes runtime facts, not durable enabled intent. + public func stop() async throws { + defer { running = false } + try await module.stop() + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift new file mode 100644 index 0000000..54af37f --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift @@ -0,0 +1,83 @@ +import Foundation +import PostgresNIO + +extension PeerDatabase { + /// First joining may create a selection; subsequent reconnects never recreate removed participation. + public func join( + _ target: ConnectionTarget, password: String, extension descriptor: NativeExtensionDescriptor + ) async throws { + let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! + let key = "\(target.peerID).\(descriptor.name)" + let firstJoin = !defaults.bool(forKey: key) + let client = try client(for: target, password: password) + let issue: String? = try await client.withTransaction(logger: Self.logger) { connection in + if firstJoin { + try await connection.query( + "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", + logger: Self.logger) + try await connection.query( + """ + INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) + VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) + ON CONFLICT (name,peer_id) DO NOTHING + """, logger: Self.logger) + } + do { + try await Self.requireSelection(descriptor, peerID: target.peerID, on: connection) + return nil + } catch PeerError.needsAction(let message) { return message } + } + defaults.set(true, forKey: key) + if let issue { throw PeerError.needsAction(issue) } + } + + public static func requireSelection( + _ descriptor: NativeExtensionDescriptor, peerID: UUID, on connection: PostgresConnection + ) async throws { + let rows = try await connection.query( + """ + SELECT distribution,version,enabled FROM inkcre.extension_peer_participations + WHERE name=\(descriptor.name) AND peer_id=\(peerID) FOR SHARE + """, logger: Self.logger) + for try await (distribution, version, enabled) in rows.decode((String?, String, Bool).self) { + guard distribution == descriptor.distribution, version == descriptor.version else { + throw PeerError.needsAction("此 Peer 选择的扩展版本不在当前应用中,请核对扩展选择。") + } + guard enabled else { throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在线明确启用后继续。") } + return + } + throw PeerError.needsAction("此 Peer 的扩展参与已移除;不会在重连时自动重建。") + } + + public func setEnabled( + _ enabled: Bool, target: ConnectionTarget, password: String, + extension descriptor: NativeExtensionDescriptor + ) async throws { + _ = try await register(target, password: password) + let client = try client(for: target, password: password) + try await client.withTransaction(logger: Self.logger) { connection in + if enabled { + try await connection.query( + "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", + logger: Self.logger) + try await connection.query( + """ + INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) + VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) + ON CONFLICT (name,peer_id) DO NOTHING + """, logger: Self.logger) + } + let rows = try await connection.query( + """ + UPDATE inkcre.extension_peer_participations SET enabled=\(enabled) + WHERE name=\(descriptor.name) AND peer_id=\(target.peerID) + AND distribution=\(descriptor.distribution) AND version=\(descriptor.version) + RETURNING name + """, logger: Self.logger) + for try await _ in rows.decode(String.self) { return } + if enabled { throw PeerError.needsAction("信息库中没有与本机匹配的扩展选择。") } + } + let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! + defaults.set(true, forKey: "\(target.peerID).\(descriptor.name)") + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift new file mode 100644 index 0000000..2f5d097 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -0,0 +1,53 @@ +import Foundation + +public actor ConnectionSettings { + private let url: URL + public init(url: URL) { self.url = url } + public func load() throws -> ConnectionTarget? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try JSONDecoder().decode(ConnectionTarget.self, from: Data(contentsOf: url)) + } + public func save(_ target: ConnectionTarget) throws { + try target.validate() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try JSONEncoder().encode(target).write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + public func saveEdited( + host: String, port: String, database: String, username: String, tls: ConnectionTarget.TLS, + web: String, password: String, previous: ConnectionTarget?, credentials: CredentialStore + ) throws -> ConnectionTarget { + let host = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(port) else { throw PeerError.invalid("数据库端口无效。") } + let web = web.trimmingCharacters(in: .whitespacesAndNewlines) + let webURL = web.isEmpty ? nil : URL(string: web) + if !web.isEmpty && webURL == nil { throw PeerError.invalid("Web 地址无效。") } + let same = previous?.host == host && previous?.port == port && previous?.database == database + let sameCredentials = same && previous?.username == username + let target = ConnectionTarget( + id: sameCredentials ? previous!.id : UUID(), peerID: same ? previous!.peerID : UUID(), + host: host, port: port, database: database, username: username, tls: tls, webURL: webURL, + name: host) + try target.validate() + if !password.isEmpty { try credentials.save(password, for: target.id) } + _ = try credentials.read(for: target.id) + try save(target) + return target + } + +} + +public struct PeerHostContext: Sendable { + public let identity: String + public let directory: URL + public let connections: ConnectionSettings + public let credentials: CredentialStore + public init(identity: String, directory: URL) { + self.identity = identity + self.directory = directory + connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + credentials = CredentialStore(service: "\(identity).connections") + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift new file mode 100644 index 0000000..a7b7346 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct ConnectionTarget: Codable, Sendable, Equatable, Identifiable { + public enum TLS: String, Codable, Sendable, CaseIterable { case require, disable } + public let id: UUID + public let peerID: UUID + public var host: String + public var port: Int + public var database: String + public var username: String + public var tls: TLS + public var webURL: URL? + public var name: String + + public init( + id: UUID = UUID(), peerID: UUID = UUID(), host: String, port: Int = 5432, + database: String, username: String, tls: TLS = .require, + webURL: URL? = nil, name: String = "我的信息库" + ) { + self.id = id + self.peerID = peerID + self.host = host + self.port = port + self.database = database + self.username = username + self.tls = tls + self.webURL = webURL + self.name = name + } + + public func validate() throws { + guard !host.isEmpty, !host.contains(where: { $0.isWhitespace }), !host.contains("/"), + (1...65535).contains(port), !database.isEmpty, !username.isEmpty, + ![host, database, username].contains(where: { $0.contains("\0") }) + else { + throw PeerError.invalid("请输入有效的数据库主机、端口、库名与运行角色。") + } + if tls == .disable && !["localhost", "127.0.0.1", "::1"].contains(host) { + throw PeerError.invalid("远端数据库必须使用 TLS;仅本机回环开发连接可关闭 TLS。") + } + if let url = webURL { + guard ["http", "https"].contains(url.scheme), url.host != nil, url.user == nil, + url.password == nil, url.query == nil, url.fragment == nil + else { + throw PeerError.invalid("Web 地址须为不含凭据的 HTTP(S) 地址。") + } + } + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/Credentials.swift b/Packages/PeerCore/Sources/PeerCore/Credentials.swift new file mode 100644 index 0000000..f013449 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/Credentials.swift @@ -0,0 +1,46 @@ +import Foundation +import Security + +public struct CredentialStore: Sendable { + private let service: String + + public init(service: String) { self.service = service } + + public func save(_ secret: String, for connection: UUID) throws { + guard !secret.isEmpty else { + throw PeerError.invalid("请输入数据库密码。") + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + ] + let data = Data(secret.utf8) + var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw PeerError.storage("Keychain 保存失败(\(status))。") } + } + + public func read(for connection: UUID) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data, + let secret = String(data: data, encoding: .utf8) + else { + throw PeerError.needsAction("无法读取此信息库的凭据,请在连接设置中重新保存。") + } + return secret + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift new file mode 100644 index 0000000..edd8621 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift @@ -0,0 +1,22 @@ +import Foundation +import PostgresNIO + +extension PeerDatabase { + public static func failure(_ error: Error, dispatched: Bool = false) -> PeerError { + if let transaction = error as? PostgresTransactionError, + transaction.rollbackError == nil, transaction.commitError == nil, + let cause = transaction.closureError + { + return failure(cause, dispatched: dispatched) + } + if let error = error as? PeerError { return error } + if let pg = error as? PSQLError, let code = pg.serverInfo?[.sqlState] { + switch String(code.prefix(2)) { + case "28", "42": return .needsAction("数据库角色权限或协议不符,请检查运行凭据与数据库初始化。") + case "22", "23": return .needsAction("数据库拒绝了写入参数或约束。") + default: break + } + } + return dispatched ? .outcomeUnknown : .connection("暂时无法连接 PostgreSQL,请检查网络和连接配置。") + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift new file mode 100644 index 0000000..9fcbcf2 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift @@ -0,0 +1,59 @@ +import Foundation +import PostgresNIO + +public struct GraphBlock: Sendable, Encodable { + public let id: Int + public let resolver: String + public let content: String + public init(id: Int, resolver: String, content: String) { + self.id = id + self.resolver = resolver + self.content = content + } +} +public struct GraphRelation: Sendable, Encodable { + public let from_: Int + public let to_: Int + public let content: String + public init(from_: Int, to_: Int, content: String) { + self.from_ = from_ + self.to_ = to_ + self.content = content + } +} + +/// Uses the caller's transaction; storage pointers and byte mechanics belong to the Host. +public enum InfoBase { + public static func persist( + blocks: [GraphBlock], relations: [GraphRelation], + blobs: [Int: Data], on connection: PostgresConnection + ) async throws -> [Int: Int] { + var ids: [Int: Int] = [:] + for block in blocks { + var content = block.content + var storage: Int? = nil + if let bytes = blobs[block.id] { + let blobID = UUID() + try await connection.query( + "INSERT INTO inkcre.storage_blobs(id,data) VALUES (\(blobID),\(bytes))", + logger: PeerDatabase.logger) + content = "{\"blob_id\":\"\(blobID.uuidString.lowercased())\"}" + storage = -4 + } + let rows = try await connection.query( + """ + INSERT INTO inkcre.blocks(resolver,content,storage) VALUES (\(block.resolver),\(content),\(storage)) RETURNING id + """, logger: PeerDatabase.logger) + for try await id in rows.decode(Int.self) { ids[block.id] = id } + } + for relation in relations { + guard let from = ids[relation.from_], let to = ids[relation.to_] else { + throw PeerError.invalid("图关系引用了本次提交以外的 Block。") + } + try await connection.query( + "INSERT INTO inkcre.relations(from_,to_,content) VALUES (\(from),\(to),\(relation.content))", + logger: PeerDatabase.logger) + } + return ids + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift b/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift new file mode 100644 index 0000000..309f618 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/NativeExtension.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct NativeExtensionDescriptor: Codable, Sendable, Equatable { + public let name: String + public let distribution: String + public let version: String + public init(name: String, distribution: String, version: String) { + self.name = name + self.distribution = distribution + self.version = version + } +} + +@MainActor +public protocol NativeExtension: AnyObject { + var descriptor: NativeExtensionDescriptor { get } + func start() throws + func setEnabled(_ enabled: Bool) async throws + func stop() async throws + func showMain() +} diff --git a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift new file mode 100644 index 0000000..f7a8d95 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift @@ -0,0 +1,61 @@ +import Foundation +import Logging +import PostgresNIO + +public actor PeerDatabase { + public static let shared = PeerDatabase() + public static let logger = Logger(label: "dev.inkcre.database") + private struct Pool { + let target: ConnectionTarget + let password: String + let client: PostgresClient + let task: Task + } + private var pools: [UUID: Pool] = [:] + + public init() {} + + public func client(for target: ConnectionTarget, password: String) throws -> PostgresClient { + try target.validate() + if let pool = pools[target.id], pool.target == target, pool.password == password { + return pool.client + } + pools.removeValue(forKey: target.id)?.task.cancel() + var configuration = PostgresClient.Configuration( + host: target.host, port: target.port, username: target.username, password: password, + database: target.database, + tls: target.tls == .require ? .require(.makeClientConfiguration()) : .disable + ) + configuration.options.minimumConnections = 0 + configuration.options.maximumConnections = 2 + configuration.options.connectTimeout = .seconds(10) + let client = PostgresClient(configuration: configuration, backgroundLogger: Self.logger) + let task = Task { await client.run() } + pools[target.id] = Pool(target: target, password: password, client: client, task: task) + return client + } + + public func shutdown() async { + let tasks = pools.values.map(\.task) + pools.removeAll() + for task in tasks { task.cancel() } + for task in tasks { await task.value } + } + + public func register(_ target: ConnectionTarget, password: String) async throws -> Date { + let client = try client(for: target, password: password) + let name = Host.current().localizedName ?? "InKCre Mac" + return try await client.withTransaction(logger: Self.logger) { connection in + try await connection.query( + """ + INSERT INTO inkcre.peers (id,name,application_version,capabilities,config_schema,config,labels) + VALUES (\(target.peerID),\(name),'0.1.0','[]','{}','{}',ARRAY['macOS']) + ON CONFLICT (id) DO UPDATE SET application_version=EXCLUDED.application_version + """, logger: Self.logger) + let rows = try await connection.query( + "SELECT inkcre.renew_peer_lease(\(target.peerID),120)", logger: Self.logger) + for try await expiry in rows.decode(Date.self) { return expiry.addingTimeInterval(-120) } + throw PeerError.connection("信息库未返回 Peer 联络结果。") + } + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/PeerError.swift b/Packages/PeerCore/Sources/PeerCore/PeerError.swift new file mode 100644 index 0000000..4919b07 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/PeerError.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum PeerError: LocalizedError, Sendable { + case invalid(String) + case storage(String) + case connection(String) + case needsAction(String) + case outcomeUnknown + + public var errorDescription: String? { + switch self { + case .invalid(let message), .storage(let message), + .connection(let message), .needsAction(let message): + message + case .outcomeUnknown: "写入已发出,结果尚未确认。" + } + } +} diff --git a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift new file mode 100644 index 0000000..80623b3 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -0,0 +1,85 @@ +import PeerCore +import SwiftUI + +public struct PeerConnectionEditor: View { + private let settings: ConnectionSettings + private let credentials: CredentialStore + private let onSaved: @MainActor (ConnectionTarget) -> Void + private let onCancel: @MainActor () -> Void + private let target: ConnectionTarget? + @Environment(\.dismiss) private var dismiss + @State private var server: String + @State private var port: String + @State private var database: String + @State private var username: String + @State private var tls: ConnectionTarget.TLS + @State private var web: String + @State private var secret = "" + @State private var saving = false + @State private var issue: String? + @FocusState private var addressFocused: Bool + + public init( + settings: ConnectionSettings, credentials: CredentialStore, target: ConnectionTarget?, + onCancel: @escaping @MainActor () -> Void = {}, + onSaved: @escaping @MainActor (ConnectionTarget) -> Void + ) { + self.settings = settings + self.credentials = credentials + self.target = target + self.onCancel = onCancel + self.onSaved = onSaved + _server = State(initialValue: target?.host ?? "") + _port = State(initialValue: String(target?.port ?? 5432)) + _database = State(initialValue: target?.database ?? "inkcre") + _username = State(initialValue: target?.username ?? "") + _tls = State(initialValue: target?.tls ?? .require) + _web = State(initialValue: target?.webURL?.absoluteString ?? "") + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("连接共享信息库").font(.title2).bold() + Form { + TextField("PostgreSQL 主机", text: $server).focused($addressFocused) + TextField("端口", text: $port) + TextField("数据库", text: $database) + TextField("运行角色", text: $username) + Picker("TLS", selection: $tls) { + Text("校验证书").tag(ConnectionTarget.TLS.require) + Text("关闭(仅本机开发)").tag(ConnectionTarget.TLS.disable) + } + TextField("Web 地址(可选)", text: $web) + SecureField("数据库密码", text: $secret) + }.textFieldStyle(.roundedBorder).disabled(saving) + Text("凭据保存在本机 Keychain。已有连接留空可沿用原凭据;更换库或运行角色会建立新连接,已有待办保留原目标。") + .font(.caption).foregroundStyle(.secondary) + if let issue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + HStack { + Spacer() + Button("取消") { + dismiss() + onCancel() + }.keyboardShortcut(.cancelAction).disabled(saving) + Button(saving ? "正在保存…" : "保存") { + saving = true + issue = nil + Task { + defer { saving = false } + do { + let saved = try await settings.saveEdited( + host: server, port: port, database: database, username: username, tls: tls, + web: web, password: secret, previous: target, credentials: credentials) + onSaved(saved) + secret = "" + dismiss() + } catch { issue = error.localizedDescription } + } + }.keyboardShortcut(.defaultAction) + .disabled(saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(20).frame(width: 520) + .interactiveDismissDisabled(saving) + .onAppear { addressFocused = true } + } +} diff --git a/README.md b/README.md index 4bdee1a..dd582d9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ ## 构建与打开 -需要 Xcode 26.2 / Swift 6.2,无第三方 Swift 依赖。 +需要 Xcode 26.2 / Swift 6.2。PostgresNIO 及依赖通过 SwiftPM 解析,精确修订固定在 Package.resolved。 ```sh swift test @@ -22,7 +22,7 @@ open dist/InKCre.app 需要反复开发和实测系统采集时,使用本人明确指定的 Apple Development 身份:`DEVELOPMENT_SIGNING_IDENTITY='Apple Development: …' bash scripts/build-app.sh`。也可将该身份的名称或证书 SHA-1 单独保存在 ignored `.local-development-signing-identity`(一行文本,不执行其中内容)。脚本不会自动选择证书或回退到另一个身份;正式发布仍使用独立的 `SIGNING_IDENTITY` 输入。ad-hoc 身份随代码版本变化,系统可能不再认可先前的隐私授权;固定路径和 bundle ID 本身不足以解决此问题。[Apple 对代码身份与授权的说明](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements)。 -默认 `⌃⌥I` 读取当前选区并唤起快捷面板,`⌃⌥S` 框选截图;采集编辑器内 `⇧⌘V` 粘贴材料、`⌘↩` 收入,Esc 仅收起快捷面板并保留草稿。设置中的五项快捷键均可分别录入、清除或恢复默认,修改立即生效并在重启后保留;旧版预设会自动迁移。点击对应按键按钮后按下组合键,Esc 取消录入。组合键须包含 ⌘、⌃ 或 ⌥,收起面板的默认 Esc 除外。重复组合或系统注册冲突会提示并保留原设置,其他快捷键不受影响。录入期间暂停全局触发,离开设置或切换应用时取消录入。 +默认 `⌃⌥I` 读取当前选区并唤起快捷面板,`⌃⌥S` 框选截图;采集编辑器内 `⇧⌘V` 粘贴材料、`⌘↩` 收入,Esc 仅收起快捷面板并保留草稿。设置中的五项快捷键均可分别录入、清除或恢复默认,修改立即生效并在重启后保留。点击对应按键按钮后按下组合键,Esc 取消录入。组合键须包含 ⌘、⌃ 或 ⌥,收起面板的默认 Esc 除外。重复组合或系统注册冲突会提示并保留原设置,其他快捷键不受影响。录入期间暂停全局触发,离开设置或切换应用时取消录入。 收集和截图在全局生效;粘贴和收入作用于主窗口采集页或快捷面板;收起仅作用于快捷面板。输入法组词时采集快捷键交给输入法处理。菜单栏入口始终可用。选区需要辅助功能权限,截图需要屏幕录制权限;输入和明确粘贴不需要这些权限。 @@ -36,25 +36,27 @@ open dist/InKCre.app ## 连接信息库 -连接设置需要共享信息库的 PostgREST HTTP(S) 地址和部署签名凭据,可选填 Web Peer 地址。凭据仅存于 Keychain。该身份具有部署 Peer 的完整权限,应使用你有权访问的信息库。 +连接设置使用 PostgreSQL 主机、端口、库名、独立运行角色及密码,可选 Web 地址。远端使用 TLS 并校验证书;只有回环开发地址允许关闭 TLS。密码存于 Keychain,角色拥有已准入数据库协议权限,不使用迁移管理员或 JWT 签名密钥。 “连接信息库”或“编辑连接”打开原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后网络检查在后台进行,不阻塞采集。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。 -部署只需要具备 `capture_receipts`、`graph_digest` 和 `commit_capture` 的共享 PostgreSQL/PostgREST 数据库协议。Mac 自己构图并原子写入,不发现或调用 Python 执行者;Python Peer 停止不影响收入。Web 或 Python Peer 若要解释这些内容,可各自按需启用 Capture 读取分发;它们不是写入的前置条件。共享合同见 [主动采集持久化契约](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 +共享数据库需要本分支的 Capture 回执表与原生分发参与合同。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写图、原图和回执;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 “草稿已保留在本机”表示 SQLite 已成功落盘;“本机已保留,待收入”表示冻结材料和目标已经保存;“结果待确认”表示请求可能已经成功;只有共享回执通过材料指纹核对后才显示“已收入”。网络中断、退出或切换连接不会给旧提交换身份或改目标。后台恢复先查回执,必要时复用原材料、图与原 UUID 向同一信息库重试。共享根被删除后重试也不会重建。 -材料累计最多 32 MiB,编码后的请求最多 48 MiB;图片保留原始字节,支持 PNG/JPEG/GIF/WebP。实际代理必须允许同等请求体。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。 +材料累计最多 32 MiB,支持 PNG/JPEG/GIF/WebP,原生数据库通过 bytea 参数保存原图。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。 待收入记录可“取消收入”,停止后续自动发送并保留原材料、UUID 和目标;“继续收入”先查原回执。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 ## 本地数据与发布 -开发包数据位于 `~/Library/Application Support/dev.inkcre.capture/capture.sqlite`,使用 WAL/FULL。冻结包包含图片,确认后原文和图片从该记录移除,保留共享引用;WAL 和 SQLite 空闲页不是安全擦除机制。备份或迁移时先退出应用,保留该目录和 Keychain 中的连接凭据。较新本地数据库格式会阻止旧版本打开,避免破坏数据。 +开发包的 Host 配置位于 `~/Library/Application Support/dev.inkcre.capture/connection.json`;Capture 数据位于同目录 `extensions/inkcre-capture/capture.sqlite`,使用 WAL/FULL。确认后清理本机材料,保留共享引用。WAL 和空闲页不提供安全擦除保证。此前未发布开发版的数据文件原样保留,本版没有旧格式导入或 HTTP 回退层。 -本机格式 3 使用确认后的直接删除,移除了格式 2 的回收站行为。打开旧数据不会自动清理材料:旧“已删除”项保留原内容和状态,显示在普通记录中,原先取消的待办不会自动发送。格式升级后旧版本应用不能继续打开,以免写回不同的删除语义。SQLite 的 WAL 和空闲页不提供安全擦除保证。 +`Packages/PeerCore` 提供数据库、标准图/存储、凭据和原生模块生命周期;`extensions/capture` 是独立 Swift 包,拥有采集业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。应用菜单“扩展…”展示实际携带的分发版本和本机运行状态,可独立启停。停用撤下入口并等待当前工作收束,保留材料;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 -应用图标、菜单栏 template 图标和明暗面板标识来自用户提供的 2026-10-21 logo pack。应用 ICNS 在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。资源选择、原始出处、小尺寸/高 DPI 几何与再生步骤见 `Sources/InKCreApp/Resources/Brand/README.md`,构建不依赖 Downloads 中的原始目录。 +Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 + +应用图标、菜单栏 template 图标和明暗面板标识来自用户提供的 2026-10-21 logo pack。应用 ICNS 在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。资源选择、原始出处、小尺寸/高 DPI 几何与再生步骤见 `extensions/capture/Sources/CaptureMac/Resources/Brand/README.md`,构建不依赖 Downloads 中的原始目录。 发布构建显式设置稳定 `BUNDLE_ID` 与 `SIGNING_IDENTITY`;可用 `NOTARY_PROFILE` 指定已配置的 notarytool Keychain profile。脚本不会寻找或创建发布凭据。发布后保持 bundle ID 与签名团队不变,以保留系统权限、Keychain 和待办身份。 @@ -63,4 +65,7 @@ BUNDLE_ID=你的稳定BundleID SIGNING_IDENTITY='Developer ID Application: …' NOTARY_PROFILE=你的Profile bash scripts/build-app.sh ``` -Swift 测试覆盖冻结包重开、目标绑定、原子确认、原生构图、直接数据库提交与已有冻结材料指纹兼容。原生权限、多应用选区、多屏、Intel 实机与公证安装还需对应环境验证,不能由 universal 编译结果推断通过。 +Swift 测试覆盖冻结包重开、目标绑定、原子确认、原生构图、原生数据库事务、共享材料指纹与扩展生命周期。原生权限、多应用选区、多屏、Intel 实机与公证安装还需对应环境验证,不能由 universal 编译结果推断通过。 + + +原创 Swift 代码使用 MIT,Cida 参考/改写文件保留 Apache-2.0。见 [开源许可与致谢](THIRD_PARTY_NOTICES.md)。构建脚本从本次解析的依赖收集完整许可,随开发包交付;菜单“开源许可与致谢…”可打开通知。 diff --git a/Sources/CaptureCore/CaptureGraph.swift b/Sources/CaptureCore/CaptureGraph.swift deleted file mode 100644 index a9d69e5..0000000 --- a/Sources/CaptureCore/CaptureGraph.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation - -/// The native Peer owns Capture semantics; the database only commits this prepared graph. -struct CaptureCommit: Encodable { - struct Block: Encodable { - let id: Int - let resolver: String - let content: String - } - - struct Relation: Encodable { - let from_: Int - let to_: Int - let content: String - } - - struct Blob: Encodable { - let block_id: Int - let data_base64: Data - } - - struct Graph: Encodable { - let root_local_id = -1 - var blocks: [Block] - var relations: [Relation] = [] - var blobs: [Blob] = [] - } - - let submission_id: UUID - let payload_digest: String - let graph: Graph - - init(_ payload: CapturePayload) throws { - try payload.validate() - struct Metadata: Encodable { - let captured_at: Date - let entry: CaptureEntry - let origin: CaptureOrigin? - } - struct Attachment: Encodable { - let mime_type: String - let filename: String? - } - func json(_ value: T) throws -> String { - String(decoding: try CaptureJSON.encode(value), as: UTF8.self) - } - var graph = Graph(blocks: [Block( - id: -1, resolver: "extensions.capture.capture.v1", - content: try json(Metadata(captured_at: payload.capturedAt, entry: payload.entry, origin: payload.origin)) - )]) - for (index, material) in payload.materials.enumerated() { - let id = -(graph.blocks.count + 1) - switch material.kind { - case .text: - guard let text = material.text else { throw CaptureError.invalid("文字材料格式不正确。") } - graph.blocks.append(Block(id: id, resolver: "core.text.v1", content: text)) - case .image: - guard let bytes = material.data, let mime = material.mimeType else { - throw CaptureError.invalid("图片材料格式不正确。") - } - graph.blocks.append(Block( - id: id, resolver: "extensions.capture.attachment.v1", - content: try json(Attachment(mime_type: mime, filename: material.filename)) - )) - graph.blocks.append(Block(id: id - 1, resolver: "core.image.v1", content: "")) - graph.blobs.append(Blob(block_id: id - 1, data_base64: bytes)) - graph.relations.append(Relation(from_: id, to_: id - 1, content: "content")) - } - graph.relations.append(Relation(from_: -1, to_: id, content: "material:\(index)")) - } - if let annotation = payload.annotation, !annotation.isEmpty { - let id = -(graph.blocks.count + 1) - graph.blocks.append(Block(id: id, resolver: "core.text.v1", content: annotation)) - graph.relations.append(Relation(from_: -1, to_: id, content: "annotation")) - } - submission_id = payload.submissionID - payload_digest = try payload.canonicalDigest() - self.graph = graph - } -} diff --git a/Sources/CaptureCore/Credentials.swift b/Sources/CaptureCore/Credentials.swift deleted file mode 100644 index c555816..0000000 --- a/Sources/CaptureCore/Credentials.swift +++ /dev/null @@ -1,70 +0,0 @@ -import CryptoKit -import Foundation -import Security - -public struct CredentialStore: Sendable { - private let service: String - - public init(service: String) { self.service = service } - - public func save(_ secret: String, for connection: UUID) throws { - guard secret.utf8.count >= 32 else { - throw CaptureError.invalid("部署签名凭据至少需要 32 个 UTF-8 字节。") - } - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: connection.uuidString, - ] - let data = Data(secret.utf8) - var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) - if status == errSecItemNotFound { - var item = query - item[kSecValueData as String] = data - item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - status = SecItemAdd(item as CFDictionary, nil) - } - guard status == errSecSuccess else { throw CaptureError.storage("Keychain 保存失败(\(status))。") } - } - - public func read(for connection: UUID) throws -> String { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: connection.uuidString, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - guard status == errSecSuccess, let data = result as? Data, - let secret = String(data: data, encoding: .utf8) else { - throw CaptureError.needsAction("无法读取此信息库的凭据,请在连接设置中重新保存。") - } - return secret - } -} - -public enum PeerToken { - public static func create(secret: String, now: Date = Date()) throws -> String { - guard secret.utf8.count >= 32 else { throw CaptureError.invalid("部署签名凭据过短。") } - let header = try JSONSerialization.data(withJSONObject: ["alg": "HS256", "typ": "JWT"]) - let seconds = Int(now.timeIntervalSince1970) - let claims = try JSONSerialization.data(withJSONObject: [ - "role": "authenticated", "iss": "inkcre-peer", "aud": "inkcre-api", - "iat": seconds, "exp": seconds + 3600, - ]) - let content = "\(base64URL(header)).\(base64URL(claims))" - let signature = HMAC.authenticationCode( - for: Data(content.utf8), using: SymmetricKey(data: Data(secret.utf8)) - ) - return "\(content).\(base64URL(Data(signature)))" - } - - private static func base64URL(_ data: Data) -> String { - data.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} diff --git a/Sources/CaptureCore/PeerClient.swift b/Sources/CaptureCore/PeerClient.swift deleted file mode 100644 index d7d47e3..0000000 --- a/Sources/CaptureCore/PeerClient.swift +++ /dev/null @@ -1,182 +0,0 @@ -import Foundation - -public struct PeerClient: Sendable { - private let session: URLSession - - public init(session: URLSession? = nil) { - if let session { - self.session = session - } else { - let configuration = URLSessionConfiguration.ephemeral - configuration.timeoutIntervalForRequest = 20 - configuration.timeoutIntervalForResource = 90 - configuration.httpCookieStorage = nil - self.session = URLSession(configuration: configuration, delegate: RejectRedirects(), delegateQueue: nil) - } - } - - public func register(_ target: ConnectionTarget, secret: String) async throws -> Date { - try target.validate() - let identity = target.peerID.uuidString.lowercased() - let registration: [String: Any] = [ - "id": identity, "name": Host.current().localizedName ?? "InKCre Mac", - "application_version": "0.1.0", "capabilities": [], - "config_schema": [:], "config": [:], "labels": ["macOS"], - ] - _ = try await database( - target, path: "peers", method: "POST", - query: [.init(name: "on_conflict", value: "id")], - body: JSONSerialization.data(withJSONObject: registration), secret: secret, - prefer: "resolution=ignore-duplicates,return=minimal" - ) - let runtime: [String: Any] = [ - "application_version": "0.1.0", "capabilities": [], "config_schema": [:], - ] - _ = try await database( - target, path: "peers", method: "PATCH", - query: [.init(name: "id", value: "eq.\(identity)")], - body: JSONSerialization.data(withJSONObject: runtime), secret: secret - ) - let lease = try await database( - target, path: "rpc/renew_peer_lease", method: "POST", - body: JSONSerialization.data(withJSONObject: ["peer": identity, "ttl_seconds": 120]), - secret: secret - ) - return try CaptureJSON.decode(Date.self, from: lease).addingTimeInterval(-120) - } - - public func checkConnection(_ target: ConnectionTarget, secret: String) async throws { - _ = try await register(target, secret: secret) - _ = try await database( - target, path: "capture_receipts", - query: [.init(name: "select", value: "submission_id,graph_digest"), .init(name: "limit", value: "0")], - secret: secret - ) - } - - public func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { - guard let target = record.target else { throw CaptureError.invalid("待办缺少目标。") } - let data = try await database( - target, path: "capture_receipts", - query: [ - .init(name: "submission_id", value: "eq.\(record.id.uuidString.lowercased())"), - .init(name: "select", value: "submission_id,payload_digest,root_block_id,committed_at"), - ], secret: secret - ) - struct Receipt: Decodable { - let submission_id: UUID - let payload_digest: String - let root_block_id: Int - let committed_at: Date - } - let receipts = try CaptureJSON.decode([Receipt].self, from: data) - guard let receipt = receipts.first else { return nil } - guard receipts.count == 1, receipt.submission_id == record.id else { - throw CaptureError.needsAction("共享回执与待办不一致。") - } - guard let payload = record.payload, try payload.canonicalDigest() == receipt.payload_digest else { - throw CaptureError.needsAction("共享回执的材料指纹与本机不同。原材料已保留,请检查提交身份冲突。") - } - let roots = try await database( - target, path: "blocks", - query: [.init(name: "id", value: "eq.\(receipt.root_block_id)"), - .init(name: "select", value: "id")], secret: secret - ) - struct Root: Decodable { let id: Int } - let exists = try CaptureJSON.decode([Root].self, from: roots).contains { $0.id == receipt.root_block_id } - return CaptureResult( - submissionID: record.id, rootBlockID: receipt.root_block_id, - committedAt: receipt.committed_at, availability: exists ? .available : .deleted - ) - } - - public func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { - guard let target = record.target, let payload = record.payload else { - throw CaptureError.invalid("待办缺少冻结的材料或目标。") - } - let body = try CaptureJSON.encode(CaptureCommit(payload)) - guard body.count <= 48 * 1024 * 1024 else { - throw CaptureError.needsAction("编码后的请求超过 48 MiB,请保留材料并处理。") - } - let data = try await database( - target, path: "rpc/commit_capture", method: "POST", body: body, - secret: secret, isCaptureDispatch: true - ) - guard let reported = try? CaptureJSON.decode(CaptureResult.self, from: data), - reported.submissionID == record.id else { throw CaptureError.outcomeUnknown } - // Local materials are released only after the shared receipt validates their fingerprint. - guard let persisted = try await recover(record, secret: secret), - persisted.rootBlockID == reported.rootBlockID else { throw CaptureError.outcomeUnknown } - return persisted - } - - private func database( - _ target: ConnectionTarget, path: String, method: String = "GET", - query: [URLQueryItem] = [], body: Data? = nil, secret: String, prefer: String? = nil, - isCaptureDispatch: Bool = false - ) async throws -> Data { - var url = target.postgrestURL - for component in path.split(separator: "/") { url.appendPathComponent(String(component)) } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - throw CaptureError.invalid("连接地址无效。") - } - components.queryItems = query.isEmpty ? nil : query - guard let endpoint = components.url else { throw CaptureError.invalid("连接地址无效。") } - return try await request( - endpoint, method: method, body: body, secret: secret, - profile: "inkcre", prefer: prefer, isCaptureDispatch: isCaptureDispatch - ) - } - - private func request( - _ url: URL, method: String, body: Data?, secret: String, - profile: String? = nil, prefer: String? = nil, isCaptureDispatch: Bool - ) async throws -> Data { - var request = URLRequest(url: url) - request.httpMethod = method - request.httpBody = body - request.setValue("Bearer \(try PeerToken.create(secret: secret))", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") } - if let profile { - request.setValue(profile, forHTTPHeaderField: "Accept-Profile") - request.setValue(profile, forHTTPHeaderField: "Content-Profile") - } - if let prefer { request.setValue(prefer, forHTTPHeaderField: "Prefer") } - let data: Data - let response: URLResponse - do { - (data, response) = try await session.data(for: request) - } catch { - if isCaptureDispatch { throw CaptureError.outcomeUnknown } - throw CaptureError.connection("暂时无法连接信息库,请检查网络与连接地址。") - } - guard let http = response as? HTTPURLResponse else { - throw CaptureError.outcomeUnknown - } - guard (200..<300).contains(http.statusCode) else { - if isCaptureDispatch, http.statusCode >= 500 || http.statusCode == 429 { throw CaptureError.outcomeUnknown } - switch http.statusCode { - case 401, 403: throw CaptureError.needsAction("凭据被拒绝,请更新连接凭据并检查系统时间。") - case 404: throw CaptureError.needsAction("目标缺少 Capture 写入协议,请更新共享数据库。") - case 409: throw CaptureError.needsAction("提交身份与已保存内容冲突,请保留原待办。") - case 413: throw CaptureError.needsAction("请求超过服务端或代理限额,材料仍保留。") - case 422: throw CaptureError.needsAction("服务端拒绝了材料格式,请保留待办并检查内容。") - case 429, 502, 503, 504: throw CaptureError.connection("服务暂时不可用,稍后自动重试。") - default: throw CaptureError.needsAction("信息库返回错误(HTTP \(http.statusCode)),材料仍保留。") - } - } - return data - } -} - -private final class RejectRedirects: NSObject, URLSessionTaskDelegate { - func urlSession( - _ session: URLSession, task: URLSessionTask, - willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, - completionHandler: @escaping (URLRequest?) -> Void - ) { - // Credentials and private material go only to the explicitly configured/published endpoint. - completionHandler(nil) - } -} diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index a087fd5..00b4438 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -1,345 +1,247 @@ import AppKit -import CaptureCore +import CaptureMac +import PeerCore +import PeerMacUI import SwiftUI -import ApplicationServices @main enum InKCreApplication { @MainActor static func main() { let application = NSApplication.shared - let delegate = AppDelegate() + let delegate = PeerHost() application.delegate = delegate withExtendedLifetime(delegate) { application.run() } } } @MainActor -final class CapturePanel: NSPanel { - override var canBecomeKey: Bool { true } - override var canBecomeMain: Bool { false } - // The configurable panel shortcut handles dismissal, including an intentionally disabled Esc. - override func cancelOperation(_ sender: Any?) {} -} - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSUserInterfaceValidations { - private var statusItem: NSStatusItem? - private var panel: CapturePanel? - private var mainWindow: PeerMainWindow? - private var model: CaptureModel? - private let regionCapture = RegionCapture() - private var previousApplication: NSRunningApplication? - - private var canChangeCaptureContext: Bool { - !regionCapture.isCapturing && NSApp.modalWindow == nil - && mainWindow?.attachedSheet == nil && panel?.attachedSheet == nil - } - - func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - canChangeCaptureContext && model?.submitting != true - } +final class PeerHost: NSObject, NSApplicationDelegate { + private let context: PeerHostContext + private let runtime: BundledExtensionRuntime + private var module: any NativeExtension { runtime.module } + override init() { + let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent(identity) + context = PeerHostContext(identity: identity, directory: directory) + runtime = BundledExtensionRuntime(module: CaptureExtension(context: context)) + super.init() + } + private var presence: Task? + private var connectionIssue: String? + private var manager: NSWindow? + private var toggle: NSButton? + private var status: NSTextField? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), - let icon = NSImage(contentsOf: url) { NSApp.applicationIconImage = icon } + let icon = NSImage(contentsOf: url) + { + NSApp.applicationIconImage = icon + } + installHostMenu() do { - let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" - let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent(identity) - let model = try CaptureModel(directory: directory, identity: identity) - self.model = model - let mainWindow = PeerMainWindow(model: model) - mainWindow.delegate = self - self.mainWindow = mainWindow - let panel = CapturePanel( - contentRect: NSRect(x: 0, y: 0, width: 570, height: 640), - styleMask: [.titled, .closable, .resizable, .nonactivatingPanel], - backing: .buffered, defer: false - ) - panel.title = "随手收入 InKCre" - panel.minSize = NSSize(width: 500, height: 450) - panel.level = .floating - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.isReleasedWhenClosed = false - panel.hidesOnDeactivate = false - panel.delegate = self - panel.contentView = NSHostingView(rootView: QuickCaptureView(model: model)) - panel.center() - self.panel = panel - model.presentEditor = { [weak self] in self?.showCaptureSurface() } - model.dismissQuickCapture = { [weak self] in self?.hide() } - model.showMain = { [weak self] in self?.showMain() } - model.showQuick = { [weak self] in self?.showQuick() } - model.takeScreenshot = { [weak self] in self?.screenshot() } - model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in - window === panel || window === mainWindow - } - model.shortcuts.perform = { [weak self, weak model] action in - guard let self, let model, self.canChangeCaptureContext else { return } - switch action { - case .capture: self.showCapture() - case .screenshot: self.quickScreenshot() - case .paste: if model.editorOwner == .quick || model.pane == .capture { model.pasteMaterial() } - case .submit: if model.editorOwner == .quick || model.pane == .capture { model.submit() } - case .hide: if NSApp.keyWindow === self.panel { self.hide() } - } + try runtime.startIfEnabled() + } catch { show(error) } + presence = Task { + while !Task.isCancelled { + do { + if let target = try await context.connections.load() { + _ = try await PeerDatabase.shared.register( + target, password: context.credentials.read(for: target.id)) + } + connectionIssue = nil + } catch { connectionIssue = PeerDatabase.failure(error).localizedDescription } + updateStatus() + do { try await Task.sleep(for: .seconds(30)) } catch { break } } - try model.shortcuts.start() - if let message = model.shortcuts.message { model.error = message } - configureMenu() - model.start() - showMain() - } catch { - let alert = NSAlert() - alert.messageText = "InKCre 无法打开本地资料" - alert.informativeText = error.localizedDescription - alert.runModal() - NSApp.terminate(nil) } + if !runtime.running { showExtensions(nil) } } - private func configureMenu() { - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.image = BrandAssets.menuIcon - let menu = NSMenu() - for (title, selector) in [ - ("显示主窗口", #selector(showMain)), - ("随手收集", #selector(showCapture)), - ("写下想法", #selector(quickThought)), - ("粘贴材料", #selector(quickPaste)), - ("框选截图", #selector(quickScreenshot)), - ("采集记录", #selector(showQueue)), - ("Peer 与连接", #selector(showSettings)), - ] { - let command = NSMenuItem(title: title, action: selector, keyEquivalent: "") - command.target = self - menu.addItem(command) + private func installHostMenu() { + let main = NSMenu() + func addMenu(_ title: String) -> NSMenu { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + let menu = NSMenu(title: title) + item.submenu = menu + main.addItem(item) + return menu } - menu.addItem(.separator()) - menu.addItem(withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - item.menu = menu - statusItem = item - - let mainMenu = NSMenu() - let appItem = NSMenuItem(title: "InKCre", action: nil, keyEquivalent: "") - let appMenu = NSMenu(title: "InKCre") - appMenu.addItem(withTitle: "关于 InKCre", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") - appMenu.addItem(withTitle: "设置…", action: #selector(showSettings), keyEquivalent: ",").target = self - appMenu.addItem(.separator()) - appMenu.addItem(withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") - let hideOthers = appMenu.addItem(withTitle: "隐藏其他应用", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") - hideOthers.keyEquivalentModifierMask = [.command, .option] - appMenu.addItem(withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") - appMenu.addItem(.separator()) - appMenu.addItem(withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - appItem.submenu = appMenu - mainMenu.addItem(appItem) - let fileItem = NSMenuItem(title: "文件", action: nil, keyEquivalent: "") - let file = NSMenu(title: "文件") - file.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n").target = self - file.addItem(withTitle: "快捷采集", action: #selector(showQuick), keyEquivalent: "").target = self - file.addItem(.separator()) - file.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") - fileItem.submenu = file - mainMenu.addItem(fileItem) - let editItem = NSMenuItem(title: "编辑", action: nil, keyEquivalent: "") - let edit = NSMenu(title: "编辑") + let app = addMenu("InKCre") + app.addItem( + withTitle: "关于 InKCre", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "") + app.addItem(withTitle: "设置…", action: #selector(showExtensions(_:)), keyEquivalent: ",").target = self + app.addItem(withTitle: "Peer 连接…", action: #selector(showConnection(_:)), keyEquivalent: "").target = + self + app.addItem(withTitle: "开源许可与致谢…", action: #selector(showLicenses(_:)), keyEquivalent: "").target = + self + app.addItem(.separator()) + app.addItem(withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let others = app.addItem( + withTitle: "隐藏其他应用", action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h") + others.keyEquivalentModifierMask = [.command, .option] + app.addItem( + withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + app.addItem(.separator()) + app.addItem( + withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + let edit = addMenu("编辑") for (title, action, key) in [ - ("撤销", Selector(("undo:")), "z"), - ("剪切", #selector(NSText.cut(_:)), "x"), - ("复制", #selector(NSText.copy(_:)), "c"), - ("粘贴", #selector(NSText.paste(_:)), "v"), + ("撤销", Selector(("undo:")), "z"), ("剪切", #selector(NSText.cut(_:)), "x"), + ("复制", #selector(NSText.copy(_:)), "c"), ("粘贴", #selector(NSText.paste(_:)), "v"), ("全选", #selector(NSText.selectAll(_:)), "a"), - ] { edit.addItem(withTitle: title, action: action, keyEquivalent: key) } + ] { + edit.addItem(withTitle: title, action: action, keyEquivalent: key) + } let redo = edit.addItem(withTitle: "重做", action: Selector(("redo:")), keyEquivalent: "z") redo.keyEquivalentModifierMask = [.command, .shift] - editItem.submenu = edit - mainMenu.addItem(editItem) - let viewItem = NSMenuItem(title: "显示", action: nil, keyEquivalent: "") - let view = NSMenu(title: "显示") - let sidebar = view.addItem(withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") - sidebar.keyEquivalentModifierMask = [.command, .control] - let fullscreen = view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") - fullscreen.keyEquivalentModifierMask = [.command, .control] - viewItem.submenu = view - mainMenu.addItem(viewItem) - let windowItem = NSMenuItem(title: "窗口", action: nil, keyEquivalent: "") - let windows = NSMenu(title: "窗口") - windows.addItem(withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") - windows.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") - windows.addItem(withTitle: "显示主窗口", action: #selector(showMain), keyEquivalent: "").target = self - windows.addItem(.separator()) - windows.addItem(withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") - windowItem.submenu = windows - mainMenu.addItem(windowItem) - NSApp.windowsMenu = windows - NSApp.mainMenu = mainMenu - } - - @objc private func showCapture() { - guard canChangeCaptureContext, let model, !model.submitting else { return } - prepareQuickCapture() - let request = UUID() - model.selectionRequest = request - guard let application = NSWorkspace.shared.frontmostApplication, - application.processIdentifier != ProcessInfo.processInfo.processIdentifier else { showQuick(); return } - previousApplication = application - guard AXIsProcessTrusted() else { - model.newThought(notice: "尚未允许辅助功能访问。可直接写下想法、明确粘贴,或在设置中开启选区采集。") - return - } - let pid = application.processIdentifier - let bundleID = application.bundleIdentifier - Task { - let snapshot = await Task.detached { SelectedText.read(pid: pid, applicationID: bundleID) }.value - guard model.selectionRequest == request, - NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } - var text = snapshot.text - if text == nil, !snapshot.secure { - text = await SelectedText.copyFallback(pid: pid, snapshot: snapshot) { - model.selectionRequest == request - && NSWorkspace.shared.frontmostApplication?.processIdentifier == pid - } - } - guard model.selectionRequest == request, - NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } - if let text { - guard await Task.detached(operation: { SelectedText.isCurrent(pid: pid, snapshot: snapshot) }).value, - model.selectionRequest == request, - NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } - await model.receive( - CapturePayload(entry: .selection, origin: snapshot.origin, materials: [Material(text: text)]), - request: request - ) - } else { - let notice = snapshot.secure - ? "密码或安全输入区域不会被采集。" - : "未取得可靠选区。可以明确复制后粘贴、框选截图,或直接写想法。" - model.newThought(notice: notice) + let view = addMenu("显示") + view.addItem( + withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), + keyEquivalent: "s" + ).keyEquivalentModifierMask = [.command, .control] + view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") + .keyEquivalentModifierMask = [.command, .control] + let window = addMenu("窗口") + window.addItem( + withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + window.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + window.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + window.addItem(withTitle: "Peer 与扩展", action: #selector(showExtensions(_:)), keyEquivalent: "") + .target = self + window.addItem( + withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + NSApp.windowsMenu = window + NSApp.mainMenu = main + } + + @objc private func showExtensions(_ sender: Any?) { + if manager == nil { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 250), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.title = "InKCre 扩展" + window.isReleasedWhenClosed = false + let title = NSTextField(labelWithString: "随手收集 · \(module.descriptor.name)") + title.font = .boldSystemFont(ofSize: 16) + let version = NSTextField( + labelWithString: "\(module.descriptor.distribution) · \(module.descriptor.version) · 随应用交付") + version.textColor = .secondaryLabelColor + let button = NSButton( + checkboxWithTitle: "在此 Mac 启用", target: self, action: #selector(changeEnabled(_:))) + let label = NSTextField(wrappingLabelWithString: "") + let stack = NSStackView(views: [title, version, button, label]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 14 + stack.translatesAutoresizingMaskIntoConstraints = false + window.contentView?.addSubview(stack) + if let content = window.contentView { + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), + stack.centerYAnchor.constraint(equalTo: content.centerYAnchor), + ]) } + toggle = button + status = label + manager = window + window.center() } - } - @objc private func newThought() { guard canChangeCaptureContext else { return }; showMain(); model?.newThought() } - @objc private func quickThought() { guard canChangeCaptureContext else { return }; prepareQuickCapture(); model?.newThought() } - @objc private func quickPaste() { guard canChangeCaptureContext else { return }; prepareQuickCapture(); model?.pasteMaterial() } - @objc private func quickScreenshot() { guard canChangeCaptureContext else { return }; prepareQuickCapture(); screenshot() } - @objc private func showQueue() { guard canChangeCaptureContext else { return }; model?.pane = .queue; showMain() } - @objc private func showSettings() { guard canChangeCaptureContext else { return }; model?.openSettings() } - @objc private func screenshot() { - guard canChangeCaptureContext, let model, !model.submitting else { return } - model.selectionRequest = UUID() - let request = model.selectionRequest - let owner = model.editorOwner - let restoreMain = mainWindow?.isVisible == true - model.screenCaptureNotice = nil - panel?.orderOut(nil) - mainWindow?.orderOut(nil) - regionCapture.begin { [weak self] result in - guard let self else { return } - model.editorOwner = owner - if restoreMain { self.mainWindow?.orderBack(nil) } - switch result { - case .success(let data): - guard let data else { self.showCaptureSurface(); return } - Task { - await model.receive( - CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]), - request: request - ) - } - case .failure(let error): - if error is ScreenRecordingPermissionError { - model.screenCaptureNotice = error.localizedDescription - if owner == .main { model.pane = .capture } - } else { model.error = error.localizedDescription } - self.showCaptureSurface() - } - } - } - - private func prepareQuickCapture() { - if let current = NSWorkspace.shared.frontmostApplication, - current.processIdentifier != ProcessInfo.processInfo.processIdentifier { - previousApplication = current - } - model?.shortcuts.cancelRecording() - mainWindow?.makeFirstResponder(nil) - model?.editorOwner = .quick - } - - @objc private func showQuick() { - guard canChangeCaptureContext else { return } - prepareQuickCapture() + updateStatus() NSApp.activate(ignoringOtherApps: true) - panel?.makeKeyAndOrderFront(nil) + manager?.makeKeyAndOrderFront(nil) } - @objc private func showMain() { - guard !regionCapture.isCapturing else { return } - if mainWindow?.attachedSheet != nil || NSApp.modalWindow != nil { - NSApp.activate(ignoringOtherApps: true) - mainWindow?.makeKeyAndOrderFront(nil) - return + @objc private func changeEnabled(_ sender: NSButton) { + guard !runtime.applying else { return } + let enable = sender.state == .on + sender.isEnabled = false + status?.stringValue = "正在应用…" + Task { + defer { updateStatus() } + do { try await runtime.setEnabled(enable) } catch { show(error) } } - panel?.makeFirstResponder(nil) - panel?.orderOut(nil) - model?.editorOwner = .main - if mainWindow?.isMiniaturized == true { mainWindow?.deminiaturize(nil) } - NSApp.activate(ignoringOtherApps: true) - mainWindow?.makeKeyAndOrderFront(nil) } - private func showCaptureSurface() { - if model?.editorOwner == .quick { showQuick() } - else { showMain() } + private func updateStatus() { + toggle?.state = runtime.enabled ? .on : .off + toggle?.isEnabled = !runtime.applying + status?.stringValue = + runtime.applying + ? "正在应用…" + : runtime.syncIssue ?? connectionIssue ?? (runtime.running ? "本机运行中" : "已停用;本机材料与共享内容保留") } - private func hide() { - model?.shortcuts.cancelRecording() - panel?.makeFirstResponder(nil) - panel?.orderOut(nil) - model?.editorOwner = .main - model?.saveSoon() - if NSWorkspace.shared.frontmostApplication?.processIdentifier == ProcessInfo.processInfo.processIdentifier { - previousApplication?.activate() + @objc private func showConnection(_ sender: Any?) { + showExtensions(nil) + Task { + do { + let target = try await context.connections.load() + guard let manager, manager.attachedSheet == nil else { return } + let sheet = NSWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + let close: @MainActor @Sendable () -> Void = { [weak manager, weak sheet] in + guard let manager, let sheet else { return } + manager.endSheet(sheet) + sheet.orderOut(nil) + } + let content = NSHostingView( + rootView: PeerConnectionEditor( + settings: context.connections, credentials: context.credentials, + target: target, onCancel: close, + onSaved: { [weak self] _ in + close() + guard let self else { return } + Task { + do { + await self.runtime.waitUntilIdle() + try await self.runtime.stop() + try self.runtime.startIfEnabled() + self.updateStatus() + } catch { self.show(error) } + } + })) + sheet.contentView = content + sheet.setContentSize(content.fittingSize) + manager.beginSheet(sheet, completionHandler: { _ in }) + } catch { show(error) } } } - func windowShouldClose(_ sender: NSWindow) -> Bool { - if sender === panel { hide(); return false } - sender.makeFirstResponder(nil) - model?.saveSoon() - return true - } - - func windowDidBecomeKey(_ notification: Notification) { - if notification.object as? NSWindow === mainWindow, panel?.isVisible == true { - panel?.makeFirstResponder(nil) - panel?.orderOut(nil) - model?.editorOwner = .main + @objc private func showLicenses(_ sender: Any?) { + if let url = Bundle.main.url(forResource: "THIRD_PARTY_NOTICES", withExtension: "txt") { + NSWorkspace.shared.open(url) } } - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + private func show(_ error: Error) { + let alert = NSAlert() + alert.messageText = "InKCre 扩展操作未完成" + alert.informativeText = error.localizedDescription + alert.runModal() + } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - showMain() + if runtime.running { module.showMain() } else { showExtensions(nil) } return false } - + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - guard let model else { return .terminateNow } - NSApp.keyWindow?.makeFirstResponder(nil) + presence?.cancel() Task { do { - try await model.flush() - model.stop() - model.shortcuts.stop() + await runtime.waitUntilIdle() + try await runtime.stop() + await presence?.value + await PeerDatabase.shared.shutdown() sender.reply(toApplicationShouldTerminate: true) } catch { - model.error = error.localizedDescription - showCaptureSurface() + show(error) sender.reply(toApplicationShouldTerminate: false) } } diff --git a/Sources/InKCreApp/ConnectionEditor.swift b/Sources/InKCreApp/ConnectionEditor.swift deleted file mode 100644 index 826787e..0000000 --- a/Sources/InKCreApp/ConnectionEditor.swift +++ /dev/null @@ -1,52 +0,0 @@ -import CaptureCore -import SwiftUI - -struct ConnectionEditor: View { - @ObservedObject var model: CaptureModel - @Environment(\.dismiss) private var dismiss - @State private var server: String - @State private var web: String - @State private var secret = "" - @State private var saving = false - @State private var issue: String? - @FocusState private var addressFocused: Bool - - init(model: CaptureModel, target: ConnectionTarget?) { - self.model = model - _server = State(initialValue: target?.postgrestURL.absoluteString ?? "") - _web = State(initialValue: target?.webURL?.absoluteString ?? "") - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("连接共享信息库").font(.title2).bold() - Form { - TextField("PostgREST 地址", text: $server).focused($addressFocused) - TextField("Web 地址(可选)", text: $web) - SecureField("部署签名凭据", text: $secret) - }.textFieldStyle(.roundedBorder).disabled(saving) - Text("凭据保存在本机 Keychain。已有连接留空可沿用原凭据;更换地址会建立新连接,旧待办仍属于原信息库。") - .font(.caption).foregroundStyle(.secondary) - if let issue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } - HStack { - Spacer() - Button("取消") { dismiss() }.keyboardShortcut(.cancelAction).disabled(saving) - Button(saving ? "正在保存…" : "保存并检查") { - saving = true - issue = nil - Task { - defer { saving = false } - do { - try await model.saveConnection(server: server, web: web, secret: secret) - secret = "" - dismiss() - } catch { issue = error.localizedDescription } - } - }.keyboardShortcut(.defaultAction) - .disabled(saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - }.padding(20).frame(width: 520) - .interactiveDismissDisabled(saving) - .onAppear { addressFocused = true } - } -} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..c5631a6 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,9 @@ +# 开源许可与来源 + +InKCre Swift 原创实现使用 MIT。参考或改写的 Cida 文件使用 Apache-2.0,保留其独立许可。完整许可随源码与应用交付。 + +Cida(辞达)由 Xuanwo 及贡献者开发。选区读取和框选交互的来源、核对版本与 InKCre 修改范围记录于 extensions/capture/Legal/Cida/ATTRIBUTION.txt。本应用不借用 Cida 的图标或产品身份,不声称上游为本应用背书。 + +PostgresNIO 及传递依赖使用各自许可。Package.resolved 固定来源与修订;scripts/collect-licenses.py 从实际解析的源码收集完整 LICENSE/NOTICE,包括 BoringSSL 的内嵌许可。缺失许可会使打包失败。生成结果随应用保存在 Contents/Resources/THIRD_PARTY_NOTICES.txt,可从应用菜单打开。 + +用户提供的 InKCre logo pack 用于本项目品牌;代码 MIT 许可不授予 InKCre 商标或品牌资源的使用权。品牌目录保留出处记录。 diff --git a/Tests/CaptureCoreTests/LivePeerTests.swift b/Tests/CaptureCoreTests/LivePeerTests.swift deleted file mode 100644 index 628feb0..0000000 --- a/Tests/CaptureCoreTests/LivePeerTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -import CaptureCore -import Foundation -import Security -import Testing - -@Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_CAPTURE_TEST_POSTGREST"] != nil)) -func liveQueueConfirmsOriginalImageAndRecoversReceiptAfterRestart() async throws { - let environment = ProcessInfo.processInfo.environment - let address = try #require(environment["INKCRE_CAPTURE_TEST_POSTGREST"]) - let target = ConnectionTarget(postgrestURL: try #require(URL(string: address))) - let secret = try #require(environment["INKCRE_CAPTURE_TEST_SECRET"]) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let service = "dev.inkcre.capture.acceptance.\(UUID().uuidString)" - let credentials = CredentialStore(service: service) - try credentials.save(secret, for: target.id) - defer { - SecItemDelete([ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: target.id.uuidString, - ] as CFDictionary) - try? FileManager.default.removeItem(at: directory) - } - struct Fixture: Decodable { let payload: CapturePayload } - let fixtureURL = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) - let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] - let payload = CapturePayload(entry: .paste, origin: fixture.payload.origin, - materials: fixture.payload.materials, annotation: fixture.payload.annotation) - let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let frozen = try await store.freeze(CaptureRecord(payload: payload), target: target) - let queue = CaptureQueue(store: store, credentials: credentials) - try await queue.process(force: true) - if environment["INKCRE_CAPTURE_TEST_DROP_RESPONSE"] == "1" { - let uncertain = try #require(try await store.record(frozen.id)) - #expect(uncertain.state == .unknown) - #expect(uncertain.payload?.materials[1].data == payload.materials[1].data) - let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - try await CaptureQueue(store: reopened, credentials: credentials).process(force: true) - } - let confirmed = try #require(try await store.record(frozen.id)) - #expect(confirmed.state == .confirmed, Comment(rawValue: confirmed.message ?? "")) - #expect(confirmed.payload == nil) - let result = try #require(confirmed.result) - - // A second local database represents a process that lost its success response. - let recoveredStore = try CaptureStore(url: directory.appendingPathComponent("recovered.sqlite")) - _ = try await recoveredStore.freeze(CaptureRecord(payload: payload), target: target) - _ = try await recoveredStore.markDispatch(frozen.id) - try await CaptureQueue(store: recoveredStore, credentials: credentials).process(force: true) - let recovered = try #require(try await recoveredStore.record(frozen.id)) - #expect(recovered.result == result) - #expect(recovered.payload == nil) - var conflictingPayload = payload - conflictingPayload.annotation = "同一身份,错误的其他材料" - let conflictStore = try CaptureStore(url: directory.appendingPathComponent("conflict.sqlite")) - _ = try await conflictStore.freeze(CaptureRecord(payload: conflictingPayload), target: target) - try await CaptureQueue(store: conflictStore, credentials: credentials).process(force: true) - let conflict = try #require(try await conflictStore.record(frozen.id)) - #expect(conflict.state == .needsAction) - #expect(conflict.payload?.annotation == conflictingPayload.annotation) - if let resultPath = environment["INKCRE_CAPTURE_TEST_RESULT"] { - try CaptureJSON.encode(result).write(to: URL(fileURLWithPath: resultPath), options: .atomic) - } -} diff --git a/Tests/CaptureCoreTests/PeerClientTests.swift b/Tests/CaptureCoreTests/PeerClientTests.swift deleted file mode 100644 index df0d546..0000000 --- a/Tests/CaptureCoreTests/PeerClientTests.swift +++ /dev/null @@ -1,100 +0,0 @@ -import CaptureCore -import Foundation -import Synchronization -import Testing - -private final class StubTransport: URLProtocol, @unchecked Sendable { - static let handler = Mutex<(@Sendable (URLRequest) throws -> Data)?>(nil) - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } - override func startLoading() { - do { - let callback = Self.handler.withLock { $0 } - let unwrapped = try #require(callback) - let data = try unwrapped(request) - let response = try #require(HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)) - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: data) - client?.urlProtocolDidFinishLoading(self) - } catch { client?.urlProtocol(self, didFailWithError: error) } - } - override func stopLoading() {} -} - -@Suite(.serialized) -struct PeerClientTests { - private func client() -> PeerClient { - let configuration = URLSessionConfiguration.ephemeral - configuration.protocolClasses = [StubTransport.self] - return PeerClient(session: URLSession(configuration: configuration)) - } - - @Test func recoveryRejectsSameIdentityWithDifferentMaterials() async throws { - var record = CaptureRecord(payload: CapturePayload(materials: [Material(text: "本机原材料")])) - record.target = ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!) - let identity = record.id.uuidString - StubTransport.handler.withLock { handler in - handler = { request in - #expect(request.url?.path == "/capture_receipts") - return Data(""" - [{"submission_id":"\(identity)","payload_digest":"\(String(repeating: "0", count: 64))", - "root_block_id":42,"committed_at":"2026-10-21T01:02:03Z"}] - """.utf8) - } - } - defer { StubTransport.handler.withLock { $0 = nil } } - do { - _ = try await client().recover(record, secret: String(repeating: "s", count: 32)) - Issue.record("冲突回执不能确认本机材料") - } catch CaptureError.needsAction(let message) { - #expect(message.contains("指纹")) - } - } - - @Test func writesDirectlyToDatabaseAndRetriesIdenticalGraphAfterUnknownOutcome() async throws { - var record = CaptureRecord(payload: CapturePayload(materials: [Material(text: "保持同一份材料")])) - record.target = ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!) - let identity = record.id.uuidString - let received = Mutex<[String]>([]) - let receiptPublished = Mutex(false) - let digest = try #require(record.payload).canonicalDigest() - StubTransport.handler.withLock { handler in - handler = { request in - #expect(request.url?.host == "fixture.test") - #expect(request.value(forHTTPHeaderField: "Accept-Profile") == "inkcre") - switch request.url?.path { - case "/rpc/commit_capture": - #expect(request.httpMethod == "POST") - let attempt = received.withLock { $0.append(request.url!.path); return $0.count } - if attempt == 1 { throw URLError(.networkConnectionLost) } - return Data(""" - {"submission_id":"\(identity)","root_block_id":42,"committed_at":"2026-10-21T01:02:03Z","availability":"available"} - """.utf8) - case "/capture_receipts": - guard receiptPublished.withLock({ $0 }) else { return Data("[]".utf8) } - return Data(""" - [{"submission_id":"\(identity)","payload_digest":"\(digest)","root_block_id":42, - "committed_at":"2026-10-21T01:02:03Z"}] - """.utf8) - case "/blocks": return Data("[{\"id\":42}]".utf8) - default: - Issue.record("写入不能发现或调用其他 Peer:\(request.url?.path ?? "")") - throw URLError(.unsupportedURL) - } - } - } - defer { StubTransport.handler.withLock { $0 = nil } } - let peer = client() - await #expect(throws: CaptureError.self) { - _ = try await peer.submit(record, secret: String(repeating: "s", count: 32)) - } - // An HTTP success without its durable shared receipt must not release local material. - await #expect(throws: CaptureError.self) { - _ = try await peer.submit(record, secret: String(repeating: "s", count: 32)) - } - receiptPublished.withLock { $0 = true } - let result = try await peer.submit(record, secret: String(repeating: "s", count: 32)) - #expect(result.submissionID == record.id) - #expect(received.withLock { $0.count == 3 }) - } -} diff --git a/Tests/InKCreAppTests/AppIconTests.swift b/Tests/CaptureMacTests/AppIconTests.swift similarity index 93% rename from Tests/InKCreAppTests/AppIconTests.swift rename to Tests/CaptureMacTests/AppIconTests.swift index d33ea7f..4e6bb08 100644 --- a/Tests/InKCreAppTests/AppIconTests.swift +++ b/Tests/CaptureMacTests/AppIconTests.swift @@ -5,7 +5,7 @@ struct AppIconTests { @Test func iconsetHasTransparentMacMarginsAtEveryScale() throws { let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent() - let folder = root.appendingPathComponent("Sources/InKCreApp/Resources/Brand/AppIcon.iconset") + let folder = root.appendingPathComponent("extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset") for (name, pixels, fraction) in [ ("16x16", 16, 0.875), ("16x16@2x", 32, 0.875), ("32x32", 32, 0.875), ("32x32@2x", 64, 0.875), diff --git a/Tests/InKCreAppTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift similarity index 95% rename from Tests/InKCreAppTests/CaptureInteractionTests.swift rename to Tests/CaptureMacTests/CaptureInteractionTests.swift index f3f1b84..4ddcc59 100644 --- a/Tests/InKCreAppTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -1,7 +1,8 @@ import AppKit -import CaptureCore +import Capture +import PeerCore import Testing -@testable import InKCreApp +@testable import CaptureMac @Suite(.serialized) @MainActor struct CaptureInteractionTests { @@ -9,7 +10,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } var mainOpened = false model.showMain = { mainOpened = true } model.submit() @@ -25,10 +26,10 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } let old = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧的已收入记录")])) try await model.store.saveDraft(old) - _ = try await model.store.freeze(old, target: ConnectionTarget(postgrestURL: URL(string: "https://fixture.test")!)) + _ = try await model.store.freeze(old, target: ConnectionTarget(host: "fixture.test", database: "inkcre", username: "inkcre_core")) try await model.store.confirm(old.id, result: CaptureResult( submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available )) @@ -46,7 +47,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } model.thought = "主窗口中未提交的草稿" let previousID = model.draft.id model.pane = .settings @@ -75,7 +76,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } model.pane = .queue await model.receive(CapturePayload(materials: [Material(text: "回到主窗口采集")])) #expect(model.pane == .capture) @@ -94,7 +95,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } let material = Material(text: "误采的材料") let kept = Material(text: "保留的材料") model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [material, kept], annotation: "附注")) @@ -115,7 +116,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } model.thought = "尚未等到自动保存的中文 👩🏽‍💻" model.annotation = "最后一次附注" let id = model.draft.id @@ -141,7 +142,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } var closed = false model.dismissQuickCapture = { closed = true } model.cancelCapture() @@ -163,7 +164,7 @@ struct CaptureInteractionTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } let other = CaptureRecord(payload: CapturePayload(materials: [Material(text: "另一份草稿")])) try await model.store.saveDraft(other) model.thought = "当前尚未等到自动保存的内容" diff --git a/Tests/InKCreAppTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift similarity index 56% rename from Tests/InKCreAppTests/CaptureLayoutTests.swift rename to Tests/CaptureMacTests/CaptureLayoutTests.swift index e804c9a..73b2c97 100644 --- a/Tests/InKCreAppTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -1,7 +1,9 @@ import AppKit import SwiftUI +import PeerCore +import PeerMacUI import Testing -@testable import InKCreApp +@testable import CaptureMac @Suite(.serialized) @MainActor struct CaptureLayoutTests { @@ -10,7 +12,7 @@ struct CaptureLayoutTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") - defer { model.stop() } + defer { model.cancelWork() } for width in [520, 550, 790] { let renderer = ImageRenderer(content: CaptureRecordsView(model: model) .environment(\.colorScheme, .light) @@ -44,4 +46,27 @@ struct CaptureLayoutTests { } } } + @Test func nativeConnectionEditorRendersWithoutOpeningAWindow() async throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") + defer { model.cancelWork() } + let view = NSHostingView(rootView: PeerConnectionEditor(settings: model.connections, credentials: model.credentials, target: nil, onSaved: { _ in }).environment(\.colorScheme, .light).background(Color.white)) + let window = NSWindow(contentRect: NSRect(x:0,y:0,width:520,height:440), styleMask: [.titled], backing:.buffered, defer:false) + window.appearance = NSAppearance(named:.aqua) + window.contentView = view + defer { window.orderOut(nil) } + try await Task.sleep(for: .milliseconds(100)) + let size = view.fittingSize + #expect(size.width >= 500 && size.height > 300) + view.frame = NSRect(origin: .zero, size: size) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent("native-connection.png")) + } + } + } diff --git a/Tests/InKCreAppTests/RegionOverlayTests.swift b/Tests/CaptureMacTests/RegionOverlayTests.swift similarity index 99% rename from Tests/InKCreAppTests/RegionOverlayTests.swift rename to Tests/CaptureMacTests/RegionOverlayTests.swift index 33feb48..a7e506f 100644 --- a/Tests/InKCreAppTests/RegionOverlayTests.swift +++ b/Tests/CaptureMacTests/RegionOverlayTests.swift @@ -1,6 +1,6 @@ import AppKit import Testing -@testable import InKCreApp +@testable import CaptureMac @Suite(.serialized) @MainActor struct RegionOverlayTests { diff --git a/Tests/InKCreAppTests/ShortcutTests.swift b/Tests/CaptureMacTests/ShortcutTests.swift similarity index 87% rename from Tests/InKCreAppTests/ShortcutTests.swift rename to Tests/CaptureMacTests/ShortcutTests.swift index d289e70..989c961 100644 --- a/Tests/InKCreAppTests/ShortcutTests.swift +++ b/Tests/CaptureMacTests/ShortcutTests.swift @@ -1,8 +1,9 @@ import AppKit import Carbon.HIToolbox -import CaptureCore +import Capture +import PeerCore import Testing -@testable import InKCreApp +@testable import CaptureMac @Suite(.serialized) @MainActor struct ShortcutTests { @@ -72,25 +73,12 @@ struct ShortcutTests { #expect(reopened.bindings[.capture] == replacement) } - @Test func legacyPresetsPreserveExistingIntent() throws { - let (defaults, domain) = defaults() - defer { defaults.removePersistentDomain(forName: domain) } - defaults.set("disabled", forKey: "shortcutPreset") - let disabled = try GlobalShortcuts(defaults: defaults) - #expect(disabled.bindings[.capture] == nil && disabled.bindings[.screenshot] == nil) - #expect(disabled.bindings[.submit] != nil) - defaults.set("commandShift", forKey: "shortcutPreset") - let migrated = try GlobalShortcuts(defaults: defaults) - #expect(migrated.bindings[.capture]?.modifiers == [.command, .shift]) - #expect(migrated.bindings[.screenshot]?.modifiers == [.command, .shift]) - } - @Test func duplicateAndPlainTypingCannotReplaceOldSettings() throws { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - #expect(throws: CaptureError.self) { try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) } - #expect(throws: CaptureError.self) { + #expect(throws: PeerError.self) { try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) } + #expect(throws: PeerError.self) { try shortcuts.set(RecordedShortcut(keyCode: UInt16(kVK_ANSI_A), modifiers: .shift, keyLabel: "A"), for: .submit) } #expect(shortcuts.bindings[.capture] == ShortcutAction.capture.defaultShortcut) @@ -114,7 +102,7 @@ struct ShortcutTests { #expect(RegisterEventHotKey(UInt32(blocked.keyCode), blocked.carbonModifiers, EventHotKeyID(signature: 0x54455354, id: 77), GetApplicationEventTarget(), 0, &occupied) == noErr) defer { if let occupied { UnregisterEventHotKey(occupied) } } - #expect(throws: CaptureError.self) { try shortcuts.set(blocked, for: .capture) } + #expect(throws: PeerError.self) { try shortcuts.set(blocked, for: .capture) } #expect(shortcuts.bindings[.capture] == old) #expect(try GlobalShortcuts(defaults: defaults).bindings[.capture] == old) var probe: EventHotKeyRef? diff --git a/Tests/CaptureCoreTests/CaptureContractTests.swift b/Tests/CaptureTests/CaptureContractTests.swift similarity index 57% rename from Tests/CaptureCoreTests/CaptureContractTests.swift rename to Tests/CaptureTests/CaptureContractTests.swift index a57321b..9b78d5d 100644 --- a/Tests/CaptureCoreTests/CaptureContractTests.swift +++ b/Tests/CaptureTests/CaptureContractTests.swift @@ -1,10 +1,16 @@ -@testable import CaptureCore import Foundation +import PeerCore import Testing -@Test func preservesExistingFrozenPayloadDigests() throws { - struct Fixture: Decodable { let payload: CapturePayload; let digest: String } - let url = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) +@testable import Capture + +@Test func sharedMaterialFingerprintMatchesCrossDistributionFixtures() throws { + struct Fixture: Decodable { + let payload: CapturePayload + let digest: String + } + let url = try #require( + Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixtures = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url)) for fixture in fixtures { #expect(try fixture.payload.canonicalDigest() == fixture.digest) @@ -15,8 +21,12 @@ import Testing } @Test func nativeGraphSeparatesOriginalsImageSourceAndAnnotation() throws { - struct Fixture: Decodable { let payload: CapturePayload; let digest: String } - let url = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + struct Fixture: Decodable { + let payload: CapturePayload + let digest: String + } + let url = try #require( + Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1] let data = try CaptureJSON.encode(CaptureCommit(fixture.payload)) #expect(data == (try CaptureJSON.encode(CaptureCommit(fixture.payload)))) @@ -27,30 +37,37 @@ import Testing let relations = try #require(graph["relations"] as? [[String: Any]]) let blobs = try #require(graph["blobs"] as? [[String: Any]]) #expect(blocks.map { $0["id"] as? Int } == [-1, -2, -3, -4, -5]) - #expect(blocks.map { $0["resolver"] as? String } == [ - "extensions.capture.capture.v1", "core.text.v1", - "extensions.capture.attachment.v1", "core.image.v1", "core.text.v1", - ]) + #expect( + blocks.map { $0["resolver"] as? String } == [ + "extensions.capture.capture.v1", "core.text.v1", + "extensions.capture.attachment.v1", "core.image.v1", "core.text.v1", + ]) #expect(blocks[1]["content"] as? String == fixture.payload.materials[0].text) #expect(blocks[4]["content"] as? String == fixture.payload.annotation) - #expect(relations.map { $0["content"] as? String } == ["material:0", "content", "material:1", "annotation"]) + #expect( + relations.map { $0["content"] as? String } == ["material:0", "content", "material:1", "annotation"]) #expect(blobs.count == 1 && blobs[0]["block_id"] as? Int == -4) - #expect(Data(base64Encoded: try #require(blobs[0]["data_base64"] as? String)) == fixture.payload.materials[1].data) + #expect( + Data(base64Encoded: try #require(blobs[0]["data_base64"] as? String)) + == fixture.payload.materials[1].data) } @Test func nativeValidationRejectsMismatchedImagesAndCountsUTF8Bytes() throws { struct Fixture: Decodable { let payload: CapturePayload } - let url = try #require(Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) - let image = try #require(CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data) - #expect(throws: CaptureError.self) { + let url = try #require( + Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let image = try #require( + CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data) + #expect(throws: PeerError.self) { try CapturePayload(materials: [Material(image: image, mimeType: "image/jpeg")]).validate() } - #expect(throws: CaptureError.self) { - try CapturePayload(materials: [Material(image: Data("broken".utf8), mimeType: "image/png")]).validate() + #expect(throws: PeerError.self) { + try CapturePayload(materials: [Material(image: Data("broken".utf8), mimeType: "image/png")]) + .validate() } let text = String(repeating: "x", count: 32 * 1024 * 1024 - 3) try CapturePayload(materials: [Material(text: text)], annotation: "中").validate() - #expect(throws: CaptureError.self) { + #expect(throws: PeerError.self) { try CapturePayload(materials: [Material(text: text)], annotation: "中文").validate() } } diff --git a/Tests/CaptureTests/CaptureLifecycleTests.swift b/Tests/CaptureTests/CaptureLifecycleTests.swift new file mode 100644 index 0000000..3e80f6f --- /dev/null +++ b/Tests/CaptureTests/CaptureLifecycleTests.swift @@ -0,0 +1,63 @@ +import Capture +import Foundation +import PeerCore +import Security +import Testing + +private actor PausedRecovery: CaptureTransport { + private var entered = false + private var arrival: CheckedContinuation? + private var release: CheckedContinuation? + private(set) var dispatches = 0 + func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + await withCheckedContinuation { continuation in + release = continuation + entered = true + arrival?.resume() + arrival = nil + } + return nil + } + func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { + dispatches += 1 + throw PeerError.invalid("must not dispatch after stop") + } + func waitForRecovery() async { + if !entered { await withCheckedContinuation { arrival = $0 } } + } + func finishRecovery() { + release?.resume() + release = nil + } +} + +@Test func stoppingCaptureWaitsForRecoveryAndPreventsFurtherDispatch() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "dev.inkcre.lifecycle.\(UUID().uuidString)" + defer { + SecItemDelete( + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] + as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + let target = ConnectionTarget(host: "localhost", database: "inkcre", username: "test") + let credentials = CredentialStore(service: service) + try credentials.save("test-password", for: target.id) + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + for text in ["第一份", "第二份"] { + _ = try await store.freeze( + CaptureRecord(payload: CapturePayload(materials: [Material(text: text)])), target: target) + } + let transport = PausedRecovery() + let queue = CaptureQueue(store: store, credentials: credentials, client: transport) + let processing = Task { try await queue.process(force: true) } + await transport.waitForRecovery() + let stopped = Task { await queue.stop() } + while !(await queue.isStopped) { await Task.yield() } + await transport.finishRecovery() + try await processing.value + await stopped.value + try await queue.process(force: true) + #expect(await transport.dispatches == 0) + #expect(try await store.records().allSatisfy { $0.state == .pending && $0.attempts == 0 }) +} diff --git a/Tests/CaptureCoreTests/CaptureStoreTests.swift b/Tests/CaptureTests/CaptureStoreTests.swift similarity index 60% rename from Tests/CaptureCoreTests/CaptureStoreTests.swift rename to Tests/CaptureTests/CaptureStoreTests.swift index 29825b1..49e96c8 100644 --- a/Tests/CaptureCoreTests/CaptureStoreTests.swift +++ b/Tests/CaptureTests/CaptureStoreTests.swift @@ -1,7 +1,9 @@ +import CSQLite import Foundation +import PeerCore import Testing -import CSQLite -@testable import CaptureCore + +@testable import Capture struct CaptureStoreTests { @Test func deletedDraftIsAbsentAfterRestartAndLateAutosaveCannotRecreateIt() async throws { @@ -9,11 +11,14 @@ struct CaptureStoreTests { defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) - let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "最后一次编辑")], annotation: "保留附注")) + let draft = CaptureRecord( + payload: CapturePayload(materials: [Material(text: "最后一次编辑")], annotation: "保留附注")) try await store.deleteRecord(draft.id) try await store.saveDraft(draft) - await #expect(throws: CaptureError.self) { - _ = try await store.freeze(draft, target: ConnectionTarget(postgrestURL: URL(string: "https://example.test")!)) + await #expect(throws: PeerError.self) { + _ = try await store.freeze( + draft, + target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core")) } let reopened = try CaptureStore(url: url) #expect(try await reopened.record(draft.id) == nil) @@ -24,11 +29,11 @@ struct CaptureStoreTests { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let target = ConnectionTarget(postgrestURL: URL(string: "https://example.test")!) + let target = ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "尚未发送")])) _ = try await store.freeze(draft, target: target) try await store.cancel(draft.id) - await #expect(throws: CaptureError.self) { _ = try await store.markDispatch(draft.id) } + await #expect(throws: PeerError.self) { _ = try await store.markDispatch(draft.id) } try await store.report(draft.id, state: .pending, message: "迟到的错误") // No credential exists: an accidental dispatch would change the state to needsAction. let credentials = CredentialStore(service: "dev.inkcre.test.\(UUID().uuidString)") @@ -47,7 +52,9 @@ struct CaptureStoreTests { defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "在途材料")])) - _ = try await store.freeze(draft, target: ConnectionTarget(postgrestURL: URL(string: "https://example.test")!)) + _ = try await store.freeze( + draft, target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + ) _ = try await store.markDispatch(draft.id) try await store.cancel(draft.id) try await store.report(draft.id, state: .unknown, message: "迟到的失败") @@ -56,8 +63,9 @@ struct CaptureStoreTests { #expect(cancelled.payload != nil) #expect(cancelled.message?.contains("可能已收入") == true) try await store.deleteRecord(draft.id) - let result = CaptureResult(submissionID: draft.id, rootBlockID: 42, - committedAt: Date(timeIntervalSince1970: 1780000000), availability: .available) + let result = CaptureResult( + submissionID: draft.id, rootBlockID: 42, + committedAt: Date(timeIntervalSince1970: 1_780_000_000), availability: .available) try await store.confirm(draft.id, result: result) try await store.report(draft.id, state: .unknown, message: "删除后的迟到失败") #expect(try await store.record(draft.id) == nil) @@ -77,43 +85,6 @@ struct CaptureStoreTests { #expect(try await store.record(draft.id)?.payload?.materials.first?.text == "清空后继续输入") } - @Test func versionTwoRecordsKeepTheirContentAndCancelledState() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - defer { try? FileManager.default.removeItem(at: directory) } - let url = directory.appendingPathComponent("capture.sqlite") - let store = try CaptureStore(url: url) - let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧版保留的草稿")])) - let pending = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧版取消的收入")])) - let confirmed = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧版已收入")])) - try await store.saveDraft(draft) - _ = try await store.freeze(pending, target: ConnectionTarget(postgrestURL: URL(string: "https://example.test")!)) - try await store.cancel(pending.id) - _ = try await store.freeze(confirmed, target: ConnectionTarget(postgrestURL: URL(string: "https://example.test")!)) - try await store.confirm(confirmed.id, result: CaptureResult( - submissionID: confirmed.id, rootBlockID: 42, committedAt: Date(), availability: .available - )) - let before = try await store.records() - var handle: OpaquePointer? - #expect(sqlite3_open(url.path, &handle) == SQLITE_OK) - let database = try #require(handle) - defer { sqlite3_close(database) } - for record in before { - var body = try #require(try JSONSerialization.jsonObject(with: CaptureJSON.encode(record)) as? [String: Any]) - body["deletedAt"] = "2026-10-21T00:00:00.000Z" - let hex = try JSONSerialization.data(withJSONObject: body).map { String(format: "%02x", $0) }.joined() - #expect(sqlite3_exec(database, "UPDATE records SET body=X'\(hex)' WHERE id='\(record.id.uuidString)'", nil, nil, nil) == SQLITE_OK) - } - #expect(sqlite3_exec(database, "PRAGMA user_version=2", nil, nil, nil) == SQLITE_OK) - let reopened = try CaptureStore(url: url) - let credentials = CredentialStore(service: "dev.inkcre.test.\(UUID().uuidString)") - try await CaptureQueue(store: reopened, credentials: credentials).process(force: true) - #expect(try await reopened.records().count == 3) - for record in before { - let retained = try #require(try await reopened.record(record.id)) - #expect(try CaptureJSON.encode(retained) == CaptureJSON.encode(record)) - } - } - @Test func failedFreezeRetainsTheSavedDraftAndCannotDispatch() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } @@ -125,17 +96,22 @@ struct CaptureStoreTests { #expect(sqlite3_open(url.path, &connection) == SQLITE_OK) let db = try #require(connection) defer { sqlite3_close(db) } - #expect(sqlite3_exec(db, """ - CREATE TRIGGER fail_write BEFORE INSERT ON records - BEGIN SELECT RAISE(ABORT, 'acceptance storage failure'); END; - """, nil, nil, nil) == SQLITE_OK) - await #expect(throws: CaptureError.self) { - _ = try await store.freeze(draft, target: ConnectionTarget(postgrestURL: URL(string: "https://example.test")!)) + #expect( + sqlite3_exec( + db, + """ + CREATE TRIGGER fail_write BEFORE INSERT ON records + BEGIN SELECT RAISE(ABORT, 'acceptance storage failure'); END; + """, nil, nil, nil) == SQLITE_OK) + await #expect(throws: PeerError.self) { + _ = try await store.freeze( + draft, + target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core")) } let retained = try #require(try await store.record(draft.id)) #expect(retained.state == .draft) #expect(retained.payload?.materials[0].text == "保存失败仍须保留") - await #expect(throws: CaptureError.self) { _ = try await store.markDispatch(draft.id) } + await #expect(throws: PeerError.self) { _ = try await store.markDispatch(draft.id) } } @Test func frozenMaterialSurvivesReopeningAndTargetChanges() async throws { @@ -143,17 +119,25 @@ struct CaptureStoreTests { defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) - let target = ConnectionTarget(postgrestURL: URL(string: "https://one.example/db")!) - let image = try #require(Data(base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAHCAIAAABPxRC5AAAAE0lEQVR4nGPkUbJgQAJMyBwa8AEvKAB0sOhdcgAAAABJRU5ErkJggg==")) - let draft = CaptureRecord(payload: CapturePayload( - entry: .paste, - materials: [Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), Material(image: image, mimeType: "image/png")], - annotation: "自己的想法" - )) + let target = ConnectionTarget(host: "one.example", database: "inkcre", username: "inkcre_core") + let image = try #require( + Data( + base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAHCAIAAABPxRC5AAAAE0lEQVR4nGPkUbJgQAJMyBwa8AEvKAB0sOhdcgAAAABJRU5ErkJggg==" + )) + let draft = CaptureRecord( + payload: CapturePayload( + entry: .paste, + materials: [ + Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), Material(image: image, mimeType: "image/png"), + ], + annotation: "自己的想法" + )) try await store.saveDraft(draft) _ = try await store.freeze(draft, target: target) _ = try await store.markDispatch(draft.id) - try await store.saveConnection(ConnectionTarget(postgrestURL: URL(string: "https://two.example/db")!)) + try await ConnectionSettings(url: directory.appendingPathComponent("connection.json")).save( + ConnectionTarget(host: "two.example", database: "inkcre", username: "inkcre_core")) let reopened = try CaptureStore(url: url) let recovered = try #require(try await reopened.record(draft.id)) #expect(recovered.state == .unknown) @@ -161,8 +145,8 @@ struct CaptureStoreTests { #expect(recovered.payload?.materials[0].text == draft.payload?.materials[0].text) #expect(recovered.payload?.materials[1].data == image) #expect(recovered.payload?.annotation == "自己的想法") - await #expect(throws: CaptureError.self) { try await reopened.saveDraft(draft) } - await #expect(throws: CaptureError.self) { _ = try await reopened.freeze(draft, target: target) } + await #expect(throws: PeerError.self) { try await reopened.saveDraft(draft) } + await #expect(throws: PeerError.self) { _ = try await reopened.freeze(draft, target: target) } } @Test func confirmationAtomicallyRetainsIdentityAndClearsMaterial() async throws { @@ -171,10 +155,12 @@ struct CaptureStoreTests { let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "一次意图")])) - _ = try await store.freeze(draft, target: ConnectionTarget(postgrestURL: URL(string: "https://example.test")!)) + _ = try await store.freeze( + draft, target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + ) _ = try await store.markDispatch(draft.id) let result = CaptureResult( - submissionID: draft.id, rootBlockID: 123, committedAt: Date(timeIntervalSince1970: 1780000000), + submissionID: draft.id, rootBlockID: 123, committedAt: Date(timeIntervalSince1970: 1_780_000_000), availability: .deleted ) try await store.confirm(draft.id, result: result) @@ -183,15 +169,15 @@ struct CaptureStoreTests { #expect(recovered.state == .confirmed) #expect(recovered.payload == nil) #expect(recovered.result == result) - await #expect(throws: CaptureError.self) { try await reopened.retry(draft.id) } + await #expect(throws: PeerError.self) { try await reopened.retry(draft.id) } } @Test func emptyDraftCannotEraseAnAlreadyFrozenIntention() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let target = ConnectionTarget(postgrestURL: URL(string: "https://example.test")!) - await #expect(throws: CaptureError.self) { + let target = ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + await #expect(throws: PeerError.self) { _ = try await store.freeze(CaptureRecord(), target: target) } let first = CaptureRecord(payload: CapturePayload(materials: [Material(text: "相同内容")])) diff --git a/Tests/CaptureCoreTests/Fixtures/capture-v1.json b/Tests/CaptureTests/Fixtures/capture-v1.json similarity index 100% rename from Tests/CaptureCoreTests/Fixtures/capture-v1.json rename to Tests/CaptureTests/Fixtures/capture-v1.json diff --git a/Tests/CaptureTests/LivePeerTests.swift b/Tests/CaptureTests/LivePeerTests.swift new file mode 100644 index 0000000..4bff258 --- /dev/null +++ b/Tests/CaptureTests/LivePeerTests.swift @@ -0,0 +1,136 @@ +import Capture +import Foundation +import PeerCore +import Security +import Testing + +@Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_CAPTURE_TEST_DATABASE"] != nil)) +func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async throws { + let env = ProcessInfo.processInfo.environment + let target = ConnectionTarget( + host: "127.0.0.1", port: Int(env["INKCRE_CAPTURE_TEST_PORT"] ?? "5432")!, + database: try #require(env["INKCRE_CAPTURE_TEST_DATABASE"]), username: "inkcre_core", + tls: .disable) + let password = try #require(env["INKCRE_CAPTURE_TEST_PASSWORD"]) + let database = PeerDatabase() + let client = CaptureClient(database: database) + try await client.checkConnection(target, secret: password) + let pg = try await database.client(for: target, password: password) + struct Fixture: Decodable { let payload: CapturePayload } + let fixtureURL = try #require( + Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] + let payload = CapturePayload( + entry: .paste, origin: fixture.payload.origin, materials: fixture.payload.materials, + annotation: fixture.payload.annotation) + var record = CaptureRecord(payload: payload) + record.target = target + let submission = record + async let a = client.submit(submission, secret: password) + async let b = client.submit(submission, secret: password) + async let c = client.submit(submission, secret: password) + let results = try await [a, b, c] + let result = try #require(results.first) + #expect(results.allSatisfy { $0 == result }) + let rows = try await pg.query( + """ + WITH RECURSIVE captured(id) AS (SELECT \(result.rootBlockID)::integer UNION + SELECT to_ FROM inkcre.relations JOIN captured ON from_=captured.id) + SELECT b.resolver,b.content,s.data FROM inkcre.blocks b JOIN captured USING(id) + LEFT JOIN inkcre.storage_blobs s ON s.id=CASE WHEN b.storage=-4 THEN (b.content::jsonb->>'blob_id')::uuid END + """) + var blockCount = 0 + for try await (resolver, content, bytes) in rows.decode((String, String, Data?).self) { + blockCount += 1 + if resolver == "core.image.v1" { #expect(bytes == payload.materials[1].data) } + if resolver == "core.text.v1" { + #expect([payload.materials[0].text, payload.annotation].contains(content)) + } + } + #expect(blockCount == 5) + + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "dev.inkcre.native.acceptance.\(UUID().uuidString)" + let credentials = CredentialStore(service: service) + try credentials.save(password, for: target.id) + defer { + SecItemDelete( + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] + as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + // Commit has happened but the local process has not observed its response. + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + _ = try await store.freeze(CaptureRecord(payload: payload), target: target) + _ = try await store.markDispatch(payload.submissionID) + let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + try await CaptureQueue(store: reopened, credentials: credentials, client: client).process( + force: true) + let recovered = try #require(try await reopened.record(payload.submissionID)) + #expect(recovered.state == .confirmed && recovered.payload == nil) + #expect(recovered.result == result) + + var changed = payload + changed.annotation = "另一份材料" + var conflict = CaptureRecord(payload: changed) + conflict.target = target + await #expect(throws: PeerError.self) { _ = try await client.submit(conflict, secret: password) } + + let before = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { + _ in true + } + await #expect(throws: (any Error).self) { + try await pg.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await InfoBase.persist( + blocks: [GraphBlock(id: -1, resolver: "core.text.v1", content: "must rollback")], + relations: [GraphRelation(from_: -1, to_: -999, content: "invalid")], blobs: [:], + on: connection) + } + } + let after = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { _ in + true + } + #expect(before == after) + try await pg.query("DELETE FROM inkcre.blocks WHERE id=\(result.rootBlockID)") + #expect(try await client.submit(record, secret: password).availability == .deleted) + try await database.setEnabled( + false, target: target, password: password, extension: CaptureClient.descriptor) + do { + _ = try await client.submit(record, secret: password) + Issue.record("停用后不得继续共享写入") + } catch PeerError.needsAction(let message) { #expect(message.contains("停用")) } + await #expect(throws: PeerError.self) { + try await client.checkConnection(target, secret: password) + } + try await pg.query( + "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)") + await #expect(throws: PeerError.self) { + try await client.checkConnection(target, secret: password) + } + let removed = try await pg.query( + "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)" + ).decode(Int.self).first { _ in true } + #expect(removed == 0) + // An explicit enable is allowed to create a first participation before Capture starts. + try await database.setEnabled( + true, target: target, password: password, extension: CaptureClient.descriptor) + try await client.checkConnection(target, secret: password) + let first = ConnectionTarget( + host: target.host, port: target.port, database: target.database, username: target.username, + tls: target.tls) + try await database.setEnabled( + true, target: first, password: password, extension: CaptureClient.descriptor) + try await pg.query( + "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)") + await #expect(throws: PeerError.self) { + try await client.checkConnection(first, secret: password) + } + let removedBeforeFirstJoin = try await pg.query( + "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)" + ).decode(Int.self).first { _ in true } + #expect(removedBeforeFirstJoin == 0) + try await database.setEnabled( + true, target: first, password: password, extension: CaptureClient.descriptor) + try await client.checkConnection(first, secret: password) + await database.shutdown() +} diff --git a/Tests/CaptureTests/NativeExtensionTests.swift b/Tests/CaptureTests/NativeExtensionTests.swift new file mode 100644 index 0000000..9a18a32 --- /dev/null +++ b/Tests/CaptureTests/NativeExtensionTests.swift @@ -0,0 +1,37 @@ +import Foundation +import PeerCore +import Testing + +@MainActor private final class Module: NativeExtension { + let descriptor = NativeExtensionDescriptor( + name: "inkcre/test", distribution: "swift_bundled", version: "0.1.0") + var starts = 0 + var stops = 0 + var intents: [Bool] = [] + func start() throws { starts += 1 } + func stop() async throws { stops += 1 } + func setEnabled(_ enabled: Bool) async throws { intents.append(enabled) } + func showMain() {} +} + +@Test @MainActor func nativeExtensionLifecycleKeepsRuntimeSeparateFromEnabledIntent() async throws { + let domain = "dev.inkcre.extension-tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: domain)! + defer { defaults.removePersistentDomain(forName: domain) } + let module = Module() + let runtime = BundledExtensionRuntime(module: module, defaults: defaults) + try runtime.startIfEnabled() + #expect(runtime.running && module.starts == 1) + try await runtime.stop() + #expect(!runtime.running && runtime.enabled && module.intents.isEmpty) + try runtime.startIfEnabled() + try await runtime.setEnabled(false) + #expect(!runtime.running && !runtime.enabled && module.intents == [false]) + let restarted = BundledExtensionRuntime(module: module, defaults: defaults) + try restarted.startIfEnabled() + #expect(!restarted.running && module.starts == 2) + try await restarted.setEnabled(true) + #expect(restarted.running && module.starts == 3) + #expect(module.intents == [false, true]) + try await restarted.stop() +} diff --git a/extensions/capture/LICENSE b/extensions/capture/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/extensions/capture/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/capture/Legal/Cida/ATTRIBUTION.txt b/extensions/capture/Legal/Cida/ATTRIBUTION.txt new file mode 100644 index 0000000..cd459fd --- /dev/null +++ b/extensions/capture/Legal/Cida/ATTRIBUTION.txt @@ -0,0 +1,9 @@ +Cida(辞达) +上游:https://github.com/Xuanwo/cida +作者:Xuanwo 及 Cida 贡献者 +核对版本:3099e0dcbc2bafe0a86e6a04d1941b78afacbfd6 +许可:Apache License 2.0,完整文本见同目录 LICENSE。 + +InKCre 的 SelectedText.swift、RegionCapture.swift 参考与改写了 Cida 的选区读取、剪贴板保护和框选交互。 +InKCre 为持久收集目的修改了选区验证、复制边界、窗口路由和原图保存,不包含 Cida 翻译业务。 +Cida 名称仅用于来源致谢,不表示上游对 InKCre 的认可或合作。 diff --git a/extensions/capture/Legal/Cida/LICENSE b/extensions/capture/Legal/Cida/LICENSE new file mode 100644 index 0000000..57bc88a --- /dev/null +++ b/extensions/capture/Legal/Cida/LICENSE @@ -0,0 +1,202 @@ + 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. + diff --git a/extensions/capture/Package.swift b/extensions/capture/Package.swift new file mode 100644 index 0000000..0df7def --- /dev/null +++ b/extensions/capture/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "InKCreCapture", platforms: [.macOS(.v15)], + products: [ + .library(name: "Capture", targets: ["Capture"]), + .library(name: "CaptureMac", targets: ["CaptureMac"]), + ], + dependencies: [.package(path: "../../Packages/PeerCore")], + targets: [ + .systemLibrary(name: "CSQLite"), + .target( + name: "Capture", dependencies: ["CSQLite", .product(name: "PeerCore", package: "PeerCore")], + resources: [.copy("Resources/extension.json")]), + .target( + name: "CaptureMac", + dependencies: [ + "Capture", .product(name: "PeerCore", package: "PeerCore"), + .product(name: "PeerMacUI", package: "PeerCore"), + ], resources: [.copy("Resources/Brand")]), + ] +) diff --git a/extensions/capture/README.md b/extensions/capture/README.md new file mode 100644 index 0000000..023a6dd --- /dev/null +++ b/extensions/capture/README.md @@ -0,0 +1,7 @@ +# Capture Swift 分发 + +`inkcre/capture` 的 first-party 原生分发,独立 Swift Package。`Capture` 拥有材料、图、SQLite 待办、事务编排与恢复;`CaptureMac` 提供 macOS 选区/剪贴板/截图和原生采集界面。各平台遵守共享 Capture 合同,图结构不由宿主 Core 定义。 + +Host 提供 PeerCore 的数据库、标准图/Storage、持久目录、凭据和扩展生命周期。精确分发身份来自 `Sources/Capture/Resources/extension.json`;随签名应用交付并独立启停,不依赖另一个 Peer 执行收入。 + +在仓库根执行 `swift test`;本包可通过 `swift build --package-path extensions/capture` 单独构建。Cida 来源和 Apache-2.0 许可见 Legal/Cida,原创代码见 LICENSE。 diff --git a/Sources/CSQLite/module.modulemap b/extensions/capture/Sources/CSQLite/module.modulemap similarity index 100% rename from Sources/CSQLite/module.modulemap rename to extensions/capture/Sources/CSQLite/module.modulemap diff --git a/Sources/CSQLite/shim.h b/extensions/capture/Sources/CSQLite/shim.h similarity index 100% rename from Sources/CSQLite/shim.h rename to extensions/capture/Sources/CSQLite/shim.h diff --git a/extensions/capture/Sources/Capture/CaptureClient.swift b/extensions/capture/Sources/Capture/CaptureClient.swift new file mode 100644 index 0000000..c191340 --- /dev/null +++ b/extensions/capture/Sources/Capture/CaptureClient.swift @@ -0,0 +1,130 @@ +import CryptoKit +import Foundation +import PeerCore +import PostgresNIO + +public protocol CaptureTransport: Sendable { + func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? + func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult +} + +public struct CaptureClient: CaptureTransport { + public static let descriptor: NativeExtensionDescriptor = { + let bundle: Bundle + if Bundle.main.bundleURL.pathExtension == "app" { + guard let url = Bundle.main.url(forResource: "InKCreCapture_Capture", withExtension: "bundle"), + let packaged = Bundle(url: url) + else { + preconditionFailure("Missing packaged Capture distribution") + } + bundle = packaged + } else { + bundle = .module + } + guard let url = bundle.url(forResource: "extension", withExtension: "json"), + let data = try? Data(contentsOf: url), + let descriptor = try? JSONDecoder().decode(NativeExtensionDescriptor.self, from: data) + else { + preconditionFailure("Invalid bundled Capture manifest") + } + return descriptor + }() + private let database: PeerDatabase + public init(database: PeerDatabase = .shared) { self.database = database } + + public func checkConnection(_ target: ConnectionTarget, secret: String) async throws { + do { + _ = try await database.register(target, password: secret) + try await database.join(target, password: secret, extension: Self.descriptor) + let client = try await database.client(for: target, password: secret) + _ = try await client.query( + "SELECT submission_id,payload_digest,graph_digest FROM inkcre.capture_receipts LIMIT 0") + } catch { throw PeerDatabase.failure(error, dispatched: false) } + } + + public func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + guard let target = record.target, let payload = record.payload else { + throw PeerError.invalid("待办缺少材料或目标。") + } + do { + let client = try await database.client(for: target, password: secret) + return try await Self.receipt(record.id, digest: payload.canonicalDigest(), client: client) + } catch { throw PeerDatabase.failure(error, dispatched: false) } + } + + private static func receipt(_ id: UUID, digest: String, client: PostgresClient) async throws + -> CaptureResult? + { + let rows = try await client.query( + """ + SELECT r.payload_digest,r.root_block_id,r.committed_at,EXISTS(SELECT 1 FROM inkcre.blocks b WHERE b.id=r.root_block_id) + FROM inkcre.capture_receipts r WHERE r.submission_id=\(id) + """) + for try await (saved, root, date, exists) in rows.decode((String, Int, Date, Bool).self) { + guard saved == digest else { throw PeerError.needsAction("提交身份与已保存材料冲突,原材料已保留。") } + return CaptureResult( + submissionID: id, rootBlockID: root, committedAt: date, + availability: exists ? .available : .deleted) + } + return nil + } + + public func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { + guard let target = record.target, let payload = record.payload else { + throw PeerError.invalid("待办缺少冻结材料或目标。") + } + let command = try CaptureCommit(payload) + let graphDigest = SHA256.hash(data: try CaptureJSON.encode(command.graph)).map { + String(format: "%02x", $0) + }.joined() + do { + let client = try await database.client(for: target, password: secret) + let result = try await client.withTransaction(logger: PeerDatabase.logger) { connection in + try await PeerDatabase.requireSelection( + Self.descriptor, peerID: target.peerID, on: connection) + // The UUID lock covers receipt lookup, graph writes and the receipt itself. + try await connection.query( + "SELECT pg_advisory_xact_lock(hashtextextended(\(record.id.uuidString.lowercased()),0))", + logger: PeerDatabase.logger) + let rows = try await connection.query( + """ + SELECT r.payload_digest,r.graph_digest,r.root_block_id,r.committed_at,EXISTS(SELECT 1 FROM inkcre.blocks b WHERE b.id=r.root_block_id) + FROM inkcre.capture_receipts r WHERE r.submission_id=\(record.id) + """, logger: PeerDatabase.logger) + for try await (saved, graph, root, date, exists) in rows.decode( + (String, String, Int, Date, Bool).self) + { + guard saved == command.payload_digest, graph == graphDigest else { + throw PeerError.needsAction("提交身份与已保存材料或图冲突。") + } + return CaptureResult( + submissionID: record.id, rootBlockID: root, committedAt: date, + availability: exists ? .available : .deleted) + } + let ids = try await InfoBase.persist( + blocks: command.graph.blocks, relations: command.graph.relations, + blobs: Dictionary( + uniqueKeysWithValues: command.graph.blobs.map { ($0.block_id, $0.data_base64) }), + on: connection + ) + guard let root = ids[command.graph.root_local_id] else { throw PeerError.invalid("采集图缺少根。") } + let inserted = try await connection.query( + """ + INSERT INTO inkcre.capture_receipts(submission_id,payload_digest,graph_digest,root_block_id) + VALUES (\(record.id),\(command.payload_digest),\(graphDigest),\(root)) RETURNING committed_at + """, logger: PeerDatabase.logger) + for try await date in inserted.decode(Date.self) { + return CaptureResult( + submissionID: record.id, rootBlockID: root, committedAt: date, + availability: .available) + } + throw PeerError.outcomeUnknown + } + guard let persisted = try await recover(record, secret: secret), + persisted.rootBlockID == result.rootBlockID + else { throw PeerError.outcomeUnknown } + return persisted + } catch { throw PeerDatabase.failure(error, dispatched: true) } + } + +} diff --git a/Sources/CaptureCore/CaptureDigest.swift b/extensions/capture/Sources/Capture/CaptureDigest.swift similarity index 59% rename from Sources/CaptureCore/CaptureDigest.swift rename to extensions/capture/Sources/Capture/CaptureDigest.swift index 760bcef..1ae1660 100644 --- a/Sources/CaptureCore/CaptureDigest.swift +++ b/extensions/capture/Sources/Capture/CaptureDigest.swift @@ -1,9 +1,10 @@ import CryptoKit import Foundation +import PeerCore -public extension CapturePayload { +extension CapturePayload { /// Matches Capture v1's sorted UTF-8 JSON, including explicit null optionals. - func canonicalDigest() throws -> String { + public func canonicalDigest() throws -> String { // The immutable wire payload has millisecond precision. Hash that same representation. let wire = try CaptureJSON.decode(CapturePayload.self, from: CaptureJSON.encode(self)) let formatter = DateFormatter() @@ -11,21 +12,26 @@ public extension CapturePayload { formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" let null = NSNull() - let origin: Any = wire.origin.map { - ["application_id": $0.applicationID as Any? ?? null, - "title": $0.title as Any? ?? null, "url": $0.url as Any? ?? null] - } ?? null + let origin: Any = + wire.origin.map { + [ + "application_id": $0.applicationID as Any? ?? null, + "title": $0.title as Any? ?? null, "url": $0.url as Any? ?? null, + ] + } ?? null let materials: [[String: Any]] = try wire.materials.map { material in switch material.kind { case .text: - guard let text = material.text else { throw CaptureError.invalid("文字材料格式不正确。") } + guard let text = material.text else { throw PeerError.invalid("文字材料格式不正确。") } return ["kind": "text", "text": text] case .image: guard let data = material.data, let mime = material.mimeType else { - throw CaptureError.invalid("图片材料格式不正确。") + throw PeerError.invalid("图片材料格式不正确。") } - return ["kind": "image", "mime_type": mime, "filename": material.filename as Any? ?? null, - "data_sha256": Self.sha256(data)] + return [ + "kind": "image", "mime_type": mime, "filename": material.filename as Any? ?? null, + "data_sha256": Self.sha256(data), + ] } } let document: [String: Any] = [ @@ -34,9 +40,10 @@ public extension CapturePayload { "entry": wire.entry.rawValue, "origin": origin, "materials": materials, "annotation": wire.annotation as Any? ?? null, ] - return Self.sha256(try JSONSerialization.data( - withJSONObject: document, options: [.sortedKeys, .withoutEscapingSlashes] - )) + return Self.sha256( + try JSONSerialization.data( + withJSONObject: document, options: [.sortedKeys, .withoutEscapingSlashes] + )) } private static func sha256(_ data: Data) -> String { diff --git a/extensions/capture/Sources/Capture/CaptureGraph.swift b/extensions/capture/Sources/Capture/CaptureGraph.swift new file mode 100644 index 0000000..5284de8 --- /dev/null +++ b/extensions/capture/Sources/Capture/CaptureGraph.swift @@ -0,0 +1,73 @@ +import Foundation +import PeerCore + +/// Capture owns this graph; the Host supplies transaction and storage mechanics. +struct CaptureCommit: Encodable, Sendable { + struct Blob: Encodable, Sendable { + let block_id: Int + let data_base64: Data + } + + struct Graph: Encodable, Sendable { + let root_local_id = -1 + var blocks: [GraphBlock] + var relations: [GraphRelation] = [] + var blobs: [Blob] = [] + } + + let submission_id: UUID + let payload_digest: String + let graph: Graph + + init(_ payload: CapturePayload) throws { + try payload.validate() + struct Metadata: Encodable { + let captured_at: Date + let entry: CaptureEntry + let origin: CaptureOrigin? + } + struct Attachment: Encodable { + let mime_type: String + let filename: String? + } + func json(_ value: T) throws -> String { + String(decoding: try CaptureJSON.encode(value), as: UTF8.self) + } + var graph = Graph(blocks: [ + GraphBlock( + id: -1, resolver: "extensions.capture.capture.v1", + content: try json( + Metadata(captured_at: payload.capturedAt, entry: payload.entry, origin: payload.origin)) + ) + ]) + for (index, material) in payload.materials.enumerated() { + let id = -(graph.blocks.count + 1) + switch material.kind { + case .text: + guard let text = material.text else { throw PeerError.invalid("文字材料格式不正确。") } + graph.blocks.append(GraphBlock(id: id, resolver: "core.text.v1", content: text)) + case .image: + guard let bytes = material.data, let mime = material.mimeType else { + throw PeerError.invalid("图片材料格式不正确。") + } + graph.blocks.append( + GraphBlock( + id: id, resolver: "extensions.capture.attachment.v1", + content: try json(Attachment(mime_type: mime, filename: material.filename)) + )) + graph.blocks.append(GraphBlock(id: id - 1, resolver: "core.image.v1", content: "")) + graph.blobs.append(Blob(block_id: id - 1, data_base64: bytes)) + graph.relations.append(GraphRelation(from_: id, to_: id - 1, content: "content")) + } + graph.relations.append(GraphRelation(from_: -1, to_: id, content: "material:\(index)")) + } + if let annotation = payload.annotation, !annotation.isEmpty { + let id = -(graph.blocks.count + 1) + graph.blocks.append(GraphBlock(id: id, resolver: "core.text.v1", content: annotation)) + graph.relations.append(GraphRelation(from_: -1, to_: id, content: "annotation")) + } + submission_id = payload.submissionID + payload_digest = try payload.canonicalDigest() + self.graph = graph + } +} diff --git a/Sources/CaptureCore/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift similarity index 66% rename from Sources/CaptureCore/CaptureQueue.swift rename to extensions/capture/Sources/Capture/CaptureQueue.swift index 48c8054..b2aba93 100644 --- a/Sources/CaptureCore/CaptureQueue.swift +++ b/extensions/capture/Sources/Capture/CaptureQueue.swift @@ -1,22 +1,38 @@ import Foundation +import PeerCore public actor CaptureQueue { private let store: CaptureStore private let credentials: CredentialStore - private let client: PeerClient + private let client: any CaptureTransport private var running = false + public private(set) var isStopped = false + private var idleWaiters: [CheckedContinuation] = [] - public init(store: CaptureStore, credentials: CredentialStore, client: PeerClient = PeerClient()) { + public init( + store: CaptureStore, credentials: CredentialStore, client: any CaptureTransport = CaptureClient() + ) { self.store = store self.credentials = credentials self.client = client } + public func resume() { isStopped = false } + public func stop() async { + isStopped = true + if running { await withCheckedContinuation { idleWaiters.append($0) } } + } + public func process(force: Bool = false) async throws { - guard !running else { return } + guard !isStopped, !Task.isCancelled, !running else { return } running = true - defer { running = false } + defer { + running = false + for waiter in idleWaiters { waiter.resume() } + idleWaiters.removeAll() + } for item in try await store.records() where [.pending, .unknown].contains(item.state) { + guard !isStopped, !Task.isCancelled else { return } let delay = min(300.0, pow(2.0, Double(min(item.attempts, 8))) * 2) if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { continue } guard let target = item.target else { continue } @@ -26,10 +42,12 @@ public actor CaptureQueue { try await store.confirm(item.id, result: result) continue } + guard !isStopped, !Task.isCancelled else { return } let dispatched = try await store.markDispatch(item.id) + guard !isStopped, !Task.isCancelled else { return } let result = try await client.submit(dispatched, secret: secret) try await store.confirm(item.id, result: result) - } catch let error as CaptureError { + } catch let error as PeerError { let state: CaptureRecord.State switch error { case .needsAction, .invalid: state = .needsAction diff --git a/Sources/CaptureCore/CaptureStore.swift b/extensions/capture/Sources/Capture/CaptureStore.swift similarity index 71% rename from Sources/CaptureCore/CaptureStore.swift rename to extensions/capture/Sources/Capture/CaptureStore.swift index 86f7078..559aca2 100644 --- a/Sources/CaptureCore/CaptureStore.swift +++ b/extensions/capture/Sources/Capture/CaptureStore.swift @@ -1,5 +1,6 @@ import CSQLite import Foundation +import PeerCore // Only CaptureStore's actor accesses this connection; the wrapper closes it on deallocation. private final class SQLiteConnection: @unchecked Sendable { @@ -12,7 +13,7 @@ private final class SQLiteConnection: @unchecked Sendable { ) guard status == SQLITE_OK, let database else { if let database { sqlite3_close(database) } - throw CaptureError.storage("无法打开本地采集数据库。") + throw PeerError.storage("无法打开本地采集数据库。") } handle = database sqlite3_busy_timeout(handle, 5000) @@ -29,12 +30,13 @@ private final class SQLiteConnection: @unchecked Sendable { func statement(_ sql: String, _ body: (OpaquePointer) throws -> T) throws -> T { var statement: OpaquePointer? guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, - let statement else { throw error() } + let statement + else { throw error() } defer { sqlite3_finalize(statement) } return try body(statement) } - func error() -> CaptureError { + func error() -> PeerError { .storage("本地保存失败:\(String(cString: sqlite3_errmsg(handle)))") } } @@ -55,15 +57,15 @@ public actor CaptureStore { guard sqlite3_step(statement) == SQLITE_ROW else { throw database.error() } return sqlite3_column_int(statement, 0) } - guard version <= 3 else { - throw CaptureError.storage("本地数据来自较新版本,请更新 InKCre 后再打开。") + guard version == 0 || version == 1 else { + throw PeerError.storage("本地数据来自较新版本,请更新 InKCre 后再打开。") } - try database.execute(""" + try database.execute( + """ PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, body BLOB NOT NULL); - CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, body BLOB NOT NULL); - PRAGMA user_version=3; + PRAGMA user_version=1; """) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } @@ -95,26 +97,25 @@ public actor CaptureStore { guard !deletedIDs.contains(record.id) else { return } let previous = try self.record(record.id) guard record.state == .draft, - previous?.state ?? .draft == .draft else { - throw CaptureError.invalid("已提交的材料不能作为草稿覆盖。") + previous?.state ?? .draft == .draft + else { + throw PeerError.invalid("已提交的材料不能作为草稿覆盖。") } try save(record) } public func freeze(_ draft: CaptureRecord, target: ConnectionTarget) throws -> CaptureRecord { guard !deletedIDs.contains(draft.id), draft.state == .draft, - let payload = draft.payload, payload.submissionID == draft.id else { - throw CaptureError.invalid("草稿状态不正确。") + let payload = draft.payload, payload.submissionID == draft.id + else { + throw PeerError.invalid("草稿状态不正确。") } try payload.validate() try target.validate() - guard try CaptureJSON.encode(payload).count <= 48 * 1024 * 1024 else { - throw CaptureError.invalid("编码后的请求超过 48 MiB。") - } try database.execute("BEGIN IMMEDIATE") do { if let old = try record(draft.id), old.state != .draft { - throw CaptureError.invalid("这份材料已经进入待办,请查看原提交。") + throw PeerError.invalid("这份材料已经进入待办,请查看原提交。") } var frozen = draft frozen.target = target @@ -132,8 +133,9 @@ public actor CaptureStore { public func markDispatch(_ id: UUID) throws -> CaptureRecord { guard var value = try record(id), [.pending, .unknown].contains(value.state), - value.payload != nil, value.target != nil else { - throw CaptureError.invalid("待办当前不能发送。") + value.payload != nil, value.target != nil + else { + throw PeerError.invalid("待办当前不能发送。") } // Persist uncertainty before network dispatch, so process death never loses it. value.state = .unknown @@ -145,12 +147,13 @@ public actor CaptureStore { public func report(_ id: UUID, state: CaptureRecord.State, message: String) throws { guard !deletedIDs.contains(id) else { return } - guard var value = try record(id) else { throw CaptureError.invalid("找不到待办。") } + guard var value = try record(id) else { throw PeerError.invalid("找不到待办。") } // A late failure must not resume an intention the user already cancelled or deleted. guard value.state != .cancelled else { return } guard value.state != .draft, value.state != .confirmed, - [.pending, .unknown, .needsAction].contains(state) else { - throw CaptureError.invalid("待办状态不可变更。") + [.pending, .unknown, .needsAction].contains(state) + else { + throw PeerError.invalid("待办状态不可变更。") } value.state = state value.message = message @@ -160,8 +163,9 @@ public actor CaptureStore { public func retry(_ id: UUID) throws { guard var value = try record(id), - value.state != .draft, value.state != .confirmed else { - throw CaptureError.invalid("这份材料无需重试。") + value.state != .draft, value.state != .confirmed + else { + throw PeerError.invalid("这份材料无需重试。") } value.state = value.attempts == 0 ? .pending : .unknown value.message = "使用原提交身份重新确认。" @@ -172,11 +176,14 @@ public actor CaptureStore { @discardableResult public func cancel(_ id: UUID) throws -> CaptureRecord { guard var value = try record(id), - [.pending, .unknown, .needsAction, .cancelled].contains(value.state) else { - throw CaptureError.invalid("这份材料已经收入或不是待办,请刷新后查看。") + [.pending, .unknown, .needsAction, .cancelled].contains(value.state) + else { + throw PeerError.invalid("这份材料已经收入或不是待办,请刷新后查看。") } value.state = .cancelled - value.message = value.attempts == 0 ? "尚未发送。材料保留在本机,可继续收入。" + value.message = + value.attempts == 0 + ? "尚未发送。材料保留在本机,可继续收入。" : "已停止后续发送;此前请求可能已收入。继续收入时会先确认原回执。" value.updatedAt = Date() try save(value) @@ -191,8 +198,9 @@ public actor CaptureStore { public func confirm(_ id: UUID, result: CaptureResult) throws { guard !deletedIDs.contains(id) else { return } guard var value = try record(id), value.state != .draft, - result.submissionID == id else { - throw CaptureError.invalid("提交回执与本机记录不匹配。") + result.submissionID == id + else { + throw PeerError.invalid("提交回执与本机记录不匹配。") } value.result = result value.state = .confirmed @@ -206,7 +214,7 @@ public actor CaptureStore { public func discardDraft(_ id: UUID) throws { guard !deletedIDs.contains(id), let value = try record(id) else { return } guard value.state == .draft else { - throw CaptureError.invalid("只能丢弃尚未提交的草稿。") + throw PeerError.invalid("只能丢弃尚未提交的草稿。") } try deleteRow(id) } @@ -218,21 +226,6 @@ public actor CaptureStore { } } - public func connection() throws -> ConnectionTarget? { - try database.statement("SELECT body FROM settings WHERE key='connection'") { statement in - let result = sqlite3_step(statement) - if result == SQLITE_DONE { return nil } - guard result == SQLITE_ROW else { throw database.error() } - return try CaptureJSON.decode(ConnectionTarget.self, from: data(statement)) - } - } - - public func saveConnection(_ connection: ConnectionTarget) throws { - try connection.validate() - try write("INSERT OR REPLACE INTO settings(key,body) VALUES ('connection',?)", - data: CaptureJSON.encode(connection)) - } - private func save(_ record: CaptureRecord) throws { let encoded = try CaptureJSON.encode(record) try database.statement("INSERT OR REPLACE INTO records(id,body) VALUES (?,?)") { statement in @@ -246,17 +239,6 @@ public actor CaptureStore { } } - private func write(_ sql: String, data: Data) throws { - try database.statement(sql) { statement in - let result = data.withUnsafeBytes { bytes in - sqlite3_bind_blob(statement, 1, bytes.baseAddress, Int32(bytes.count), transient) - } - guard result == SQLITE_OK, sqlite3_step(statement) == SQLITE_DONE else { - throw database.error() - } - } - } - private func data(_ statement: OpaquePointer) -> Data { let count = Int(sqlite3_column_bytes(statement, 0)) guard let pointer = sqlite3_column_blob(statement, 0) else { return Data() } diff --git a/Sources/CaptureCore/Models.swift b/extensions/capture/Sources/Capture/Models.swift similarity index 66% rename from Sources/CaptureCore/Models.swift rename to extensions/capture/Sources/Capture/Models.swift index 470a319..2612afa 100644 --- a/Sources/CaptureCore/Models.swift +++ b/extensions/capture/Sources/Capture/Models.swift @@ -1,23 +1,8 @@ import Foundation import ImageIO +import PeerCore import UniformTypeIdentifiers -public enum CaptureError: LocalizedError, Sendable { - case invalid(String) - case storage(String) - case connection(String) - case needsAction(String) - case outcomeUnknown - - public var errorDescription: String? { - switch self { - case .invalid(let message), .storage(let message), - .connection(let message), .needsAction(let message): message - case .outcomeUnknown: "请求已发出,结果待确认。原材料已保留,将用同一提交身份恢复。" - } - } -} - public enum CaptureEntry: String, Codable, Sendable { case selection, paste, screenshot, manual } @@ -103,75 +88,51 @@ public struct CapturePayload: Codable, Sendable { public func validate() throws { guard !materials.isEmpty || !(annotation ?? "").isEmpty else { - throw CaptureError.invalid("先写下想法,或加入一份材料。") + throw PeerError.invalid("先写下想法,或加入一份材料。") } - guard materials.count <= 128 else { throw CaptureError.invalid("材料数量超过 128 项。") } + guard materials.count <= 128 else { throw PeerError.invalid("材料数量超过 128 项。") } for (value, limit) in [(origin?.applicationID, 1024), (origin?.title, 4096), (origin?.url, 8192)] { guard (value?.unicodeScalars.count ?? 0) <= limit else { - throw CaptureError.invalid("来源信息过长,请缩短后收入。") + throw PeerError.invalid("来源信息过长,请缩短后收入。") } } let size = materials.reduce(annotation?.utf8.count ?? 0) { $0 + $1.byteCount } guard size <= 32 * 1024 * 1024 else { - throw CaptureError.invalid("材料累计超过 32 MiB,请拆分后收入。") + throw PeerError.invalid("材料累计超过 32 MiB,请拆分后收入。") } for material in materials { switch material.kind { case .text: guard let text = material.text, !text.isEmpty, material.data == nil else { - throw CaptureError.invalid("文字材料为空或格式不正确。") + throw PeerError.invalid("文字材料为空或格式不正确。") } case .image: guard let bytes = material.data, !bytes.isEmpty, - ["image/png", "image/jpeg", "image/gif", "image/webp"].contains(material.mimeType), - material.text == nil else { - throw CaptureError.invalid("图片为空或格式不支持。") + ["image/png", "image/jpeg", "image/gif", "image/webp"].contains(material.mimeType), + material.text == nil + else { + throw PeerError.invalid("图片为空或格式不支持。") } guard (material.filename?.unicodeScalars.count ?? 0) <= 512, - let source = CGImageSourceCreateWithData(bytes as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), - CGImageSourceGetStatus(source) == .statusComplete, - let identifier = CGImageSourceGetType(source) as String?, - UTType(identifier)?.preferredMIMEType == material.mimeType, - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], - let width = properties[kCGImagePropertyPixelWidth] as? Int, - let height = properties[kCGImagePropertyPixelHeight] as? Int, - width > 0, height > 0, width <= 89_478_485 / height, - CGImageSourceCreateImageAtIndex(source, 0, [kCGImageSourceShouldCache: false] as CFDictionary) != nil else { - throw CaptureError.invalid("图片内容与格式不符、文件损坏或解码尺寸过大。") + let source = CGImageSourceCreateWithData( + bytes as CFData, [kCGImageSourceShouldCache: false] as CFDictionary), + CGImageSourceGetStatus(source) == .statusComplete, + let identifier = CGImageSourceGetType(source) as String?, + UTType(identifier)?.preferredMIMEType == material.mimeType, + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + width > 0, height > 0, width <= 89_478_485 / height, + CGImageSourceCreateImageAtIndex( + source, 0, [kCGImageSourceShouldCache: false] as CFDictionary) != nil + else { + throw PeerError.invalid("图片内容与格式不符、文件损坏或解码尺寸过大。") } } } } } -public struct ConnectionTarget: Codable, Sendable, Equatable, Identifiable { - public let id: UUID - public let peerID: UUID - public var postgrestURL: URL - public var webURL: URL? - public var name: String - - public init( - id: UUID = UUID(), peerID: UUID = UUID(), postgrestURL: URL, - webURL: URL? = nil, name: String = "我的信息库" - ) { - self.id = id - self.peerID = peerID - self.postgrestURL = postgrestURL - self.webURL = webURL - self.name = name - } - - public func validate() throws { - for url in [postgrestURL, webURL].compactMap({ $0 }) { - guard ["http", "https"].contains(url.scheme), url.host != nil, - url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { - throw CaptureError.invalid("连接地址须为不含凭据、查询参数或片段的 HTTP(S) 地址。") - } - } - } -} - public struct CaptureResult: Codable, Sendable, Equatable { public enum Availability: String, Codable, Sendable { case available, deleted } public let submissionID: UUID @@ -182,7 +143,9 @@ public struct CaptureResult: Codable, Sendable, Equatable { public init(submissionID: UUID, rootBlockID: Int, committedAt: Date, availability: Availability) { self.submissionID = submissionID self.rootBlockID = rootBlockID - self.committedAt = committedAt + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + self.committedAt = formatter.date(from: formatter.string(from: committedAt))! self.availability = availability } @@ -249,7 +212,7 @@ public enum CaptureJSON { if let date = formatter.date(from: text) { return date } formatter.formatOptions = [.withInternetDateTime] guard let date = formatter.date(from: text) else { - throw CaptureError.invalid("时间格式无效。") + throw PeerError.invalid("时间格式无效。") } return date } diff --git a/extensions/capture/Sources/Capture/Resources/extension.json b/extensions/capture/Sources/Capture/Resources/extension.json new file mode 100644 index 0000000..a833863 --- /dev/null +++ b/extensions/capture/Sources/Capture/Resources/extension.json @@ -0,0 +1 @@ +{"name":"inkcre/capture","distribution":"swift_bundled","version":"0.1.0","license":"MIT AND Apache-2.0"} diff --git a/Sources/InKCreApp/BrandAssets.swift b/extensions/capture/Sources/CaptureMac/BrandAssets.swift similarity index 96% rename from Sources/InKCreApp/BrandAssets.swift rename to extensions/capture/Sources/CaptureMac/BrandAssets.swift index f7cf0ef..1a3f3c4 100644 --- a/Sources/InKCreApp/BrandAssets.swift +++ b/extensions/capture/Sources/CaptureMac/BrandAssets.swift @@ -14,7 +14,7 @@ enum BrandAssets { private static let resources: Bundle = { // SwiftPM's generated accessor searches beside the executable; a macOS app packages resources in Contents/Resources. if Bundle.main.bundleURL.pathExtension == "app" { - guard let url = Bundle.main.url(forResource: "InKCre_InKCreApp", withExtension: "bundle"), + guard let url = Bundle.main.url(forResource: "InKCreCapture_CaptureMac", withExtension: "bundle"), let bundle = Bundle(url: url) else { preconditionFailure("Missing packaged InKCre resources") } return bundle } diff --git a/Sources/InKCreApp/CaptureEditor.swift b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift similarity index 99% rename from Sources/InKCreApp/CaptureEditor.swift rename to extensions/capture/Sources/CaptureMac/CaptureEditor.swift index 7de1a14..06e8144 100644 --- a/Sources/InKCreApp/CaptureEditor.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift @@ -1,5 +1,6 @@ import AppKit -import CaptureCore +import Capture +import PeerCore import SwiftUI struct CaptureEditor: View { diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift new file mode 100644 index 0000000..ae04797 --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -0,0 +1,369 @@ +import AppKit +import ApplicationServices +import Capture +import PeerCore +import SwiftUI + +@MainActor +final class CapturePanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + // The configurable panel shortcut handles dismissal, including an intentionally disabled Esc. + override func cancelOperation(_ sender: Any?) {} +} + +@MainActor +public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate, NSUserInterfaceValidations { + public let descriptor = CaptureClient.descriptor + private let context: PeerHostContext + public init(context: PeerHostContext) { + self.context = context + super.init() + } + private var commandItem: NSMenuItem? + private var statusItem: NSStatusItem? + private var panel: CapturePanel? + private var mainWindow: PeerMainWindow? + private var model: CaptureModel? + private let regionCapture = RegionCapture() + private var selectionTask: Task? + private var stopping = false + private var previousApplication: NSRunningApplication? + + private var canChangeCaptureContext: Bool { + !stopping && model != nil && !regionCapture.isCapturing && NSApp.modalWindow == nil + && !NSApp.windows.contains(where: { $0.attachedSheet != nil }) + } + + public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + canChangeCaptureContext && model?.submitting != true + } + + public func start() throws { + stopping = false + if let model { + try model.shortcuts.start() + configureMenu() + model.start(restore: false) + showMain() + return + } + do { + let model = try CaptureModel( + directory: context.directory.appendingPathComponent("extensions/inkcre-capture"), + identity: context.identity, connections: context.connections) + self.model = model + let mainWindow = PeerMainWindow(model: model) + mainWindow.delegate = self + self.mainWindow = mainWindow + let panel = CapturePanel( + contentRect: NSRect(x: 0, y: 0, width: 570, height: 640), + styleMask: [.titled, .closable, .resizable, .nonactivatingPanel], + backing: .buffered, defer: false + ) + panel.title = "随手收入 InKCre" + panel.minSize = NSSize(width: 500, height: 450) + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.isReleasedWhenClosed = false + panel.hidesOnDeactivate = false + panel.delegate = self + panel.contentView = NSHostingView(rootView: QuickCaptureView(model: model)) + panel.center() + self.panel = panel + model.presentEditor = { [weak self] in self?.showCaptureSurface() } + model.dismissQuickCapture = { [weak self] in self?.hide() } + model.showMain = { [weak self] in self?.showMain() } + model.showQuick = { [weak self] in self?.showQuick() } + model.takeScreenshot = { [weak self] in self?.screenshot() } + model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in + window === panel || window === mainWindow + } + model.shortcuts.perform = { [weak self, weak model] action in + guard let self, let model, self.canChangeCaptureContext else { return } + switch action { + case .capture: self.showCapture() + case .screenshot: self.quickScreenshot() + case .paste: + if model.editorOwner == .quick || model.pane == .capture { model.pasteMaterial() } + case .submit: if model.editorOwner == .quick || model.pane == .capture { model.submit() } + case .hide: if NSApp.keyWindow === self.panel { self.hide() } + } + } + try model.shortcuts.start() + if let message = model.shortcuts.message { model.error = message } + configureMenu() + model.start() + showMain() + } catch { + model?.cancelWork() + model?.shortcuts.stop() + panel?.orderOut(nil) + mainWindow?.orderOut(nil) + throw error + } + } + + private func configureMenu() { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = BrandAssets.menuIcon + let menu = NSMenu() + for (title, selector) in [ + ("显示主窗口", #selector(showMain)), + ("随手收集", #selector(showCapture)), + ("写下想法", #selector(quickThought)), + ("粘贴材料", #selector(quickPaste)), + ("框选截图", #selector(quickScreenshot)), + ("采集记录", #selector(showQueue)), + ("Peer 与连接", #selector(showSettings)), + ] { + let command = NSMenuItem(title: title, action: selector, keyEquivalent: "") + command.target = self + menu.addItem(command) + } + menu.addItem(.separator()) + menu.addItem( + withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + item.menu = menu + statusItem = item + + let commands = NSMenuItem(title: "采集", action: nil, keyEquivalent: "") + let capture = NSMenu(title: "采集") + for (title, action, key) in [ + ("新想法", #selector(newThought), "n"), ("快捷采集", #selector(showQuick), ""), + ("采集记录", #selector(showQueue), ""), ("采集设置", #selector(showSettings), ""), + ] { + capture.addItem(withTitle: title, action: action, keyEquivalent: key).target = self + } + commands.submenu = capture + NSApp.mainMenu?.insertItem(commands, at: 1) + commandItem = commands + } + + @objc private func showCapture() { + guard canChangeCaptureContext, let model, !model.submitting else { return } + prepareQuickCapture() + let request = UUID() + model.selectionRequest = request + guard let application = NSWorkspace.shared.frontmostApplication, + application.processIdentifier != ProcessInfo.processInfo.processIdentifier + else { + showQuick() + return + } + previousApplication = application + guard AXIsProcessTrusted() else { + model.newThought(notice: "尚未允许辅助功能访问。可直接写下想法、明确粘贴,或在设置中开启选区采集。") + return + } + let pid = application.processIdentifier + let bundleID = application.bundleIdentifier + selectionTask?.cancel() + selectionTask = Task { + let snapshot = await Task.detached { SelectedText.read(pid: pid, applicationID: bundleID) }.value + guard model.selectionRequest == request, + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + else { return } + var text = snapshot.text + if text == nil, !snapshot.secure { + text = await SelectedText.copyFallback(pid: pid, snapshot: snapshot) { + model.selectionRequest == request + && NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + } + } + guard model.selectionRequest == request, + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + else { return } + if let text { + guard + await Task.detached(operation: { SelectedText.isCurrent(pid: pid, snapshot: snapshot) }) + .value, + model.selectionRequest == request, + NSWorkspace.shared.frontmostApplication?.processIdentifier == pid + else { return } + await model.receive( + CapturePayload( + entry: .selection, origin: snapshot.origin, materials: [Material(text: text)]), + request: request + ) + } else { + let notice = + snapshot.secure + ? "密码或安全输入区域不会被采集。" + : "未取得可靠选区。可以明确复制后粘贴、框选截图,或直接写想法。" + model.newThought(notice: notice) + } + } + } + @objc private func newThought() { + guard canChangeCaptureContext else { return } + showMain() + model?.newThought() + } + @objc private func quickThought() { + guard canChangeCaptureContext else { return } + prepareQuickCapture() + model?.newThought() + } + @objc private func quickPaste() { + guard canChangeCaptureContext else { return } + prepareQuickCapture() + model?.pasteMaterial() + } + @objc private func quickScreenshot() { + guard canChangeCaptureContext else { return } + prepareQuickCapture() + screenshot() + } + @objc private func showQueue() { + guard canChangeCaptureContext else { return } + model?.pane = .queue + showMain() + } + @objc private func showSettings() { + guard canChangeCaptureContext else { return } + model?.openSettings() + } + @objc private func screenshot() { + guard canChangeCaptureContext, let model, !model.submitting else { return } + model.selectionRequest = UUID() + let request = model.selectionRequest + let owner = model.editorOwner + let restoreMain = mainWindow?.isVisible == true + model.screenCaptureNotice = nil + panel?.orderOut(nil) + mainWindow?.orderOut(nil) + regionCapture.begin { [weak self] result in + guard let self else { return } + model.editorOwner = owner + if restoreMain { self.mainWindow?.orderBack(nil) } + switch result { + case .success(let data): + guard let data else { + self.showCaptureSurface() + return + } + model.perform { + await model.receive( + CapturePayload( + entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]), + request: request + ) + } + case .failure(let error): + if error is ScreenRecordingPermissionError { + model.screenCaptureNotice = error.localizedDescription + if owner == .main { model.pane = .capture } + } else { + model.error = error.localizedDescription + } + self.showCaptureSurface() + } + } + } + + private func prepareQuickCapture() { + if let current = NSWorkspace.shared.frontmostApplication, + current.processIdentifier != ProcessInfo.processInfo.processIdentifier + { + previousApplication = current + } + model?.shortcuts.cancelRecording() + mainWindow?.makeFirstResponder(nil) + model?.editorOwner = .quick + } + + @objc private func showQuick() { + guard canChangeCaptureContext else { return } + prepareQuickCapture() + NSApp.activate(ignoringOtherApps: true) + panel?.makeKeyAndOrderFront(nil) + } + + @objc public func showMain() { + guard !regionCapture.isCapturing else { return } + if mainWindow?.attachedSheet != nil || NSApp.modalWindow != nil { + NSApp.activate(ignoringOtherApps: true) + mainWindow?.makeKeyAndOrderFront(nil) + return + } + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + if mainWindow?.isMiniaturized == true { mainWindow?.deminiaturize(nil) } + NSApp.activate(ignoringOtherApps: true) + mainWindow?.makeKeyAndOrderFront(nil) + } + + private func showCaptureSurface() { + if model?.editorOwner == .quick { showQuick() } else { showMain() } + } + + private func hide() { + model?.shortcuts.cancelRecording() + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + model?.saveSoon() + if NSWorkspace.shared.frontmostApplication?.processIdentifier + == ProcessInfo.processInfo.processIdentifier + { + previousApplication?.activate() + } + } + + public func windowShouldClose(_ sender: NSWindow) -> Bool { + if sender === panel { + hide() + return false + } + sender.makeFirstResponder(nil) + model?.saveSoon() + return true + } + + public func windowDidBecomeKey(_ notification: Notification) { + if notification.object as? NSWindow === mainWindow, panel?.isVisible == true { + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + } + } + + public func setEnabled(_ enabled: Bool) async throws { + guard let target = try await context.connections.load() else { return } + let password = try context.credentials.read(for: target.id) + do { + try await PeerDatabase.shared.setEnabled( + enabled, target: target, password: password, extension: descriptor) + } catch { + if case PeerError.connection = PeerDatabase.failure(error) { + throw PeerError.connection("离线:本机开关生效,共享意图未更新。重连以信息库选择为准。") + } + throw PeerDatabase.failure(error) + } + } + + public func stop() async throws { + guard let model else { return } + NSApp.keyWindow?.makeFirstResponder(nil) + stopping = true + model.cancelWork() + model.shortcuts.stop() + selectionTask?.cancel() + await regionCapture.stop() + await selectionTask?.value + selectionTask = nil + await model.stop() + panel?.orderOut(nil) + mainWindow?.orderOut(nil) + if let statusItem { NSStatusBar.system.removeStatusItem(statusItem) } + statusItem = nil + if let commandItem { NSApp.mainMenu?.removeItem(commandItem) } + commandItem = nil + try await model.flush() + panel = nil + mainWindow = nil + self.model = nil + } +} diff --git a/Sources/InKCreApp/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift similarity index 76% rename from Sources/InKCreApp/CaptureModel.swift rename to extensions/capture/Sources/CaptureMac/CaptureModel.swift index 3da02d8..c1de037 100644 --- a/Sources/InKCreApp/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -1,7 +1,8 @@ import AppKit -import CaptureCore +import Capture import Combine import Foundation +import PeerCore @MainActor final class CaptureModel: ObservableObject { @@ -64,6 +65,7 @@ final class CaptureModel: ObservableObject { var showQuick: (() -> Void)? let store: CaptureStore let credentials: CredentialStore + let connections: ConnectionSettings private let queue: CaptureQueue private var saveTask: Task? private var heartbeat: Task? @@ -71,8 +73,12 @@ final class CaptureModel: ObservableObject { private var connectionRevision = UUID() private var submissionStatusID: UUID? private var editRevision = 0 + private var actions: [UUID: Task] = [:] + private var stopping = false - init(directory: URL, identity: String) throws { + init(directory: URL, identity: String, connections: ConnectionSettings? = nil) throws { + self.connections = + connections ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) shortcuts = try GlobalShortcuts() store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) credentials = CredentialStore(service: "\(identity).connections") @@ -90,8 +96,11 @@ final class CaptureModel: ObservableObject { set { guard var payload = draft.payload else { return } if let index = payload.materials.firstIndex(where: { $0.kind == .text }) { - if newValue.isEmpty { payload.materials.remove(at: index) } - else { payload.materials[index].text = newValue } + if newValue.isEmpty { + payload.materials.remove(at: index) + } else { + payload.materials[index].text = newValue + } } else if !newValue.isEmpty { payload.materials.insert(Material(text: newValue), at: 0) } @@ -101,20 +110,27 @@ final class CaptureModel: ObservableObject { } var annotation: String { get { draft.payload?.annotation ?? "" } - set { draft.payload?.annotation = newValue.isEmpty ? nil : newValue; saveSoon() } + set { + draft.payload?.annotation = newValue.isEmpty ? nil : newValue + saveSoon() + } } - func start() { + func start(restore: Bool = true) { + stopping = false submitting = true heartbeat = Task { - do { - connection = try await store.connection() - try await refresh() - if let latest = records.first(where: { $0.state == .draft }) { - draft = latest - saveStatus = "已恢复本机草稿" - } - } catch { self.error = error.localizedDescription } + await queue.resume() + if restore { + do { + connection = try await connections.load() + try await refresh() + if let latest = records.first(where: { $0.state == .draft }) { + draft = latest + saveStatus = "已恢复本机草稿" + } + } catch { self.error = error.localizedDescription } + } submitting = false var cycle = 0 while !Task.isCancelled { @@ -122,7 +138,7 @@ final class CaptureModel: ObservableObject { if cycle % 4 == 0, !checkingConnection, let connection { let revision = connectionRevision do { - try await PeerClient().checkConnection( + try await CaptureClient().checkConnection( connection, secret: credentials.read(for: connection.id) ) if connectionRevision == revision && !checkingConnection { @@ -141,9 +157,36 @@ final class CaptureModel: ObservableObject { } } - func stop() { heartbeat?.cancel(); saveTask?.cancel(); connectionCheck?.cancel() } + func cancelWork() { + stopping = true + selectionRequest = UUID() + heartbeat?.cancel() + saveTask?.cancel() + connectionCheck?.cancel() + for task in actions.values { task.cancel() } + } + + func stop() async { + cancelWork() + await queue.stop() + for task in Array(actions.values) { await task.value } + await heartbeat?.value + await connectionCheck?.value + await saveTask?.value + await queue.stop() + } + + func perform(_ operation: @escaping @MainActor () async -> Void) { + guard !stopping else { return } + let id = UUID() + actions[id] = Task { + await operation() + actions.removeValue(forKey: id) + } + } func saveSoon() { + guard !stopping else { return } submissionStatusID = nil editRevision += 1 saveTask?.cancel() @@ -176,8 +219,7 @@ final class CaptureModel: ObservableObject { let value = draft let revision = editRevision saveStatus = "正在保留草稿…" - do { try await store.saveDraft(value) } - catch { + do { try await store.saveDraft(value) } catch { if draft.id == value.id, revision == editRevision { saveStatus = "尚未保留,请勿退出" } throw error } @@ -189,7 +231,7 @@ final class CaptureModel: ObservableObject { guard !submitting else { return } submitting = true selectionRequest = UUID() - Task { + perform { [self] in defer { submitting = false } do { saveTask?.cancel() @@ -207,11 +249,11 @@ final class CaptureModel: ObservableObject { guard !submitting else { return } submitting = true selectionRequest = UUID() - Task { + perform { [self] in defer { submitting = false } do { let materials = try ClipboardReader.materials() - guard !materials.isEmpty else { throw CaptureError.invalid("剪贴板没有可用的文字、链接或图片。") } + guard !materials.isEmpty else { throw PeerError.invalid("剪贴板没有可用的文字、链接或图片。") } saveTask?.cancel() try await flush() draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: materials)) @@ -219,7 +261,10 @@ final class CaptureModel: ObservableObject { captureNotice = nil try await flush() revealEditor() - } catch { self.error = error.localizedDescription; presentEditor?() } + } catch { + self.error = error.localizedDescription + presentEditor?() + } } } @@ -242,17 +287,24 @@ final class CaptureModel: ObservableObject { captureNotice = notice try await flush() revealEditor() - } catch { self.error = error.localizedDescription; presentEditor?() } + } catch { + self.error = error.localizedDescription + presentEditor?() + } } func submit() { guard !submitting, hasContent else { return } - guard let connection else { openSettings(); showingConnectionEditor = true; return } + guard let connection else { + openSettings() + showingConnectionEditor = true + return + } // An input method owns Return until its marked text is committed. if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.hasMarkedText() { return } submitting = true saveTask?.cancel() - Task { + perform { [self] in do { let frozen = try await store.freeze(draft, target: connection) draft = CaptureRecord() @@ -261,7 +313,10 @@ final class CaptureModel: ObservableObject { try await refresh() submitting = false await sendPending(force: true) - } catch { submitting = false; self.error = error.localizedDescription } + } catch { + submitting = false + self.error = error.localizedDescription + } } } @@ -269,8 +324,7 @@ final class CaptureModel: ObservableObject { do { try await queue.process(force: force) try await refresh() - } - catch { self.error = error.localizedDescription } + } catch { self.error = error.localizedDescription } } func refresh() async throws { @@ -284,14 +338,15 @@ final class CaptureModel: ObservableObject { guard record.state == .draft, !submitting else { return } submitting = true selectionRequest = UUID() - Task { + perform { [self] in defer { submitting = false } do { saveTask?.cancel() try await flush() guard let current = try await store.record(record.id), - current.state == .draft else { - throw CaptureError.invalid("这份草稿已变更,请刷新记录后再打开。") + current.state == .draft + else { + throw PeerError.invalid("这份草稿已变更,请刷新记录后再打开。") } draft = current submissionStatusID = nil @@ -302,15 +357,18 @@ final class CaptureModel: ObservableObject { } func retry(_ record: CaptureRecord) { - Task { - do { try await store.retry(record.id); await sendPending(force: true) } - catch { self.error = error.localizedDescription } + perform { [self] in + do { + try await store.retry(record.id) + await sendPending(force: true) + } catch { self.error = error.localizedDescription } } } func requestRemoveMaterial(_ id: UUID) { guard !submitting, let materials = draft.payload?.materials, - materials.contains(where: { $0.id == id }) else { return } + materials.contains(where: { $0.id == id }) + else { return } removalRequest = .material(draftID: draft.id, materialID: id) } @@ -338,9 +396,11 @@ final class CaptureModel: ObservableObject { func cancelCapture(close: Bool = true) { requestDiscard(draft, close: close) } func cancel(_ record: CaptureRecord) { - Task { - do { try await store.cancel(record.id); try await refresh() } - catch { self.error = error.localizedDescription } + perform { [self] in + do { + try await store.cancel(record.id) + try await refresh() + } catch { self.error = error.localizedDescription } } } @@ -349,7 +409,7 @@ final class CaptureModel: ObservableObject { submitting = true selectionRequest = UUID() saveTask?.cancel() - Task { + perform { [self] in defer { submitting = false } do { if draft.id != record.id { try await flush() } @@ -384,46 +444,35 @@ final class CaptureModel: ObservableObject { } func requestScreenRecordingAccess() { - if CGRequestScreenCaptureAccess() { screenCaptureNotice = nil } - else { screenCaptureNotice = "授权后请完全退出并重新打开 InKCre。若系统开关已开启,也请检查当前应用是否需要重新授权。" } + if CGRequestScreenCaptureAccess() { + screenCaptureNotice = nil + } else { + screenCaptureNotice = "授权后请完全退出并重新打开 InKCre。若系统开关已开启,也请检查当前应用是否需要重新授权。" + } } func openScreenRecordingSettings() { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") + { NSWorkspace.shared.open(url) } } - func saveConnection(server: String, web: String, secret: String) async throws { - guard let address = URL(string: server.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw CaptureError.invalid("请输入信息库 PostgREST 地址。") - } - let web = web.trimmingCharacters(in: .whitespacesAndNewlines) - let webAddress = web.isEmpty ? nil : URL(string: web) - if !web.isEmpty && webAddress == nil { throw CaptureError.invalid("Web 地址无效。") } - let previous = editingConnection - let sameTarget = previous?.postgrestURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - == address.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let target = ConnectionTarget( - id: sameTarget ? previous!.id : UUID(), peerID: sameTarget ? previous!.peerID : UUID(), - postgrestURL: address, webURL: webAddress, name: address.host ?? "我的信息库" - ) - try target.validate() - if !secret.isEmpty { try credentials.save(secret, for: target.id) } - let savedSecret = try credentials.read(for: target.id) - try await store.saveConnection(target) + func connectionSaved(_ target: ConnectionTarget) { connection = target editingConnection = target connectionRevision = UUID() lastContactAt = nil peerConnectionIssue = nil - checkConnection(target, secret: savedSecret) + checkConnection() } func checkConnection() { guard let target = connection else { return } - do { checkConnection(target, secret: try credentials.read(for: target.id)) } - catch { peerConnectionIssue = error.localizedDescription } + do { checkConnection(target, secret: try credentials.read(for: target.id)) } catch { + peerConnectionIssue = error.localizedDescription + } } private func checkConnection(_ target: ConnectionTarget, secret: String) { @@ -433,7 +482,7 @@ final class CaptureModel: ObservableObject { connectionCheck = Task { defer { if !Task.isCancelled && connectionRevision == revision { checkingConnection = false } } do { - try await PeerClient().checkConnection(target, secret: secret) + try await CaptureClient().checkConnection(target, secret: secret) guard !Task.isCancelled, connectionRevision == revision else { return } lastContactAt = Date() peerConnectionIssue = nil @@ -446,11 +495,14 @@ final class CaptureModel: ObservableObject { func openResult(_ record: CaptureRecord) { guard let result = record.result, result.availability == .available, - var url = record.target?.webURL else { + var url = record.target?.webURL + else { error = "未配置 Web 地址;提交引用仍保留在此。" return } - for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { url.appendPathComponent(part) } + for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { + url.appendPathComponent(part) + } NSWorkspace.shared.open(url) } @@ -459,8 +511,9 @@ final class CaptureModel: ObservableObject { let panel = NSSavePanel() panel.nameFieldStringValue = "capture-\(record.id.uuidString).json" if panel.runModal() == .OK, let url = panel.url { - do { try CaptureJSON.encode(record).write(to: url, options: .atomic) } - catch { self.error = error.localizedDescription } + do { try CaptureJSON.encode(record).write(to: url, options: .atomic) } catch { + self.error = error.localizedDescription + } } } } diff --git a/Sources/InKCreApp/CaptureRecordsView.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift similarity index 99% rename from Sources/InKCreApp/CaptureRecordsView.swift rename to extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift index 4f81d71..d8c9308 100644 --- a/Sources/InKCreApp/CaptureRecordsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift @@ -1,5 +1,6 @@ import AppKit -import CaptureCore +import Capture +import PeerCore import SwiftUI struct CaptureRecordsView: View { diff --git a/Sources/InKCreApp/ClipboardReader.swift b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift similarity index 91% rename from Sources/InKCreApp/ClipboardReader.swift rename to extensions/capture/Sources/CaptureMac/ClipboardReader.swift index 699207d..8b37c0e 100644 --- a/Sources/InKCreApp/ClipboardReader.swift +++ b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift @@ -1,5 +1,6 @@ import AppKit -import CaptureCore +import Capture +import PeerCore import UniformTypeIdentifiers @MainActor @@ -26,7 +27,7 @@ enum ClipboardReader { continue } if item.types.contains(.fileURL) { - throw CaptureError.invalid("首版不导入文件。请粘贴文字、链接或图片本身。") + throw PeerError.invalid("首版不导入文件。请粘贴文字、链接或图片本身。") } if let text = item.string(forType: .string) ?? item.string(forType: .URL), !text.isEmpty { result.append(Material(text: text)) diff --git a/Sources/InKCreApp/ConnectionView.swift b/extensions/capture/Sources/CaptureMac/ConnectionView.swift similarity index 91% rename from Sources/InKCreApp/ConnectionView.swift rename to extensions/capture/Sources/CaptureMac/ConnectionView.swift index b0dc228..b35caa9 100644 --- a/Sources/InKCreApp/ConnectionView.swift +++ b/extensions/capture/Sources/CaptureMac/ConnectionView.swift @@ -1,4 +1,5 @@ import AppKit +import PeerMacUI import SwiftUI @preconcurrency import ApplicationServices @@ -29,7 +30,8 @@ struct ConnectionView: View { } Section("连接共享信息库") { if let target = model.connection { - LabeledContent("PostgREST 地址") { Text(target.postgrestURL.absoluteString).textSelection(.enabled) } + LabeledContent("PostgreSQL") { Text("\(target.host):\(target.port)/\(target.database)").textSelection(.enabled) } + LabeledContent("运行角色", value: target.username) if let web = target.webURL { LabeledContent("Web 地址") { Text(web.absoluteString).textSelection(.enabled) } } @@ -67,7 +69,7 @@ struct ConnectionView: View { } .formStyle(.grouped) .sheet(isPresented: $model.showingConnectionEditor) { - ConnectionEditor(model: model, target: model.editingConnection) + PeerConnectionEditor(settings: model.connections, credentials: model.credentials, target: model.editingConnection, onSaved: model.connectionSaved) } .onDisappear { model.shortcuts.cancelRecording() } .onAppear { refreshPermissions() } diff --git a/Sources/InKCreApp/GlobalShortcuts.swift b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift similarity index 87% rename from Sources/InKCreApp/GlobalShortcuts.swift rename to extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift index c4d5489..9b25632 100644 --- a/Sources/InKCreApp/GlobalShortcuts.swift +++ b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift @@ -1,6 +1,7 @@ import AppKit import Carbon.HIToolbox -import CaptureCore +import Capture +import PeerCore import Combine @MainActor @@ -23,16 +24,7 @@ final class GlobalShortcuts: ObservableObject { bindings = try JSONDecoder().decode([ShortcutAction: RecordedShortcut].self, from: data) } else { bindings = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) - // Preserve an existing preset, including deliberate disabling, on first upgrade. - switch defaults.string(forKey: "shortcutPreset") { - case "commandShift": - for action in ShortcutAction.allCases where action.isGlobal { - let old = action.defaultShortcut - bindings[action] = RecordedShortcut(keyCode: old.keyCode, modifiers: [.command, .shift], keyLabel: old.keyLabel) - } - case "disabled": bindings[.capture] = nil; bindings[.screenshot] = nil - default: break - } + } for (action, shortcut) in bindings { try validate(shortcut, for: action) } } @@ -52,7 +44,7 @@ final class GlobalShortcuts: ObservableObject { MainActor.assumeIsolated { if owner.recording == nil { owner.perform?(action) } } return noErr }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) - guard status == noErr else { throw CaptureError.needsAction("无法启用快捷键事件,请重新打开 InKCre。") } + guard status == noErr else { throw PeerError.needsAction("无法启用快捷键事件,请重新打开 InKCre。") } monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in let consumed = MainActor.assumeIsolated { guard let self else { return false } @@ -136,7 +128,7 @@ final class GlobalShortcuts: ObservableObject { private func validate(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { try shortcut.validate(for: action) if let other = ShortcutAction.allCases.first(where: { $0 != action && bindings[$0] == shortcut }) { - throw CaptureError.invalid("\(shortcut.label) 已用于“\(other.title)”,请换一个组合。原设置未更改。") + throw PeerError.invalid("\(shortcut.label) 已用于“\(other.title)”,请换一个组合。原设置未更改。") } } @@ -146,7 +138,7 @@ final class GlobalShortcuts: ObservableObject { EventHotKeyID(signature: 0x494e4b43, id: action.rawValue), GetApplicationEventTarget(), 0, &reference) guard status == noErr, let reference else { - throw CaptureError.needsAction("“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") + throw PeerError.needsAction("“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") } // Register the replacement first; failure must not remove the working shortcut. if let old = keys.updateValue(reference, forKey: action) { UnregisterEventHotKey(old) } diff --git a/Sources/InKCreApp/KeyboardShortcut.swift b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift similarity index 96% rename from Sources/InKCreApp/KeyboardShortcut.swift rename to extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift index 825fa9f..f565189 100644 --- a/Sources/InKCreApp/KeyboardShortcut.swift +++ b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift @@ -1,6 +1,7 @@ import AppKit import Carbon.HIToolbox -import CaptureCore +import Capture +import PeerCore enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable { case capture = 1, screenshot, paste, submit, hide @@ -68,7 +69,7 @@ struct RecordedShortcut: Codable, Equatable, Sendable { guard keyCode <= 127, !keyLabel.isEmpty, modifierBits & ~Self.allowedModifiers.rawValue == 0, plainEscape || !modifiers.intersection([.command, .control, .option]).isEmpty else { - throw CaptureError.invalid("请同时按住 ⌘、⌃ 或 ⌥,避免影响正常输入。") + throw PeerError.invalid("请同时按住 ⌘、⌃ 或 ⌥,避免影响正常输入。") } } } diff --git a/Sources/InKCreApp/PeerMainView.swift b/extensions/capture/Sources/CaptureMac/PeerMainView.swift similarity index 100% rename from Sources/InKCreApp/PeerMainView.swift rename to extensions/capture/Sources/CaptureMac/PeerMainView.swift diff --git a/Sources/InKCreApp/PeerMainWindow.swift b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift similarity index 100% rename from Sources/InKCreApp/PeerMainWindow.swift rename to extensions/capture/Sources/CaptureMac/PeerMainWindow.swift diff --git a/Sources/InKCreApp/RegionCapture.swift b/extensions/capture/Sources/CaptureMac/RegionCapture.swift similarity index 72% rename from Sources/InKCreApp/RegionCapture.swift rename to extensions/capture/Sources/CaptureMac/RegionCapture.swift index b0206e8..c44bac8 100644 --- a/Sources/InKCreApp/RegionCapture.swift +++ b/extensions/capture/Sources/CaptureMac/RegionCapture.swift @@ -1,5 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// References and adapts Cida by Xuanwo and contributors. Modified for InKCre persistent capture. +// Source revision and complete license: Legal/Cida/. import AppKit -import CaptureCore +import Capture +import PeerCore import ScreenCaptureKit struct ScreenRecordingPermissionError: LocalizedError { @@ -10,6 +14,7 @@ struct ScreenRecordingPermissionError: LocalizedError { @MainActor final class RegionCapture { + private var captureTask: Task? private var windows: [NSWindow] = [] private var completion: ((Result) -> Void)? private let hasPermission: () -> Bool @@ -28,13 +33,14 @@ final class RegionCapture { } self.completion = completion guard !NSScreen.screens.isEmpty else { - finish(.failure(CaptureError.invalid("没有可用的屏幕,请重新截图。"))) + finish(.failure(PeerError.invalid("没有可用的屏幕,请重新截图。"))) return } NSApp.activate(ignoringOtherApps: true) for screen in NSScreen.screens { - let window = RegionWindow(contentRect: screen.frame, styleMask: .borderless, - backing: .buffered, defer: false) + let window = RegionWindow( + contentRect: screen.frame, styleMask: .borderless, + backing: .buffered, defer: false) window.level = .screenSaver window.isOpaque = false window.backgroundColor = .clear @@ -45,13 +51,14 @@ final class RegionCapture { let overlay = RegionOverlay(frame: NSRect(origin: .zero, size: screen.frame.size)) overlay.didBeginSelection = { [weak self, weak overlay] in for window in self?.windows ?? [] { - if let other = window.contentView as? RegionOverlay, other !== overlay { other.resetSelection() } + if let other = window.contentView as? RegionOverlay, other !== overlay { + other.resetSelection() + } } } overlay.complete = { [weak self] rect in guard let self, !self.windows.isEmpty else { return } - if let rect { self.capture(screen: screen, rect: rect) } - else { self.finish(.success(nil)) } + if let rect { self.capture(screen: screen, rect: rect) } else { self.finish(.success(nil)) } } window.contentView = overlay window.makeFirstResponder(overlay) @@ -62,6 +69,14 @@ final class RegionCapture { NSCursor.crosshair.push() } + func stop() async { + completion = nil + if !windows.isEmpty { hide() } + captureTask?.cancel() + await captureTask?.value + captureTask = nil + } + private func hide() { for window in windows { window.orderOut(nil) } windows.removeAll() @@ -77,17 +92,26 @@ final class RegionCapture { private func capture(screen: NSScreen, rect: CGRect) { hide() - Task { + captureTask = Task { do { - guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { - throw CaptureError.invalid("无法识别所选屏幕。") + guard + let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber + else { + throw PeerError.invalid("无法识别所选屏幕。") + } + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: true) + guard !Task.isCancelled, completion != nil else { return } + guard let display = content.displays.first(where: { $0.displayID == number.uint32Value }) + else { + throw PeerError.invalid("所选屏幕已断开,请重新截图。") } - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - guard let display = content.displays.first(where: { $0.displayID == number.uint32Value }) else { - throw CaptureError.invalid("所选屏幕已断开,请重新截图。") + let own = content.applications.filter { + $0.processID == ProcessInfo.processInfo.processIdentifier } - let own = content.applications.filter { $0.processID == ProcessInfo.processInfo.processIdentifier } - let filter = SCContentFilter(display: display, excludingApplications: own, exceptingWindows: []) + let filter = SCContentFilter( + display: display, excludingApplications: own, exceptingWindows: []) let configuration = SCStreamConfiguration() // Overlay coordinates are local, top-left points; SCK maps them to this display. configuration.sourceRect = rect @@ -95,17 +119,26 @@ final class RegionCapture { configuration.height = max(1, Int((rect.height * CGFloat(filter.pointPixelScale)).rounded())) configuration.showsCursor = false configuration.captureResolution = .best - let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) - guard let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else { - throw CaptureError.invalid("截图编码失败,请重试。") + let image = try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: configuration) + guard !Task.isCancelled, completion != nil else { return } + guard let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) + else { + throw PeerError.invalid("截图编码失败,请重试。") } - try CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]).validate() + try CapturePayload( + entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")] + ).validate() finish(.success(data)) } catch { let failure = error as NSError - if failure.domain == SCStreamErrorDomain, failure.code == SCStreamError.Code.userDeclined.rawValue { + if failure.domain == SCStreamErrorDomain, + failure.code == SCStreamError.Code.userDeclined.rawValue + { finish(.failure(ScreenRecordingPermissionError())) - } else { finish(.failure(error)) } + } else { + finish(.failure(error)) + } } } } @@ -135,7 +168,10 @@ final class RegionOverlay: NSView { wantsLayer = true hint.textColor = .white hint.font = .systemFont(ofSize: 15, weight: .medium) - for (button, action) in [(accept, #selector(confirmSelection)), (reset, #selector(resetSelection)), (cancel, #selector(cancelSelection))] { + for (button, action) in [ + (accept, #selector(confirmSelection)), (reset, #selector(resetSelection)), + (cancel, #selector(cancelSelection)), + ] { button.target = self button.action = action button.bezelStyle = .rounded @@ -173,8 +209,10 @@ final class RegionOverlay: NSView { NSColor.white.setStroke() border.lineWidth = 2 border.stroke() - for point in [CGPoint(x: selection.minX, y: selection.minY), CGPoint(x: selection.maxX, y: selection.minY), - CGPoint(x: selection.minX, y: selection.maxY), CGPoint(x: selection.maxX, y: selection.maxY)] { + for point in [ + CGPoint(x: selection.minX, y: selection.minY), CGPoint(x: selection.maxX, y: selection.minY), + CGPoint(x: selection.minX, y: selection.maxY), CGPoint(x: selection.maxX, y: selection.maxY), + ] { let handle = NSBezierPath(rect: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)) NSColor.white.setFill() handle.fill() @@ -184,7 +222,9 @@ final class RegionOverlay: NSView { } } NSColor.black.withAlphaComponent(0.8).setFill() - NSBezierPath(roundedRect: CGRect(x: 12, y: 12, width: bounds.width - 24, height: 50), xRadius: 10, yRadius: 10).fill() + NSBezierPath( + roundedRect: CGRect(x: 12, y: 12, width: bounds.width - 24, height: 50), xRadius: 10, yRadius: 10 + ).fill() } override func mouseDown(with event: NSEvent) { @@ -201,8 +241,10 @@ final class RegionOverlay: NSView { override func mouseDragged(with event: NSEvent) { guard let start else { return } let end = convert(event.locationInWindow, from: nil) - selection = CGRect(x: min(start.x, end.x), y: min(start.y, end.y), - width: abs(start.x - end.x), height: abs(start.y - end.y)).intersection(bounds) + selection = CGRect( + x: min(start.x, end.x), y: min(start.y, end.y), + width: abs(start.x - end.x), height: abs(start.y - end.y) + ).intersection(bounds) hint.stringValue = "已选 \(Int(selection.width)) × \(Int(selection.height)) · 松手后确认截取" display() } @@ -212,7 +254,9 @@ final class RegionOverlay: NSView { let valid = selection.width >= 2 && selection.height >= 2 accept.isEnabled = valid reset.isEnabled = !selection.isEmpty - hint.stringValue = valid ? "已选 \(Int(selection.width)) × \(Int(selection.height)) · 点击截取或按回车" + hint.stringValue = + valid + ? "已选 \(Int(selection.width)) × \(Int(selection.height)) · 点击截取或按回车" : "请拖动框选区域 · Esc 或右键取消" } override func keyDown(with event: NSEvent) { diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png diff --git a/Sources/InKCreApp/Resources/Brand/AppIcon.svg b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.svg similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIcon.svg rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.svg diff --git a/Sources/InKCreApp/Resources/Brand/AppIconSmall.svg b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIconSmall.svg similarity index 100% rename from Sources/InKCreApp/Resources/Brand/AppIconSmall.svg rename to extensions/capture/Sources/CaptureMac/Resources/Brand/AppIconSmall.svg diff --git a/Sources/InKCreApp/Resources/Brand/MarkDark.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/MarkDark.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png diff --git a/Sources/InKCreApp/Resources/Brand/MarkLight.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/MarkLight.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/MarkLight.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/MarkLight.png diff --git a/Sources/InKCreApp/Resources/Brand/MenuTemplate.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/MenuTemplate.png similarity index 100% rename from Sources/InKCreApp/Resources/Brand/MenuTemplate.png rename to extensions/capture/Sources/CaptureMac/Resources/Brand/MenuTemplate.png diff --git a/Sources/InKCreApp/Resources/Brand/PROVENANCE.json b/extensions/capture/Sources/CaptureMac/Resources/Brand/PROVENANCE.json similarity index 100% rename from Sources/InKCreApp/Resources/Brand/PROVENANCE.json rename to extensions/capture/Sources/CaptureMac/Resources/Brand/PROVENANCE.json diff --git a/Sources/InKCreApp/Resources/Brand/README.md b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md similarity index 100% rename from Sources/InKCreApp/Resources/Brand/README.md rename to extensions/capture/Sources/CaptureMac/Resources/Brand/README.md diff --git a/Sources/InKCreApp/SelectedText.swift b/extensions/capture/Sources/CaptureMac/SelectedText.swift similarity index 96% rename from Sources/InKCreApp/SelectedText.swift rename to extensions/capture/Sources/CaptureMac/SelectedText.swift index 902713f..f1db9fb 100644 --- a/Sources/InKCreApp/SelectedText.swift +++ b/extensions/capture/Sources/CaptureMac/SelectedText.swift @@ -1,6 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// References and adapts Cida by Xuanwo and contributors. Modified for InKCre persistent capture. +// Source revision and complete license: Legal/Cida/. import AppKit import ApplicationServices -import CaptureCore +import Capture +import PeerCore import Carbon.HIToolbox struct SelectionSnapshot: Sendable { diff --git a/Sources/InKCreApp/ShortcutSettingsView.swift b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift similarity index 100% rename from Sources/InKCreApp/ShortcutSettingsView.swift rename to extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 4275b3d..a5e690f 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -22,11 +22,19 @@ fi swift build -c "$configuration" --arch arm64 --arch x86_64 bin_path="$(swift build -c "$configuration" --arch arm64 --arch x86_64 --show-bin-path)" app="dist/InKCre.app" +rm -rf "$app" mkdir -p "$app/Contents/MacOS" mkdir -p "$app/Contents/Resources" cp "$bin_path/InKCre" "$app/Contents/MacOS/InKCre" -cp -R "$bin_path/InKCre_InKCreApp.bundle" "$app/Contents/Resources/" -iconutil -c icns Sources/InKCreApp/Resources/Brand/AppIcon.iconset -o "$app/Contents/Resources/AppIcon.icns" +cp -R "$bin_path/InKCreCapture_Capture.bundle" "$app/Contents/Resources/" +cp -R "$bin_path/InKCreCapture_CaptureMac.bundle" "$app/Contents/Resources/" +for resource in "$bin_path"/swift-*.bundle; do + [[ -d "$resource" ]] || continue + cp -R "$resource" "$app/Contents/Resources/" +done +python3 scripts/collect-licenses.py "$app/Contents/Resources/THIRD_PARTY_NOTICES.txt" +cp LICENSE "$app/Contents/Resources/LICENSE" +iconutil -c icns extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset -o "$app/Contents/Resources/AppIcon.icns" cat > "$app/Contents/Info.plist" < @@ -38,7 +46,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion8 +CFBundleVersion9 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication diff --git a/scripts/build-macos-icon.sh b/scripts/build-macos-icon.sh index 376a222..299303d 100644 --- a/scripts/build-macos-icon.sh +++ b/scripts/build-macos-icon.sh @@ -2,7 +2,7 @@ set -euo pipefail cd "$(dirname "$0")/.." command -v rsvg-convert >/dev/null || { echo 'Regenerating icons requires librsvg (rsvg-convert).' >&2; exit 1; } -brand=Sources/InKCreApp/Resources/Brand +brand=extensions/capture/Sources/CaptureMac/Resources/Brand for entry in '16x16:16:small' '16x16@2x:32:small' '32x32:32:small' '32x32@2x:64:small' \ '128x128:128:regular' '128x128@2x:256:regular' '256x256:256:regular' \ '256x256@2x:512:regular' '512x512:512:regular' '512x512@2x:1024:regular'; do diff --git a/scripts/collect-licenses.py b/scripts/collect-licenses.py new file mode 100644 index 0000000..956797c --- /dev/null +++ b/scripts/collect-licenses.py @@ -0,0 +1,22 @@ +"""Bundle exact dependency licenses and notices; never fetch moving upstream at package time.""" +import json +import pathlib +import sys + +root = pathlib.Path(__file__).resolve().parents[1] +output = pathlib.Path(sys.argv[1]) +parts = [(root / "THIRD_PARTY_NOTICES.md").read_text(), (root / "LICENSE").read_text()] +for path in sorted((root / "extensions/capture/Legal").rglob("*")): + if path.is_file(): + parts += [str(path.relative_to(root)), path.read_text()] +for pin in json.loads((root / "Package.resolved").read_text())["pins"]: + checkout = root / ".build/checkouts" / pin["identity"] + files = sorted(p for p in checkout.rglob("*") if p.is_file() and + p.name.upper().startswith(("LICENSE", "LICENCE", "NOTICE", "COPYING"))) + if not files: + raise SystemExit(f"Missing license for {pin['identity']}") + parts.append(f"{pin['identity']} {pin['state'].get('version', '')}\n{pin['location']}\nrevision {pin['state']['revision']}") + for path in files: + parts += [str(path.relative_to(checkout)), path.read_text(errors="strict")] +output.parent.mkdir(parents=True, exist_ok=True) +output.write_text("\n\n" + ("\n\n" + "=" * 72 + "\n\n").join(parts)) From 731f1769f8e14a18f6d65a22c7264ea64abf48ea Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 15:17:41 +0800 Subject: [PATCH 04/14] =?UTF-8?q?fix(capture):=20=E5=A4=8D=E7=94=A8=20Bett?= =?UTF-8?q?erShot=20=E9=87=8D=E5=81=9A=E5=BA=94=E7=94=A8=E5=86=85=E6=88=AA?= =?UTF-8?q?=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固定并注明上游选区、几何与冻结帧源码及 BSD-3-Clause 许可 - 冻结画面后支持移动、八点缩放、就近确认和取消,保留材料验证与扩展停用边界 - 增加实际 AppKit 视图离屏、关键交互与原图像素检查,更新 v11 开发包 --- README.md | 4 +- THIRD_PARTY_NOTICES.md | 4 +- .../CaptureMacTests/RegionOverlayTests.swift | 263 +++++++-- .../capture/Legal/BetterShot/ATTRIBUTION.txt | 18 + extensions/capture/Legal/BetterShot/LICENSE | 32 ++ extensions/capture/Legal/Cida/ATTRIBUTION.txt | 5 +- extensions/capture/README.md | 2 +- .../Sources/Capture/Resources/extension.json | 2 +- .../Sources/CaptureMac/RegionCapture.swift | 299 ++--------- .../Vendor/BetterShot/FrozenScreenFrame.swift | 44 ++ .../Vendor/BetterShot/RegionGeometry.swift | 64 +++ .../BetterShot/RegionSelectionOverlay.swift | 503 ++++++++++++++++++ scripts/build-app.sh | 2 +- 13 files changed, 958 insertions(+), 284 deletions(-) create mode 100644 extensions/capture/Legal/BetterShot/ATTRIBUTION.txt create mode 100644 extensions/capture/Legal/BetterShot/LICENSE create mode 100644 extensions/capture/Sources/CaptureMac/Vendor/BetterShot/FrozenScreenFrame.swift create mode 100644 extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionGeometry.swift create mode 100644 extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionSelectionOverlay.swift diff --git a/README.md b/README.md index dd582d9..a0f7abd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ open dist/InKCre.app 直接 AX 读取不可用时,仅在系统明确报告非空选区且不是安全输入时尝试复制回退。没有可靠选区时显示降级提示,不采集旧剪贴板或编辑器默认复制的整行。真实来源缺失就留空;明确粘贴不会伪造来源应用。通用文件、网页抓取、OCR 和知识查询不属于此版本。 -截图时拖动框选,黑白双边框与四角标记显示所选区域;松手后可以“截取”、重新框选或取消。回车确认,Esc、右键和“取消”按钮退出截图,保留原来的采集草稿。截图只保存最终确认的单屏区域。 +截图在应用内使用 BetterShot 选区组件的固定版本改编,先冻结各屏画面,再拖动框选。松手后可拖动区域移动、拖动八个手柄缩放,或在区域外重新框选;附近的原生“截取”/“取消”按钮始终留在屏幕内。回车或双击选区确认,Esc、右键和“取消”退出并保留原草稿。确认只从看到的冻结画面裁剪原图 PNG,不重新拍摄,不调用 Apple 截图工具栏。全屏冻结帧仅在本次会话内存中存在,只持久化明确确认的单屏区域。 截图入口只静默检查授权;未授权时在面板内显示说明,不反复调用系统授权请求或弹出应用警告。只有明确点击“请求屏幕录制权限”才发起授权。授权后按系统要求完全退出并重新打开应用,不能仅凭系统开关亮起就断言当前进程已获得权限。[Apple 的 ScreenCaptureKit 示例说明](https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos)。 @@ -68,4 +68,4 @@ BUNDLE_ID=你的稳定BundleID SIGNING_IDENTITY='Developer ID Application: …' Swift 测试覆盖冻结包重开、目标绑定、原子确认、原生构图、原生数据库事务、共享材料指纹与扩展生命周期。原生权限、多应用选区、多屏、Intel 实机与公证安装还需对应环境验证,不能由 universal 编译结果推断通过。 -原创 Swift 代码使用 MIT,Cida 参考/改写文件保留 Apache-2.0。见 [开源许可与致谢](THIRD_PARTY_NOTICES.md)。构建脚本从本次解析的依赖收集完整许可,随开发包交付;菜单“开源许可与致谢…”可打开通知。 +原创 Swift 代码使用 MIT,Cida 选区读取与采集协调改编保留 Apache-2.0,BetterShot 选区与冻结图组件保留 BSD-3-Clause。见 [开源许可与致谢](THIRD_PARTY_NOTICES.md)。构建脚本从本次解析的依赖收集完整许可,随开发包交付;菜单“开源许可与致谢…”可打开通知。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c5631a6..afa93b5 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2,7 +2,9 @@ InKCre Swift 原创实现使用 MIT。参考或改写的 Cida 文件使用 Apache-2.0,保留其独立许可。完整许可随源码与应用交付。 -Cida(辞达)由 Xuanwo 及贡献者开发。选区读取和框选交互的来源、核对版本与 InKCre 修改范围记录于 extensions/capture/Legal/Cida/ATTRIBUTION.txt。本应用不借用 Cida 的图标或产品身份,不声称上游为本应用背书。 +Cida(辞达)由 Xuanwo 及贡献者开发。选区读取与采集协调骨架的来源、核对版本与 InKCre 修改范围记录于 extensions/capture/Legal/Cida/ATTRIBUTION.txt。本应用不借用 Cida 的图标或产品身份,不声称上游为本应用背书。 + +BetterShot 由 Kartik Labhshetwar 及贡献者开发。CaptureMac/Vendor/BetterShot 中的选区、几何和冻结图裁剪来自固定修订,使用 BSD-3-Clause;来源及修改范围见 extensions/capture/Legal/BetterShot/ATTRIBUTION.txt,完整许可随源码与应用交付。未引入其 Cap 3D 或 MacShot 滚动截图组件,不使用其品牌或声称上游背书。 PostgresNIO 及传递依赖使用各自许可。Package.resolved 固定来源与修订;scripts/collect-licenses.py 从实际解析的源码收集完整 LICENSE/NOTICE,包括 BoringSSL 的内嵌许可。缺失许可会使打包失败。生成结果随应用保存在 Contents/Resources/THIRD_PARTY_NOTICES.txt,可从应用菜单打开。 diff --git a/Tests/CaptureMacTests/RegionOverlayTests.swift b/Tests/CaptureMacTests/RegionOverlayTests.swift index a7e506f..511a506 100644 --- a/Tests/CaptureMacTests/RegionOverlayTests.swift +++ b/Tests/CaptureMacTests/RegionOverlayTests.swift @@ -1,4 +1,7 @@ +// Geometry and frozen-pixel cases adapted from BetterShot's checks, BSD-3-Clause. +// Copyright (c) 2026, Kartik Labhshetwar. See extensions/capture/Legal/BetterShot/. import AppKit +import ImageIO import Testing @testable import CaptureMac @@ -28,54 +31,242 @@ struct RegionOverlayTests { context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) } - @Test func firstDragKeepsVisibleSelectionUntilExplicitConfirmation() throws { + private func drag(_ view: SelectionView, from start: CGPoint, to end: CGPoint) throws { + view.mouseDown(with: try event(.leftMouseDown, at: start, in: view)) + view.mouseDragged(with: try event(.leftMouseDragged, at: end, in: view)) + view.mouseUp(with: try event(.leftMouseUp, at: end, in: view)) + } + + private func window() -> NSWindow { _ = NSApplication.shared - let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 600, height: 400), styleMask: .borderless, + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 800, height: 520), styleMask: .borderless, backing: .buffered, defer: false) - let view = RegionOverlay(frame: CGRect(x: 0, y: 0, width: 600, height: 400)) + window.isReleasedWhenClosed = false + return window + } + + @Test func selectMoveResizeAndClickConfirmUsesOneVisibleAdjustableRegion() throws { + let window = window() + defer { window.close() } + var results: [CGRect] = [] + let view = SelectionView(frame: window.contentView!.bounds, scaleFactor: 2, + onSelect: { results.append($0) }, onCancel: { Issue.record("不应取消。") }) window.contentView = view - var completed = false - var calls = 0 - var result: CGRect? - view.complete = { result = $0; completed = true; calls += 1 } #expect(view.acceptsFirstMouse(for: nil)) - view.mouseDown(with: try event(.leftMouseDown, at: CGPoint(x: 400, y: 300), in: view)) - view.mouseDragged(with: try event(.leftMouseDragged, at: CGPoint(x: 100, y: 100), in: view)) - view.mouseUp(with: try event(.leftMouseUp, at: CGPoint(x: 100, y: 100), in: view)) - #expect(view.selection == CGRect(x: 100, y: 100, width: 300, height: 200)) - #expect(!completed) + try drag(view, from: CGPoint(x: 500, y: 400), to: CGPoint(x: 100, y: 120)) + #expect(view.selection == CGRect(x: 100, y: 120, width: 400, height: 280)) + #expect(results.isEmpty) + try drag(view, from: CGPoint(x: 200, y: 200), to: CGPoint(x: 240, y: 230)) + #expect(view.selection == CGRect(x: 140, y: 150, width: 400, height: 280)) + try drag(view, from: CGPoint(x: 540, y: 430), to: CGPoint(x: 600, y: 470)) + let rect = CGRect(x: 140, y: 150, width: 460, height: 320) + #expect(view.selection == rect) + #expect(results.isEmpty) + view.layoutSubtreeIfNeeded() + let buttonRect = view.confirmButton.convert(view.confirmButton.bounds, to: view) + #expect(view.bounds.contains(buttonRect)) + #expect(!view.confirmButton.isHidden && view.confirmButton.isEnabled) + #expect(view.hitTest(CGPoint(x: buttonRect.midX, y: buttonRect.midY)) === view.confirmButton) let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) let scale = CGFloat(bitmap.pixelsWide) / view.bounds.width - let edge = try #require(bitmap.colorAt(x: Int(100 * scale), y: Int(200 * scale))) - let inside = try #require(bitmap.colorAt(x: Int(200 * scale), y: Int(200 * scale))) - let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(200 * scale))) - #expect(edge.alphaComponent > 0.95) + let edge = try #require(bitmap.colorAt(x: Int(rect.minX * scale), y: Int((view.bounds.height - rect.midY) * scale))) + let inside = try #require(bitmap.colorAt(x: Int(rect.midX * scale), y: Int((view.bounds.height - rect.midY) * scale))) + let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(250 * scale))) + #expect(edge.alphaComponent > 0.9) #expect(inside.alphaComponent < 0.05) #expect(outside.alphaComponent > 0.25 && outside.alphaComponent < 0.5) + view.confirmButton.performClick(nil) view.confirmSelection() - view.confirmSelection() - #expect(completed) - #expect(calls == 1) - #expect(result == view.selection) + view.cancelSelection() + #expect(results == [rect]) + #expect(!window.isVisible) } - @Test func resettingOrCancellingNeverCapturesAccidentalTinySelection() throws { + @Test func tinySelectionCanBeRedrawnAndCancelButtonOrEscapeNeverConfirms() throws { + for escape in [false, true] { + let window = window() + defer { window.close() } + var cancelled = 0 + let view = SelectionView(frame: window.contentView!.bounds, scaleFactor: 1, + onSelect: { _ in Issue.record("取消不得产图。") }, onCancel: { cancelled += 1 }) + window.contentView = view + try drag(view, from: CGPoint(x: 200, y: 200), to: CGPoint(x: 201, y: 201)) + #expect(view.selection == nil) + view.confirmSelection() + #expect(cancelled == 0) + try drag(view, from: CGPoint(x: 40, y: 40), to: CGPoint(x: 300, y: 260)) + #expect(view.confirmButton.isEnabled) + if escape { + let key = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, + characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, keyCode: 53)) + view.keyDown(with: key) + } else { view.cancelButton.performClick(nil) } + view.cancelSelection() + #expect(cancelled == 1) + } + } + + @Test func stopClosesActualOverlayAndDiscardsPendingResultWithoutPresentingWindows() async throws { _ = NSApplication.shared - let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 600, height: 400), styleMask: .borderless, - backing: .buffered, defer: false) - let view = RegionOverlay(frame: CGRect(x: 0, y: 0, width: 600, height: 400)) - window.contentView = view - var calls = 0 - view.complete = { rect in #expect(rect == nil); calls += 1 } - view.mouseDown(with: try event(.leftMouseDown, at: CGPoint(x: 200, y: 200), in: view)) - view.mouseUp(with: try event(.leftMouseUp, at: CGPoint(x: 201, y: 201), in: view)) - view.confirmSelection() - #expect(calls == 0) - view.resetSelection() - #expect(view.selection.isEmpty) - view.cancelSelection() - view.cancelSelection() - #expect(calls == 1) + let screen = try #require(NSScreen.screens.first) + let number = try #require(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + let frame = FrozenScreenFrame(displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), + image: try pixels(width: 8, height: 6)) + var windows: [NSWindow] = [] + let closing = WindowCloseObserver() + let overlay = RegionSelectionOverlay(present: { windows = $0; windows.forEach { $0.delegate = closing } }) + let capture = RegionCapture(hasPermission: { true }, freezeScreens: { [frame] }, overlay: overlay) + var callbacks = 0 + capture.begin { _ in callbacks += 1 } + for _ in 0..<100 where windows.isEmpty { await Task.yield() } + #expect(capture.isCapturing) + #expect(windows.count == 1) + #expect(windows.allSatisfy { !$0.isVisible }) + await capture.stop() + #expect(!capture.isCapturing) + #expect(callbacks == 0) + #expect(windows.allSatisfy { !$0.isVisible }) + #expect(closing.closed == 1) + overlay.cancel() } + + @Test func confirmedRegionReturnsOnlyFrozenPngThroughCaptureCoordinator() async throws { + _ = NSApplication.shared + let screen = try #require(NSScreen.screens.first) + let number = try #require(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + let frame = FrozenScreenFrame(displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), + image: try pixels(width: 8, height: 6)) + var freezes = 0 + let overlay = RegionSelectionOverlay(present: { windows in + let view = windows[0].contentView!.subviews[0] as! SelectionView + view.beginDrag(at: CGPoint(x: screen.frame.width / 4, y: screen.frame.height / 4)) + view.endDrag(at: CGPoint(x: screen.frame.width * 3 / 4, y: screen.frame.height * 3 / 4)) + view.confirmButton.performClick(nil) + }) + let capture = RegionCapture(hasPermission: { true }, freezeScreens: { freezes += 1; return [frame] }, overlay: overlay) + let result: Result = await withCheckedContinuation { continuation in + capture.begin { continuation.resume(returning: $0) } + } + let data = try #require(try result.get()) + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + let expected = try frame.croppedImage(in: CGRect(x: frame.pointsRect.minX + frame.pointsRect.width / 4, + y: frame.pointsRect.minY + frame.pointsRect.height / 4, + width: frame.pointsRect.width / 2, height: frame.pointsRect.height / 2)) + #expect(rgba(image) == rgba(expected)) + #expect(freezes == 1) + #expect(!capture.isCapturing) + } + + @Test func frozenBackgroundAndControlsRenderInsideSmallDisplayInBothAppearances() throws { + for name in [NSAppearance.Name.aqua, .darkAqua] { + let window = window() + defer { window.close() } + window.appearance = NSAppearance(named: name) + let image = NSImage(size: window.contentView!.bounds.size) + image.lockFocus() + NSColor(calibratedRed: 0.91, green: 0.93, blue: 0.96, alpha: 1).setFill() + NSBezierPath(rect: CGRect(origin: .zero, size: image.size)).fill() + NSColor.white.setFill() + NSBezierPath(roundedRect: CGRect(x: 80, y: 80, width: 640, height: 360), xRadius: 12, yRadius: 12).fill() + ("InKCre · 合成截图验收" as NSString).draw(at: CGPoint(x: 120, y: 365), withAttributes: [ + .font: NSFont.systemFont(ofSize: 26, weight: .semibold), .foregroundColor: NSColor.black]) + ("选中眼前的信息,随手收入。\n\n框选后仍可移动、调整大小,再确认保存。\n这段文字与背景均为测试生成的合成内容。" as NSString).draw( + in: CGRect(x: 120, y: 170, width: 550, height: 160), withAttributes: [ + .font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.darkGray]) + image.unlockFocus() + let backdrop = FrozenBackdropView(image: try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)), + frame: window.contentView!.bounds) + let view = SelectionView(frame: backdrop.bounds, scaleFactor: 2, onSelect: { _ in }, onCancel: {}) + backdrop.addSubview(view) + window.contentView = backdrop + for state in ["ready", "selected", "edge"] { + if state == "selected" { try drag(view, from: CGPoint(x: 100, y: 130), to: CGPoint(x: 690, y: 415)) } + if state == "edge" { try drag(view, from: CGPoint(x: 740, y: 10), to: CGPoint(x: 790, y: 70)) } + backdrop.layoutSubtreeIfNeeded() + let button = state == "ready" ? view.cancelButton : view.confirmButton + let rect = button.convert(button.bounds, to: view) + #expect(rect.width > 35 && rect.height > 15) + #expect(view.bounds.contains(rect)) + #expect(view.hitTest(CGPoint(x: rect.midX, y: rect.midY)) === button) + let bitmap = try #require(backdrop.bitmapImageRepForCachingDisplay(in: backdrop.bounds)) + backdrop.cacheDisplay(in: backdrop.bounds, to: bitmap) + let scale = CGFloat(bitmap.pixelsWide) / backdrop.bounds.width + let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(260 * scale))?.usingColorSpace(.deviceRGB)) + #expect(outside.redComponent > 0.5 && outside.redComponent < 0.75) + if state == "selected" { + let inside = try #require(bitmap.colorAt(x: Int(670 * scale), y: Int(370 * scale))?.usingColorSpace(.deviceRGB)) + #expect(inside.redComponent > 0.95 && inside.alphaComponent > 0.95) + } + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + let file = "capture-\(name.rawValue)-\(state).png" + try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent(file)) + } + } + } + } + + @Test func frozenCropUsesOriginalPixelsAtEachDisplayScaleAndGlobalOrigin() throws { + let image = try pixels(width: 8, height: 6) + let frame = FrozenScreenFrame(displayID: 2, pointsRect: CGRect(x: -4, y: -3, width: 4, height: 3), image: image) + let region = CGRect(x: -3, y: -2.5, width: 2, height: 1.5) + let cropped = try frame.croppedImage(in: region) + #expect(cropped.width == 4 && cropped.height == 3) + let bytes = rgba(cropped) + for y in 0..<3 { + for x in 0..<4 { + let i = (y * 4 + x) * 4 + #expect(bytes[i] == UInt8((x + 2) * 20)) + #expect(bytes[i + 1] == UInt8((y + 1) * 30)) + } + } + let source = try #require(CGImageSourceCreateWithData(try frame.png(in: region) as CFData, nil)) + let png = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + #expect(rgba(png) == bytes) + #expect(RegionGeometry.pointsRect(global: CGRect(x: -3, y: 101, width: 2, height: 1.5), primaryHeight: 100) == region) + let single = FrozenScreenFrame(displayID: 3, pointsRect: CGRect(x: 100, y: 50, width: 8, height: 6), image: image) + let singleCrop = try single.croppedImage(in: CGRect(x: 102, y: 51, width: 4, height: 3)) + #expect(singleCrop.width == 4 && singleCrop.height == 3) + #expect(throws: (any Error).self) { try frame.croppedImage(in: .zero) } + #expect(throws: (any Error).self) { try frame.croppedImage(in: CGRect(x: -5, y: -3, width: 2, height: 2)) } + } + + @Test func resizingAcrossOppositeEdgeAndMovingStaysWithinDisplay() { + let selection = CGRect(x: 100, y: 100, width: 200, height: 100) + let screen = CGRect(x: 0, y: 0, width: 1000, height: 800) + #expect(RegionAdjustment.apply(.left, delta: CGSize(width: 250, height: 0), to: selection, within: screen) + == CGRect(x: 300, y: 100, width: 50, height: 100)) + #expect(RegionAdjustment.apply(.move, delta: CGSize(width: 900, height: -10), to: selection, within: screen) + == CGRect(x: 800, y: 90, width: 200, height: 100)) + } + + private func pixels(width: Int, height: Int) throws -> CGImage { + var bytes = [UInt8]() + for y in 0.. [UInt8] { + var bytes = [UInt8](repeating: 0, count: image.width * image.height * 4) + bytes.withUnsafeMutableBytes { buffer in + let context = CGContext(data: buffer.baseAddress, width: image.width, height: image.height, + bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + } + return bytes + } +} + +@MainActor private final class WindowCloseObserver: NSObject, NSWindowDelegate { + var closed = 0 + func windowWillClose(_ notification: Notification) { closed += 1 } } diff --git a/extensions/capture/Legal/BetterShot/ATTRIBUTION.txt b/extensions/capture/Legal/BetterShot/ATTRIBUTION.txt new file mode 100644 index 0000000..6e52633 --- /dev/null +++ b/extensions/capture/Legal/BetterShot/ATTRIBUTION.txt @@ -0,0 +1,18 @@ +BetterShot +上游:https://github.com/KartikLabhshetwar/better-shot +作者:Kartik Labhshetwar 及 BetterShot 贡献者 +固定版本:7b7d00778fd972cf672242771d7a25870dd301b4 +许可:BSD-3-Clause;完整上游 LICENSE 保存在同目录。 + +提取文件:Sources/Capture/RegionSelectionOverlay.swift、RegionGeometry.swift、FrozenScreenFrame.swift。 +本地位置:Sources/CaptureMac/Vendor/BetterShot/。 + +InKCre 保留选区绘制、移动/八点缩放、几何转换和冻结图裁剪实现。 +修改:删除宿主偏好、控制拖拽、前次选区和系统窗口截取分支;通过显式冻结帧输入接入 Capture; +增加原生确认/取消按钮、中文与辅助功能说明、单次完成和停用清理; +冻结背景与遮罩改为 AppKit 绘制,以便离屏验证与实际显示使用同一视图,并增加黑白描边;裁剪 PNG 返回内存数据而非写文件。 +RegionOverlayTests.swift 中的几何与冻结像素断言改编自上游 RegionGeometryCheck.swift、FrozenScreenFrameCheck.swift。 + +未引入 BetterShot 的 Cap 3D/录屏编辑、MacShot 滚动截屏、上传、更新或其他应用实现, +上述三个文件仅依赖 Apple 框架。本目录不是完整 BetterShot 应用;根 LICENSE 的其他组件提示保留原文。 +BetterShot 名称仅用于来源致谢,不表示上游对 InKCre 的认可、合作或背书。 diff --git a/extensions/capture/Legal/BetterShot/LICENSE b/extensions/capture/Legal/BetterShot/LICENSE new file mode 100644 index 0000000..1c42421 --- /dev/null +++ b/extensions/capture/Legal/BetterShot/LICENSE @@ -0,0 +1,32 @@ +BSD 3-Clause License + +Copyright (c) 2026, Kartik Labhshetwar + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Additional components: Cap 3D rendering adaptations are AGPL-3.0-only. +See Resources/Licenses/Cap.txt for license terms and combined-application +distribution requirements. diff --git a/extensions/capture/Legal/Cida/ATTRIBUTION.txt b/extensions/capture/Legal/Cida/ATTRIBUTION.txt index cd459fd..a54f023 100644 --- a/extensions/capture/Legal/Cida/ATTRIBUTION.txt +++ b/extensions/capture/Legal/Cida/ATTRIBUTION.txt @@ -4,6 +4,7 @@ Cida(辞达) 核对版本:3099e0dcbc2bafe0a86e6a04d1941b78afacbfd6 许可:Apache License 2.0,完整文本见同目录 LICENSE。 -InKCre 的 SelectedText.swift、RegionCapture.swift 参考与改写了 Cida 的选区读取、剪贴板保护和框选交互。 -InKCre 为持久收集目的修改了选区验证、复制边界、窗口路由和原图保存,不包含 Cida 翻译业务。 +InKCre 的 SelectedText.swift 与 RegionCapture.swift 的采集协调骨架参考与改写了 Cida 的选区读取、剪贴板保护和系统截图调用。 +原选框已删除,选区 UI 现改用独立注明 BSD-3-Clause 的 BetterShot 组件。 +InKCre 为持久收集目的修改了选区验证、复制边界和窗口路由,不包含 Cida 翻译业务。 Cida 名称仅用于来源致谢,不表示上游对 InKCre 的认可或合作。 diff --git a/extensions/capture/README.md b/extensions/capture/README.md index 023a6dd..e79e306 100644 --- a/extensions/capture/README.md +++ b/extensions/capture/README.md @@ -4,4 +4,4 @@ Host 提供 PeerCore 的数据库、标准图/Storage、持久目录、凭据和扩展生命周期。精确分发身份来自 `Sources/Capture/Resources/extension.json`;随签名应用交付并独立启停,不依赖另一个 Peer 执行收入。 -在仓库根执行 `swift test`;本包可通过 `swift build --package-path extensions/capture` 单独构建。Cida 来源和 Apache-2.0 许可见 Legal/Cida,原创代码见 LICENSE。 +在仓库根执行 `swift test`;本包可通过 `swift build --package-path extensions/capture` 单独构建。Cida 选区读取与采集协调改编的 Apache-2.0 许可见 Legal/Cida;BetterShot 应用内选区与冻结图组件的 BSD-3-Clause 许可、固定版本和修改说明见 Legal/BetterShot。冻结帧只在截图会话内存中存在,确认后裁剪原图 PNG;停用关闭选区并丢弃会话,不调用系统截图工具栏。原创代码见 LICENSE。 diff --git a/extensions/capture/Sources/Capture/Resources/extension.json b/extensions/capture/Sources/Capture/Resources/extension.json index a833863..5c81b81 100644 --- a/extensions/capture/Sources/Capture/Resources/extension.json +++ b/extensions/capture/Sources/Capture/Resources/extension.json @@ -1 +1 @@ -{"name":"inkcre/capture","distribution":"swift_bundled","version":"0.1.0","license":"MIT AND Apache-2.0"} +{"name":"inkcre/capture","distribution":"swift_bundled","version":"0.1.0","license":"MIT AND Apache-2.0 AND BSD-3-Clause"} diff --git a/extensions/capture/Sources/CaptureMac/RegionCapture.swift b/extensions/capture/Sources/CaptureMac/RegionCapture.swift index c44bac8..9d2d5b5 100644 --- a/extensions/capture/Sources/CaptureMac/RegionCapture.swift +++ b/extensions/capture/Sources/CaptureMac/RegionCapture.swift @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 -// References and adapts Cida by Xuanwo and contributors. Modified for InKCre persistent capture. -// Source revision and complete license: Legal/Cida/. +// Capture coordination originally adapted from Cida by Xuanwo and contributors. +// InKCre now uses the BetterShot selection component; sources and changes: Legal/. import AppKit import Capture import PeerCore @@ -15,278 +15,97 @@ struct ScreenRecordingPermissionError: LocalizedError { @MainActor final class RegionCapture { private var captureTask: Task? - private var windows: [NSWindow] = [] private var completion: ((Result) -> Void)? private let hasPermission: () -> Bool + private let freezeScreens: () async throws -> [FrozenScreenFrame] + private let overlay: RegionSelectionOverlay var isCapturing: Bool { completion != nil } - init(hasPermission: @escaping () -> Bool = CGPreflightScreenCaptureAccess) { + init( + hasPermission: @escaping () -> Bool = CGPreflightScreenCaptureAccess, + freezeScreens: @escaping () async throws -> [FrozenScreenFrame] = RegionCapture.freezeScreens, + overlay: RegionSelectionOverlay = RegionSelectionOverlay() + ) { self.hasPermission = hasPermission + self.freezeScreens = freezeScreens + self.overlay = overlay } func begin(completion: @escaping (Result) -> Void) { guard self.completion == nil else { return } - // Checking is silent; requesting access is an explicit settings action, never a retry side effect. + // Requesting access belongs to the explicit settings action, never a screenshot retry. guard hasPermission() else { completion(.failure(ScreenRecordingPermissionError())) return } self.completion = completion - guard !NSScreen.screens.isEmpty else { - finish(.failure(PeerError.invalid("没有可用的屏幕,请重新截图。"))) - return - } - NSApp.activate(ignoringOtherApps: true) - for screen in NSScreen.screens { - let window = RegionWindow( - contentRect: screen.frame, styleMask: .borderless, - backing: .buffered, defer: false) - window.level = .screenSaver - window.isOpaque = false - window.backgroundColor = .clear - window.hasShadow = false - window.title = "框选截图" - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - window.isReleasedWhenClosed = false - let overlay = RegionOverlay(frame: NSRect(origin: .zero, size: screen.frame.size)) - overlay.didBeginSelection = { [weak self, weak overlay] in - for window in self?.windows ?? [] { - if let other = window.contentView as? RegionOverlay, other !== overlay { - other.resetSelection() + captureTask = Task { + do { + let frames = try await freezeScreens() + guard !Task.isCancelled else { return } + guard !frames.isEmpty else { throw PeerError.invalid("没有可用的屏幕,请重新截图。") } + let outcome = await overlay.selectRegion(frozenFrames: frames) + guard !Task.isCancelled else { return } + switch outcome { + case .cancelled: + finish(.success(nil)) + case .region(let selection): + guard let frame = frames.first(where: { $0.displayID == selection.displayID }) else { + throw PeerError.invalid("找不到所选画面,请重新截图。") } + let data = try await Task.detached(priority: .userInitiated) { + try frame.png(in: selection.pointsRect) + }.value + guard !Task.isCancelled else { return } + try CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]).validate() + finish(.success(data)) + } + } catch { + guard !Task.isCancelled else { return } + let failure = error as NSError + if failure.domain == SCStreamErrorDomain, failure.code == SCStreamError.Code.userDeclined.rawValue { + finish(.failure(ScreenRecordingPermissionError())) + } else { + finish(.failure(error)) } } - overlay.complete = { [weak self] rect in - guard let self, !self.windows.isEmpty else { return } - if let rect { self.capture(screen: screen, rect: rect) } else { self.finish(.success(nil)) } - } - window.contentView = overlay - window.makeFirstResponder(overlay) - windows.append(window) - window.orderFrontRegardless() - if screen.frame.contains(NSEvent.mouseLocation) { window.makeKey() } } - NSCursor.crosshair.push() } func stop() async { completion = nil - if !windows.isEmpty { hide() } captureTask?.cancel() + overlay.cancel() await captureTask?.value captureTask = nil } - private func hide() { - for window in windows { window.orderOut(nil) } - windows.removeAll() - NSCursor.pop() - } - private func finish(_ result: Result) { - if !windows.isEmpty { hide() } let callback = completion completion = nil + captureTask = nil callback?(result) } - private func capture(screen: NSScreen, rect: CGRect) { - hide() - captureTask = Task { - do { - guard - let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] - as? NSNumber - else { - throw PeerError.invalid("无法识别所选屏幕。") - } - let content = try await SCShareableContent.excludingDesktopWindows( - false, onScreenWindowsOnly: true) - guard !Task.isCancelled, completion != nil else { return } - guard let display = content.displays.first(where: { $0.displayID == number.uint32Value }) - else { - throw PeerError.invalid("所选屏幕已断开,请重新截图。") - } - let own = content.applications.filter { - $0.processID == ProcessInfo.processInfo.processIdentifier - } - let filter = SCContentFilter( - display: display, excludingApplications: own, exceptingWindows: []) - let configuration = SCStreamConfiguration() - // Overlay coordinates are local, top-left points; SCK maps them to this display. - configuration.sourceRect = rect - configuration.width = max(1, Int((rect.width * CGFloat(filter.pointPixelScale)).rounded())) - configuration.height = max(1, Int((rect.height * CGFloat(filter.pointPixelScale)).rounded())) - configuration.showsCursor = false - configuration.captureResolution = .best - let image = try await SCScreenshotManager.captureImage( - contentFilter: filter, configuration: configuration) - guard !Task.isCancelled, completion != nil else { return } - guard let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) - else { - throw PeerError.invalid("截图编码失败,请重试。") - } - try CapturePayload( - entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")] - ).validate() - finish(.success(data)) - } catch { - let failure = error as NSError - if failure.domain == SCStreamErrorDomain, - failure.code == SCStreamError.Code.userDeclined.rawValue - { - finish(.failure(ScreenRecordingPermissionError())) - } else { - finish(.failure(error)) - } - } - } - } -} - -private final class RegionWindow: NSWindow { - override var canBecomeKey: Bool { true } -} - -@MainActor -final class RegionOverlay: NSView { - var complete: ((CGRect?) -> Void)? - var didBeginSelection: (() -> Void)? - private var start: CGPoint? - private(set) var selection = CGRect.zero - private let hint = NSTextField(labelWithString: "拖动框选本屏区域 · Esc 或右键取消") - private let accept = NSButton(title: "截取 ↩", target: nil, action: nil) - private let reset = NSButton(title: "重新框选", target: nil, action: nil) - private let cancel = NSButton(title: "取消", target: nil, action: nil) - override var isFlipped: Bool { true } - override var acceptsFirstResponder: Bool { true } - // A global shortcut can open this view while another app still owns the first click. - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } - - override init(frame: NSRect) { - super.init(frame: frame) - wantsLayer = true - hint.textColor = .white - hint.font = .systemFont(ofSize: 15, weight: .medium) - for (button, action) in [ - (accept, #selector(confirmSelection)), (reset, #selector(resetSelection)), - (cancel, #selector(cancelSelection)), - ] { - button.target = self - button.action = action - button.bezelStyle = .rounded - addSubview(button) - } - addSubview(hint) - accept.isEnabled = false - reset.isEnabled = false - } - - required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - - override func layout() { - super.layout() - hint.frame = CGRect(x: 24, y: 22, width: max(200, bounds.width - 370), height: 25) - cancel.frame = CGRect(x: bounds.maxX - 110, y: 20, width: 86, height: 32) - accept.frame = CGRect(x: bounds.maxX - 212, y: 20, width: 94, height: 32) - reset.frame = CGRect(x: bounds.maxX - 314, y: 20, width: 94, height: 32) - } - - override func resetCursorRects() { addCursorRect(bounds, cursor: .crosshair) } - - override func draw(_ dirtyRect: NSRect) { - NSGraphicsContext.current?.cgContext.clear(bounds) - let shade = NSBezierPath(rect: bounds) - if !selection.isEmpty { shade.appendRect(selection) } - shade.windingRule = .evenOdd - NSColor.black.withAlphaComponent(0.35).setFill() - shade.fill() - if !selection.isEmpty { - let border = NSBezierPath(rect: selection) - NSColor.black.setStroke() - border.lineWidth = 4 - border.stroke() - NSColor.white.setStroke() - border.lineWidth = 2 - border.stroke() - for point in [ - CGPoint(x: selection.minX, y: selection.minY), CGPoint(x: selection.maxX, y: selection.minY), - CGPoint(x: selection.minX, y: selection.maxY), CGPoint(x: selection.maxX, y: selection.maxY), - ] { - let handle = NSBezierPath(rect: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)) - NSColor.white.setFill() - handle.fill() - NSColor.black.setStroke() - handle.lineWidth = 1 - handle.stroke() + private static func freezeScreens() async throws -> [FrozenScreenFrame] { + let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) + let own = content.applications.filter { $0.processID == ProcessInfo.processInfo.processIdentifier } + var frames: [FrozenScreenFrame] = [] + for screen in NSScreen.screens { + try Task.checkCancellation() + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber, + let display = content.displays.first(where: { $0.displayID == number.uint32Value }) else { + throw PeerError.invalid("屏幕配置已改变,请重新截图。") } + let filter = SCContentFilter(display: display, excludingApplications: own, exceptingWindows: []) + let configuration = SCStreamConfiguration() + configuration.width = max(1, Int((filter.contentRect.width * CGFloat(filter.pointPixelScale)).rounded())) + configuration.height = max(1, Int((filter.contentRect.height * CGFloat(filter.pointPixelScale)).rounded())) + configuration.showsCursor = false + configuration.captureResolution = .best + let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) + frames.append(FrozenScreenFrame(displayID: display.displayID, pointsRect: display.frame, image: image)) } - NSColor.black.withAlphaComponent(0.8).setFill() - NSBezierPath( - roundedRect: CGRect(x: 12, y: 12, width: bounds.width - 24, height: 50), xRadius: 10, yRadius: 10 - ).fill() - } - - override func mouseDown(with event: NSEvent) { - window?.makeKey() - window?.makeFirstResponder(self) - didBeginSelection?() - start = convert(event.locationInWindow, from: nil) - selection = .zero - accept.isEnabled = false - reset.isEnabled = false - hint.stringValue = "拖动框选本屏区域 · Esc 或右键取消" - needsDisplay = true - } - override func mouseDragged(with event: NSEvent) { - guard let start else { return } - let end = convert(event.locationInWindow, from: nil) - selection = CGRect( - x: min(start.x, end.x), y: min(start.y, end.y), - width: abs(start.x - end.x), height: abs(start.y - end.y) - ).intersection(bounds) - hint.stringValue = "已选 \(Int(selection.width)) × \(Int(selection.height)) · 松手后确认截取" - display() - } - override func mouseUp(with event: NSEvent) { - mouseDragged(with: event) - start = nil - let valid = selection.width >= 2 && selection.height >= 2 - accept.isEnabled = valid - reset.isEnabled = !selection.isEmpty - hint.stringValue = - valid - ? "已选 \(Int(selection.width)) × \(Int(selection.height)) · 点击截取或按回车" - : "请拖动框选区域 · Esc 或右键取消" - } - override func keyDown(with event: NSEvent) { - switch event.keyCode { - case 53: cancelSelection() - case 36, 76: confirmSelection() - default: super.keyDown(with: event) - } - } - override func cancelOperation(_ sender: Any?) { cancelSelection() } - override func rightMouseDown(with event: NSEvent) { cancelSelection() } - - @objc func resetSelection() { - selection = .zero - start = nil - accept.isEnabled = false - reset.isEnabled = false - hint.stringValue = "拖动框选本屏区域 · Esc 或右键取消" - needsDisplay = true - } - @objc func confirmSelection() { - guard accept.isEnabled else { return } - accept.isEnabled = false - finishSelection(selection) - } - @objc func cancelSelection() { finishSelection(nil) } - - private func finishSelection(_ rect: CGRect?) { - let callback = complete - complete = nil - callback?(rect) + return frames } } diff --git a/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/FrozenScreenFrame.swift b/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/FrozenScreenFrame.swift new file mode 100644 index 0000000..86fc948 --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/FrozenScreenFrame.swift @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Kartik Labhshetwar +// Adapted for InKCre. Fixed source and modifications: Legal/BetterShot/ATTRIBUTION.txt. +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// The displayed frame is also the export source; selecting never takes a second shot. +struct FrozenScreenFrame: Sendable { + let displayID: CGDirectDisplayID + let pointsRect: CGRect + let image: CGImage + + nonisolated func croppedImage(in region: CGRect) throws -> CGImage { + guard pointsRect.width > 0, pointsRect.height > 0, + region.width > 0, region.height > 0, pointsRect.contains(region) else { + throw NSError(domain: "InKCre.FrozenScreenCapture", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "所选区域已超出冻结画面,请重新截图。" + ]) + } + let sx = CGFloat(image.width) / pointsRect.width + let sy = CGFloat(image.height) / pointsRect.height + let pixels = CGRect( + x: (region.minX - pointsRect.minX) * sx, + y: (region.minY - pointsRect.minY) * sy, + width: region.width * sx, + height: region.height * sy + ).integral.intersection(CGRect(x: 0, y: 0, width: image.width, height: image.height)) + guard let cropped = image.cropping(to: pixels) else { throw CocoaError(.fileReadCorruptFile) } + return cropped + } + + nonisolated func png(in region: CGRect) throws -> Data { + let cropped = try croppedImage(in: region) + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { + throw CocoaError(.fileWriteUnknown) + } + CGImageDestinationAddImage(destination, cropped, nil) + guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) } + return data as Data + } +} diff --git a/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionGeometry.swift b/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionGeometry.swift new file mode 100644 index 0000000..3768077 --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionGeometry.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Kartik Labhshetwar +// Adapted for InKCre. Fixed source and modifications: Legal/BetterShot/ATTRIBUTION.txt. +import CoreGraphics + +enum RegionGeometry { + static func globalRect(local: CGRect, screenFrame: CGRect) -> CGRect { + local.offsetBy(dx: screenFrame.minX, dy: screenFrame.minY) + } + + /// Flips a global AppKit (bottom-left) rect into the frozen image's top-left point space. + static func pointsRect(global: CGRect, primaryHeight: CGFloat) -> CGRect { + CGRect(x: global.minX, y: primaryHeight - global.maxY, width: global.width, height: global.height) + } + +} + +enum RegionHandle: CaseIterable { + case topLeft, top, topRight, right, bottomRight, bottom, bottomLeft, left, move + + var movesLeft: Bool { [.topLeft, .left, .bottomLeft].contains(self) } + var movesRight: Bool { [.topRight, .right, .bottomRight].contains(self) } + var movesTop: Bool { [.topLeft, .top, .topRight].contains(self) } + var movesBottom: Bool { [.bottomLeft, .bottom, .bottomRight].contains(self) } + + func point(in rect: CGRect) -> CGPoint { + let x = movesLeft ? rect.minX : movesRight ? rect.maxX : rect.midX + let y = movesTop ? rect.maxY : movesBottom ? rect.minY : rect.midY + return CGPoint(x: x, y: y) + } +} + +enum RegionAdjustment { + static let handleRadius: CGFloat = 8 + + static func handle(at point: CGPoint, in rect: CGRect) -> RegionHandle? { + let nearest = RegionHandle.allCases + .filter { $0 != .move } + .map { ($0, distance(point, $0.point(in: rect))) } + .min { $0.1 < $1.1 } + if let nearest, nearest.1 <= handleRadius { return nearest.0 } + return rect.contains(point) ? .move : nil + } + + static func apply(_ handle: RegionHandle, delta: CGSize, to rect: CGRect, within bounds: CGRect) -> CGRect { + if handle == .move { + return CGRect( + x: min(max(rect.minX + delta.width, bounds.minX), bounds.maxX - rect.width), + y: min(max(rect.minY + delta.height, bounds.minY), bounds.maxY - rect.height), + width: rect.width, + height: rect.height + ) + } + let x0 = clamp(rect.minX + (handle.movesLeft ? delta.width : 0), bounds.minX, bounds.maxX) + let x1 = clamp(rect.maxX + (handle.movesRight ? delta.width : 0), bounds.minX, bounds.maxX) + let y0 = clamp(rect.minY + (handle.movesBottom ? delta.height : 0), bounds.minY, bounds.maxY) + let y1 = clamp(rect.maxY + (handle.movesTop ? delta.height : 0), bounds.minY, bounds.maxY) + return CGRect(x: min(x0, x1), y: min(y0, y1), width: abs(x1 - x0), height: abs(y1 - y0)) + } + + private static func clamp(_ v: CGFloat, _ lo: CGFloat, _ hi: CGFloat) -> CGFloat { min(max(v, lo), hi) } + + private static func distance(_ a: CGPoint, _ b: CGPoint) -> CGFloat { hypot(a.x - b.x, a.y - b.y) } +} diff --git a/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionSelectionOverlay.swift b/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionSelectionOverlay.swift new file mode 100644 index 0000000..5ecee07 --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/Vendor/BetterShot/RegionSelectionOverlay.swift @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Kartik Labhshetwar +// Adapted for InKCre. Fixed source and modifications: Legal/BetterShot/ATTRIBUTION.txt. +import AppKit + +struct RegionSelection { + let pointsRect: CGRect + let displayID: CGDirectDisplayID +} + +enum RegionSelectionOutcome { + case region(RegionSelection) + case cancelled +} + +@MainActor +final class RegionSelectionOverlay { + private var overlayWindows: [NSWindow] = [] + private var selectionViews: [SelectionView] = [] + private var continuation: CheckedContinuation? + private var cursorPushed = false + private let present: ([NSWindow]) -> Void + + init(present: @escaping ([NSWindow]) -> Void = { windows in + NSApp.activate(ignoringOtherApps: true) + for window in windows { window.orderFrontRegardless() } + (windows.first(where: { $0.frame.contains(NSEvent.mouseLocation) }) ?? windows.first)?.makeKey() + }) { + self.present = present + } + + func selectRegion(frozenFrames: [FrozenScreenFrame]) async -> RegionSelectionOutcome { + await withCheckedContinuation { continuation in + self.continuation = continuation + for screen in NSScreen.screens { + guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber, + let frame = frozenFrames.first(where: { $0.displayID == number.uint32Value }) else { continue } + let window = OverlayWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.isOpaque = false + window.backgroundColor = .clear + window.level = .screenSaver + window.hasShadow = false + window.acceptsMouseMovedEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.title = "框选截图" + let view = SelectionView(frame: CGRect(origin: .zero, size: screen.frame.size), + scaleFactor: CGFloat(frame.image.width) / frame.pointsRect.width) { [weak self] rect in + let global = RegionGeometry.globalRect(local: rect, screenFrame: screen.frame) + self?.finish(.region(RegionSelection( + pointsRect: RegionGeometry.pointsRect(global: global, primaryHeight: CGDisplayBounds(CGMainDisplayID()).height), + displayID: frame.displayID))) + } onCancel: { [weak self] in self?.cancel() } + view.onBeginSelection = { [weak self, weak view] in + self?.selectionViews.forEach { if $0 !== view { $0.clearSelection() } } + } + let backdrop = FrozenBackdropView(image: frame.image, frame: view.frame) + backdrop.addSubview(view) + window.contentView = backdrop + window.makeFirstResponder(view) + overlayWindows.append(window) + selectionViews.append(view) + } + guard !overlayWindows.isEmpty else { finish(.cancelled); return } + present(overlayWindows) + if overlayWindows.contains(where: \.isVisible) { + NSCursor.crosshair.push() + cursorPushed = true + } + } + } + + func cancel() { finish(.cancelled) } + + private func finish(_ outcome: RegionSelectionOutcome) { + guard let continuation else { return } + self.continuation = nil + if cursorPushed { NSCursor.pop(); cursorPushed = false } + for window in overlayWindows { window.orderOut(nil); window.close() } + overlayWindows.removeAll() + selectionViews.removeAll() + continuation.resume(returning: outcome) + } +} + +// MARK: - Overlay Window (prevents AppKit cursor resets) + +private final class OverlayWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } + + override func cursorUpdate(with event: NSEvent) { + // Swallow cursor updates — we manage the cursor ourselves in SelectionView + } +} + +// MARK: - Frozen Backdrop + +final class FrozenBackdropView: NSView { + private let image: NSImage + + init(image: CGImage, frame: NSRect) { + self.image = NSImage(cgImage: image, size: frame.size) + super.init(frame: frame) + } + + required init?(coder: NSCoder) { fatalError() } + + override func draw(_ dirtyRect: NSRect) { image.draw(in: bounds) } +} + +// MARK: - Selection View + +final class SelectionView: NSView { + private var dragStart: NSPoint? + private var dragCurrent: NSPoint? + private var mouseLocation: NSPoint? + private(set) var selection: CGRect? + private var activeHandle: RegionHandle? + private var handleDragOrigin: NSPoint? + private var handleDragRect: CGRect? + var onBeginSelection: () -> Void = {} + private var trackingArea: NSTrackingArea? + private let scaleFactor: CGFloat + private let crosshairCursor: NSCursor + private let onSelect: (CGRect) -> Void + private let onCancel: () -> Void + + private var finished = false + private let toolbar = NSVisualEffectView() + private let hint = NSTextField(labelWithString: "") + let confirmButton = NSButton(title: "截取", target: nil, action: nil) + let cancelButton = NSButton(title: "取消", target: nil, action: nil) + private var controls: NSStackView! + + init( + frame: CGRect, scaleFactor: CGFloat, + onSelect: @escaping (CGRect) -> Void, onCancel: @escaping () -> Void + ) { + self.scaleFactor = scaleFactor + self.crosshairCursor = .crosshair + self.onSelect = onSelect + self.onCancel = onCancel + super.init(frame: frame) + toolbar.material = .hudWindow + toolbar.blendingMode = .withinWindow + toolbar.state = .active + toolbar.wantsLayer = true + toolbar.layer?.cornerRadius = 10 + hint.font = .systemFont(ofSize: 12, weight: .medium) + hint.textColor = .labelColor + confirmButton.bezelStyle = .rounded + confirmButton.keyEquivalent = "\r" + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + confirmButton.target = self + confirmButton.action = #selector(confirmSelection) + cancelButton.target = self + cancelButton.action = #selector(cancelSelection) + confirmButton.setAccessibilityIdentifier("capture.confirm") + cancelButton.setAccessibilityIdentifier("capture.cancel") + controls = NSStackView(views: [hint, cancelButton, confirmButton]) + controls.orientation = .horizontal + controls.alignment = .centerY + controls.spacing = 10 + controls.edgeInsets = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + toolbar.addSubview(controls) + addSubview(toolbar) + setAccessibilityElement(true) + setAccessibilityRole(.group) + setAccessibilityLabel("截图选区") + setAccessibilityHelp("拖动框选,松手后可移动区域或拖动八个手柄调整。回车截取,Esc 或取消按钮退出。") + updateToolbar() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override var acceptsFirstResponder: Bool { true } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.makeFirstResponder(self) + updateTrackingAreas() + } + + override func updateTrackingAreas() { + if let existing = trackingArea { + removeTrackingArea(existing) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + super.updateTrackingAreas() + } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: crosshairCursor) + setCursor(crosshairCursor) + } + + override func cursorUpdate(with event: NSEvent) { + setCursor(crosshairCursor) + } + + // MARK: - Drawing + + override func draw(_ dirtyRect: NSRect) { + let shade = NSBezierPath(rect: bounds) + let rect = if let start = dragStart, let current = dragCurrent { rectFromPoints(start, current) } else { selection } + if let rect, rect.width > 2, rect.height > 2 { shade.appendRect(rect) } + shade.windingRule = .evenOdd + NSColor.black.withAlphaComponent(0.3).setFill() + shade.fill() + + if let start = dragStart, let current = dragCurrent { + drawSelection(start: start, current: current) + } else if let selection { + drawAdjustableSelection(selection) + } else if let mouse = mouseLocation { + drawGuideLines(at: mouse) + } + } + + private func drawGuideLines(at point: NSPoint) { + let lineColor = NSColor.white.withAlphaComponent(0.4) + lineColor.setStroke() + + let path = NSBezierPath() + path.lineWidth = 0.5 + + // Vertical guide line + path.move(to: NSPoint(x: point.x, y: bounds.minY)) + path.line(to: NSPoint(x: point.x, y: bounds.maxY)) + + // Horizontal guide line + path.move(to: NSPoint(x: bounds.minX, y: point.y)) + path.line(to: NSPoint(x: bounds.maxX, y: point.y)) + + path.stroke() + } + + private func drawSelection(start: NSPoint, current: NSPoint) { + let selectionRect = rectFromPoints(start, current) + guard selectionRect.width > 2, selectionRect.height > 2 else { return } + + drawBorder(selectionRect) + + drawLabel(pixelSize(selectionRect), below: selectionRect) + } + + private func drawAdjustableSelection(_ rect: CGRect) { + drawBorder(rect) + + for handle in RegionHandle.allCases where handle != .move { + let p = handle.point(in: rect) + let dot = NSBezierPath(ovalIn: CGRect(x: p.x - 4, y: p.y - 4, width: 8, height: 8)) + NSColor.white.setFill() + dot.fill() + NSColor.black.withAlphaComponent(0.5).setStroke() + dot.lineWidth = 1 + dot.stroke() + } + + } + + private func pixelSize(_ rect: CGRect) -> String { + "\(Int(rect.width * scaleFactor)) × \(Int(rect.height * scaleFactor))" + } + + private func drawBorder(_ rect: CGRect) { + let path = NSBezierPath(rect: rect) + NSColor.black.withAlphaComponent(0.75).setStroke() + path.lineWidth = 3.5 + path.stroke() + NSColor.white.setStroke() + path.lineWidth = 1.5 + path.stroke() + } + + private func drawLabel(_ text: String, below rect: CGRect) { + let label = text as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .medium), + .foregroundColor: NSColor.white, + ] + let labelSize = label.size(withAttributes: attrs) + let width = labelSize.width + 12 + let height = labelSize.height + 4 + let labelRect = CGRect( + x: min(max(rect.midX - width / 2, bounds.minX + 8), bounds.maxX - width - 8), + y: max(rect.minY - height - 4, bounds.minY + 8), + width: width, + height: height + ) + NSColor.black.withAlphaComponent(0.7).setFill() + NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill() + label.draw(at: NSPoint(x: labelRect.minX + 6, y: labelRect.minY + 2), withAttributes: attrs) + } + + // MARK: - Mouse Events + + func clearSelection() { + selection = nil + dragStart = nil + dragCurrent = nil + activeHandle = nil + updateToolbar() + needsDisplay = true + } + + private func adjustmentHandle(at point: NSPoint) -> RegionHandle? { + guard let selection, let handle = RegionAdjustment.handle(at: point, in: selection) else { return nil } + return handle + } + + private func updateCursor(at point: NSPoint) { + guard let handle = adjustmentHandle(at: point) else { + setCursor(crosshairCursor) + return + } + setCursor(Self.cursor(for: handle, dragging: false)) + } + + private static func cursor(for handle: RegionHandle, dragging: Bool) -> NSCursor { + let position: NSCursor.FrameResizePosition + switch handle { + case .move: return dragging ? .closedHand : .openHand + case .topLeft: position = .topLeft + case .top: position = .top + case .topRight: position = .topRight + case .right: position = .right + case .bottomRight: position = .bottomRight + case .bottom: position = .bottom + case .bottomLeft: position = .bottomLeft + case .left: position = .left + } + return .frameResize(position: position, directions: .all) + } + + override func mouseEntered(with event: NSEvent) { + updateCursor(at: convert(event.locationInWindow, from: nil)) + } + + override func mouseMoved(with event: NSEvent) { + let loc = convert(event.locationInWindow, from: nil) + mouseLocation = loc + updateCursor(at: loc) + needsDisplay = true + } + + override func mouseDown(with event: NSEvent) { + beginDrag(at: convert(event.locationInWindow, from: nil), clickCount: event.clickCount) + } + + func beginDrag(at loc: CGPoint, clickCount: Int = 1) { + guard !finished else { return } + if window?.isVisible == true { window?.makeKey() } + window?.makeFirstResponder(self) + mouseLocation = nil + if clickCount == 2, let selection, selection.contains(loc) { + confirmSelection() + return + } + if let selection, let handle = adjustmentHandle(at: loc) { + activeHandle = handle + handleDragOrigin = loc + handleDragRect = selection + setCursor(Self.cursor(for: handle, dragging: true)) + return + } + setCursor(crosshairCursor) + onBeginSelection() + selection = nil + dragStart = clamped(loc) + dragCurrent = clamped(loc) + updateToolbar() + needsDisplay = true + } + + override func mouseDragged(with event: NSEvent) { + updateDrag(at: convert(event.locationInWindow, from: nil)) + } + + func updateDrag(at loc: CGPoint) { + guard !finished else { return } + let loc = clamped(loc) + if let handle = activeHandle, let origin = handleDragOrigin, let base = handleDragRect { + let delta = CGSize(width: loc.x - origin.x, height: loc.y - origin.y) + selection = RegionAdjustment.apply(handle, delta: delta, to: base, within: bounds) + } else if dragStart != nil { + setCursor(crosshairCursor) + dragCurrent = loc + } + updateToolbar() + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + endDrag(at: convert(event.locationInWindow, from: nil)) + } + + func endDrag(at end: CGPoint) { + guard !finished else { return } + updateDrag(at: end) + let end = clamped(end) + if activeHandle != nil { + activeHandle = nil + handleDragOrigin = nil + handleDragRect = nil + updateCursor(at: end) + updateToolbar() + return + } + guard let start = dragStart else { return } + dragStart = nil + dragCurrent = nil + let rect = rectFromPoints(start, end) + + if rect.width > 3, rect.height > 3 { + selection = rect + updateCursor(at: end) + } + updateToolbar() + needsDisplay = true + } + + override func keyDown(with event: NSEvent) { + switch event.keyCode { + case 53: cancelSelection() + case 36, 76: confirmSelection() + default: super.keyDown(with: event) + } + } + + override func cancelOperation(_ sender: Any?) { cancelSelection() } + override func rightMouseDown(with event: NSEvent) { cancelSelection() } + + @objc func confirmSelection() { + guard let selection, selection.width > 3, selection.height > 3 else { return } + finish(selection) + } + + @objc func cancelSelection() { finish(nil) } + + private func finish(_ rect: CGRect?) { + guard !finished else { return } + finished = true + confirmButton.isEnabled = false + cancelButton.isEnabled = false + if let rect { onSelect(rect) } else { onCancel() } + } + + private func clamped(_ point: CGPoint) -> CGPoint { + CGPoint(x: min(max(point.x, bounds.minX), bounds.maxX), y: min(max(point.y, bounds.minY), bounds.maxY)) + } + + private func setCursor(_ cursor: NSCursor) { + if window?.isVisible == true { cursor.set() } + } + + override func layout() { + super.layout() + updateToolbar() + } + + private func updateToolbar() { + toolbar.isHidden = dragStart != nil || activeHandle != nil + let valid = selection.map { $0.width > 3 && $0.height > 3 } ?? false + confirmButton.isHidden = !valid + confirmButton.isEnabled = valid && !finished + hint.stringValue = selection.map { "\(pixelSize($0)) · 拖动移动,边缘调整" } ?? "拖动框选 · Esc 取消" + setAccessibilityValue(hint.stringValue) + controls.layoutSubtreeIfNeeded() + let size = controls.fittingSize + let x = selection.map { $0.maxX - size.width } ?? (bounds.midX - size.width / 2) + var y = bounds.maxY - size.height - 20 + if let selection { + y = selection.minY - size.height - 12 + if y < 8 { y = selection.maxY + 12 } + } + toolbar.frame = CGRect(x: min(max(x, 8), max(8, bounds.maxX - size.width - 8)), + y: min(max(y, 8), max(8, bounds.maxY - size.height - 8)), + width: size.width, height: size.height) + controls.frame = toolbar.bounds + } + + private func rectFromPoints(_ a: NSPoint, _ b: NSPoint) -> CGRect { + CGRect( + x: min(a.x, b.x), + y: min(a.y, b.y), + width: abs(b.x - a.x), + height: abs(b.y - a.y) + ) + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index a5e690f..de6e6c0 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -46,7 +46,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion9 +CFBundleVersion11 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From 7a7bebda1a0e53461f4f981d9f520b0a987dd071 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 17:22:12 +0800 Subject: [PATCH 05/14] =?UTF-8?q?fix(mac):=20=E7=BB=9F=E4=B8=80=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E4=BE=A7=E6=A0=8F=E5=91=BD=E4=BB=A4=E3=80=81=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E9=87=87=E9=9B=86=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以 ⌘⌥S 和原生工具栏共同控制侧栏,集中连接、扩展与采集设置 - 统一快捷键与菜单可用条件,保留平台命令并修复记录预览及 Web 配置返回路径 - 增加隐藏原生窗口、实际视图渲染与关键命令检查,构建 v12 开发包 --- Package.swift | 1 + README.md | 10 +- Sources/InKCreApp/AppMain.swift | 159 ++++++++---------- Sources/InKCreApp/PeerSettingsWindow.swift | 101 +++++++++++ .../CaptureInteractionTests.swift | 59 ++++++- .../CaptureMacTests/CaptureLayoutTests.swift | 62 +++++++ Tests/CaptureMacTests/ShortcutTests.swift | 51 ++++++ Tests/HostTests/HostInterfaceTests.swift | 75 +++++++++ .../Sources/CaptureMac/CaptureExtension.swift | 86 ++++++++-- .../Sources/CaptureMac/CaptureModel.swift | 47 ++++-- .../CaptureMac/CaptureRecordsView.swift | 16 +- .../CaptureMac/CaptureSettingsView.swift | 42 +++++ .../Sources/CaptureMac/ConnectionView.swift | 36 +--- .../Sources/CaptureMac/GlobalShortcuts.swift | 23 ++- .../Sources/CaptureMac/KeyboardShortcut.swift | 18 ++ .../Sources/CaptureMac/PeerMainView.swift | 11 +- .../Sources/CaptureMac/PeerMainWindow.swift | 15 ++ scripts/build-app.sh | 2 +- 18 files changed, 627 insertions(+), 187 deletions(-) create mode 100644 Sources/InKCreApp/PeerSettingsWindow.swift create mode 100644 Tests/HostTests/HostInterfaceTests.swift create mode 100644 extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift diff --git a/Package.swift b/Package.swift index 752a233..c334d2b 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,7 @@ let package = Package( .product(name: "PeerMacUI", package: "PeerCore"), .product(name: "CaptureMac", package: "capture"), ]), + .testTarget(name: "HostTests", dependencies: ["InKCreApp"], path: "Tests/HostTests"), .testTarget( name: "CaptureTests", dependencies: [ diff --git a/README.md b/README.md index a0f7abd..823c788 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 启动或点击 Dock 图标打开普通主窗口,侧栏提供采集、记录、Peer 与连接。主窗口可缩放、最小化,记住尺寸位置;关闭主窗口后进程、菜单栏入口和队列继续运行,`⌘Q` 才退出。Peer 页展示当前信息库、连接对应的 Peer 身份、最近联络结果和待收入数;配置连接本身不表示在线。 -主窗口使用系统 `NavigationSplitView` 和原生工具栏,标题随当前页面更新。采集页工具栏提供新想法、粘贴材料、框选截图,各页保留快捷面板入口;记录和 Peer 页不显示采集动作。侧栏开关固定在窗口控制按钮旁,系统分隔线跟随侧栏宽度,内容区不另放重复顶栏。窗口通过 `NSHostingController` 与 `fullSizeContentView` 承载 SwiftUI,不用弹性占位或自制侧栏控制器修补位置。显示菜单提供系统侧栏切换和全屏命令。 +主窗口使用系统 `NavigationSplitView` 和原生工具栏,标题随当前页面更新。采集页工具栏提供新想法、粘贴材料、框选截图,各页保留快捷面板入口;记录和 Peer 页不显示采集动作。侧栏开关固定在窗口控制按钮旁,系统分隔线跟随侧栏宽度,内容区不另放重复顶栏。窗口通过 `NSHostingController` 与 `fullSizeContentView` 承载 SwiftUI,不用弹性占位或自制侧栏控制器修补位置。显示菜单以 `⌘⌥S` 切换侧栏,与系统工具栏按钮共用 `NavigationSplitView` 的可见状态;命令沿窗口响应链执行,设置窗口和快捷面板不会误切主窗口侧栏。全屏仍使用系统命令。 全局入口打开独立快捷采集面板。主窗口与面板共用同一草稿、记录和队列,同时只有一份可编辑采集视图。“在主窗口继续”转回主窗口采集页;关闭或收起快捷面板保留草稿并归还前一个应用的焦点。主窗口菜单提供新想法、设置、关闭、最小化和窗口重新打开等普通 Mac 操作。 @@ -22,9 +22,9 @@ open dist/InKCre.app 需要反复开发和实测系统采集时,使用本人明确指定的 Apple Development 身份:`DEVELOPMENT_SIGNING_IDENTITY='Apple Development: …' bash scripts/build-app.sh`。也可将该身份的名称或证书 SHA-1 单独保存在 ignored `.local-development-signing-identity`(一行文本,不执行其中内容)。脚本不会自动选择证书或回退到另一个身份;正式发布仍使用独立的 `SIGNING_IDENTITY` 输入。ad-hoc 身份随代码版本变化,系统可能不再认可先前的隐私授权;固定路径和 bundle ID 本身不足以解决此问题。[Apple 对代码身份与授权的说明](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements)。 -默认 `⌃⌥I` 读取当前选区并唤起快捷面板,`⌃⌥S` 框选截图;采集编辑器内 `⇧⌘V` 粘贴材料、`⌘↩` 收入,Esc 仅收起快捷面板并保留草稿。设置中的五项快捷键均可分别录入、清除或恢复默认,修改立即生效并在重启后保留。点击对应按键按钮后按下组合键,Esc 取消录入。组合键须包含 ⌘、⌃ 或 ⌥,收起面板的默认 Esc 除外。重复组合或系统注册冲突会提示并保留原设置,其他快捷键不受影响。录入期间暂停全局触发,离开设置或切换应用时取消录入。 +默认 `⌃⌥I` 读取当前选区并唤起快捷面板,`⌃⌥S` 框选截图;采集编辑器内 `⇧⌘V` 粘贴材料、`⌘↩` 收入,Esc 仅收起快捷面板并保留草稿。设置中的五项快捷键均可分别录入、清除或恢复默认,修改立即生效并在重启后保留。点击对应按键按钮后按下组合键,Esc 取消录入。组合键须包含 ⌘、⌃ 或 ⌥,收起面板的默认 Esc 除外。`⌘W`、`⌘Q`、`⌘,`、`⌘⌥S` 等窗口、导航和编辑命令保留给平台操作。重复组合、标准命令冲突或系统注册冲突会提示并保留原设置,其他快捷键不受影响。录入期间暂停全局触发,离开设置或切换应用时取消录入。 -收集和截图在全局生效;粘贴和收入作用于主窗口采集页或快捷面板;收起仅作用于快捷面板。输入法组词时采集快捷键交给输入法处理。菜单栏入口始终可用。选区需要辅助功能权限,截图需要屏幕录制权限;输入和明确粘贴不需要这些权限。 +收集和截图在全局生效;粘贴和收入作用于主窗口采集页或快捷面板;收起仅作用于快捷面板。输入法组词时采集快捷键交给输入法处理。“采集”菜单显示当前配置的快捷键;菜单和按键使用相同的可用条件,无法执行的局部快捷键不会吞掉输入。选区需要辅助功能权限,截图需要屏幕录制权限;输入和明确粘贴不需要这些权限。 直接 AX 读取不可用时,仅在系统明确报告非空选区且不是安全输入时尝试复制回退。没有可靠选区时显示降级提示,不采集旧剪贴板或编辑器默认复制的整行。真实来源缺失就留空;明确粘贴不会伪造来源应用。通用文件、网页抓取、OCR 和知识查询不属于此版本。 @@ -38,7 +38,7 @@ open dist/InKCre.app 连接设置使用 PostgreSQL 主机、端口、库名、独立运行角色及密码,可选 Web 地址。远端使用 TLS 并校验证书;只有回环开发地址允许关闭 TLS。密码存于 Keychain,角色拥有已准入数据库协议权限,不使用迁移管理员或 JWT 签名密钥。 -“连接信息库”或“编辑连接”打开原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后网络检查在后台进行,不阻塞采集。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。 +`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页只展示状态,所有连接编辑与失败记录的“修复连接”都打开设置窗口中的同一个原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。 共享数据库需要本分支的 Capture 回执表与原生分发参与合同。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写图、原图和回执;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 @@ -52,7 +52,7 @@ open dist/InKCre.app 开发包的 Host 配置位于 `~/Library/Application Support/dev.inkcre.capture/connection.json`;Capture 数据位于同目录 `extensions/inkcre-capture/capture.sqlite`,使用 WAL/FULL。确认后清理本机材料,保留共享引用。WAL 和空闲页不提供安全擦除保证。此前未发布开发版的数据文件原样保留,本版没有旧格式导入或 HTTP 回退层。 -`Packages/PeerCore` 提供数据库、标准图/存储、凭据和原生模块生命周期;`extensions/capture` 是独立 Swift 包,拥有采集业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。应用菜单“扩展…”展示实际携带的分发版本和本机运行状态,可独立启停。停用撤下入口并等待当前工作收束,保留材料;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 +`Packages/PeerCore` 提供数据库、标准图/存储、凭据和原生模块生命周期;`extensions/capture` 是独立 Swift 包,拥有采集业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。停用撤下入口并等待当前工作收束,保留材料;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 00b4438..8e5a95e 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -15,23 +15,30 @@ enum InKCreApplication { } @MainActor -final class PeerHost: NSObject, NSApplicationDelegate { - private let context: PeerHostContext - private let runtime: BundledExtensionRuntime +final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserInterfaceValidations { + let context: PeerHostContext + let runtime: BundledExtensionRuntime + let capture: CaptureExtension private var module: any NativeExtension { runtime.module } - override init() { + override convenience init() { let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent(identity) - context = PeerHostContext(identity: identity, directory: directory) - runtime = BundledExtensionRuntime(module: CaptureExtension(context: context)) + self.init(context: PeerHostContext(identity: identity, directory: directory)) + } + + init(context: PeerHostContext, defaults: UserDefaults = .standard) { + self.context = context + capture = CaptureExtension(context: context) + runtime = BundledExtensionRuntime(module: capture, defaults: defaults) super.init() + capture.showPreferences = { [weak self] in self?.showSettings(.capture) } + capture.editConnection = { [weak self] target in self?.editConnection(target) } } private var presence: Task? - private var connectionIssue: String? - private var manager: NSWindow? - private var toggle: NSButton? - private var status: NSTextField? + @Published var connectionIssue: String? + @Published var connection: ConnectionTarget? + private var manager: PeerSettingsWindow? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) @@ -60,7 +67,7 @@ final class PeerHost: NSObject, NSApplicationDelegate { if !runtime.running { showExtensions(nil) } } - private func installHostMenu() { + func installHostMenu() { let main = NSMenu() func addMenu(_ title: String) -> NSMenu { let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") @@ -73,7 +80,7 @@ final class PeerHost: NSObject, NSApplicationDelegate { app.addItem( withTitle: "关于 InKCre", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") - app.addItem(withTitle: "设置…", action: #selector(showExtensions(_:)), keyEquivalent: ",").target = self + app.addItem(withTitle: "设置…", action: #selector(showPreferences(_:)), keyEquivalent: ",").target = self app.addItem(withTitle: "Peer 连接…", action: #selector(showConnection(_:)), keyEquivalent: "").target = self app.addItem(withTitle: "开源许可与致谢…", action: #selector(showLicenses(_:)), keyEquivalent: "").target = @@ -103,7 +110,7 @@ final class PeerHost: NSObject, NSApplicationDelegate { view.addItem( withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s" - ).keyEquivalentModifierMask = [.command, .control] + ).keyEquivalentModifierMask = [.command, .option] view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") .keyEquivalentModifierMask = [.command, .control] let window = addMenu("窗口") @@ -111,7 +118,7 @@ final class PeerHost: NSObject, NSApplicationDelegate { withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") window.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") window.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") - window.addItem(withTitle: "Peer 与扩展", action: #selector(showExtensions(_:)), keyEquivalent: "") + window.addItem(withTitle: "扩展设置…", action: #selector(showExtensions(_:)), keyEquivalent: "") .target = self window.addItem( withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") @@ -119,98 +126,64 @@ final class PeerHost: NSObject, NSApplicationDelegate { NSApp.mainMenu = main } - @objc private func showExtensions(_ sender: Any?) { - if manager == nil { - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 500, height: 250), - styleMask: [.titled, .closable], backing: .buffered, defer: false) - window.title = "InKCre 扩展" - window.isReleasedWhenClosed = false - let title = NSTextField(labelWithString: "随手收集 · \(module.descriptor.name)") - title.font = .boldSystemFont(ofSize: 16) - let version = NSTextField( - labelWithString: "\(module.descriptor.distribution) · \(module.descriptor.version) · 随应用交付") - version.textColor = .secondaryLabelColor - let button = NSButton( - checkboxWithTitle: "在此 Mac 启用", target: self, action: #selector(changeEnabled(_:))) - let label = NSTextField(wrappingLabelWithString: "") - let stack = NSStackView(views: [title, version, button, label]) - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 14 - stack.translatesAutoresizingMaskIntoConstraints = false - window.contentView?.addSubview(stack) - if let content = window.contentView { - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 24), - stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -24), - stack.centerYAnchor.constraint(equalTo: content.centerYAnchor), - ]) - } - toggle = button - status = label - manager = window - window.center() + @objc private func showPreferences(_ sender: Any?) { showSettings() } + @objc private func showExtensions(_ sender: Any?) { showSettings(.extensions) } + @objc private func showConnection(_ sender: Any?) { showSettings(.connection) } + + func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + if [#selector(showPreferences(_:)), #selector(showConnection(_:)), #selector(showExtensions(_:))].contains(item.action) { + return manager?.attachedSheet != nil || capture.canOpenSettings } - updateStatus() + return true + } + + private func showSettings(_ pane: PeerSettingsWindow.Pane? = nil) { + guard manager?.attachedSheet != nil || capture.canOpenSettings else { return } + capture.prepareForSettings() + if manager == nil { manager = PeerSettingsWindow(host: self) } + if let pane, manager?.attachedSheet == nil { manager?.select(pane) } NSApp.activate(ignoringOtherApps: true) manager?.makeKeyAndOrderFront(nil) + Task { + do { connection = try await context.connections.load() } + catch { connectionIssue = error.localizedDescription } + } } - @objc private func changeEnabled(_ sender: NSButton) { + func changeEnabled(_ enable: Bool) { guard !runtime.applying else { return } - let enable = sender.state == .on - sender.isEnabled = false - status?.stringValue = "正在应用…" Task { - defer { updateStatus() } + objectWillChange.send() do { try await runtime.setEnabled(enable) } catch { show(error) } + manager?.refreshCaptureSettings(host: self) + updateStatus() } } - private func updateStatus() { - toggle?.state = runtime.enabled ? .on : .off - toggle?.isEnabled = !runtime.applying - status?.stringValue = - runtime.applying - ? "正在应用…" - : runtime.syncIssue ?? connectionIssue ?? (runtime.running ? "本机运行中" : "已停用;本机材料与共享内容保留") - } + private func updateStatus() { objectWillChange.send() } - @objc private func showConnection(_ sender: Any?) { - showExtensions(nil) - Task { - do { - let target = try await context.connections.load() - guard let manager, manager.attachedSheet == nil else { return } - let sheet = NSWindow( - contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) - let close: @MainActor @Sendable () -> Void = { [weak manager, weak sheet] in - guard let manager, let sheet else { return } - manager.endSheet(sheet) - sheet.orderOut(nil) - } - let content = NSHostingView( - rootView: PeerConnectionEditor( - settings: context.connections, credentials: context.credentials, - target: target, onCancel: close, - onSaved: { [weak self] _ in - close() - guard let self else { return } - Task { - do { - await self.runtime.waitUntilIdle() - try await self.runtime.stop() - try self.runtime.startIfEnabled() - self.updateStatus() - } catch { self.show(error) } - } - })) - sheet.contentView = content - sheet.setContentSize(content.fittingSize) - manager.beginSheet(sheet, completionHandler: { _ in }) - } catch { show(error) } + func editConnection(_ target: ConnectionTarget?) { + guard capture.canOpenSettings else { return } + showSettings(.connection) + guard let manager, manager.attachedSheet == nil else { return } + let sheet = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + let close: @MainActor @Sendable () -> Void = { [weak manager, weak sheet] in + guard let manager, let sheet else { return } + manager.endSheet(sheet) + sheet.orderOut(nil) } + let content = NSHostingView(rootView: PeerConnectionEditor( + settings: context.connections, credentials: context.credentials, + target: target, onCancel: close, + onSaved: { [weak self] target in + close() + self?.connection = target + self?.connectionIssue = nil + self?.capture.connectionDidChange(target) + })) + sheet.contentView = content + sheet.setContentSize(content.fittingSize) + manager.beginSheet(sheet) } @objc private func showLicenses(_ sender: Any?) { diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift new file mode 100644 index 0000000..d7b1a89 --- /dev/null +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -0,0 +1,101 @@ +import AppKit +import PeerCore +import SwiftUI + +@MainActor +final class PeerSettingsWindow: NSWindow, NSWindowDelegate { + private weak var host: PeerHost? + enum Pane: Int { case connection, extensions, capture } + private let tabs = NSTabViewController() + + init(host: PeerHost) { + super.init(contentRect: NSRect(x: 0, y: 0, width: 640, height: 580), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + self.host = host + delegate = self + title = "InKCre 设置" + isReleasedWhenClosed = false + minSize = NSSize(width: 600, height: 540) + tabs.tabStyle = .toolbar + let connection = NSTabViewItem(viewController: NSHostingController(rootView: ConnectionSettingsView(host: host))) + connection.label = "连接" + connection.image = NSImage(systemSymbolName: "network", accessibilityDescription: "连接") + let extensions = NSTabViewItem(viewController: NSHostingController(rootView: ExtensionSettingsView(host: host))) + extensions.label = "扩展" + extensions.image = NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "扩展") + let capture = NSTabViewItem(viewController: captureController(host: host)) + capture.label = "采集" + capture.image = NSImage(systemSymbolName: "viewfinder", accessibilityDescription: "采集") + tabs.tabViewItems = [connection, extensions, capture] + contentViewController = tabs + center() + } + + func windowWillClose(_ notification: Notification) { host?.capture.finishEditingSettings() } + func windowDidResignKey(_ notification: Notification) { host?.capture.finishEditingSettings() } + + func select(_ pane: Pane) { tabs.selectedTabViewItemIndex = pane.rawValue } + + func refreshCaptureSettings(host: PeerHost) { + replaceCaptureSettings(with: captureController(host: host)) + } + + func replaceCaptureSettings(with controller: NSViewController) { + let selected = tabs.selectedTabViewItemIndex + let previous = tabs.tabViewItems[Pane.capture.rawValue] + let replacement = NSTabViewItem(viewController: controller) + replacement.label = previous.label + replacement.image = previous.image + // NSTabViewController must update containment when replacing a hosted pane. + tabs.removeTabViewItem(previous) + tabs.insertTabViewItem(replacement, at: Pane.capture.rawValue) + tabs.selectedTabViewItemIndex = selected + } + + private func captureController(host: PeerHost) -> NSViewController { + host.capture.makeSettingsViewController() ?? NSHostingController(rootView: + ContentUnavailableView("采集扩展已停用", systemImage: "viewfinder", + description: Text("在“扩展”中启用后,可设置采集快捷键和系统权限。"))) + } +} + +private struct ConnectionSettingsView: View { + @ObservedObject var host: PeerHost + var body: some View { + Form { + Section("共享信息库") { + if let target = host.connection { + LabeledContent("信息库", value: target.name) + LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent("运行角色", value: target.username) + if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } + } else { + Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") + .foregroundStyle(.secondary) + } + Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { host.editConnection(host.connection) } + if let issue = host.connectionIssue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + } + Text("连接由此 Mac 的各扩展共享;密码保存在系统钥匙串中。") + .font(.caption).foregroundStyle(.secondary) + }.formStyle(.grouped) + } +} + +private struct ExtensionSettingsView: View { + @ObservedObject var host: PeerHost + var body: some View { + Form { + Section("随手收集") { + Toggle("在此 Mac 启用", isOn: Binding(get: { host.runtime.enabled }, set: { host.changeEnabled($0) })) + .disabled(host.runtime.applying) + Text(host.runtime.applying ? "正在应用…" : host.runtime.syncIssue ?? + (host.runtime.running ? "本机运行中" : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留")) + .foregroundStyle(.secondary) + LabeledContent("版本", value: host.capture.descriptor.version) + Text("随应用交付,提供选区、粘贴、截图与想法采集。") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + } +} diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 4ddcc59..675f6ca 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -12,12 +12,11 @@ struct CaptureInteractionTests { let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } var mainOpened = false - model.showMain = { mainOpened = true } + model.editConnection = { _ in mainOpened = true } model.submit() #expect(!model.hasContent) #expect(!model.submitting) #expect(!mainOpened) - #expect(!model.showingConnectionEditor) #expect(model.pane == .capture) #expect(model.error == nil) } @@ -50,13 +49,13 @@ struct CaptureInteractionTests { defer { model.cancelWork() } model.thought = "主窗口中未提交的草稿" let previousID = model.draft.id - model.pane = .settings + model.pane = .peer model.editorOwner = .quick var presented: CaptureModel.EditorOwner? model.presentEditor = { presented = model.editorOwner } let payload = CapturePayload(entry: .selection, materials: [Material(text: "快捷采集的新材料")]) await model.receive(payload) - #expect(model.pane == .settings) + #expect(model.pane == .peer) #expect(presented == .quick) #expect(model.draft.id == payload.submissionID) #expect(try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿") @@ -72,7 +71,7 @@ struct CaptureInteractionTests { #expect(model.draft.id == currentID) } - @Test func mainCaptureRevealsEditorAndMissingConnectionOpensMainSettings() async throws { + @Test func missingConnectionUsesHostSettingsWithoutReplacingTheDraft() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") @@ -82,15 +81,59 @@ struct CaptureInteractionTests { #expect(model.pane == .capture) model.editorOwner = .quick var mainOpened = false - model.showMain = { mainOpened = true; model.editorOwner = .main } + model.editConnection = { _ in mainOpened = true } model.submit() #expect(mainOpened) - #expect(model.editorOwner == .main) - #expect(model.pane == .settings) + #expect(model.editorOwner == .quick) + #expect(model.pane == .capture) #expect(model.hasContent) #expect(model.lastContactAt == nil) } + @Test func configuringWebAddressMakesExistingResultsOpenableWithoutRetargetingThem() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.result.\(UUID().uuidString)") + defer { model.cancelWork() } + let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "inkcre_core") + var record = CaptureRecord() + record.target = target + record.state = .confirmed + record.result = CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + #expect(model.resultURL(for: record) == nil) + var configured = target + configured.webURL = URL(string: "https://fixture.test") + model.connection = configured + #expect(model.resultURL(for: record)?.absoluteString == "https://fixture.test/info-base/list/blocks/42/content") + #expect(record.target == target) + model.connection = ConnectionTarget(host: "other.test", database: "other", username: "inkcre_core", webURL: URL(string: "https://other.test")) + #expect(model.resultURL(for: record) == nil) + model.connection = configured + record.result = CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .deleted) + #expect(model.resultURL(for: record) == nil) + } + + @Test func commandAvailabilityFollowsEditorContentAndPresentation() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.commands.\(UUID().uuidString)") + defer { model.cancelWork() } + #expect(!model.canPerform(.submit, isEditor: true, isQuickPanel: false)) + model.thought = "待收入的内容" + #expect(model.canPerform(.submit, isEditor: true, isQuickPanel: false)) + #expect(!model.canPerform(.submit, isEditor: false, isQuickPanel: false)) + #expect(!model.canPerform(.paste, isEditor: false, isQuickPanel: false)) + #expect(!model.canPerform(.hide, isEditor: true, isQuickPanel: false)) + #expect(model.canPerform(.hide, isEditor: true, isQuickPanel: true)) + model.requestDiscard(model.draft) + for action in ShortcutAction.allCases { + #expect(!model.canPerform(action, isEditor: true, isQuickPanel: true)) + } + model.removalRequest = nil + model.submitting = true + #expect(!model.canPerform(.screenshot, isEditor: true, isQuickPanel: true)) + } + @Test func materialRemovalRequiresConfirmationAndPreservesOtherContent() async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } diff --git a/Tests/CaptureMacTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift index 73b2c97..d507b37 100644 --- a/Tests/CaptureMacTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -1,4 +1,5 @@ import AppKit +import Capture import SwiftUI import PeerCore import PeerMacUI @@ -69,4 +70,65 @@ struct CaptureLayoutTests { } } + @Test func mainWindowSidebarAndPrimarySurfacesRenderOffscreen() async throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") + defer { model.cancelWork() } + let window = PeerMainWindow(model: model) + window.setFrameAutosaveName("") + window.setFrame(NSRect(x: 0, y: 0, width: 760, height: 520), display: false) + defer { window.orderOut(nil) } + let item = NSMenuItem(title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") + #expect(window.validateUserInterfaceItem(item)) + #expect(item.title == "隐藏侧栏") + #expect(window.tryToPerform(item.action!, with: item)) + #expect(model.columnVisibility == .detailOnly) + #expect(window.validateUserInterfaceItem(item)) + #expect(item.title == "显示侧栏") + try await snapshot(window, named: "main-sidebar-hidden") + #expect(window.tryToPerform(item.action!, with: item)) + #expect(model.columnVisibility == .all) + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + for pane in [CaptureModel.Pane.capture, .queue, .peer] { + model.pane = pane + try await snapshot(window, named: "main-\(pane)-\(appearance.rawValue)") + } + model.records = [CaptureRecord(payload: CapturePayload(annotation: "只有补充想法的草稿也必须有预览。"))] + model.pane = .queue + try await snapshot(window, named: "records-annotation-\(appearance.rawValue)") + model.records = [] + } + let auxiliary = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 560), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + defer { auxiliary.orderOut(nil) } + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + auxiliary.appearance = NSAppearance(named: appearance) + auxiliary.contentViewController = NSHostingController(rootView: CaptureSettingsView(model: model)) + auxiliary.setContentSize(NSSize(width: 600, height: 560)) + try await snapshot(auxiliary, named: "capture-settings-\(appearance.rawValue)") + model.editorOwner = .quick + model.thought = "用于界面验收的合成想法,不读取剪贴板或屏幕。" + auxiliary.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) + auxiliary.setContentSize(NSSize(width: 500, height: 450)) + try await snapshot(auxiliary, named: "quick-\(appearance.rawValue)") + } + #expect(!window.isVisible && !auxiliary.isVisible) + } + + private func snapshot(_ window: NSWindow, named name: String) async throws { + try await Task.sleep(for: .milliseconds(200)) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + #expect(bitmap.pixelsWide >= 500 && bitmap.pixelsHigh >= 400) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: path).appendingPathComponent("\(name).png")) + } + } + } diff --git a/Tests/CaptureMacTests/ShortcutTests.swift b/Tests/CaptureMacTests/ShortcutTests.swift index 989c961..4db4782 100644 --- a/Tests/CaptureMacTests/ShortcutTests.swift +++ b/Tests/CaptureMacTests/ShortcutTests.swift @@ -52,6 +52,57 @@ struct ShortcutTests { #expect(String(utf16CodeUnits: result, count: count) == original) } + @Test func unavailableActionsPassThroughAndAvailableActionsRunOnce() throws { + _ = NSApplication.shared + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + shortcuts.isCaptureWindow = { _ in true } + var enabled = false + var performed = 0 + shortcuts.canPerform = { _, _ in enabled } + shortcuts.perform = { _ in performed += 1 } + let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: 0, context: nil, characters: "\r", + charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) + #expect(shortcuts.handle(event) === event) + #expect(performed == 0) + enabled = true + #expect(shortcuts.handle(event) == nil) + #expect(performed == 1) + } + + @Test func invalidSavedBindingDoesNotPreventOpeningSettingsOrDisableOtherActions() throws { + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + var saved = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) + saved[.hide] = RecordedShortcut(keyCode: UInt16(kVK_ANSI_W), modifiers: .command, keyLabel: "W") + let data = try JSONEncoder().encode(saved) + defaults.set(data, forKey: GlobalShortcuts.settingsKey) + let shortcuts = try GlobalShortcuts(defaults: defaults) + #expect(shortcuts.bindings[.hide] == nil) + #expect(shortcuts.bindings[.capture] == ShortcutAction.capture.defaultShortcut) + #expect(shortcuts.message?.contains("⌘W") == true) + #expect(defaults.data(forKey: GlobalShortcuts.settingsKey) == data) + } + + @Test func nativeWindowAndEditingCommandsCannotBeReassigned() throws { + let (defaults, domain) = defaults() + defer { defaults.removePersistentDomain(forName: domain) } + let shortcuts = try GlobalShortcuts(defaults: defaults) + for (key, label, modifiers) in [ + (kVK_ANSI_W, "W", NSEvent.ModifierFlags.command), (kVK_ANSI_Q, "Q", .command), + (kVK_ANSI_Comma, ",", .command), (kVK_ANSI_V, "V", .command), + (kVK_ANSI_S, "S", [.command, .option]) + ] { + #expect(throws: PeerError.self) { + try shortcuts.set(RecordedShortcut(keyCode: UInt16(key), modifiers: modifiers, keyLabel: label), for: .hide) + } + } + #expect(shortcuts.bindings[.hide] == ShortcutAction.hide.defaultShortcut) + #expect(defaults.data(forKey: GlobalShortcuts.settingsKey) == nil) + } + private func defaults() -> (UserDefaults, String) { let name = "dev.inkcre.shortcut-tests.\(UUID().uuidString)" return (UserDefaults(suiteName: name)!, name) diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift new file mode 100644 index 0000000..ce9b41b --- /dev/null +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -0,0 +1,75 @@ +import AppKit +import PeerCore +import SwiftUI +@testable import CaptureMac +import Testing +@testable import InKCreApp + +@Suite(.serialized) @MainActor +struct HostInterfaceTests { + @Test func nativeSidebarShortcutAndSettingsTabsRemainAvailableWithoutCapture() async throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let domain = "dev.inkcre.host-tests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { + defaults.removePersistentDomain(forName: domain) + try? FileManager.default.removeItem(at: directory) + } + defaults.set(false, forKey: "extensions.inkcre/capture.enabled") + let host = PeerHost(context: PeerHostContext(identity: domain, directory: directory), defaults: defaults) + let previousMenu = NSApp.mainMenu + let previousWindowsMenu = NSApp.windowsMenu + defer { NSApp.mainMenu = previousMenu; NSApp.windowsMenu = previousWindowsMenu } + host.installHostMenu() + let menu = try #require(NSApp.mainMenu) + let viewMenu = try #require(menu.items.first(where: { $0.title == "显示" })?.submenu) + let sidebar = try #require(viewMenu.items.first(where: { $0.action == #selector(NSSplitViewController.toggleSidebar(_:)) })) + #expect(sidebar.keyEquivalent == "s") + #expect(sidebar.keyEquivalentModifierMask == [.command, .option]) + #expect(sidebar.target == nil) + let settings = try #require(menu.items.first?.submenu?.items.first(where: { $0.keyEquivalent == "," })) + #expect(settings.keyEquivalentModifierMask == .command) + #expect(host.validateUserInterfaceItem(settings)) + let window = PeerSettingsWindow(host: host) + defer { window.orderOut(nil) } + #expect(!window.isVisible) + let tabs = try #require(window.contentViewController as? NSTabViewController) + #expect(tabs.tabStyle == .toolbar) + #expect(tabs.tabViewItems.map(\.label) == ["连接", "扩展", "采集"]) + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + for pane in [PeerSettingsWindow.Pane.connection, .extensions, .capture] { + window.select(pane) + try await Task.sleep(for: .milliseconds(100)) + #expect(tabs.selectedTabViewItemIndex == pane.rawValue) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: path).appendingPathComponent("settings-\(pane)-\(appearance.rawValue).png")) + } + } + } + let captureModel = try CaptureModel(directory: directory.appendingPathComponent("capture"), identity: domain) + defer { captureModel.cancelWork() } + window.replaceCaptureSettings(with: NSHostingController(rootView: CaptureSettingsView(model: captureModel))) + window.select(.capture) + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + try await Task.sleep(for: .milliseconds(200)) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + #expect(view.bounds.width >= 600 && view.bounds.height >= 400) + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: path).appendingPathComponent("settings-capture-enabled-\(appearance.rawValue).png")) + } + } + #expect(!window.isVisible) + } +} diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index ae04797..23648f1 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -1,6 +1,7 @@ import AppKit import ApplicationServices import Capture +import Combine import PeerCore import SwiftUI @@ -20,8 +21,32 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate self.context = context super.init() } + public var showPreferences: (() -> Void)? + public var editConnection: ((ConnectionTarget?) -> Void)? + + public func makeSettingsViewController() -> NSViewController? { + model.map { NSHostingController(rootView: CaptureSettingsView(model: $0)) } + } + + public func connectionDidChange(_ target: ConnectionTarget) { model?.connectionSaved(target) } + + public var canOpenSettings: Bool { + model == nil || (canChangeCaptureContext && model?.submitting != true) + } + + public func finishEditingSettings() { model?.shortcuts.cancelRecording() } + + public func prepareForSettings() { + model?.shortcuts.cancelRecording() + panel?.makeFirstResponder(nil) + panel?.orderOut(nil) + model?.editorOwner = .main + model?.saveSoon() + } + private var commandItem: NSMenuItem? private var statusItem: NSStatusItem? + private var shortcutObservation: AnyCancellable? private var panel: CapturePanel? private var mainWindow: PeerMainWindow? private var model: CaptureModel? @@ -36,7 +61,13 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - canChangeCaptureContext && model?.submitting != true + guard canChangeCaptureContext, let model, !model.submitting, + model.shortcuts.recording == nil else { return false } + if item.action == #selector(performShortcut(_:)), let item = item as? NSMenuItem, + let action = ShortcutAction(rawValue: UInt32(item.tag)) { + return canPerform(action, in: NSApp.keyWindow) + } + return model.error == nil && model.removalRequest == nil } public func start() throws { @@ -75,23 +106,26 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.dismissQuickCapture = { [weak self] in self?.hide() } model.showMain = { [weak self] in self?.showMain() } model.showQuick = { [weak self] in self?.showQuick() } + model.editConnection = { [weak self] target in self?.editConnection?(target) } model.takeScreenshot = { [weak self] in self?.screenshot() } model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in window === panel || window === mainWindow } + model.shortcuts.canPerform = { [weak self] action, window in + self?.canPerform(action, in: window) == true + } model.shortcuts.perform = { [weak self, weak model] action in - guard let self, let model, self.canChangeCaptureContext else { return } + guard let self, let model else { return } switch action { case .capture: self.showCapture() case .screenshot: self.quickScreenshot() - case .paste: - if model.editorOwner == .quick || model.pane == .capture { model.pasteMaterial() } - case .submit: if model.editorOwner == .quick || model.pane == .capture { model.submit() } - case .hide: if NSApp.keyWindow === self.panel { self.hide() } + case .paste: model.pasteMaterial() + case .submit: model.submit() + case .hide: self.hide() } } try model.shortcuts.start() - if let message = model.shortcuts.message { model.error = message } + if let message = model.shortcuts.message { model.captureNotice = message } configureMenu() model.start() showMain() @@ -115,7 +149,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate ("粘贴材料", #selector(quickPaste)), ("框选截图", #selector(quickScreenshot)), ("采集记录", #selector(showQueue)), - ("Peer 与连接", #selector(showSettings)), + ("采集设置…", #selector(showSettings)), ] { let command = NSMenuItem(title: title, action: selector, keyEquivalent: "") command.target = self @@ -129,15 +163,38 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate let commands = NSMenuItem(title: "采集", action: nil, keyEquivalent: "") let capture = NSMenu(title: "采集") - for (title, action, key) in [ - ("新想法", #selector(newThought), "n"), ("快捷采集", #selector(showQuick), ""), - ("采集记录", #selector(showQueue), ""), ("采集设置", #selector(showSettings), ""), - ] { - capture.addItem(withTitle: title, action: action, keyEquivalent: key).target = self + capture.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n").target = self + for action in ShortcutAction.allCases { + let item = NSMenuItem(title: action.title, action: #selector(performShortcut(_:)), keyEquivalent: "") + item.target = self + item.tag = Int(action.rawValue) + capture.addItem(item) } + capture.addItem(.separator()) + capture.addItem(withTitle: "采集记录", action: #selector(showQueue), keyEquivalent: "").target = self + capture.addItem(withTitle: "采集设置…", action: #selector(showSettings), keyEquivalent: "").target = self commands.submenu = capture NSApp.mainMenu?.insertItem(commands, at: 1) commandItem = commands + shortcutObservation = model?.shortcuts.$bindings.sink { bindings in + for item in capture.items { + guard let action = ShortcutAction(rawValue: UInt32(item.tag)) else { continue } + item.keyEquivalent = bindings[action]?.menuKeyEquivalent ?? "" + item.keyEquivalentModifierMask = bindings[action]?.modifiers ?? [] + } + } + } + + private func canPerform(_ action: ShortcutAction, in window: NSWindow?) -> Bool { + guard canChangeCaptureContext, let model, model.shortcuts.recording == nil else { return false } + let quick = window === panel && model.editorOwner == .quick + let editor = quick || (window === mainWindow && model.pane == .capture && model.editorOwner == .main) + return model.canPerform(action, isEditor: editor, isQuickPanel: quick) + } + + @objc private func performShortcut(_ sender: NSMenuItem) { + guard let action = ShortcutAction(rawValue: UInt32(sender.tag)) else { return } + model?.shortcuts.execute(action, in: NSApp.keyWindow) } @objc private func showCapture() { @@ -222,7 +279,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } @objc private func showSettings() { guard canChangeCaptureContext else { return } - model?.openSettings() + showPreferences?() } @objc private func screenshot() { guard canChangeCaptureContext, let model, !model.submitting else { return } @@ -361,6 +418,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate statusItem = nil if let commandItem { NSApp.mainMenu?.removeItem(commandItem) } commandItem = nil + shortcutObservation = nil try await model.flush() panel = nil mainWindow = nil diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index c1de037..fd64cd2 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -3,10 +3,11 @@ import Capture import Combine import Foundation import PeerCore +import SwiftUI @MainActor final class CaptureModel: ObservableObject { - enum Pane: Hashable { case capture, queue, settings } + enum Pane: Hashable { case capture, queue, peer } enum EditorOwner { case main, quick } enum RemovalRequest { case material(draftID: UUID, materialID: UUID) @@ -41,12 +42,11 @@ final class CaptureModel: ObservableObject { } } @Published var pane = Pane.capture + @Published var columnVisibility = NavigationSplitViewVisibility.all @Published var editorOwner = EditorOwner.main @Published var draft = CaptureRecord() @Published var records: [CaptureRecord] = [] @Published var connection: ConnectionTarget? - @Published var editingConnection: ConnectionTarget? - @Published var showingConnectionEditor = false @Published var error: String? @Published var saveStatus = "随时写下想法,或加入材料" @Published var submitting = false @@ -63,6 +63,7 @@ final class CaptureModel: ObservableObject { var dismissQuickCapture: (() -> Void)? var showMain: (() -> Void)? var showQuick: (() -> Void)? + var editConnection: ((ConnectionTarget?) -> Void)? let store: CaptureStore let credentials: CredentialStore let connections: ConnectionSettings @@ -85,6 +86,16 @@ final class CaptureModel: ObservableObject { queue = CaptureQueue(store: store, credentials: credentials) } + func canPerform(_ action: ShortcutAction, isEditor: Bool, isQuickPanel: Bool) -> Bool { + guard !submitting, error == nil, removalRequest == nil else { return false } + switch action { + case .capture, .screenshot: return true + case .paste: return isEditor + case .submit: return isEditor && hasContent + case .hide: return isQuickPanel + } + } + var pendingCount: Int { records.filter { [.pending, .unknown, .needsAction].contains($0.state) }.count } @@ -296,8 +307,7 @@ final class CaptureModel: ObservableObject { func submit() { guard !submitting, hasContent else { return } guard let connection else { - openSettings() - showingConnectionEditor = true + openConnectionSettings() return } // An input method owns Return until its marked text is committed. @@ -426,11 +436,8 @@ final class CaptureModel: ObservableObject { } } - func openSettings(target: ConnectionTarget? = nil) { - editingConnection = target ?? connection - pane = .settings - showMain?() - if target != nil { showingConnectionEditor = true } + func openConnectionSettings(target: ConnectionTarget? = nil) { + editConnection?(target ?? connection) } func continueInMain() { @@ -461,7 +468,6 @@ final class CaptureModel: ObservableObject { func connectionSaved(_ target: ConnectionTarget) { connection = target - editingConnection = target connectionRevision = UUID() lastContactAt = nil peerConnectionIssue = nil @@ -493,16 +499,21 @@ final class CaptureModel: ObservableObject { } } - func openResult(_ record: CaptureRecord) { - guard let result = record.result, result.availability == .available, - var url = record.target?.webURL - else { - error = "未配置 Web 地址;提交引用仍保留在此。" - return - } + func resultURL(for record: CaptureRecord) -> URL? { + guard let result = record.result, result.availability == .available, let target = record.target else { return nil } + let base = connection?.peerID == target.peerID ? connection?.webURL : target.webURL + guard var url = base else { return nil } for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { url.appendPathComponent(part) } + return url + } + + func openResult(_ record: CaptureRecord) { + guard let url = resultURL(for: record) else { + error = "未配置可用的 Web 地址,或共享内容已删除。" + return + } NSWorkspace.shared.open(url) } diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift index d8c9308..eb7414e 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift @@ -19,7 +19,7 @@ struct CaptureRecordsView: View { HStack { Text(record.statusLabel).font(.headline) Spacer() - Text(record.updatedAt, style: .time).font(.caption).foregroundStyle(.secondary) + Text(record.updatedAt, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(.secondary) } if let text = record.payload?.materials.first(where: { $0.kind == .text })?.text { Text(text).lineLimit(3) @@ -28,6 +28,9 @@ struct CaptureRecordsView: View { Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 100) .accessibilityLabel("记录中的图片") } + if let annotation = record.payload?.annotation, !annotation.isEmpty { + Text(annotation).lineLimit(3).foregroundStyle(.secondary) + } if let message = record.message { Text(message).font(.caption).foregroundStyle(.secondary) } if let target = record.target { Text(target.name).font(.caption).foregroundStyle(.secondary) } if let result = record.result { Text("Block \(result.rootBlockID)").font(.caption).textSelection(.enabled) } @@ -35,14 +38,19 @@ struct CaptureRecordsView: View { if record.state == .draft { Button("继续编辑") { model.resume(record) } } else if record.state == .confirmed { - Button("打开") { model.openResult(record) } - .disabled(record.result?.availability == .deleted) + if record.result?.availability == .deleted { + Text("共享内容已删除").font(.caption).foregroundStyle(.secondary) + } else if model.resultURL(for: record) == nil { + Button("配置 Web 地址…") { model.openConnectionSettings(target: record.target) } + } else { + Button("打开") { model.openResult(record) } + } } else { Button(record.state == .cancelled ? "继续收入" : "立即重试") { model.retry(record) } if record.state != .cancelled { Button("取消收入") { model.cancel(record) } } - Button("修复连接") { model.openSettings(target: record.target) } + Button("修复连接") { model.openConnectionSettings(target: record.target) } } Button("删除记录", role: .destructive) { model.requestDiscard(record) } if record.payload != nil { Button("导出材料") { model.export(record) } } diff --git a/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift new file mode 100644 index 0000000..d9ca02b --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI +@preconcurrency import ApplicationServices + +struct CaptureSettingsView: View { + @ObservedObject var model: CaptureModel + @State private var selectionAllowed = AXIsProcessTrusted() + @State private var screenAllowed = CGPreflightScreenCaptureAccess() + + var body: some View { + Form { + Section("快捷键") { + ShortcutSettingsView(shortcuts: model.shortcuts) + } + Section("系统权限") { + LabeledContent("读取选区", value: selectionAllowed ? "已允许" : "尚未允许") + if !selectionAllowed { + Button("允许读取选区(辅助功能)") { + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + } + LabeledContent("屏幕录制", value: screenAllowed ? "已允许" : "尚未允许") + if !screenAllowed { + Button("请求屏幕录制权限") { model.requestScreenRecordingAccess(); refreshPermissions() } + } + Button("打开屏幕录制权限设置") { model.openScreenRecordingSettings() } + Text("选区与截图分别需要系统授权;输入和明确粘贴不需要这两项权限。") + .font(.caption).foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + .onDisappear { model.shortcuts.cancelRecording() } + .onAppear { refreshPermissions() } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshPermissions() } + } + + private func refreshPermissions() { + selectionAllowed = AXIsProcessTrusted() + screenAllowed = CGPreflightScreenCaptureAccess() + } +} diff --git a/extensions/capture/Sources/CaptureMac/ConnectionView.swift b/extensions/capture/Sources/CaptureMac/ConnectionView.swift index b35caa9..84ad790 100644 --- a/extensions/capture/Sources/CaptureMac/ConnectionView.swift +++ b/extensions/capture/Sources/CaptureMac/ConnectionView.swift @@ -1,12 +1,9 @@ import AppKit -import PeerMacUI import SwiftUI @preconcurrency import ApplicationServices struct ConnectionView: View { @ObservedObject var model: CaptureModel - @State private var selectionAllowed = AXIsProcessTrusted() - @State private var screenAllowed = CGPreflightScreenCaptureAccess() var body: some View { Form { @@ -40,44 +37,13 @@ struct ConnectionView: View { .foregroundStyle(.secondary) } Button(model.connection == nil ? "连接信息库…" : "编辑连接…") { - model.editingConnection = model.connection - model.showingConnectionEditor = true + model.openConnectionSettings() } if model.connection != nil { Button("重新检查连接") { model.checkConnection() }.disabled(model.checkingConnection) } } - Section("快捷键") { - ShortcutSettingsView(shortcuts: model.shortcuts) - } - Section("系统权限") { - LabeledContent("读取选区", value: selectionAllowed ? "已允许" : "尚未允许") - if !selectionAllowed { - Button("允许读取选区(辅助功能)") { - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary - _ = AXIsProcessTrustedWithOptions(options) - } - } - LabeledContent("屏幕录制", value: screenAllowed ? "已允许" : "尚未允许") - if !screenAllowed { - Button("请求屏幕录制权限") { model.requestScreenRecordingAccess(); refreshPermissions() } - } - Button("打开屏幕录制权限设置") { model.openScreenRecordingSettings() } - Text("选区与截图分别需要系统授权;输入和明确粘贴不需要这两项权限。") - .font(.caption).foregroundStyle(.secondary) - } } .formStyle(.grouped) - .sheet(isPresented: $model.showingConnectionEditor) { - PeerConnectionEditor(settings: model.connections, credentials: model.credentials, target: model.editingConnection, onSaved: model.connectionSaved) - } - .onDisappear { model.shortcuts.cancelRecording() } - .onAppear { refreshPermissions() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshPermissions() } - } - - private func refreshPermissions() { - selectionAllowed = AXIsProcessTrusted() - screenAllowed = CGPreflightScreenCaptureAccess() } } diff --git a/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift index 9b25632..367dc28 100644 --- a/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift +++ b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift @@ -10,6 +10,7 @@ final class GlobalShortcuts: ObservableObject { @Published private(set) var recording: ShortcutAction? @Published private(set) var message: String? var perform: ((ShortcutAction) -> Void)? + var canPerform: ((ShortcutAction, NSWindow?) -> Bool)? var isCaptureWindow: ((NSWindow?) -> Bool)? private let defaults: UserDefaults private var keys: [ShortcutAction: EventHotKeyRef] = [:] @@ -26,7 +27,15 @@ final class GlobalShortcuts: ObservableObject { bindings = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) } - for (action, shortcut) in bindings { try validate(shortcut, for: action) } + var failures: [String] = [] + for (action, shortcut) in bindings { + do { try validate(shortcut, for: action) } + catch { + bindings[action] = nil + failures.append("已停用无效的“\(action.title)”快捷键:\(error.localizedDescription)") + } + } + if !failures.isEmpty { message = failures.joined(separator: "\n") } } func start() throws { @@ -41,7 +50,7 @@ final class GlobalShortcuts: ObservableObject { guard status == noErr, identity.signature == 0x494e4b43, let action = ShortcutAction(rawValue: identity.id) else { return OSStatus(eventNotHandledErr) } let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() - MainActor.assumeIsolated { if owner.recording == nil { owner.perform?(action) } } + MainActor.assumeIsolated { _ = owner.execute(action, in: nil) } return noErr }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) guard status == noErr else { throw PeerError.needsAction("无法启用快捷键事件,请重新打开 InKCre。") } @@ -121,10 +130,18 @@ final class GlobalShortcuts: ObservableObject { guard let action = ShortcutAction.allCases.first(where: { !$0.isGlobal && bindings[$0]?.keyCode == event.keyCode && bindings[$0]?.modifierBits == modifiers }) else { return event } - if !event.isARepeat { perform?(action) } + guard canPerform?(action, event.window) == true else { return event } + if !event.isARepeat { _ = execute(action, in: event.window) } return nil } + @discardableResult + func execute(_ action: ShortcutAction, in window: NSWindow?) -> Bool { + guard recording == nil, canPerform?(action, window) == true, let perform else { return false } + perform(action) + return true + } + private func validate(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { try shortcut.validate(for: action) if let other = ShortcutAction.allCases.first(where: { $0 != action && bindings[$0] == shortcut }) { diff --git a/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift index f565189..53bd313 100644 --- a/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift +++ b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift @@ -57,6 +57,16 @@ struct RecordedShortcut: Codable, Equatable, Sendable { [(NSEvent.ModifierFlags.control, "⌃"), (.option, "⌥"), (.shift, "⇧"), (.command, "⌘")] .filter { modifiers.contains($0.0) }.map(\.1).joined() + keyLabel } + var menuKeyEquivalent: String { + let special = ["↩": "\r", "Esc": "\u{1b}", "⇥": "\t", "空格": " ", "⌫": "\u{8}", "⌦": "\u{7f}", + "←": "\u{f702}", "→": "\u{f703}", "↑": "\u{f700}", "↓": "\u{f701}", + "↖": "\u{f729}", "↘": "\u{f72b}", "⇞": "\u{f72c}", "⇟": "\u{f72d}"] + if let key = special[keyLabel] { return key } + if keyLabel.hasPrefix("F"), let number = Int(keyLabel.dropFirst()), (1...20).contains(number), + let scalar = UnicodeScalar(0xf703 + number) { return String(scalar) } + return keyLabel.lowercased() + } + var carbonModifiers: UInt32 { [(NSEvent.ModifierFlags.control, controlKey), (.option, optionKey), (.shift, shiftKey), (.command, cmdKey)] .reduce(0) { $0 | (modifiers.contains($1.0) ? UInt32($1.1) : 0) } @@ -65,6 +75,14 @@ struct RecordedShortcut: Codable, Equatable, Sendable { left.keyCode == right.keyCode && left.modifierBits == right.modifierBits } func validate(for action: ShortcutAction) throws { + // Preserve the app's window, navigation and text-editing commands. + let commandKeys = [kVK_ANSI_Q, kVK_ANSI_W, kVK_ANSI_H, kVK_ANSI_M, kVK_ANSI_N, + kVK_ANSI_Comma, kVK_ANSI_C, kVK_ANSI_V, kVK_ANSI_X, kVK_ANSI_A, kVK_ANSI_Z] + let reserved = modifiers == .command && commandKeys.contains(Int(keyCode)) + || modifiers == [.command, .shift] && [kVK_ANSI_Z, kVK_ANSI_W].contains(Int(keyCode)) + || modifiers == [.command, .option] && [kVK_ANSI_H, kVK_ANSI_S].contains(Int(keyCode)) + || modifiers == [.command, .control] && keyCode == kVK_ANSI_F + guard !reserved else { throw PeerError.invalid("\(label) 是应用的标准命令,请使用其他组合。原设置未更改。") } let plainEscape = action == .hide && keyCode == kVK_Escape && modifierBits == 0 guard keyCode <= 127, !keyLabel.isEmpty, modifierBits & ~Self.allowedModifiers.rawValue == 0, diff --git a/extensions/capture/Sources/CaptureMac/PeerMainView.swift b/extensions/capture/Sources/CaptureMac/PeerMainView.swift index 1dfa224..53358d6 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainView.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainView.swift @@ -5,7 +5,7 @@ struct PeerMainView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - NavigationSplitView { + NavigationSplitView(columnVisibility: $model.columnVisibility) { VStack(spacing: 0) { HStack(spacing: 10) { Image(nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark) @@ -18,12 +18,11 @@ struct PeerMainView: View { }.padding() List(selection: Binding(get: { Optional(model.pane) }, set: { guard let value = $0 else { return } - if value == .settings { model.openSettings() } - else { model.pane = value } + model.pane = value })) { Label("采集", systemImage: "square.and.pencil").tag(CaptureModel.Pane.capture) Label("记录", systemImage: "tray.full").tag(CaptureModel.Pane.queue) - Label("Peer 与连接", systemImage: "network").tag(CaptureModel.Pane.settings) + Label("Peer 与连接", systemImage: "network").tag(CaptureModel.Pane.peer) }.listStyle(.sidebar) VStack(alignment: .leading, spacing: 4) { Text(model.connection?.name ?? "未连接信息库") @@ -44,7 +43,7 @@ struct PeerMainView: View { } } else { CaptureEditor(model: model) } case .queue: CaptureRecordsView(model: model) - case .settings: ConnectionView(model: model) + case .peer: ConnectionView(model: model) } }.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(title) @@ -73,7 +72,7 @@ struct PeerMainView: View { switch model.pane { case .capture: "采集" case .queue: "记录" - case .settings: "Peer 与连接" + case .peer: "Peer 与连接" } } } diff --git a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift index 69e7a8d..73c593e 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift @@ -3,7 +3,22 @@ import SwiftUI @MainActor final class PeerMainWindow: NSWindow { + private let model: CaptureModel + + @objc func toggleSidebar(_ sender: Any?) { + withAnimation { + model.columnVisibility = model.columnVisibility == .detailOnly ? .all : .detailOnly + } + } + + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + guard item.action == #selector(toggleSidebar(_:)) else { return super.validateUserInterfaceItem(item) } + (item as? NSMenuItem)?.title = model.columnVisibility == .detailOnly ? "显示侧栏" : "隐藏侧栏" + return attachedSheet == nil + } + init(model: CaptureModel) { + self.model = model super.init(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], backing: .buffered, defer: false) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index de6e6c0..8db2367 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -46,7 +46,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion11 +CFBundleVersion12 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From f3f6b56b3c2caec1324968fa5e1269d67a78d2dd Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 22 Oct 2026 18:05:31 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix(mac):=20=E4=BF=AE=E5=A4=8D=E9=87=87?= =?UTF-8?q?=E9=9B=86=E4=B8=BB=E6=B5=81=E7=A8=8B=E7=9A=84=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=E8=BF=94=E5=9B=9E=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 连接设置返回原草稿,历史待办凭据和 Web 地址编辑不切换当前信息库 - 精简输入区,提供原图与完整材料查看,保留收入状态和记录定位入口 - 验证关键旅程与实际视图,更新签名开发包至 v13 --- .../Sources/PeerCore/Credentials.swift | 2 +- .../PeerMacUI/PeerConnectionEditor.swift | 5 +- README.md | 6 +- Sources/InKCreApp/AppMain.swift | 15 +- .../CaptureInteractionTests.swift | 150 ++++++++++++++++-- .../CaptureMacTests/CaptureLayoutTests.swift | 67 +++++++- .../Sources/Capture/CaptureStore.swift | 12 ++ .../Sources/CaptureMac/CaptureEditor.swift | 133 ++++++++++------ .../Sources/CaptureMac/CaptureExtension.swift | 6 +- .../CaptureMac/CaptureImagePreview.swift | 51 ++++++ .../Sources/CaptureMac/CaptureModel.swift | 66 +++++--- .../CaptureMac/CaptureRecordSettings.swift | 71 +++++++++ .../CaptureMac/CaptureRecordsView.swift | 133 +++++++++++----- .../Sources/CaptureMac/PeerMainView.swift | 2 +- scripts/build-app.sh | 2 +- 15 files changed, 583 insertions(+), 138 deletions(-) create mode 100644 extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift create mode 100644 extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift diff --git a/Packages/PeerCore/Sources/PeerCore/Credentials.swift b/Packages/PeerCore/Sources/PeerCore/Credentials.swift index f013449..e5fbbbf 100644 --- a/Packages/PeerCore/Sources/PeerCore/Credentials.swift +++ b/Packages/PeerCore/Sources/PeerCore/Credentials.swift @@ -39,7 +39,7 @@ public struct CredentialStore: Sendable { guard status == errSecSuccess, let data = result as? Data, let secret = String(data: data, encoding: .utf8) else { - throw PeerError.needsAction("无法读取此信息库的凭据,请在连接设置中重新保存。") + throw PeerError.needsAction("无法读取此信息库的凭据,请更新数据库密码。") } return secret } diff --git a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift index 80623b3..16393f9 100644 --- a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -7,6 +7,7 @@ public struct PeerConnectionEditor: View { private let onSaved: @MainActor (ConnectionTarget) -> Void private let onCancel: @MainActor () -> Void private let target: ConnectionTarget? + private let saveTitle: String @Environment(\.dismiss) private var dismiss @State private var server: String @State private var port: String @@ -21,12 +22,14 @@ public struct PeerConnectionEditor: View { public init( settings: ConnectionSettings, credentials: CredentialStore, target: ConnectionTarget?, + saveTitle: String = "保存", onCancel: @escaping @MainActor () -> Void = {}, onSaved: @escaping @MainActor (ConnectionTarget) -> Void ) { self.settings = settings self.credentials = credentials self.target = target + self.saveTitle = saveTitle self.onCancel = onCancel self.onSaved = onSaved _server = State(initialValue: target?.host ?? "") @@ -61,7 +64,7 @@ public struct PeerConnectionEditor: View { dismiss() onCancel() }.keyboardShortcut(.cancelAction).disabled(saving) - Button(saving ? "正在保存…" : "保存") { + Button(saving ? "正在保存…" : saveTitle) { saving = true issue = nil Task { diff --git a/README.md b/README.md index 823c788..bc5214f 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,13 @@ open dist/InKCre.app 移除材料、删除草稿和删除记录均先显示系统确认框;取消确认不会改变内容,确认后直接删除,无法撤销。快捷面板的“取消本次”在有内容时先确认,确认后删除本次草稿并关闭面板;空面板直接关闭。“收起”只隐藏面板并保留草稿。删除已收入记录只影响本机列表,共享内容仍属于 info-base。不提供回收站或“撤销移除”。 +手写想法使用单一主要输入区;采集原文与可选附注分别呈现,附注按需展开,已有附注默认可见,编辑区不嵌套在原文滚动区内。图片可用原生 Quick Look 查看原始尺寸,预览临时文件只对当前用户可读写,关闭预览或离开内容时移除。未收入记录可以展开查看完整原文、全部图片、来源和附注,无须导出 JSON 才能核对材料。上次收入状态与当前草稿保存状态分别显示,“查看记录”定位到对应条目,继续写下一条想法不会丢失入口。 + ## 连接信息库 连接设置使用 PostgreSQL 主机、端口、库名、独立运行角色及密码,可选 Web 地址。远端使用 TLS 并校验证书;只有回环开发地址允许关闭 TLS。密码存于 Keychain,角色拥有已准入数据库协议权限,不使用迁移管理员或 JWT 签名密钥。 -`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页只展示状态,所有连接编辑与失败记录的“修复连接”都打开设置窗口中的同一个原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。 +`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页只展示状态,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 共享数据库需要本分支的 Capture 回执表与原生分发参与合同。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写图、原图和回执;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 @@ -46,6 +48,8 @@ open dist/InKCre.app 材料累计最多 32 MiB,支持 PNG/JPEG/GIF/WebP,原生数据库通过 bytea 参数保存原图。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。 +失败记录的“更新密码…”仅更新原连接的 Keychain 项,并按原 UUID、材料与数据库目标重试,不切换当前连接。已收入记录缺少打开地址时,“配置 Web 地址…”仅修改该条记录的展示地址,保存后打开共享内容;记录自身地址优先,同一 Peer 当前连接的 Web 地址仅作补充。 + 待收入记录可“取消收入”,停止后续自动发送并保留原材料、UUID 和目标;“继续收入”先查原回执。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 ## 本地数据与发布 diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 8e5a95e..48557d1 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -33,7 +33,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI runtime = BundledExtensionRuntime(module: capture, defaults: defaults) super.init() capture.showPreferences = { [weak self] in self?.showSettings(.capture) } - capture.editConnection = { [weak self] target in self?.editConnection(target) } + capture.editConnection = { [weak self] target, completion in self?.editConnection(target, onFinished: completion) } } private var presence: Task? @Published var connectionIssue: String? @@ -162,7 +162,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI private func updateStatus() { objectWillChange.send() } - func editConnection(_ target: ConnectionTarget?) { + func editConnection(_ target: ConnectionTarget?, onFinished: (@MainActor () -> Void)? = nil) { guard capture.canOpenSettings else { return } showSettings(.connection) guard let manager, manager.attachedSheet == nil else { return } @@ -174,16 +174,21 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } let content = NSHostingView(rootView: PeerConnectionEditor( settings: context.connections, credentials: context.credentials, - target: target, onCancel: close, + target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, onSaved: { [weak self] target in - close() self?.connection = target self?.connectionIssue = nil self?.capture.connectionDidChange(target) + close() })) sheet.contentView = content sheet.setContentSize(content.fittingSize) - manager.beginSheet(sheet) + manager.beginSheet(sheet) { [weak manager] _ in + if let onFinished { + manager?.orderOut(nil) + onFinished() + } + } } @objc private func showLicenses(_ sender: Any?) { diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 675f6ca..3b856b1 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -2,6 +2,7 @@ import AppKit import Capture import PeerCore import Testing +import Security @testable import CaptureMac @Suite(.serialized) @MainActor @@ -12,7 +13,7 @@ struct CaptureInteractionTests { let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } var mainOpened = false - model.editConnection = { _ in mainOpened = true } + model.editConnection = { _, _ in mainOpened = true } model.submit() #expect(!model.hasContent) #expect(!model.submitting) @@ -71,23 +72,131 @@ struct CaptureInteractionTests { #expect(model.draft.id == currentID) } - @Test func missingConnectionUsesHostSettingsWithoutReplacingTheDraft() async throws { + @Test(arguments: [false, true]) + func missingConnectionReturnsToTheOriginalDraftWithoutSubmitting(saved: Bool) async throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } - model.pane = .queue - await model.receive(CapturePayload(materials: [Material(text: "回到主窗口采集")])) - #expect(model.pane == .capture) + await model.receive(CapturePayload(materials: [Material(text: "连接后继续收入的原文")], annotation: "原附注")) + let original = model.draft model.editorOwner = .quick - var mainOpened = false - model.editConnection = { _ in mainOpened = true } + var completion: (@MainActor () -> Void)? + model.editConnection = { target, callback in + #expect(target == nil) + completion = callback + model.editorOwner = .main + } + var returnedTo: CaptureModel.EditorOwner? + model.presentEditor = { returnedTo = model.editorOwner } model.submit() - #expect(mainOpened) - #expect(model.editorOwner == .quick) - #expect(model.pane == .capture) - #expect(model.hasContent) - #expect(model.lastContactAt == nil) + #expect(returnedTo == nil) + if saved { model.connection = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") } + let finish = try #require(completion) + finish() + #expect(returnedTo == .quick) + #expect(model.draft.id == original.id) + #expect(try CaptureJSON.encode(model.draft.payload) == CaptureJSON.encode(original.payload)) + #expect(model.draft.state == .draft && !model.submitting) + #expect(try await model.store.record(original.id)?.state == .draft) + #expect(model.submittedRecord == nil) + returnedTo = nil + model.draft = CaptureRecord() + completion?() + #expect(returnedTo == nil) + } + + @Test func passwordRepairRetriesTheFrozenTargetWhileKeepingTheActiveConnection() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let identity = "dev.inkcre.password.\(UUID().uuidString)" + defer { + SecItemDelete([kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(identity).connections"] as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + let transport = JourneyTransport() + let model = try CaptureModel(directory: directory, identity: identity, client: transport) + defer { model.cancelWork() } + let originalTarget = ConnectionTarget(host: "original.test", database: "original", username: "test") + let active = ConnectionTarget(host: "active.test", database: "active", username: "test") + model.connection = active + try await model.connections.save(active) + let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "原提交材料")])) + _ = try await model.store.freeze(draft, target: originalTarget) + let cancelled = try await model.store.cancel(draft.id) + // Merely opening and cancelling the editor must leave cancelled work dormant. + await model.sendPending(force: true) + #expect(await transport.calls.isEmpty) + try await model.updatePassword("new-fixture-password", for: cancelled) + for _ in 0..<100 where model.records.first?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } + #expect(model.connection == active) + #expect(try await model.connections.load() == active) + let calls = await transport.calls + #expect(calls.map(\.kind) == ["recover", "submit"]) + for call in calls { + #expect(call.record.id == draft.id && call.record.target == originalTarget && call.secret == "new-fixture-password") + #expect(try CaptureJSON.encode(call.record.payload) == CaptureJSON.encode(draft.payload)) + } + #expect(model.records.first?.state == .confirmed) + } + + @Test func submittedFeedbackRemainsFindableWhileWritingTheNextDraft() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let identity = "dev.inkcre.feedback.\(UUID().uuidString)" + defer { + SecItemDelete([kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(identity).connections"] as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + let model = try CaptureModel(directory: directory, identity: identity, client: JourneyTransport()) + defer { model.cancelWork() } + let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") + model.connection = target + try model.credentials.save("fixture", for: target.id) + model.thought = "第一条想法" + let submittedID = model.draft.id + model.submit() + for _ in 0..<100 where model.submittedRecord?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } + let record = try #require(model.submittedRecord) + #expect(record.id == submittedID && record.state == .confirmed) + model.thought = "下一条尚未收入的想法" + let nextID = model.draft.id + try await model.flush() + #expect(model.saveStatus == "草稿已保留在本机") + #expect(model.submittedRecord?.id == submittedID) + var mainOpened = false + model.showMain = { mainOpened = true } + model.showRecord(record) + #expect(mainOpened && model.pane == .queue && model.selectedRecordID == submittedID) + #expect(model.draft.id == nextID && model.thought == "下一条尚未收入的想法") + } + + @Test func historicalWebAddressUpdatesOnlyThatRecordsDisplayMetadata() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.web.\(UUID().uuidString)") + defer { model.cancelWork() } + let original = ConnectionTarget(host: "original.test", database: "original", username: "test") + let active = ConnectionTarget(host: "active.test", database: "active", username: "test") + model.connection = active + try await model.connections.save(active) + let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "历史记录")])) + _ = try await model.store.freeze(draft, target: original) + let receipt = CaptureResult(submissionID: draft.id, rootBlockID: 42, committedAt: Date(), availability: .available) + try await model.store.confirm(draft.id, result: receipt) + let confirmed = try #require(try await model.store.record(draft.id)) + let updated = try await model.updateWebAddress("https://original-web.test", for: confirmed) + var expected = original + expected.webURL = URL(string: "https://original-web.test") + #expect(updated.target == expected && updated.id == confirmed.id && updated.result == receipt) + #expect(updated.payload == nil && updated.updatedAt == confirmed.updatedAt) + #expect(model.connection == active) + #expect(try await model.connections.load() == active) + #expect(model.resultURL(for: updated)?.host == "original-web.test") + model.connection = original + #expect(model.resultURL(for: updated)?.host == "original-web.test") + await #expect(throws: PeerError.self) { _ = try await model.updateWebAddress("javascript:alert(1)", for: confirmed) } + #expect(try await model.store.record(draft.id)?.target == expected) } @Test func configuringWebAddressMakesExistingResultsOpenableWithoutRetargetingThem() throws { @@ -221,3 +330,20 @@ struct CaptureInteractionTests { #expect(model.error == nil) } } + +private actor JourneyTransport: CaptureTransport { + struct Call: Sendable { + let kind: String + let record: CaptureRecord + let secret: String + } + private(set) var calls: [Call] = [] + func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { + calls.append(Call(kind: "recover", record: record, secret: secret)) + return nil + } + func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { + calls.append(Call(kind: "submit", record: record, secret: secret)) + return CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + } +} diff --git a/Tests/CaptureMacTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift index d507b37..c816bcd 100644 --- a/Tests/CaptureMacTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -112,19 +112,80 @@ struct CaptureLayoutTests { model.editorOwner = .quick model.thought = "用于界面验收的合成想法,不读取剪贴板或屏幕。" auxiliary.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) - auxiliary.setContentSize(NSSize(width: 500, height: 450)) + auxiliary.setContentSize(NSSize(width: 500, height: 500)) try await snapshot(auxiliary, named: "quick-\(appearance.rawValue)") } #expect(!window.isVisible && !auxiliary.isVisible) } - private func snapshot(_ window: NSWindow, named name: String) async throws { + @Test func captureJourneysRenderAtMinimumSizeWithReadableActions() async throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel(directory: directory, identity: "dev.inkcre.journey-layout.\(UUID().uuidString)") + defer { model.cancelWork() } + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 480), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + defer { window.orderOut(nil) } + let source = NSImage(size: NSSize(width: 1200, height: 800), flipped: false) { rect in + NSColor.white.setFill(); rect.fill() + NSColor.systemBlue.setFill(); NSRect(x: 40, y: 600, width: 1120, height: 150).fill() + ("截图原文 · 合成内容" as NSString).draw(at: NSPoint(x: 80, y: 650), + withAttributes: [.font: NSFont.systemFont(ofSize: 48), .foregroundColor: NSColor.white]) + ("原图 1200 × 800,可用快速查看阅读细节。" as NSString).draw(at: NSPoint(x: 80, y: 500), + withAttributes: [.font: NSFont.systemFont(ofSize: 28), .foregroundColor: NSColor.black]) + return true + } + let tiff = try #require(source.tiffRepresentation) + let bitmap = try #require(NSBitmapImageRep(data: tiff)) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let material = Capture.Material(image: data, mimeType: "image/png") + let preview = try CaptureImagePreview.makePreviewFile(material) + defer { try? FileManager.default.removeItem(at: preview) } + #expect(try Data(contentsOf: preview) == data) + #expect((try FileManager.default.attributesOfItem(atPath: preview.path)[.posixPermissions] as? NSNumber)?.intValue == 0o600) + let target = ConnectionTarget(host: "original.test", database: "inkcre", username: "inkcre_core", name: "original.test/inkcre") + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + model.editorOwner = .quick + model.draft = CaptureRecord(payload: CapturePayload(materials: [Capture.Material(text: "手写想法只有一个主要输入区,可以持续输入并自动保留。")])) + window.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) + window.setContentSize(NSSize(width: 500, height: 500)) + try await snapshot(window, named: "journey-manual-\(appearance.rawValue)") + model.draft = CaptureRecord(payload: CapturePayload(entry: .screenshot, materials: [material], annotation: "这张图说明了什么?")) + try await snapshot(window, named: "journey-image-\(appearance.rawValue)") + model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [Capture.Material(text: String(repeating: "原文内容可以滚动阅读;自己的想法在下面单独输入。\n", count: 16))])) + try await snapshot(window, named: "journey-long-source-\(appearance.rawValue)") + model.connection = target + model.submit() + for _ in 0..<100 where model.submittedRecord?.state != .needsAction { try await Task.sleep(for: .milliseconds(10)) } + let failed = try #require(model.submittedRecord) + try await snapshot(window, named: "journey-feedback-\(appearance.rawValue)") + model.showRecord(failed) + window.contentViewController = NSHostingController(rootView: CaptureRecordsView(model: model)) + window.setContentSize(NSSize(width: 520, height: 480)) + try await snapshot(window, named: "journey-failure-record-\(appearance.rawValue)") + let payload = try #require(failed.payload) + window.contentViewController = NSHostingController(rootView: + ScrollView { CaptureRecordMaterials(payload: payload).padding() }) + window.setContentSize(NSSize(width: 520, height: 480)) + try await snapshot(window, named: "journey-all-materials-\(appearance.rawValue)") + for field in [CaptureRecordSettings.Field.password, .webAddress] { + window.contentViewController = NSHostingController(rootView: CaptureRecordSettings(model: model, record: failed, field: field)) + window.setContentSize(NSSize(width: 520, height: 400)) + try await snapshot(window, named: "journey-\(field)-\(appearance.rawValue)", minimumSize: NSSize(width: 480, height: 250)) + } + } + #expect(!window.isVisible) + } + + private func snapshot(_ window: NSWindow, named name: String, minimumSize: NSSize = NSSize(width: 500, height: 400)) async throws { try await Task.sleep(for: .milliseconds(200)) let view = try #require(window.contentView?.superview) view.layoutSubtreeIfNeeded() let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) - #expect(bitmap.pixelsWide >= 500 && bitmap.pixelsHigh >= 400) + #expect(bitmap.pixelsWide >= Int(minimumSize.width) && bitmap.pixelsHigh >= Int(minimumSize.height)) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent("\(name).png")) diff --git a/extensions/capture/Sources/Capture/CaptureStore.swift b/extensions/capture/Sources/Capture/CaptureStore.swift index 559aca2..5c71ffb 100644 --- a/extensions/capture/Sources/Capture/CaptureStore.swift +++ b/extensions/capture/Sources/Capture/CaptureStore.swift @@ -211,6 +211,18 @@ public actor CaptureStore { try save(value) } + public func updateWebAddress(_ id: UUID, address: String) throws -> CaptureRecord { + guard var record = try record(id), record.state == .confirmed, var target = record.target, + let url = URL(string: address.trimmingCharacters(in: .whitespacesAndNewlines)) + else { throw PeerError.invalid("请输入有效的 Web 地址,且仅为已收入记录配置地址。") } + // The display address can change; the frozen database destination and receipt cannot. + target.webURL = url + try target.validate() + record.target = target + try save(record) + return record + } + public func discardDraft(_ id: UUID) throws { guard !deletedIDs.contains(id), let value = try record(id) else { return } guard value.state == .draft else { diff --git a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift index 06e8144..09ae65b 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift @@ -8,9 +8,11 @@ struct CaptureEditor: View { var isQuickCapture = false private enum Field: Hashable { case thought, annotation } @FocusState private var focusedField: Field? + @State private var showsAnnotation = false + private var isManual: Bool { model.draft.payload?.entry == .manual } var body: some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 12) { if isQuickCapture { HStack { Button("新想法", systemImage: "square.and.pencil") { model.newThought() } @@ -32,56 +34,77 @@ struct CaptureEditor: View { } }.padding(10).background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) } - ScrollView { - VStack(alignment: .leading, spacing: 12) { - Text(model.draft.payload?.entry == .manual ? "此刻的想法" : "本次材料") - .font(.subheadline).foregroundStyle(.secondary) - if model.draft.payload?.entry == .manual { - TextEditor(text: Binding(get: { model.thought }, set: { model.thought = $0 })) - .focused($focusedField, equals: .thought) - .font(.body).frame(minHeight: 140) - .padding(6) - .background(.background, in: RoundedRectangle(cornerRadius: 8)) - .accessibilityLabel("文字材料") - } - ForEach(Array((model.draft.payload?.materials ?? []).enumerated()), id: \.element.id) { index, material in - if material.kind != .text || model.draft.payload?.entry != .manual { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("材料 \(index + 1)").font(.caption).foregroundStyle(.secondary) - Spacer() - Button("移除", systemImage: "xmark") { model.requestRemoveMaterial(material.id) } - .accessibilityLabel("移除第 \(index + 1) 项材料") - } - if material.kind == .text { - Text(material.text ?? "").textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) - } else if let data = material.data, let image = NSImage(data: data) { - Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 220) - .accessibilityLabel("本次采集的图片") - } else { - Text("图片原始字节已保留;系统无法预览此格式。").font(.caption) + if isManual { + Text("此刻的想法").font(.subheadline).foregroundStyle(.secondary) + TextEditor(text: Binding(get: { model.thought }, set: { model.thought = $0 })) + .focused($focusedField, equals: .thought) + .font(.body).frame(minHeight: 100, maxHeight: .infinity) + .accessibilityLabel("此刻的想法") + } + if let image = standaloneImage { + HStack { + Text("本次图片").font(.subheadline).foregroundStyle(.secondary) + Spacer() + Button("移除", systemImage: "xmark") { model.requestRemoveMaterial(image.id) } + } + CaptureImagePreview(material: image, height: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else if !isManual || additionalMaterials { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("本次材料").font(.subheadline).foregroundStyle(.secondary) + ForEach(Array((model.draft.payload?.materials ?? []).enumerated()), id: \.element.id) { index, material in + if !isManual || material.id != model.draft.payload?.materials.first(where: { $0.kind == .text })?.id { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("材料 \(index + 1)").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("移除", systemImage: "xmark") { model.requestRemoveMaterial(material.id) } + .accessibilityLabel("移除第 \(index + 1) 项材料") + } + if material.kind == .text { + Text(material.text ?? "").textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + } else { + CaptureImagePreview(material: material) + } } } } - } - if let origin = model.draft.payload?.origin { - VStack(alignment: .leading, spacing: 3) { - if let title = origin.title { Text(title) } - if let url = origin.url { Text(url).textSelection(.enabled) } - if let application = origin.applicationID { Text(application) } - }.font(.caption).foregroundStyle(.secondary) - } - Text("补充想法").font(.subheadline).foregroundStyle(.secondary) + if let origin = model.draft.payload?.origin { + VStack(alignment: .leading, spacing: 3) { + if let title = origin.title { Text(title) } + if let url = origin.url { Text(url).textSelection(.enabled) } + if let application = origin.applicationID { Text(application) } + }.font(.caption).foregroundStyle(.secondary) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxHeight: .infinity) + } + if !isManual || showsAnnotation || !model.annotation.isEmpty { + DisclosureGroup("补充想法(可选)", isExpanded: $showsAnnotation) { TextEditor(text: Binding(get: { model.annotation }, set: { model.annotation = $0 })) .focused($focusedField, equals: .annotation) - .frame(minHeight: 64).padding(6) - .background(.background, in: RoundedRectangle(cornerRadius: 8)) - .accessibilityLabel("用户附注,与原文分别保存") + .frame(height: 70).accessibilityLabel("用户附注,与原文分别保存") } } Divider() - Text(model.saveStatus).font(.caption).foregroundStyle(.secondary) - .accessibilityLabel(model.saveStatus) + if let record = model.submittedRecord { + HStack { + Label("上次采集:\(record.statusLabel)", systemImage: record.state == .confirmed ? "checkmark.circle" : "tray") + Spacer() + Button("查看记录") { model.showRecord(record) } + }.font(.callout) + } + HStack(alignment: .firstTextBaseline) { + Text(model.saveStatus).font(.caption).foregroundStyle(.secondary) + Spacer() + if let target = model.connection { + Text("收入到 \(target.name)").font(.caption).foregroundStyle(.secondary) + .lineLimit(1).help("\(target.host):\(target.port)/\(target.database)") + } else { + Text("收入前需连接信息库").font(.caption).foregroundStyle(.secondary) + } + } HStack { if isQuickCapture { Button("取消本次") { model.cancelCapture() } @@ -90,20 +113,30 @@ struct CaptureEditor: View { .disabled(!model.hasContent) } Spacer() - Button("导出材料") { model.export(model.draft) } - .disabled(!model.hasContent) + Button("导出材料") { model.export(model.draft) }.disabled(!model.hasContent) Button("收入", systemImage: "tray.and.arrow.down") { model.submit() } - .buttonStyle(.borderedProminent) - .disabled(!model.hasContent || model.submitting) + .buttonStyle(.borderedProminent).disabled(!model.hasContent || model.submitting) } }.padding().disabled(model.submitting) - .onAppear { focusEditor() } - .onChange(of: model.draft.id) { focusEditor() } + .onAppear { showsAnnotation = !model.annotation.isEmpty; focusEditor() } + .onChange(of: model.draft.id) { showsAnnotation = !model.annotation.isEmpty; focusEditor() } + .onChange(of: showsAnnotation) { _, expanded in if expanded { focusedField = .annotation } } .onChange(of: model.submitting) { _, busy in if !busy { focusEditor() } } } + private var standaloneImage: Capture.Material? { + guard let payload = model.draft.payload, payload.materials.count == 1, + payload.materials[0].kind == .image, payload.origin == nil else { return nil } + return payload.materials[0] + } + + private var additionalMaterials: Bool { + let materials = model.draft.payload?.materials ?? [] + return materials.contains { $0.kind == .image } || materials.filter { $0.kind == .text }.count > 1 + } + private func focusEditor() { guard !model.submitting else { return } - focusedField = model.draft.payload?.entry == .manual ? .thought : .annotation + focusedField = isManual ? .thought : (showsAnnotation ? .annotation : nil) } } diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index 23648f1..e6c9b33 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -22,7 +22,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate super.init() } public var showPreferences: (() -> Void)? - public var editConnection: ((ConnectionTarget?) -> Void)? + public var editConnection: ((ConnectionTarget?, (@MainActor () -> Void)?) -> Void)? public func makeSettingsViewController() -> NSViewController? { model.map { NSHostingController(rootView: CaptureSettingsView(model: $0)) } @@ -93,7 +93,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate backing: .buffered, defer: false ) panel.title = "随手收入 InKCre" - panel.minSize = NSSize(width: 500, height: 450) + panel.contentMinSize = NSSize(width: 500, height: 500) panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.isReleasedWhenClosed = false @@ -106,7 +106,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.dismissQuickCapture = { [weak self] in self?.hide() } model.showMain = { [weak self] in self?.showMain() } model.showQuick = { [weak self] in self?.showQuick() } - model.editConnection = { [weak self] target in self?.editConnection?(target) } + model.editConnection = { [weak self] target, completion in self?.editConnection?(target, completion) } model.takeScreenshot = { [weak self] in self?.screenshot() } model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in window === panel || window === mainWindow diff --git a/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift new file mode 100644 index 0000000..e0a27f4 --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift @@ -0,0 +1,51 @@ +import AppKit +import Capture +import QuickLook +import SwiftUI +import UniformTypeIdentifiers + +struct CaptureImagePreview: View { + let material: Capture.Material + var height: CGFloat = 220 + @State private var previewURL: URL? + @State private var issue: String? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + do { previewURL = try Self.makePreviewFile(material) } + catch { issue = error.localizedDescription } + } label: { + VStack(alignment: .leading, spacing: 4) { + Label("查看原图", systemImage: "eye").font(.caption).foregroundStyle(.tint) + if let data = material.data, let image = NSImage(data: data) { + Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: height) + } + } + }.buttonStyle(.plain).accessibilityLabel("用快速查看打开采集原图") + if let issue { Text(issue).font(.caption).foregroundStyle(.red) } + } + .quickLookPreview($previewURL) + .onChange(of: previewURL) { previous, _ in + if let previous { try? FileManager.default.removeItem(at: previous) } + } + .onDisappear { closePreview() } + .onChange(of: material.id) { closePreview() } + } + + private func closePreview() { + if let previewURL { try? FileManager.default.removeItem(at: previewURL) } + previewURL = nil + } + + static func makePreviewFile(_ material: Capture.Material) throws -> URL { + guard let data = material.data, let mime = material.mimeType, + let suffix = UTType(mimeType: mime)?.preferredFilenameExtension + else { throw CocoaError(.fileReadCorruptFile) } + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("inkcre-preview-\(UUID().uuidString)").appendingPathExtension(suffix) + guard FileManager.default.createFile(atPath: url.path, contents: data, attributes: [.posixPermissions: 0o600]) + else { throw CocoaError(.fileWriteUnknown) } + return url + } +} diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index fd64cd2..53999c9 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -63,7 +63,7 @@ final class CaptureModel: ObservableObject { var dismissQuickCapture: (() -> Void)? var showMain: (() -> Void)? var showQuick: (() -> Void)? - var editConnection: ((ConnectionTarget?) -> Void)? + var editConnection: ((ConnectionTarget?, (@MainActor () -> Void)?) -> Void)? let store: CaptureStore let credentials: CredentialStore let connections: ConnectionSettings @@ -72,18 +72,20 @@ final class CaptureModel: ObservableObject { private var heartbeat: Task? private var connectionCheck: Task? private var connectionRevision = UUID() - private var submissionStatusID: UUID? + @Published private(set) var submissionStatusID: UUID? + @Published var selectedRecordID: UUID? private var editRevision = 0 private var actions: [UUID: Task] = [:] private var stopping = false - init(directory: URL, identity: String, connections: ConnectionSettings? = nil) throws { + init(directory: URL, identity: String, connections: ConnectionSettings? = nil, + client: any CaptureTransport = CaptureClient()) throws { self.connections = connections ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) shortcuts = try GlobalShortcuts() store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) credentials = CredentialStore(service: "\(identity).connections") - queue = CaptureQueue(store: store, credentials: credentials) + queue = CaptureQueue(store: store, credentials: credentials, client: client) } func canPerform(_ action: ShortcutAction, isEditor: Bool, isQuickPanel: Bool) -> Bool { @@ -198,7 +200,6 @@ final class CaptureModel: ObservableObject { func saveSoon() { guard !stopping else { return } - submissionStatusID = nil editRevision += 1 saveTask?.cancel() saveStatus = "正在保留草稿…" @@ -248,7 +249,6 @@ final class CaptureModel: ObservableObject { saveTask?.cancel() try await flush() draft = CaptureRecord() - submissionStatusID = nil captureNotice = notice saveStatus = "写下此刻的想法" revealEditor() @@ -268,7 +268,6 @@ final class CaptureModel: ObservableObject { saveTask?.cancel() try await flush() draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: materials)) - submissionStatusID = nil captureNotice = nil try await flush() revealEditor() @@ -294,7 +293,6 @@ final class CaptureModel: ObservableObject { try await flush() if let request, request != selectionRequest { return } draft = CaptureRecord(payload: payload) - submissionStatusID = nil captureNotice = notice try await flush() revealEditor() @@ -307,7 +305,16 @@ final class CaptureModel: ObservableObject { func submit() { guard !submitting, hasContent else { return } guard let connection else { - openConnectionSettings() + let draftID = draft.id + let owner = editorOwner + editConnection?(nil, { [weak self] in + guard let self, !self.stopping, self.draft.id == draftID else { return } + self.editorOwner = owner + self.captureNotice = self.connection == nil + ? "连接信息库后即可收入,也可以继续编辑。" + : "连接已配置,请确认内容后点击收入。" + self.revealEditor() + }) return } // An input method owns Return until its marked text is committed. @@ -319,7 +326,8 @@ final class CaptureModel: ObservableObject { let frozen = try await store.freeze(draft, target: connection) draft = CaptureRecord() submissionStatusID = frozen.id - saveStatus = frozen.statusLabel + captureNotice = nil + saveStatus = "随时写下想法,或加入材料" try await refresh() submitting = false await sendPending(force: true) @@ -339,9 +347,6 @@ final class CaptureModel: ObservableObject { func refresh() async throws { records = try await store.records() - if !hasContent, let id = submissionStatusID, let record = records.first(where: { $0.id == id }) { - saveStatus = record.statusLabel - } } func resume(_ record: CaptureRecord) { @@ -359,7 +364,6 @@ final class CaptureModel: ObservableObject { throw PeerError.invalid("这份草稿已变更,请刷新记录后再打开。") } draft = current - submissionStatusID = nil saveStatus = "已恢复本机草稿" pane = .capture } catch { self.error = error.localizedDescription } @@ -426,7 +430,6 @@ final class CaptureModel: ObservableObject { try await store.deleteRecord(record.id) if draft.id == record.id { draft = CaptureRecord() - submissionStatusID = nil captureNotice = nil saveStatus = "本机草稿已删除" } @@ -436,8 +439,35 @@ final class CaptureModel: ObservableObject { } } - func openConnectionSettings(target: ConnectionTarget? = nil) { - editConnection?(target ?? connection) + func openConnectionSettings() { + editConnection?(connection, nil) + } + + var submittedRecord: CaptureRecord? { + records.first { $0.id == submissionStatusID } + } + + func showRecord(_ record: CaptureRecord) { + selectedRecordID = record.id + pane = .queue + showMain?() + } + + func updatePassword(_ password: String, for record: CaptureRecord) async throws { + guard !password.isEmpty, + let current = try await store.record(record.id), + let target = current.target, current.state != .draft, current.state != .confirmed + else { throw PeerError.invalid("这条记录已变更,请关闭后重新查看。") } + try credentials.save(password, for: target.id) + try await store.retry(current.id) + try await refresh() + perform { [self] in await sendPending(force: true) } + } + + func updateWebAddress(_ address: String, for record: CaptureRecord) async throws -> CaptureRecord { + let updated = try await store.updateWebAddress(record.id, address: address) + try await refresh() + return updated } func continueInMain() { @@ -501,7 +531,7 @@ final class CaptureModel: ObservableObject { func resultURL(for record: CaptureRecord) -> URL? { guard let result = record.result, result.availability == .available, let target = record.target else { return nil } - let base = connection?.peerID == target.peerID ? connection?.webURL : target.webURL + let base = target.webURL ?? (connection?.peerID == target.peerID ? connection?.webURL : nil) guard var url = base else { return nil } for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { url.appendPathComponent(part) diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift new file mode 100644 index 0000000..17a4ca3 --- /dev/null +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift @@ -0,0 +1,71 @@ +import Capture +import PeerCore +import SwiftUI + +struct CaptureRecordSettings: View { + enum Field { case password, webAddress } + @ObservedObject var model: CaptureModel + let record: CaptureRecord + let field: Field + @Environment(\.dismiss) private var dismiss + @State private var value = "" + @State private var saving = false + @State private var issue: String? + @FocusState private var inputFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(field == .password ? "更新这条记录的数据库密码" : "设置这条记录的 Web 地址") + .font(.title2).bold() + if let target = record.target { + LabeledContent("信息库", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent("运行角色", value: target.username) + if field == .password { + LabeledContent("TLS", value: target.tls == .require ? "校验证书" : "本机开发连接") + } + } + if field == .password { + SecureField("新的数据库密码", text: $value).focused($inputFocused) + Text("密码保存到原连接的 Keychain 项,并继续收入这条记录。数据库目标和当前使用的信息库不会改变。") + .font(.caption).foregroundStyle(.secondary) + } else { + TextField("Web 地址,例如 https://inkcre.example.com", text: $value).focused($inputFocused) + Text("填写该信息库对应的 InKCre Web 地址,用于打开已收入内容。此操作不会更换当前连接。") + .font(.caption).foregroundStyle(.secondary) + } + if let issue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + HStack { + Spacer() + Button("取消") { dismiss() }.keyboardShortcut(.cancelAction) + Button(saving ? "正在保存…" : saveTitle) { + saving = true + issue = nil + Task { + defer { saving = false } + do { + if field == .password { + try await model.updatePassword(value, for: record) + value = "" + dismiss() + } else { + let updated = try await model.updateWebAddress(value, for: record) + dismiss() + model.openResult(updated) + } + } catch { issue = error.localizedDescription } + } + }.keyboardShortcut(.defaultAction).disabled(value.isEmpty) + } + }.padding(20).frame(width: 480).textFieldStyle(.roundedBorder) + .disabled(saving).interactiveDismissDisabled(saving) + .onAppear { + if field == .webAddress { value = record.target?.webURL?.absoluteString ?? "" } + inputFocused = true + } + } + + private var saveTitle: String { + if field == .webAddress { return "保存并打开" } + return record.state == .cancelled ? "保存并继续收入" : "保存并重试" + } +} diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift index eb7414e..e4e2f4e 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift @@ -5,6 +5,8 @@ import SwiftUI struct CaptureRecordsView: View { @ObservedObject var model: CaptureModel + @State private var passwordRecord: CaptureRecord? + @State private var webRecord: CaptureRecord? var body: some View { Group { if model.records.isEmpty { @@ -12,55 +14,102 @@ struct CaptureRecordsView: View { description: Text("草稿、待收入和已收入的记录会显示在这里。")) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - ForEach(model.records) { record in - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(record.statusLabel).font(.headline) - Spacer() - Text(record.updatedAt, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(.secondary) - } - if let text = record.payload?.materials.first(where: { $0.kind == .text })?.text { - Text(text).lineLimit(3) - } else if let material = record.payload?.materials.first(where: { $0.kind == .image }), - let data = material.data, let image = NSImage(data: data) { - Image(nsImage: image).resizable().scaledToFit().frame(maxHeight: 100) - .accessibilityLabel("记录中的图片") - } - if let annotation = record.payload?.annotation, !annotation.isEmpty { - Text(annotation).lineLimit(3).foregroundStyle(.secondary) - } - if let message = record.message { Text(message).font(.caption).foregroundStyle(.secondary) } - if let target = record.target { Text(target.name).font(.caption).foregroundStyle(.secondary) } - if let result = record.result { Text("Block \(result.rootBlockID)").font(.caption).textSelection(.enabled) } - HStack { - if record.state == .draft { - Button("继续编辑") { model.resume(record) } - } else if record.state == .confirmed { - if record.result?.availability == .deleted { - Text("共享内容已删除").font(.caption).foregroundStyle(.secondary) - } else if model.resultURL(for: record) == nil { - Button("配置 Web 地址…") { model.openConnectionSettings(target: record.target) } - } else { - Button("打开") { model.openResult(record) } + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + ForEach(model.records) { record in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(record.statusLabel).font(.headline) + Spacer() + Text(record.updatedAt, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(.secondary) + } + if let text = record.payload?.materials.first(where: { $0.kind == .text })?.text { + Text(text).lineLimit(3) + } else if let material = record.payload?.materials.first(where: { $0.kind == .image }), + material.data != nil { + CaptureImagePreview(material: material, height: 100) + } + if let annotation = record.payload?.annotation, !annotation.isEmpty { + Text(annotation).lineLimit(3).foregroundStyle(.secondary) + } + if let payload = record.payload, record.state != .draft { + DisclosureGroup("查看全部材料") { + CaptureRecordMaterials(payload: payload) } - } else { - Button(record.state == .cancelled ? "继续收入" : "立即重试") { model.retry(record) } - if record.state != .cancelled { - Button("取消收入") { model.cancel(record) } + } + if let message = record.message { Text(message).font(.caption).foregroundStyle(.secondary) } + if let target = record.target { Text(target.name).font(.caption).foregroundStyle(.secondary) } + if let result = record.result { Text("Block \(result.rootBlockID)").font(.caption).textSelection(.enabled) } + HStack { + if record.state == .draft { + Button("继续编辑") { model.resume(record) } + } else if record.state == .confirmed { + if record.result?.availability == .deleted { + Text("共享内容已删除").font(.caption).foregroundStyle(.secondary) + } else if model.resultURL(for: record) == nil { + Button("配置 Web 地址…") { webRecord = record } + } else { + Button("打开") { model.openResult(record) } + } + } else { + Button(record.state == .cancelled ? "继续收入" : "立即重试") { model.retry(record) } + if record.state != .cancelled { + Button("取消收入") { model.cancel(record) } + } + Button("更新密码…") { passwordRecord = record } } - Button("修复连接") { model.openConnectionSettings(target: record.target) } + Button("删除记录", role: .destructive) { model.requestDiscard(record) } + if record.payload != nil { Button("导出材料") { model.export(record) } } } - Button("删除记录", role: .destructive) { model.requestDiscard(record) } - if record.payload != nil { Button("导出材料") { model.export(record) } } } + .padding(8) + .background(model.selectedRecordID == record.id ? Color.accentColor.opacity(0.08) : Color.clear) + .id(record.id) + Divider() } - Divider() - } - }.padding() + }.padding() + } + .onAppear { if let id = model.selectedRecordID { proxy.scrollTo(id, anchor: .top) } } + .onChange(of: model.selectedRecordID) { _, id in if let id { proxy.scrollTo(id, anchor: .top) } } } } }.disabled(model.submitting) + .sheet(item: $passwordRecord) { record in + CaptureRecordSettings(model: model, record: record, field: .password) + } + .sheet(item: $webRecord) { record in + CaptureRecordSettings(model: model, record: record, field: .webAddress) + } + } +} + +struct CaptureRecordMaterials: View { + let payload: CapturePayload + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(payload.materials.enumerated()), id: \.element.id) { index, material in + VStack(alignment: .leading, spacing: 4) { + Text("材料 \(index + 1)").font(.caption).foregroundStyle(.secondary) + if material.kind == .text { + Text(material.text ?? "").textSelection(.enabled) + } else { + CaptureImagePreview(material: material) + } + }.frame(maxWidth: .infinity, alignment: .leading) + } + if let origin = payload.origin { + VStack(alignment: .leading, spacing: 3) { + if let title = origin.title { Text(title) } + if let url = origin.url { Text(url).textSelection(.enabled) } + if let application = origin.applicationID { Text(application) } + }.font(.caption).foregroundStyle(.secondary) + } + if let annotation = payload.annotation, !annotation.isEmpty { + Text("补充想法").font(.caption).foregroundStyle(.secondary) + Text(annotation).textSelection(.enabled) + } + }.padding(.vertical, 8) } } diff --git a/extensions/capture/Sources/CaptureMac/PeerMainView.swift b/extensions/capture/Sources/CaptureMac/PeerMainView.swift index 53358d6..4708306 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainView.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainView.swift @@ -93,7 +93,7 @@ struct QuickCaptureView: View { Divider() if model.editorOwner == .quick { CaptureEditor(model: model, isQuickCapture: true) } } - .frame(minWidth: 500, minHeight: 400) + .frame(minWidth: 500, minHeight: 500) .modifier(CaptureAlerts(model: model, owner: .quick)) } } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 8db2367..5817595 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -46,7 +46,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion12 +CFBundleVersion13 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From c4a00d8bd99339f385c34d952d91a09a1980b1a5 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Fri, 23 Oct 2026 18:24:31 +0800 Subject: [PATCH 07/14] =?UTF-8?q?feat(mac):=20=E6=8F=90=E4=BE=9B=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=96=87=E4=BB=B6=E6=8C=81=E7=BB=AD=E6=94=B6=E5=85=A5?= =?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=AE=80=E5=90=8E=E5=8F=B0=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 独立本地文件扩展直接写入 PostgreSQL,支持六格式提取与原件失效保留 - 主窗口与连接归 Host,移除 Capture 重复连接界面及轮询 - 按需加载正文,跳过无变化写事务,保证本地停用不受凭据错误阻塞 - 验证 58 项检查与签名 universal 0.1.0(build 16)构建 --- AGENTS.md | 2 +- Package.resolved | 11 +- Package.swift | 8 +- .../PeerCore/BundledExtensionRuntime.swift | 17 +- .../Sources/PeerCore/ConnectionSettings.swift | 2 +- .../Sources/PeerCore/Credentials.swift | 17 +- .../Sources/PeerCore/DatabaseError.swift | 7 + .../PeerCore/Sources/PeerCore/InfoBase.swift | 19 + .../Sources/PeerCore/SourceJobs.swift | 78 +++ README.md | 22 +- Sources/InKCreApp/AppMain.swift | 58 +- Sources/InKCreApp/PeerMainView.swift | 38 ++ Sources/InKCreApp/PeerMainWindow.swift | 43 ++ Sources/InKCreApp/PeerSettingsWindow.swift | 45 +- THIRD_PARTY_NOTICES.md | 4 + .../CaptureInteractionTests.swift | 4 +- .../CaptureMacTests/CaptureLayoutTests.swift | 21 +- .../CaptureTests/CaptureLifecycleTests.swift | 2 +- Tests/CaptureTests/CaptureStoreTests.swift | 6 +- Tests/CaptureTests/CredentialStoreTests.swift | 53 ++ Tests/CaptureTests/LivePeerTests.swift | 13 +- Tests/CaptureTests/NativeExtensionTests.swift | 30 +- Tests/HostTests/HostInterfaceTests.swift | 36 ++ .../LocalFilesInterfaceTests.swift | 125 +++++ Tests/LocalFilesTests/LocalFilesTests.swift | 190 +++++++ .../Sources/Capture/CaptureClient.swift | 12 +- .../Sources/Capture/CaptureQueue.swift | 19 +- .../Sources/Capture/CaptureStore.swift | 10 +- .../Sources/CaptureMac/CaptureExtension.swift | 34 +- .../Sources/CaptureMac/CaptureModel.swift | 79 +-- ...erMainView.swift => CapturePageView.swift} | 64 +-- .../Sources/CaptureMac/ConnectionView.swift | 49 -- .../Sources/CaptureMac/PeerMainWindow.swift | 34 -- extensions/local-files/LICENSE | 21 + extensions/local-files/Package.swift | 15 + extensions/local-files/README.md | 11 + .../Sources/LocalFiles/DirectoryScanner.swift | 84 +++ .../Sources/LocalFiles/FileExtractor.swift | 162 ++++++ .../LocalFiles/FileReconciliation.swift | 147 +++++ .../Sources/LocalFiles/LocalFileClient.swift | 169 ++++++ .../Sources/LocalFiles/Models.swift | 150 ++++++ .../LocalFiles/Resources/extension.json | 1 + .../LocalFilesMac/DirectoryWatcher.swift | 45 ++ .../LocalFilesMac/LocalFilesExtension.swift | 26 + .../LocalFilesMac/LocalFilesModel.swift | 500 ++++++++++++++++++ .../LocalFilesMac/LocalFilesView.swift | 186 +++++++ scripts/build-app.sh | 3 +- 47 files changed, 2377 insertions(+), 295 deletions(-) create mode 100644 Packages/PeerCore/Sources/PeerCore/SourceJobs.swift create mode 100644 Sources/InKCreApp/PeerMainView.swift create mode 100644 Sources/InKCreApp/PeerMainWindow.swift create mode 100644 Tests/CaptureTests/CredentialStoreTests.swift create mode 100644 Tests/LocalFilesTests/LocalFilesInterfaceTests.swift create mode 100644 Tests/LocalFilesTests/LocalFilesTests.swift rename extensions/capture/Sources/CaptureMac/{PeerMainView.swift => CapturePageView.swift} (58%) delete mode 100644 extensions/capture/Sources/CaptureMac/ConnectionView.swift delete mode 100644 extensions/capture/Sources/CaptureMac/PeerMainWindow.swift create mode 100644 extensions/local-files/LICENSE create mode 100644 extensions/local-files/Package.swift create mode 100644 extensions/local-files/README.md create mode 100644 extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift create mode 100644 extensions/local-files/Sources/LocalFiles/FileExtractor.swift create mode 100644 extensions/local-files/Sources/LocalFiles/FileReconciliation.swift create mode 100644 extensions/local-files/Sources/LocalFiles/LocalFileClient.swift create mode 100644 extensions/local-files/Sources/LocalFiles/Models.swift create mode 100644 extensions/local-files/Sources/LocalFiles/Resources/extension.json create mode 100644 extensions/local-files/Sources/LocalFilesMac/DirectoryWatcher.swift create mode 100644 extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift create mode 100644 extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift create mode 100644 extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift diff --git a/AGENTS.md b/AGENTS.md index 4036584..122d21d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 本体是 regular Mac 应用,拥有 Dock 图标、普通主窗口和应用菜单。快捷采集 NSPanel 是辅助入口;不得再把应用整体收窄为 accessory/LSUIElement 菜单栏工具。主窗口导航与快捷采集编辑位置分开,草稿和队列只有一个所有者。 -`Packages/PeerCore` 拥有通用 PostgreSQL 原生连接、事务、图/Storage、凭据、Host 配置和原生扩展生命周期。`extensions/capture` 是 first-party Swift 分发,拥有材料、系统采集、界面、SQLite 草稿/待办、构图与收入恢复。`Sources/InKCreApp` 只负责应用 Host。跨平台业务合同属于 Capture Extension,数据库 schema/迁移由 impl-py 交付。 +`Packages/PeerCore` 拥有通用 PostgreSQL 原生连接、事务、图/Storage、Source Job、凭据、Host 配置和原生扩展生命周期。`extensions/capture` 拥有材料、系统采集、界面、SQLite 草稿/待办、构图与收入恢复;`extensions/local-files` 拥有目录来源、文件提取、监视、持续对账及界面,原件留在磁盘。`Sources/InKCreApp` 拥有应用 Host、普通主窗口、导航、菜单及设置,不能由某一个扩展控制主窗口生命周期。跨平台业务合同归各 first-party extension,数据库 schema/迁移由 impl-py 交付。 Capture 和此次 Mac 分发尚未发布,直接修订候选 schema,不增加旧 HTTP、旧回执或旧本机格式兼容。保留原开发数据文件,不自动导入或清空。第三方引用必须保留许可证、固定来源、修改说明和署名;打包必须包含完整依赖许可。 diff --git a/Package.resolved b/Package.resolved index d1ee13f..bb0bc1f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6367858f5c09eed9f54900a15f2366791d9b770535a83ee4a1cad1d8f354711a", + "originHash" : "5f09cb124b915dafd85dbbdc3963e066f1ead16665778dfb8bd2759d053cb364", "pins" : [ { "identity" : "postgres-nio", @@ -117,6 +117,15 @@ "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", "version" : "1.8.1" } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "e7f85f4d3dd91ca9a1e94d3328aa1ed5120a8c0f", + "version" : "2.14.3" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index c334d2b..f1bc742 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "InKCre", platforms: [.macOS(.v15)], products: [.executable(name: "InKCre", targets: ["InKCreApp"])], - dependencies: [.package(path: "Packages/PeerCore"), .package(path: "extensions/capture")], + dependencies: [.package(path: "Packages/PeerCore"), .package(path: "extensions/capture"), .package(path: "extensions/local-files")], targets: [ .executableTarget( name: "InKCreApp", @@ -12,7 +12,13 @@ let package = Package( .product(name: "PeerCore", package: "PeerCore"), .product(name: "PeerMacUI", package: "PeerCore"), .product(name: "CaptureMac", package: "capture"), + .product(name: "LocalFilesMac", package: "local-files"), ]), + .testTarget(name: "LocalFilesTests", dependencies: [ + .product(name: "LocalFiles", package: "local-files"), + .product(name: "LocalFilesMac", package: "local-files"), + .product(name: "PeerCore", package: "PeerCore") + ], path: "Tests/LocalFilesTests"), .testTarget(name: "HostTests", dependencies: ["InKCreApp"], path: "Tests/HostTests"), .testTarget( name: "CaptureTests", diff --git a/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift b/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift index e394a51..8ff0318 100644 --- a/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift +++ b/Packages/PeerCore/Sources/PeerCore/BundledExtensionRuntime.swift @@ -30,11 +30,20 @@ public final class BundledExtensionRuntime { idleWaiters.removeAll() } syncIssue = nil - do { try await module.setEnabled(enabled) } catch PeerError.connection(let message) { - syncIssue = message + if !enabled { + defaults.set(false, forKey: key) + try await stop() + } + do { try await module.setEnabled(enabled) } catch { + if enabled { + guard case PeerError.connection = error else { throw error } + } + syncIssue = "共享状态未同步:\(error.localizedDescription)" + } + if enabled { + defaults.set(true, forKey: key) + try startIfEnabled() } - defaults.set(enabled, forKey: key) - if enabled { try startIfEnabled() } else { try await stop() } } public func waitUntilIdle() async { if applying { await withCheckedContinuation { idleWaiters.append($0) } } diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift index 2f5d097..ce35d2d 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -32,7 +32,7 @@ public actor ConnectionSettings { name: host) try target.validate() if !password.isEmpty { try credentials.save(password, for: target.id) } - _ = try credentials.read(for: target.id) + _ = try credentials.read(for: target.id, allowInteraction: true) try save(target) return target } diff --git a/Packages/PeerCore/Sources/PeerCore/Credentials.swift b/Packages/PeerCore/Sources/PeerCore/Credentials.swift index e5fbbbf..c9ee367 100644 --- a/Packages/PeerCore/Sources/PeerCore/Credentials.swift +++ b/Packages/PeerCore/Sources/PeerCore/Credentials.swift @@ -3,10 +3,13 @@ import Security public struct CredentialStore: Sendable { private let service: String + private static let accessLock = NSLock() public init(service: String) { self.service = service } public func save(_ secret: String, for connection: UUID) throws { + Self.accessLock.lock() + defer { Self.accessLock.unlock() } guard !secret.isEmpty else { throw PeerError.invalid("请输入数据库密码。") } @@ -20,13 +23,20 @@ public struct CredentialStore: Sendable { if status == errSecItemNotFound { var item = query item[kSecValueData as String] = data - item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly status = SecItemAdd(item as CFDictionary, nil) } guard status == errSecSuccess else { throw PeerError.storage("Keychain 保存失败(\(status))。") } } - public func read(for connection: UUID) throws -> String { + public func read(for connection: UUID, allowInteraction: Bool = false) throws -> String { + Self.accessLock.lock() + defer { Self.accessLock.unlock() } + // File-based Keychain ignores LAContext; serialize and restore its process-wide UI policy. + var previous: DarwinBoolean = false + guard SecKeychainGetUserInteractionAllowed(&previous) == errSecSuccess, + SecKeychainSetUserInteractionAllowed(allowInteraction) == errSecSuccess + else { throw PeerError.storage("无法设置密钥串访问方式。") } + defer { SecKeychainSetUserInteractionAllowed(previous.boolValue) } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -36,6 +46,9 @@ public struct CredentialStore: Sendable { ] var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecInteractionNotAllowed || status == errSecAuthFailed { + throw PeerError.needsAction("凭据暂不可用,请在连接设置中解锁密钥串或重新保存密码。") + } guard status == errSecSuccess, let data = result as? Data, let secret = String(data: data, encoding: .utf8) else { diff --git a/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift index edd8621..293a34f 100644 --- a/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift +++ b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift @@ -2,6 +2,13 @@ import Foundation import PostgresNIO extension PeerDatabase { + public static func isCancellation(_ error: Error) -> Bool { + if error is CancellationError { return true } + if let transaction = error as? PostgresTransactionError, + transaction.rollbackError == nil, transaction.commitError == nil, + let cause = transaction.closureError { return isCancellation(cause) } + return false + } public static func failure(_ error: Error, dispatched: Bool = false) -> PeerError { if let transaction = error as? PostgresTransactionError, transaction.rollbackError == nil, transaction.commitError == nil, diff --git a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift index 9fcbcf2..9d1f69d 100644 --- a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift +++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift @@ -24,6 +24,25 @@ public struct GraphRelation: Sendable, Encodable { /// Uses the caller's transaction; storage pointers and byte mechanics belong to the Host. public enum InfoBase { + public static func createBlock(resolver: String, content: String, on connection: PostgresConnection) async throws -> Int { + let rows = try await connection.query( + "INSERT INTO inkcre.blocks(resolver,content) VALUES (\(resolver),\(content)) RETURNING id", logger: PeerDatabase.logger) + for try await id in rows.decode(Int.self) { return id } + throw PeerError.connection("信息库未返回资料标识。") + } + + public static func updateBlock(_ id: Int, content: String, on connection: PostgresConnection) async throws { + let rows = try await connection.query( + "UPDATE inkcre.blocks SET content=\(content) WHERE id=\(id) RETURNING id", logger: PeerDatabase.logger) + for try await _ in rows.decode(Int.self) { return } + throw PeerError.needsAction("资料已被移除。") + } + + public static func relate(_ from: Int, to: Int, content: String, on connection: PostgresConnection) async throws { + try await connection.query( + "INSERT INTO inkcre.relations(from_,to_,content) VALUES (\(from),\(to),\(content))", logger: PeerDatabase.logger) + } + public static func persist( blocks: [GraphBlock], relations: [GraphRelation], blobs: [Int: Data], on connection: PostgresConnection diff --git a/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift b/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift new file mode 100644 index 0000000..8131311 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift @@ -0,0 +1,78 @@ +import Foundation +import PostgresNIO + +public struct SourceJob: Sendable { + public let id: Int + public let deadline: Date +} + +public enum SourceJobs { + public static let type = "core.source.collect.v1" + + public static func submit(source: Int, on connection: PostgresConnection) async throws -> Int { + let parameters = "{\"source\":\(source),\"config\":{}}" + let rows = try await connection.query( + """ + INSERT INTO inkcre.jobs(type,parameters,timeout_seconds,status) + SELECT id,\(parameters)::jsonb,default_timeout_seconds,'pending' FROM inkcre.job_types WHERE id=\(type) + RETURNING id + """, logger: PeerDatabase.logger) + for try await id in rows.decode(Int.self) { return id } + throw PeerError.needsAction("信息库尚未登记来源扫描命令。") + } + + public static func pending(source: Int, on client: PostgresClient) async throws -> [Int] { + try await client.query( + "UPDATE inkcre.jobs SET status='timed_out' WHERE type=\(type) AND status='running' AND parameters->'source'=to_jsonb(\(source)::bigint) AND started_at + timeout_seconds * interval '1 second' < now()" + ) + try await client.query( + "UPDATE inkcre.jobs SET status='aborted' WHERE type=\(type) AND status='pending' AND abort_requested AND parameters->'source'=to_jsonb(\(source)::bigint)" + ) + let rows = try await client.query( + "SELECT id FROM inkcre.jobs WHERE type=\(type) AND status='pending' AND parameters->'source'=to_jsonb(\(source)::bigint) ORDER BY id" + ) + var ids: [Int] = [] + for try await id in rows.decode(Int.self) { ids.append(id) } + return ids + } + + /// Eligibility is checked under the Source lock by the capability before claiming. + public static func claim(_ id: Int, source: Int, on connection: PostgresConnection) async throws + -> SourceJob? + { + let rows = try await connection.query( + """ + UPDATE inkcre.jobs SET status='running' WHERE id=\(id) AND type=\(type) AND status='pending' + AND NOT abort_requested AND (parameters || '{"config":{}}'::jsonb)=\("{\"source\":\(source),\"config\":{}}")::jsonb + AND COALESCE(parameters->'config','{}'::jsonb)='{}'::jsonb + RETURNING id,started_at + timeout_seconds * interval '1 second' + """, logger: PeerDatabase.logger) + for try await (id, deadline) in rows.decode((Int, Date).self) { + return SourceJob(id: id, deadline: deadline) + } + return nil + } + + public static func check(_ job: SourceJob, on client: PostgresClient) async throws { + try Task.checkCancellation() + if Date() >= job.deadline { throw PeerError.needsAction("扫描已超时。") } + let rows = try await client.query( + "SELECT status::text,abort_requested FROM inkcre.jobs WHERE id=\(job.id)") + for try await (status, aborted) in rows.decode((String, Bool).self) { + guard status == "running", !aborted else { throw CancellationError() } + return + } + throw CancellationError() + } + + public static func close( + _ job: SourceJob, status: String, state: String, on client: PostgresClient + ) async throws { + guard ["finished", "failed", "timed_out", "aborted"].contains(status) else { + throw PeerError.invalid("无效的任务终态。") + } + try await client.query( + "UPDATE inkcre.jobs SET status=\(status)::inkcre.jobstatus,state=\(state)::jsonb WHERE id=\(job.id) AND status='running'" + ) + } +} diff --git a/README.md b/README.md index bc5214f..8efaf93 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ 正常运行于 Dock 和应用切换器中的原生 Mac Peer,首版提供收集能力。macOS 15 或更新版本,可采集当前选区、明确粘贴的文字/链接/图片、单屏框选截图与主动输入的想法。原材料、来源与补充想法分别保存;收入不依赖 OCR 或模型处理。 -启动或点击 Dock 图标打开普通主窗口,侧栏提供采集、记录、Peer 与连接。主窗口可缩放、最小化,记住尺寸位置;关闭主窗口后进程、菜单栏入口和队列继续运行,`⌘Q` 才退出。Peer 页展示当前信息库、连接对应的 Peer 身份、最近联络结果和待收入数;配置连接本身不表示在线。 +启动或点击 Dock 图标打开普通主窗口,侧栏提供采集、记录、本地文件、Peer 与连接。主窗口可缩放、最小化,记住尺寸位置;关闭主窗口后进程、菜单栏入口和队列继续运行,`⌘Q` 才退出。Peer 页展示当前信息库、连接对应的 Peer 身份与连接问题;配置连接本身不表示在线。 -主窗口使用系统 `NavigationSplitView` 和原生工具栏,标题随当前页面更新。采集页工具栏提供新想法、粘贴材料、框选截图,各页保留快捷面板入口;记录和 Peer 页不显示采集动作。侧栏开关固定在窗口控制按钮旁,系统分隔线跟随侧栏宽度,内容区不另放重复顶栏。窗口通过 `NSHostingController` 与 `fullSizeContentView` 承载 SwiftUI,不用弹性占位或自制侧栏控制器修补位置。显示菜单以 `⌘⌥S` 切换侧栏,与系统工具栏按钮共用 `NavigationSplitView` 的可见状态;命令沿窗口响应链执行,设置窗口和快捷面板不会误切主窗口侧栏。全屏仍使用系统命令。 +主窗口使用系统 `NavigationSplitView` 和原生工具栏,标题随当前页面更新。采集页工具栏提供新想法、粘贴材料、框选截图,采集与记录页保留快捷面板入口;记录和 Peer 页不显示采集动作。侧栏开关固定在窗口控制按钮旁,系统分隔线跟随侧栏宽度,内容区不另放重复顶栏。窗口通过 `NSHostingController` 与 `fullSizeContentView` 承载 SwiftUI,不用弹性占位或自制侧栏控制器修补位置。显示菜单以 `⌘⌥S` 切换侧栏,与系统工具栏按钮共用 `NavigationSplitView` 的可见状态;命令沿窗口响应链执行,设置窗口和快捷面板不会误切主窗口侧栏。全屏仍使用系统命令。 全局入口打开独立快捷采集面板。主窗口与面板共用同一草稿、记录和队列,同时只有一份可编辑采集视图。“在主窗口继续”转回主窗口采集页;关闭或收起快捷面板保留草稿并归还前一个应用的焦点。主窗口菜单提供新想法、设置、关闭、最小化和窗口重新打开等普通 Mac 操作。 @@ -20,13 +20,15 @@ open dist/InKCre.app 构建产物是 universal `.app` 和 `dist/InKCre-0.1.0-universal.dmg`。未指定开发签名身份时使用 ad-hoc 签名;两种开发包都不能当作已公证的发行版。将 `.app` 放在固定位置,并保持稳定的开发签名身份后再授权。 +首版尚未发布,应用版本当前为 `0.1.0`。`CFBundleShortVersionString` 保存应用版本,`CFBundleVersion` 单独记录构建序号;交付说明写作 `0.1.0(build 15)`,不能将构建序号称作 `v15`。首版发布前最多递增 patch 或 build,不提升 minor/major;一般开发构建只递增 build,正式版本准备遵循组织的 Release 流程。 + 需要反复开发和实测系统采集时,使用本人明确指定的 Apple Development 身份:`DEVELOPMENT_SIGNING_IDENTITY='Apple Development: …' bash scripts/build-app.sh`。也可将该身份的名称或证书 SHA-1 单独保存在 ignored `.local-development-signing-identity`(一行文本,不执行其中内容)。脚本不会自动选择证书或回退到另一个身份;正式发布仍使用独立的 `SIGNING_IDENTITY` 输入。ad-hoc 身份随代码版本变化,系统可能不再认可先前的隐私授权;固定路径和 bundle ID 本身不足以解决此问题。[Apple 对代码身份与授权的说明](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements)。 默认 `⌃⌥I` 读取当前选区并唤起快捷面板,`⌃⌥S` 框选截图;采集编辑器内 `⇧⌘V` 粘贴材料、`⌘↩` 收入,Esc 仅收起快捷面板并保留草稿。设置中的五项快捷键均可分别录入、清除或恢复默认,修改立即生效并在重启后保留。点击对应按键按钮后按下组合键,Esc 取消录入。组合键须包含 ⌘、⌃ 或 ⌥,收起面板的默认 Esc 除外。`⌘W`、`⌘Q`、`⌘,`、`⌘⌥S` 等窗口、导航和编辑命令保留给平台操作。重复组合、标准命令冲突或系统注册冲突会提示并保留原设置,其他快捷键不受影响。录入期间暂停全局触发,离开设置或切换应用时取消录入。 收集和截图在全局生效;粘贴和收入作用于主窗口采集页或快捷面板;收起仅作用于快捷面板。输入法组词时采集快捷键交给输入法处理。“采集”菜单显示当前配置的快捷键;菜单和按键使用相同的可用条件,无法执行的局部快捷键不会吞掉输入。选区需要辅助功能权限,截图需要屏幕录制权限;输入和明确粘贴不需要这些权限。 -直接 AX 读取不可用时,仅在系统明确报告非空选区且不是安全输入时尝试复制回退。没有可靠选区时显示降级提示,不采集旧剪贴板或编辑器默认复制的整行。真实来源缺失就留空;明确粘贴不会伪造来源应用。通用文件、网页抓取、OCR 和知识查询不属于此版本。 +直接 AX 读取不可用时,仅在系统明确报告非空选区且不是安全输入时尝试复制回退。没有可靠选区时显示降级提示,不采集旧剪贴板或编辑器默认复制的整行。真实来源缺失就留空;明确粘贴不会伪造来源应用。网页抓取、OCR 和知识查询不属于此版本。目录文件由独立的本地文件扩展处理。 截图在应用内使用 BetterShot 选区组件的固定版本改编,先冻结各屏画面,再拖动框选。松手后可拖动区域移动、拖动八个手柄缩放,或在区域外重新框选;附近的原生“截取”/“取消”按钮始终留在屏幕内。回车或双击选区确认,Esc、右键和“取消”退出并保留原草稿。确认只从看到的冻结画面裁剪原图 PNG,不重新拍摄,不调用 Apple 截图工具栏。全屏冻结帧仅在本次会话内存中存在,只持久化明确确认的单屏区域。 @@ -40,6 +42,8 @@ open dist/InKCre.app 连接设置使用 PostgreSQL 主机、端口、库名、独立运行角色及密码,可选 Web 地址。远端使用 TLS 并校验证书;只有回环开发地址允许关闭 TLS。密码存于 Keychain,角色拥有已准入数据库协议权限,不使用迁移管理员或 JWT 签名密钥。 +凭据使用 macOS file-based Keychain。后台读取禁止系统授权弹窗;密钥串锁定或应用访问被拒绝时,显示需要处理的连接错误,只有主动保存连接时允许交互。访问策略属于进程,因此共享凭据入口串行执行读写并恢复原策略。开发验收应由签名应用保存测试密码,不能用 `security add-generic-password -T` 预置后假定签名分区已正确授权。 + `⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页只展示状态,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 共享数据库需要本分支的 Capture 回执表与原生分发参与合同。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写图、原图和回执;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 @@ -52,11 +56,21 @@ open dist/InKCre.app 待收入记录可“取消收入”,停止后续自动发送并保留原材料、UUID 和目标;“继续收入”先查原回执。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 +## 本地文件 + +在主窗口的“本地文件”添加目录,系统目录选择器返回的权限通过安全作用域书签保存。首次扫描以及之后的目录变化会收入 PDF、DOCX、Markdown、Org、HTML 和纯文本。文件表展示提取结果,选中后可读正文、打开原件或在 Finder 显示。错误留在对应文件中;空列表、扫描中、部分失败与连接错误分别呈现。 + +本地文件扩展通过原生 PostgreSQL 直接更新资料,修改或可证明的重命名保持资料 ID。原件删除后正文保留,位置标为失效;目录离线、权限错误或解析失败不会清空旧正文。暂停停止监视,恢复和应用重启重新核对;移除先确认,只移除 Source,保留共享图。目录始终绑定原信息库,切换当前连接不改投。 + +原件链接是 `inkcre-peer-file:///<机器绝对路径>`。原件留在磁盘,本地不另存正文队列或文件备份,数据库只存正文和元数据;其他 Peer 读取正文无需此 Mac 在线。应用内只打开属于本 Peer 的位置,不注册系统 URL handler,也不提供跨 Peer 文件传输。文件消失前尚未成功收入的内容无法恢复。 + +PDF 读取文字层;DOCX 读取主文档与表格文字,遗漏脚注、批注等附属内容,详情会标出范围。Markdown/Org 保留原语法;HTML 用 SwiftSoup 解析,不执行脚本或下载外链。输入上限 64 MiB,正文上限 8 MiB;UTF-8 与带 BOM 的 UTF-16 可直接读取。详细维护边界见 [本地文件分发](extensions/local-files/README.md)。 + ## 本地数据与发布 开发包的 Host 配置位于 `~/Library/Application Support/dev.inkcre.capture/connection.json`;Capture 数据位于同目录 `extensions/inkcre-capture/capture.sqlite`,使用 WAL/FULL。确认后清理本机材料,保留共享引用。WAL 和空闲页不提供安全擦除保证。此前未发布开发版的数据文件原样保留,本版没有旧格式导入或 HTTP 回退层。 -`Packages/PeerCore` 提供数据库、标准图/存储、凭据和原生模块生命周期;`extensions/capture` 是独立 Swift 包,拥有采集业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。停用撤下入口并等待当前工作收束,保留材料;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 +`Sources/InKCreApp` 拥有主窗口、导航、菜单与设置。`Packages/PeerCore` 提供数据库、标准图/存储、Source Job、凭据和原生模块生命周期;`extensions/capture` 与 `extensions/local-files` 是独立 Swift 包,各自拥有业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。Capture 在实际提交时办理首次准入并校验共享选择,不另设连接状态页或连接轮询。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。本地停用先保存本机停用状态、撤下入口并等待当前工作收束,再尝试更新共享意图;连接或凭据错误只报告共享状态未同步,不阻止停止,材料保留;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 48557d1..6fb5683 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -1,5 +1,6 @@ import AppKit import CaptureMac +import LocalFilesMac import PeerCore import PeerMacUI import SwiftUI @@ -19,7 +20,23 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI let context: PeerHostContext let runtime: BundledExtensionRuntime let capture: CaptureExtension - private var module: any NativeExtension { runtime.module } + let localFiles: LocalFilesExtension + let filesRuntime: BundledExtensionRuntime + enum Page: Hashable { + case capture, records, files, peer + var title: String { + switch self { case .capture: "采集"; case .records: "记录"; case .files: "本地文件"; case .peer: "Peer 与连接" } + } + } + @Published var columnVisibility = NavigationSplitViewVisibility.all + @Published var page = Page.capture { + didSet { + capture.showingCapturePage = page == .capture || page == .records + if page == .capture { capture.selectPage(.capture) } + if page == .records { capture.selectPage(.queue) } + } + } + private(set) var mainWindow: PeerMainWindow? override convenience init() { let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -31,7 +48,15 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI self.context = context capture = CaptureExtension(context: context) runtime = BundledExtensionRuntime(module: capture, defaults: defaults) + localFiles = LocalFilesExtension(context: context) + filesRuntime = BundledExtensionRuntime(module: localFiles, defaults: defaults) super.init() + capture.presentMain = { [weak self] in self?.showMain() } + capture.navigate = { [weak self] page in + self?.page = page == .capture ? .capture : .records + } + localFiles.present = { [weak self] in self?.page = .files; self?.showMain() } + localFiles.model.editConnection = { [weak self] in self?.editConnection(self?.connection) } capture.showPreferences = { [weak self] in self?.showSettings(.capture) } capture.editConnection = { [weak self] target, completion in self?.editConnection(target, onFinished: completion) } } @@ -48,13 +73,18 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI NSApp.applicationIconImage = icon } installHostMenu() + makeMainWindow() do { try runtime.startIfEnabled() + capture.selectPage(page == .records ? .queue : .capture) } catch { show(error) } + do { try filesRuntime.startIfEnabled() } catch { show(error) } + showMain() presence = Task { while !Task.isCancelled { do { - if let target = try await context.connections.load() { + connection = try await context.connections.load() + if let target = connection { _ = try await PeerDatabase.shared.register( target, password: context.credentials.read(for: target.id)) } @@ -64,7 +94,19 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI do { try await Task.sleep(for: .seconds(30)) } catch { break } } } - if !runtime.running { showExtensions(nil) } + } + + func makeMainWindow() { + guard mainWindow == nil else { return } + mainWindow = PeerMainWindow(host: self) + capture.mainWindow = mainWindow + } + + @objc func showMain() { + makeMainWindow() + if mainWindow?.isMiniaturized == true { mainWindow?.deminiaturize(nil) } + NSApp.activate(ignoringOtherApps: true) + mainWindow?.makeKeyAndOrderFront(nil) } func installHostMenu() { @@ -114,6 +156,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") .keyEquivalentModifierMask = [.command, .control] let window = addMenu("窗口") + window.addItem(withTitle: "显示主窗口", action: #selector(showMain), keyEquivalent: "0").target = self window.addItem( withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") window.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") @@ -150,11 +193,13 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } } - func changeEnabled(_ enable: Bool) { + func changeEnabled(_ enable: Bool, files: Bool = false) { + let runtime = files ? filesRuntime : runtime guard !runtime.applying else { return } Task { objectWillChange.send() do { try await runtime.setEnabled(enable) } catch { show(error) } + capture.selectPage(page == .records ? .queue : .capture) manager?.refreshCaptureSettings(host: self) updateStatus() } @@ -179,6 +224,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI self?.connection = target self?.connectionIssue = nil self?.capture.connectionDidChange(target) + Task { await self?.localFiles.model.refresh() } close() })) sheet.contentView = content @@ -205,7 +251,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - if runtime.running { module.showMain() } else { showExtensions(nil) } + showMain() return false } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } @@ -215,6 +261,8 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI do { await runtime.waitUntilIdle() try await runtime.stop() + await filesRuntime.waitUntilIdle() + try await filesRuntime.stop() await presence?.value await PeerDatabase.shared.shutdown() sender.reply(toApplicationShouldTerminate: true) diff --git a/Sources/InKCreApp/PeerMainView.swift b/Sources/InKCreApp/PeerMainView.swift new file mode 100644 index 0000000..e381be6 --- /dev/null +++ b/Sources/InKCreApp/PeerMainView.swift @@ -0,0 +1,38 @@ +import LocalFilesMac +import SwiftUI + +struct PeerMainView: View { + @ObservedObject var host: PeerHost + var body: some View { + NavigationSplitView(columnVisibility: $host.columnVisibility) { + List( + selection: Binding( + get: { Optional(host.page) }, set: { if let page = $0 { host.page = page } }) + ) { + Label("采集", systemImage: "square.and.pencil").tag(PeerHost.Page.capture) + Label("记录", systemImage: "tray.full").tag(PeerHost.Page.records) + Label("本地文件", systemImage: "folder").tag(PeerHost.Page.files) + Label("Peer 与连接", systemImage: "network").tag(PeerHost.Page.peer) + }.listStyle(.sidebar).navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 260) + } detail: { + Group { + switch host.page { + case .capture, .records: host.capture.pageView() + case .files: + if host.filesRuntime.running { + LocalFilesView(model: host.localFiles.model) + } else { + ContentUnavailableView { + Label("本地文件扩展已停用", systemImage: "folder") + } actions: { + Button("启用本地文件") { host.changeEnabled(true, files: true) }.disabled( + host.filesRuntime.applying) + } + } + case .peer: ConnectionSettingsView(host: host) + } + }.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(host.page.title) + } + } +} diff --git a/Sources/InKCreApp/PeerMainWindow.swift b/Sources/InKCreApp/PeerMainWindow.swift new file mode 100644 index 0000000..1afa7c9 --- /dev/null +++ b/Sources/InKCreApp/PeerMainWindow.swift @@ -0,0 +1,43 @@ +import AppKit +import SwiftUI + +@MainActor +final class PeerMainWindow: NSWindow, NSWindowDelegate { + private let host: PeerHost + + @objc func toggleSidebar(_ sender: Any?) { + withAnimation { + host.columnVisibility = host.columnVisibility == .detailOnly ? .all : .detailOnly + } + } + + override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { + guard item.action == #selector(toggleSidebar(_:)) else { + return super.validateUserInterfaceItem(item) + } + (item as? NSMenuItem)?.title = host.columnVisibility == .detailOnly ? "显示侧栏" : "隐藏侧栏" + return attachedSheet == nil + } + + init(host: PeerHost) { + self.host = host + super.init( + contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + title = "InKCre" + toolbarStyle = .unified + minSize = NSSize(width: 760, height: 520) + isReleasedWhenClosed = false + collectionBehavior = [.fullScreenPrimary] + delegate = self + contentViewController = NSHostingController(rootView: PeerMainView(host: host)) + if !setFrameUsingName("InKCreMainWindow") { + setContentSize(NSSize(width: 1000, height: 720)) + center() + } + setFrameAutosaveName("InKCreMainWindow") + } + func windowDidBecomeKey(_ notification: Notification) { host.capture.mainWindowBecameKey() } + func windowShouldClose(_ sender: NSWindow) -> Bool { host.capture.windowShouldClose(sender) } +} diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift index d7b1a89..38ab6f5 100644 --- a/Sources/InKCreApp/PeerSettingsWindow.swift +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -59,26 +59,30 @@ final class PeerSettingsWindow: NSWindow, NSWindowDelegate { } } -private struct ConnectionSettingsView: View { +struct ConnectionSettingsView: View { @ObservedObject var host: PeerHost var body: some View { - Form { - Section("共享信息库") { - if let target = host.connection { - LabeledContent("信息库", value: target.name) - LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") - LabeledContent("运行角色", value: target.username) - if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } - } else { - Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") - .foregroundStyle(.secondary) - } - Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { host.editConnection(host.connection) } - if let issue = host.connectionIssue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + Group { + if let target = host.connection { + Form { + Section("共享信息库") { + LabeledContent("信息库", value: target.name) + LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent("运行角色", value: target.username) + LabeledContent("Peer", value: target.peerID.uuidString.lowercased()).textSelection(.enabled) + if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } + Button("编辑连接…") { host.editConnection(target) } + if let issue = host.connectionIssue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + } + }.formStyle(.grouped) + } else { + ContentUnavailableView { + Label("未连接信息库", systemImage: "network") + } actions: { + Button("连接信息库…") { host.editConnection(nil) } + }.frame(maxWidth: .infinity, maxHeight: .infinity) } - Text("连接由此 Mac 的各扩展共享;密码保存在系统钥匙串中。") - .font(.caption).foregroundStyle(.secondary) - }.formStyle(.grouped) + } } } @@ -96,6 +100,13 @@ private struct ExtensionSettingsView: View { Text("随应用交付,提供选区、粘贴、截图与想法采集。") .font(.caption).foregroundStyle(.secondary) } + Section("本地文件") { + Toggle("在此 Mac 启用", isOn: Binding(get: { host.filesRuntime.enabled }, set: { host.changeEnabled($0, files: true) })) + .disabled(host.filesRuntime.applying) + Text(host.filesRuntime.applying ? "正在应用…" : host.filesRuntime.syncIssue ?? (host.filesRuntime.running ? "本机运行中" : "已停用")) + .foregroundStyle(.secondary) + LabeledContent("版本", value: host.localFiles.descriptor.version) + } }.formStyle(.grouped) } } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index afa93b5..e5b5843 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,3 +9,7 @@ BetterShot 由 Kartik Labhshetwar 及贡献者开发。CaptureMac/Vendor/BetterS PostgresNIO 及传递依赖使用各自许可。Package.resolved 固定来源与修订;scripts/collect-licenses.py 从实际解析的源码收集完整 LICENSE/NOTICE,包括 BoringSSL 的内嵌许可。缺失许可会使打包失败。生成结果随应用保存在 Contents/Resources/THIRD_PARTY_NOTICES.txt,可从应用菜单打开。 用户提供的 InKCre logo pack 用于本项目品牌;代码 MIT 许可不授予 InKCre 商标或品牌资源的使用权。品牌目录保留出处记录。 + +[SwiftSoup 2.14.3](https://github.com/scinfu/SwiftSoup/tree/2.14.3)(e7f85f4d3dd91ca9a1e94d3328aa1ed5120a8c0f)用于本地 HTML 文字解析,使用 MIT。构建收集上游完整 LICENSE,保留原 jsoup 作者与 Swift port 作者署名。 + +[Hister](https://github.com/asciimoo/hister) 的目录监视与客户端提取行为为本地文件功能提供启发。参考版本 f1fff29683c8c8006a35d3387384d9b1e123cebe,项目使用 AGPL-3.0-or-later。本实现未复制或改写其源码,不运行其服务;本致谢不表示上游参与或背书。 diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 3b856b1..6c44bb2 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -50,13 +50,13 @@ struct CaptureInteractionTests { defer { model.cancelWork() } model.thought = "主窗口中未提交的草稿" let previousID = model.draft.id - model.pane = .peer + model.pane = .queue model.editorOwner = .quick var presented: CaptureModel.EditorOwner? model.presentEditor = { presented = model.editorOwner } let payload = CapturePayload(entry: .selection, materials: [Material(text: "快捷采集的新材料")]) await model.receive(payload) - #expect(model.pane == .peer) + #expect(model.pane == .queue) #expect(presented == .quick) #expect(model.draft.id == payload.submissionID) #expect(try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿") diff --git a/Tests/CaptureMacTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift index c816bcd..1dadcac 100644 --- a/Tests/CaptureMacTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -70,29 +70,20 @@ struct CaptureLayoutTests { } } - @Test func mainWindowSidebarAndPrimarySurfacesRenderOffscreen() async throws { + @Test func captureSurfacesRenderOffscreen() async throws { _ = NSApplication.shared let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let model = try CaptureModel(directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") defer { model.cancelWork() } - let window = PeerMainWindow(model: model) - window.setFrameAutosaveName("") - window.setFrame(NSRect(x: 0, y: 0, width: 760, height: 520), display: false) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 760, height: 520), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + window.contentViewController = NSHostingController(rootView: CapturePageView(model: model)) + window.setContentSize(NSSize(width: 760, height: 520)) defer { window.orderOut(nil) } - let item = NSMenuItem(title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") - #expect(window.validateUserInterfaceItem(item)) - #expect(item.title == "隐藏侧栏") - #expect(window.tryToPerform(item.action!, with: item)) - #expect(model.columnVisibility == .detailOnly) - #expect(window.validateUserInterfaceItem(item)) - #expect(item.title == "显示侧栏") - try await snapshot(window, named: "main-sidebar-hidden") - #expect(window.tryToPerform(item.action!, with: item)) - #expect(model.columnVisibility == .all) for appearance in [NSAppearance.Name.aqua, .darkAqua] { window.appearance = NSAppearance(named: appearance) - for pane in [CaptureModel.Pane.capture, .queue, .peer] { + for pane in [CaptureModel.Pane.capture, .queue] { model.pane = pane try await snapshot(window, named: "main-\(pane)-\(appearance.rawValue)") } diff --git a/Tests/CaptureTests/CaptureLifecycleTests.swift b/Tests/CaptureTests/CaptureLifecycleTests.swift index 3e80f6f..e2813f5 100644 --- a/Tests/CaptureTests/CaptureLifecycleTests.swift +++ b/Tests/CaptureTests/CaptureLifecycleTests.swift @@ -55,7 +55,7 @@ private actor PausedRecovery: CaptureTransport { let stopped = Task { await queue.stop() } while !(await queue.isStopped) { await Task.yield() } await transport.finishRecovery() - try await processing.value + _ = try await processing.value await stopped.value try await queue.process(force: true) #expect(await transport.dispatches == 0) diff --git a/Tests/CaptureTests/CaptureStoreTests.swift b/Tests/CaptureTests/CaptureStoreTests.swift index 49e96c8..b528cd0 100644 --- a/Tests/CaptureTests/CaptureStoreTests.swift +++ b/Tests/CaptureTests/CaptureStoreTests.swift @@ -37,14 +37,17 @@ struct CaptureStoreTests { try await store.report(draft.id, state: .pending, message: "迟到的错误") // No credential exists: an accidental dispatch would change the state to needsAction. let credentials = CredentialStore(service: "dev.inkcre.test.\(UUID().uuidString)") - try await CaptureQueue(store: store, credentials: credentials).process(force: true) + #expect(try await store.pendingRecords().isEmpty) + #expect(try await CaptureQueue(store: store, credentials: credentials).process(force: true) == false) let retained = try #require(try await store.record(draft.id)) #expect(retained.state == .cancelled) #expect(retained.attempts == 0) #expect(retained.target == target) #expect(retained.payload?.submissionID == draft.id) try await store.retry(draft.id) + #expect(try await store.pendingRecords().map(\.id) == [draft.id]) #expect(try await store.markDispatch(draft.id).id == draft.id) + #expect(try await store.pendingRecords().first?.state == .unknown) } @Test func cancellationKeepsUnknownOutcomeButDeletionIgnoresLateCallbacks() async throws { @@ -168,6 +171,7 @@ struct CaptureStoreTests { let recovered = try #require(try await reopened.record(draft.id)) #expect(recovered.state == .confirmed) #expect(recovered.payload == nil) + #expect(try await reopened.pendingRecords().isEmpty) #expect(recovered.result == result) await #expect(throws: PeerError.self) { try await reopened.retry(draft.id) } } diff --git a/Tests/CaptureTests/CredentialStoreTests.swift b/Tests/CaptureTests/CredentialStoreTests.swift new file mode 100644 index 0000000..9c84cb2 --- /dev/null +++ b/Tests/CaptureTests/CredentialStoreTests.swift @@ -0,0 +1,53 @@ +import Foundation +import PeerCore +import Security +import Testing + +@Suite(.serialized) +struct CredentialStoreTests { + @Test func backgroundReadsRejectUntrustedItemsWithoutInteraction() throws { + let service = "dev.inkcre.credentials-test.\(UUID())" + let connection = UUID() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + ] + let command = Process() + command.executableURL = URL(fileURLWithPath: "/usr/bin/security") + command.arguments = [ + "add-generic-password", "-s", service, "-a", connection.uuidString, + "-w", "disposable-fixture", "-T", "/usr/bin/security", + ] + try command.run() + command.waitUntilExit() + #expect(command.terminationStatus == 0) + defer { SecItemDelete(query as CFDictionary) } + + let store = CredentialStore(service: service) + for _ in 0..<3 { + do { + _ = try store.read(for: connection) + Issue.record("无权访问的凭据不能静默读取成功") + } catch PeerError.needsAction(let message) { + #expect(message.contains("凭据暂不可用")) + } + } + } + + @Test func appOwnedPasswordCanBeReadAndUpdatedWithoutCaching() throws { + let service = "dev.inkcre.credentials-test.\(UUID())" + let connection = UUID() + defer { + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] as CFDictionary) + } + let store = CredentialStore(service: service) + try store.save("first-fixture", for: connection) + #expect(try store.read(for: connection) == "first-fixture") + try store.save("updated-fixture", for: connection) + #expect(try store.read(for: connection) == "updated-fixture") + } +} diff --git a/Tests/CaptureTests/LivePeerTests.swift b/Tests/CaptureTests/LivePeerTests.swift index 4bff258..e9df084 100644 --- a/Tests/CaptureTests/LivePeerTests.swift +++ b/Tests/CaptureTests/LivePeerTests.swift @@ -14,7 +14,6 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th let password = try #require(env["INKCRE_CAPTURE_TEST_PASSWORD"]) let database = PeerDatabase() let client = CaptureClient(database: database) - try await client.checkConnection(target, secret: password) let pg = try await database.client(for: target, password: password) struct Fixture: Decodable { let payload: CapturePayload } let fixtureURL = try #require( @@ -100,12 +99,12 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th Issue.record("停用后不得继续共享写入") } catch PeerError.needsAction(let message) { #expect(message.contains("停用")) } await #expect(throws: PeerError.self) { - try await client.checkConnection(target, secret: password) + _ = try await client.submit(record, secret: password) } try await pg.query( "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)") await #expect(throws: PeerError.self) { - try await client.checkConnection(target, secret: password) + _ = try await client.submit(record, secret: password) } let removed = try await pg.query( "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)" @@ -114,16 +113,18 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th // An explicit enable is allowed to create a first participation before Capture starts. try await database.setEnabled( true, target: target, password: password, extension: CaptureClient.descriptor) - try await client.checkConnection(target, secret: password) + _ = try await client.submit(record, secret: password) let first = ConnectionTarget( host: target.host, port: target.port, database: target.database, username: target.username, tls: target.tls) try await database.setEnabled( true, target: first, password: password, extension: CaptureClient.descriptor) + var firstRecord = CaptureRecord(payload: CapturePayload(materials: [Material(text: "首次准入")])) + firstRecord.target = first try await pg.query( "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)") await #expect(throws: PeerError.self) { - try await client.checkConnection(first, secret: password) + _ = try await client.submit(firstRecord, secret: password) } let removedBeforeFirstJoin = try await pg.query( "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)" @@ -131,6 +132,6 @@ func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async th #expect(removedBeforeFirstJoin == 0) try await database.setEnabled( true, target: first, password: password, extension: CaptureClient.descriptor) - try await client.checkConnection(first, secret: password) + _ = try await client.submit(firstRecord, secret: password) await database.shutdown() } diff --git a/Tests/CaptureTests/NativeExtensionTests.swift b/Tests/CaptureTests/NativeExtensionTests.swift index 9a18a32..d4239fc 100644 --- a/Tests/CaptureTests/NativeExtensionTests.swift +++ b/Tests/CaptureTests/NativeExtensionTests.swift @@ -8,9 +8,15 @@ import Testing var starts = 0 var stops = 0 var intents: [Bool] = [] + var failure: PeerError? + var stoppedBeforeSync = false func start() throws { starts += 1 } func stop() async throws { stops += 1 } - func setEnabled(_ enabled: Bool) async throws { intents.append(enabled) } + func setEnabled(_ enabled: Bool) async throws { + intents.append(enabled) + stoppedBeforeSync = stops > 0 + if let failure { throw failure } + } func showMain() {} } @@ -35,3 +41,25 @@ import Testing #expect(module.intents == [false, true]) try await restarted.stop() } + +@Test(arguments: [PeerError.needsAction("凭据不可用"), .connection("数据库离线")]) +@MainActor func disablingLocallySurvivesSharedStateFailure(_ failure: PeerError) async throws { + let domain = "dev.inkcre.extension-tests.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain) } + let module = Module() + module.failure = failure + let runtime = BundledExtensionRuntime(module: module, defaults: defaults) + try runtime.startIfEnabled() + try await runtime.setEnabled(false) + #expect(!runtime.running && !runtime.enabled && module.stops == 1) + #expect(module.stoppedBeforeSync) + #expect(runtime.syncIssue?.contains(failure.localizedDescription) == true) + let reopened = BundledExtensionRuntime(module: module, defaults: defaults) + try reopened.startIfEnabled() + #expect(!reopened.running) + if case .needsAction = failure { + await #expect(throws: PeerError.self) { try await reopened.setEnabled(true) } + #expect(!reopened.running && !reopened.enabled) + } +} diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift index ce9b41b..d842d31 100644 --- a/Tests/HostTests/HostInterfaceTests.swift +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -72,4 +72,40 @@ struct HostInterfaceTests { } #expect(!window.isVisible) } + @Test func hostOwnsMainWindowAndSidebarWhenCaptureIsDisabled() async throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let domain = "dev.inkcre.host-navigation.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain); try? FileManager.default.removeItem(at: directory) } + defaults.set(false, forKey: "extensions.inkcre/capture.enabled") + defaults.set(false, forKey: "extensions.inkcre/local-files.enabled") + let host = PeerHost(context: PeerHostContext(identity: domain, directory: directory), defaults: defaults) + let window = PeerMainWindow(host: host) + window.setFrameAutosaveName("") + defer { window.orderOut(nil) } + let item = NSMenuItem(title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") + #expect(window.tryToPerform(item.action!, with: item)) + #expect(host.columnVisibility == .detailOnly) + #expect(window.validateUserInterfaceItem(item) && item.title == "显示侧栏") + #expect(window.tryToPerform(item.action!, with: item)) + #expect(host.columnVisibility == .all) + for size in [NSSize(width: 760, height: 520), NSSize(width: 1000, height: 720)] { + window.setContentSize(size) + for page in [PeerHost.Page.capture, .files, .peer] { + host.page = page + try await Task.sleep(for: .milliseconds(150)) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + #expect(bitmap.pixelsWide >= Int(size.width)) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent("host-\(page)-\(Int(size.width)).png")) + } + } + } + #expect(!window.isVisible && host.page == .peer) + } + } diff --git a/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift b/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift new file mode 100644 index 0000000..994bbc7 --- /dev/null +++ b/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift @@ -0,0 +1,125 @@ +import AppKit +import Foundation +import LocalFiles +import PeerCore +import Security +import SwiftUI +import Testing + +@testable import LocalFilesMac + +@Suite(.serialized) @MainActor +struct LocalFilesInterfaceTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_FILES_TEST_DATABASE"] != nil)) + func monitorRestartPauseAndFileReadingInHiddenWindow() async throws { + _ = NSApplication.shared + let env = ProcessInfo.processInfo.environment + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let context = PeerHostContext( + identity: "dev.inkcre.files-tests.\(UUID())", directory: root.appendingPathComponent("host")) + let target = ConnectionTarget( + host: "127.0.0.1", port: Int(env["INKCRE_FILES_TEST_PORT"] ?? "55483")!, + database: try #require(env["INKCRE_FILES_TEST_DATABASE"]), username: "inkcre_core", + tls: .disable) + try context.credentials.save(try #require(env["INKCRE_FILES_TEST_PASSWORD"]), for: target.id) + try await context.connections.save(target) + defer { + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(context.identity).connections", + ] as CFDictionary) + try? FileManager.default.removeItem(at: root) + } + let folder = root.appendingPathComponent("documents") + try fileFixtures(folder) + let model = LocalFilesModel(context: context) + defer { + print( + "Local files final state: sources=\(model.sources.count), records=\(model.records.count), issue=\(model.issue ?? "none"), status=\(model.source.map(model.status) ?? "none")" + ) + } + try model.start() + defer { Task { await model.stop() } } + await model.refresh() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 570, height: 600), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + window.contentViewController = NSHostingController(rootView: LocalFilesView(model: model)) + window.setContentSize(NSSize(width: 570, height: 600)) + defer { window.orderOut(nil) } + try await until { !model.loading && model.target != nil } + try await snapshot(window, name: "files-empty") + await model.addDirectory(folder, target: target) + #expect(model.issue == nil) + try await until { model.records.count == 6 && model.source.map(model.isScanning) == false } + let file = try #require(model.records.first { $0.name == "note.md" }) + model.selectedFile = model.records.first { $0.name == "note.pdf" }?.id + model.selectedFile = file.id + #expect(model.selectedText == nil && model.loadingText) + try await until { model.selectedText == "# 标题\n\n第一段。\n\n- 列表\n" && !model.loadingText } + await model.refresh() + #expect(model.selectedText != nil && !model.loadingText) + try await snapshot(window, name: "files-body") + let url = folder.appendingPathComponent("note.md") + try Data("# 自动监视后的正文".utf8).write(to: url, options: .atomic) + try await until { model.selectedText == "# 自动监视后的正文" } + let source = try #require(model.source) + model.toggle(source) + try await until { model.source?.config.enabled == false && !model.working } + try Data("# 暂停时修改".utf8).write(to: url, options: .atomic) + try await Task.sleep(for: .seconds(2)) + #expect(model.selectedText == "# 自动监视后的正文") + try await snapshot(window, name: "files-paused") + model.toggle(try #require(model.source)) + try await until { model.selectedText == "# 暂停时修改" && !model.working } + await model.stop() + try Data("# 停机时修改".utf8).write(to: url, options: .atomic) + try model.start() + try await until { model.selectedText == "# 停机时修改" } + try FileManager.default.removeItem(at: url) + try await until { model.file?.metadata.available == false } + #expect(model.selectedText == "# 停机时修改") + try await snapshot(window, name: "files-original-missing") + try Data("not a pdf".utf8).write( + to: folder.appendingPathComponent("note.pdf"), options: .atomic) + try await until { model.records.contains { $0.name == "note.pdf" && $0.metadata.issue != nil } } + model.selectedFile = model.records.first { $0.name == "note.pdf" }?.id + try await until { model.selectedText != nil && !model.loadingText } + try await snapshot(window, name: "files-failure") + let restoredSource = try #require(model.source) + let client = LocalFileClient( + target: target, password: try #require(env["INKCRE_FILES_TEST_PASSWORD"])) + model.remove(restoredSource) + try await until { model.sources.isEmpty && !model.working } + #expect(try await client.records(source: restoredSource).count == 6) + #expect(!window.isVisible) + await model.stop() + } + + private func until(_ condition: @escaping @MainActor () -> Bool) async throws { + for _ in 0..<150 { + if condition() { return } + try await Task.sleep(for: .milliseconds(100)) + } + #expect(condition(), "等待本地文件主流程状态超时") + throw PeerError.needsAction("测试等待状态超时。") + } + + private func snapshot(_ window: NSWindow, name: String) async throws { + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + try await Task.sleep(for: .milliseconds(150)) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + #expect(bitmap.pixelsWide >= 570 && bitmap.pixelsHigh >= 600) + view.cacheDisplay(in: view.bounds, to: bitmap) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: URL(fileURLWithPath: path).appendingPathComponent( + "\(name)-\(appearance.rawValue).png")) + } + } + } +} diff --git a/Tests/LocalFilesTests/LocalFilesTests.swift b/Tests/LocalFilesTests/LocalFilesTests.swift new file mode 100644 index 0000000..153e75b --- /dev/null +++ b/Tests/LocalFilesTests/LocalFilesTests.swift @@ -0,0 +1,190 @@ +import AppKit +import CoreText +import Foundation +import PeerCore +import Testing + +@testable import LocalFiles + +func fileFixtures(_ directory: URL) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for (name, text) in [ + ("note.md", "# 标题\n\n第一段。\n\n- 列表\n"), + ("note.org", "* 标题\n:PROPERTIES:\n:ID: sample\n:END:\n正文\n"), + ("note.html", "

标题

第一段
第二行

"), + ("note.txt", "原始文字\n第二行\n"), + ] { try Data(text.utf8).write(to: directory.appendingPathComponent(name)) } + let document = NSAttributedString(string: "主文档正文\n表格内容") + let docx = try document.data( + from: NSRange(location: 0, length: document.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.officeOpenXML]) + try docx.write(to: directory.appendingPathComponent("note.docx")) + let data = NSMutableData() + var box = CGRect(x: 0, y: 0, width: 600, height: 800) + let consumer = try #require(CGDataConsumer(data: data)) + let context = try #require(CGContext(consumer: consumer, mediaBox: &box, nil)) + for text in ["第一页中文", "第二页文字"] { + context.beginPDFPage(nil) + context.textPosition = CGPoint(x: 50, y: 700) + CTLineDraw( + CTLineCreateWithAttributedString( + NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: 18)])), + context) + context.endPDFPage() + } + context.closePDF() + try (data as Data).write(to: directory.appendingPathComponent("note.pdf")) +} + +@Test func locationAndSixExtractorsPreserveMeaning() throws { + let peer = UUID() + let path = "/Users/test/中文 空格/#note?.md" + let location = try PeerFileLocation(peer: peer, path: path) + #expect(try PeerFileLocation(uri: location.uri) == location) + #expect(throws: PeerError.self) { try location.localURL(for: UUID()) } + for invalid in [ + "file:///Users/a", "inkcre-peer-file://not-peer/a", "\(location.uri)?query", + "\(location.uri)#fragment", + ] { + #expect(throws: PeerError.self) { try PeerFileLocation(uri: invalid) } + } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try fileFixtures(directory) + let files = try FileExtractor.files(in: directory) + #expect(files.count == 6) + for file in files { + let observation = try FileExtractor.observe(file, peer: peer) + #expect(observation.metadata.issue == nil) + let text = try #require(observation.text) + #expect(!text.isEmpty) + if file.pathExtension == "md" || file.pathExtension == "org" || file.pathExtension == "txt" { + #expect(text == String(data: try Data(contentsOf: file), encoding: .utf8)) + } + if file.pathExtension == "html" { + #expect( + !text.contains("not_body") && !text.contains("not_style") && text.contains("第一段\n第二行")) + } + if file.pathExtension == "pdf" { #expect(text.contains("第一页中文\n\n第二页文字")) } + if file.pathExtension == "docx" { + #expect(observation.metadata.revision?.scope == "main_document") + } + let serialized = try FileJSON.encode(observation.metadata) + #expect( + try FileJSON.decode(FileMetadata.self, serialized).identity == observation.metadata.identity) + } +} + +@Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_FILES_TEST_DATABASE"] != nil)) +func nativeSourceUpdatesCurrentGraphAndPreservesFailedAndDeletedBodies() async throws { + let env = ProcessInfo.processInfo.environment + let target = ConnectionTarget( + host: "127.0.0.1", port: Int(env["INKCRE_FILES_TEST_PORT"] ?? "55483")!, + database: try #require(env["INKCRE_FILES_TEST_DATABASE"]), username: "inkcre_core", + tls: .disable) + let database = PeerDatabase() + let client = LocalFileClient( + target: target, password: try #require(env["INKCRE_FILES_TEST_PASSWORD"]), database: database) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try fileFixtures(directory) + try await client.prepare() + let source = try await client.createDirectory(path: directory.path) + #expect(try await client.sources().contains { $0.id == source.id }) + let otherTarget = ConnectionTarget( + host: target.host, port: target.port, database: target.database, username: target.username, + tls: .disable) + let otherClient = LocalFileClient( + target: otherTarget, password: try #require(env["INKCRE_FILES_TEST_PASSWORD"]), + database: database) + try await otherClient.prepare() + let otherSource = try await otherClient.createDirectory(path: directory.path) + let staleRow = DirectorySource( + id: otherSource.id, config: source.config, block: source.block, state: source.state) + await #expect(throws: (any Error).self) { try await otherClient.remove(staleRow) } + #expect(try await otherClient.sources().count == 1) + try await otherClient.remove(otherSource) + let first = try await DirectoryScanner.scan(source: source, client: client) + #expect(first.errors == 0) + let original = try await client.records(source: source) + #expect(original.count == 6 && original.allSatisfy { $0.bodyID != nil && $0.metadata.available }) + let note = try #require(original.first { $0.name == "note.md" }) + let url = directory.appendingPathComponent("note.md") + try Data("# 修改后\n\n当前正文".utf8).write(to: url, options: .atomic) + _ = try await DirectoryScanner.scan(source: source, client: client) + var records = try await client.records(source: source) + #expect(records.count == 6) + #expect(try await client.text(for: #require(records.first { $0.id == note.id })) == "# 修改后\n\n当前正文") + try FileManager.default.moveItem(at: url, to: directory.appendingPathComponent("renamed.md")) + _ = try await DirectoryScanner.scan(source: source, client: client) + records = try await client.records(source: source) + #expect(records.first { $0.id == note.id }?.name == "renamed.md") + let pdf = try #require(records.first { $0.name == "note.pdf" }) + let pdfText = try await client.text(for: pdf) + try Data("broken pdf".utf8).write( + to: directory.appendingPathComponent("note.pdf"), options: .atomic) + let failed = try await DirectoryScanner.scan(source: source, client: client) + #expect(failed.errors == 1) + records = try await client.records(source: source) + #expect(try await client.text(for: #require(records.first { $0.id == pdf.id })) == pdfText) + #expect(records.first { $0.id == pdf.id }?.metadata.revision == pdf.metadata.revision) + try FileManager.default.removeItem(at: directory.appendingPathComponent("renamed.md")) + _ = try await DirectoryScanner.scan(source: source, client: client) + records = try await client.records(source: source) + #expect(records.first { $0.id == note.id }?.metadata.available == false) + #expect(try await client.text(for: #require(records.first { $0.id == note.id })) == "# 修改后\n\n当前正文") + let moved = directory.appendingPathExtension("offline") + try FileManager.default.moveItem(at: directory, to: moved) + await #expect(throws: (any Error).self) { + try await DirectoryScanner.scan(source: source, client: client) + } + try FileManager.default.moveItem(at: moved, to: directory) + #expect(try await client.records(source: source).filter { $0.metadata.available }.count == 5) + try await client.reportIssue("合成目录暂时离线", source: source) + #expect(try await client.sources().first { $0.id == source.id }?.state.issue == "合成目录暂时离线") + let pool = try await database.client(for: target, password: try #require(env["INKCRE_FILES_TEST_PASSWORD"])) + let invalidParameters = "{\"source\":\(source.id),\"config\":{\"unsupported\":true}}" + let invalidRows = try await pool.query("INSERT INTO inkcre.jobs(type,parameters,timeout_seconds,status) VALUES (\(SourceJobs.type),\(invalidParameters)::jsonb,300,'pending') RETURNING id") + let invalidID = try #require(try await invalidRows.decode(Int.self).first { _ in true }) + #expect(try await client.pendingScans(source).isEmpty) + let invalidStatus = try await pool.query("SELECT status::text FROM inkcre.jobs WHERE id=\(invalidID)") + #expect(try await invalidStatus.decode(String.self).first { _ in true } == "failed") + let jobID = try await client.submitScan(source) + let job = try #require(try await client.claim(jobID, source: source)) + #expect(try await client.claim(jobID, source: source) == nil) + let result = try await DirectoryScanner.scan(source: source, client: client, job: job) + try await client.close(job, status: "failed", state: result) + try await client.setEnabled(false, source: source) + await #expect(throws: (any Error).self) { + try await DirectoryScanner.scan(source: source, client: client) + } + await #expect(throws: (any Error).self) { try await client.remove(source) } + try await client.remove(try #require(try await client.sources().first { $0.id == source.id })) + #expect(try await client.sources().isEmpty) + #expect(try await client.records(source: source).count == 6) + await database.shutdown() +} + +@Test func unchangedObservationNeedsNoDatabaseWriteButSourceChangesStillMatter() async throws { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: folder) } + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let url = folder.appendingPathComponent("note.txt") + try Data("已收入正文".utf8).write(to: url) + let target = ConnectionTarget(host: "unused.invalid", database: "unused", username: "unused") + let original = try FileExtractor.observe(url, peer: target.peerID) + let unchanged = try FileExtractor.observe(url, peer: target.peerID, previous: original.metadata.revision) + let record = FileRecord(id: 1, metadata: original.metadata, bodyID: 2) + let source = DirectorySource(id: 1, config: DirectoryConfig(peer: target.peerID, path: folder.path), block: 3, state: DirectoryState()) + #expect(unchanged.text == nil && !unchanged.changes(original.metadata)) + try await LocalFileClient(target: target, password: "unused").save(unchanged, replacing: record, source: source) + var missing = original.metadata + missing.available = false + #expect(unchanged.changes(missing)) + var failed = original.metadata + failed.issue = "上次读取失败" + #expect(unchanged.changes(failed)) + var moved = unchanged + moved.metadata.location = try PeerFileLocation(peer: target.peerID, path: folder.appendingPathComponent("renamed.txt").path).uri + #expect(moved.changes(original.metadata)) +} diff --git a/extensions/capture/Sources/Capture/CaptureClient.swift b/extensions/capture/Sources/Capture/CaptureClient.swift index c191340..458c298 100644 --- a/extensions/capture/Sources/Capture/CaptureClient.swift +++ b/extensions/capture/Sources/Capture/CaptureClient.swift @@ -32,16 +32,6 @@ public struct CaptureClient: CaptureTransport { private let database: PeerDatabase public init(database: PeerDatabase = .shared) { self.database = database } - public func checkConnection(_ target: ConnectionTarget, secret: String) async throws { - do { - _ = try await database.register(target, password: secret) - try await database.join(target, password: secret, extension: Self.descriptor) - let client = try await database.client(for: target, password: secret) - _ = try await client.query( - "SELECT submission_id,payload_digest,graph_digest FROM inkcre.capture_receipts LIMIT 0") - } catch { throw PeerDatabase.failure(error, dispatched: false) } - } - public func recover(_ record: CaptureRecord, secret: String) async throws -> CaptureResult? { guard let target = record.target, let payload = record.payload else { throw PeerError.invalid("待办缺少材料或目标。") @@ -78,6 +68,8 @@ public struct CaptureClient: CaptureTransport { String(format: "%02x", $0) }.joined() do { + _ = try await database.register(target, password: secret) + try await database.join(target, password: secret, extension: Self.descriptor) let client = try await database.client(for: target, password: secret) let result = try await client.withTransaction(logger: PeerDatabase.logger) { connection in try await PeerDatabase.requireSelection( diff --git a/extensions/capture/Sources/Capture/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift index b2aba93..fc41bda 100644 --- a/extensions/capture/Sources/Capture/CaptureQueue.swift +++ b/extensions/capture/Sources/Capture/CaptureQueue.swift @@ -23,16 +23,18 @@ public actor CaptureQueue { if running { await withCheckedContinuation { idleWaiters.append($0) } } } - public func process(force: Bool = false) async throws { - guard !isStopped, !Task.isCancelled, !running else { return } + @discardableResult + public func process(force: Bool = false) async throws -> Bool { + guard !isStopped, !Task.isCancelled, !running else { return false } running = true + var changed = false defer { running = false for waiter in idleWaiters { waiter.resume() } idleWaiters.removeAll() } - for item in try await store.records() where [.pending, .unknown].contains(item.state) { - guard !isStopped, !Task.isCancelled else { return } + for item in try await store.pendingRecords() { + guard !isStopped, !Task.isCancelled else { return changed } let delay = min(300.0, pow(2.0, Double(min(item.attempts, 8))) * 2) if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { continue } guard let target = item.target else { continue } @@ -40,11 +42,13 @@ public actor CaptureQueue { let secret = try credentials.read(for: target.id) if let result = try await client.recover(item, secret: secret) { try await store.confirm(item.id, result: result) + changed = true continue } - guard !isStopped, !Task.isCancelled else { return } + guard !isStopped, !Task.isCancelled else { return changed } let dispatched = try await store.markDispatch(item.id) - guard !isStopped, !Task.isCancelled else { return } + changed = true + guard !isStopped, !Task.isCancelled else { return changed } let result = try await client.submit(dispatched, secret: secret) try await store.confirm(item.id, result: result) } catch let error as PeerError { @@ -56,12 +60,15 @@ public actor CaptureQueue { case .storage: throw error } try await store.report(item.id, state: state, message: error.localizedDescription) + changed = true } catch { try await store.report( item.id, state: .unknown, message: "无法确认响应,原提交已保留。请检查服务版本后重试。" ) + changed = true } } + return changed } } diff --git a/extensions/capture/Sources/Capture/CaptureStore.swift b/extensions/capture/Sources/Capture/CaptureStore.swift index 5c71ffb..63cc461 100644 --- a/extensions/capture/Sources/Capture/CaptureStore.swift +++ b/extensions/capture/Sources/Capture/CaptureStore.swift @@ -71,7 +71,15 @@ public actor CaptureStore { } public func records() throws -> [CaptureRecord] { - try database.statement("SELECT body FROM records") { statement in + try records(query: "SELECT body FROM records") + } + + public func pendingRecords() throws -> [CaptureRecord] { + try records(query: "SELECT body FROM records WHERE json_extract(CAST(body AS TEXT), '$.state') IN ('pending','unknown')") + } + + private func records(query: String) throws -> [CaptureRecord] { + try database.statement(query) { statement in var records: [CaptureRecord] = [] while true { let result = sqlite3_step(statement) diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index e6c9b33..aa68b65 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -5,6 +5,8 @@ import Combine import PeerCore import SwiftUI +public enum CapturePage: Hashable { case capture, queue } + @MainActor final class CapturePanel: NSPanel { override var canBecomeKey: Bool { true } @@ -28,7 +30,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.map { NSHostingController(rootView: CaptureSettingsView(model: $0)) } } - public func connectionDidChange(_ target: ConnectionTarget) { model?.connectionSaved(target) } + public func connectionDidChange(_ target: ConnectionTarget) { model?.connection = target } public var canOpenSettings: Bool { model == nil || (canChangeCaptureContext && model?.submitting != true) @@ -48,7 +50,16 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate private var statusItem: NSStatusItem? private var shortcutObservation: AnyCancellable? private var panel: CapturePanel? - private var mainWindow: PeerMainWindow? + public weak var mainWindow: NSWindow? + public var presentMain: (() -> Void)? + public var navigate: ((CapturePage) -> Void)? + public var showingCapturePage = true + + public func selectPage(_ page: CapturePage) { model?.pane = page } + public func pageView() -> AnyView { + if let model { return AnyView(CapturePageView(model: model)) } + return AnyView(ContentUnavailableView("采集扩展已停用", systemImage: "viewfinder")) + } private var model: CaptureModel? private let regionCapture = RegionCapture() private var selectionTask: Task? @@ -76,7 +87,6 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate try model.shortcuts.start() configureMenu() model.start(restore: false) - showMain() return } do { @@ -84,9 +94,6 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate directory: context.directory.appendingPathComponent("extensions/inkcre-capture"), identity: context.identity, connections: context.connections) self.model = model - let mainWindow = PeerMainWindow(model: model) - mainWindow.delegate = self - self.mainWindow = mainWindow let panel = CapturePanel( contentRect: NSRect(x: 0, y: 0, width: 570, height: 640), styleMask: [.titled, .closable, .resizable, .nonactivatingPanel], @@ -128,12 +135,10 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate if let message = model.shortcuts.message { model.captureNotice = message } configureMenu() model.start() - showMain() } catch { model?.cancelWork() model?.shortcuts.stop() panel?.orderOut(nil) - mainWindow?.orderOut(nil) throw error } } @@ -188,7 +193,7 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate private func canPerform(_ action: ShortcutAction, in window: NSWindow?) -> Bool { guard canChangeCaptureContext, let model, model.shortcuts.recording == nil else { return false } let quick = window === panel && model.editorOwner == .quick - let editor = quick || (window === mainWindow && model.pane == .capture && model.editorOwner == .main) + let editor = quick || (showingCapturePage && window === mainWindow && model.pane == .capture && model.editorOwner == .main) return model.canPerform(action, isEditor: editor, isQuickPanel: quick) } @@ -347,9 +352,8 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate panel?.makeFirstResponder(nil) panel?.orderOut(nil) model?.editorOwner = .main - if mainWindow?.isMiniaturized == true { mainWindow?.deminiaturize(nil) } - NSApp.activate(ignoringOtherApps: true) - mainWindow?.makeKeyAndOrderFront(nil) + if let model { navigate?(model.pane) } + presentMain?() } private func showCaptureSurface() { @@ -379,8 +383,8 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate return true } - public func windowDidBecomeKey(_ notification: Notification) { - if notification.object as? NSWindow === mainWindow, panel?.isVisible == true { + public func mainWindowBecameKey() { + if panel?.isVisible == true { panel?.makeFirstResponder(nil) panel?.orderOut(nil) model?.editorOwner = .main @@ -413,7 +417,6 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate selectionTask = nil await model.stop() panel?.orderOut(nil) - mainWindow?.orderOut(nil) if let statusItem { NSStatusBar.system.removeStatusItem(statusItem) } statusItem = nil if let commandItem { NSApp.mainMenu?.removeItem(commandItem) } @@ -421,7 +424,6 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate shortcutObservation = nil try await model.flush() panel = nil - mainWindow = nil self.model = nil } } diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index 53999c9..7546971 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -7,7 +7,7 @@ import SwiftUI @MainActor final class CaptureModel: ObservableObject { - enum Pane: Hashable { case capture, queue, peer } + typealias Pane = CapturePage enum EditorOwner { case main, quick } enum RemovalRequest { case material(draftID: UUID, materialID: UUID) @@ -42,7 +42,6 @@ final class CaptureModel: ObservableObject { } } @Published var pane = Pane.capture - @Published var columnVisibility = NavigationSplitViewVisibility.all @Published var editorOwner = EditorOwner.main @Published var draft = CaptureRecord() @Published var records: [CaptureRecord] = [] @@ -50,9 +49,6 @@ final class CaptureModel: ObservableObject { @Published var error: String? @Published var saveStatus = "随时写下想法,或加入材料" @Published var submitting = false - @Published var lastContactAt: Date? - @Published var peerConnectionIssue: String? - @Published private(set) var checkingConnection = false @Published var captureNotice: String? @Published var screenCaptureNotice: String? @Published var removalRequest: RemovalRequest? @@ -70,8 +66,6 @@ final class CaptureModel: ObservableObject { private let queue: CaptureQueue private var saveTask: Task? private var heartbeat: Task? - private var connectionCheck: Task? - private var connectionRevision = UUID() @Published private(set) var submissionStatusID: UUID? @Published var selectedRecordID: UUID? private var editRevision = 0 @@ -145,26 +139,8 @@ final class CaptureModel: ObservableObject { } catch { self.error = error.localizedDescription } } submitting = false - var cycle = 0 while !Task.isCancelled { await sendPending() - if cycle % 4 == 0, !checkingConnection, let connection { - let revision = connectionRevision - do { - try await CaptureClient().checkConnection( - connection, secret: credentials.read(for: connection.id) - ) - if connectionRevision == revision && !checkingConnection { - lastContactAt = Date() - peerConnectionIssue = nil - } - } catch { - if connectionRevision == revision && !checkingConnection { - peerConnectionIssue = error.localizedDescription - } - } - } - cycle += 1 do { try await Task.sleep(for: .seconds(10)) } catch { break } } } @@ -175,7 +151,6 @@ final class CaptureModel: ObservableObject { selectionRequest = UUID() heartbeat?.cancel() saveTask?.cancel() - connectionCheck?.cancel() for task in actions.values { task.cancel() } } @@ -184,7 +159,6 @@ final class CaptureModel: ObservableObject { await queue.stop() for task in Array(actions.values) { await task.value } await heartbeat?.value - await connectionCheck?.value await saveTask?.value await queue.stop() } @@ -222,9 +196,9 @@ final class CaptureModel: ObservableObject { if !hasContent { let id = draft.id let revision = editRevision - if try await store.record(id) != nil { try await store.discardDraft(id) } + try await store.discardDraft(id) if draft.id == id, revision == editRevision { saveStatus = "随时写下想法,或加入材料" } - try await refresh() + try await refreshRecord(id) return } draft.updatedAt = Date() @@ -236,7 +210,7 @@ final class CaptureModel: ObservableObject { throw error } if draft.id == value.id, revision == editRevision { saveStatus = "草稿已保留在本机" } - try await refresh() + try await refreshRecord(value.id) } func newThought(notice: String? = nil) { @@ -340,8 +314,7 @@ final class CaptureModel: ObservableObject { func sendPending(force: Bool = false) async { do { - try await queue.process(force: force) - try await refresh() + if try await queue.process(force: force) { try await refresh() } } catch { self.error = error.localizedDescription } } @@ -349,6 +322,13 @@ final class CaptureModel: ObservableObject { records = try await store.records() } + private func refreshRecord(_ id: UUID) async throws { + let record = try await store.record(id) + records.removeAll { $0.id == id } + if let record { records.append(record) } + records.sort { $0.updatedAt > $1.updatedAt } + } + func resume(_ record: CaptureRecord) { guard record.state == .draft, !submitting else { return } submitting = true @@ -365,7 +345,7 @@ final class CaptureModel: ObservableObject { } draft = current saveStatus = "已恢复本机草稿" - pane = .capture + revealEditor() } catch { self.error = error.localizedDescription } } } @@ -496,39 +476,6 @@ final class CaptureModel: ObservableObject { } } - func connectionSaved(_ target: ConnectionTarget) { - connection = target - connectionRevision = UUID() - lastContactAt = nil - peerConnectionIssue = nil - checkConnection() - } - - func checkConnection() { - guard let target = connection else { return } - do { checkConnection(target, secret: try credentials.read(for: target.id)) } catch { - peerConnectionIssue = error.localizedDescription - } - } - - private func checkConnection(_ target: ConnectionTarget, secret: String) { - connectionCheck?.cancel() - checkingConnection = true - let revision = connectionRevision - connectionCheck = Task { - defer { if !Task.isCancelled && connectionRevision == revision { checkingConnection = false } } - do { - try await CaptureClient().checkConnection(target, secret: secret) - guard !Task.isCancelled, connectionRevision == revision else { return } - lastContactAt = Date() - peerConnectionIssue = nil - } catch { - guard !Task.isCancelled, connectionRevision == revision else { return } - peerConnectionIssue = error.localizedDescription - } - } - } - func resultURL(for record: CaptureRecord) -> URL? { guard let result = record.result, result.availability == .available, let target = record.target else { return nil } let base = target.webURL ?? (connection?.peerID == target.peerID ? connection?.webURL : nil) diff --git a/extensions/capture/Sources/CaptureMac/PeerMainView.swift b/extensions/capture/Sources/CaptureMac/CapturePageView.swift similarity index 58% rename from extensions/capture/Sources/CaptureMac/PeerMainView.swift rename to extensions/capture/Sources/CaptureMac/CapturePageView.swift index 4708306..4072de7 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainView.swift +++ b/extensions/capture/Sources/CaptureMac/CapturePageView.swift @@ -1,53 +1,23 @@ import SwiftUI -struct PeerMainView: View { +struct CapturePageView: View { @ObservedObject var model: CaptureModel @Environment(\.colorScheme) private var colorScheme var body: some View { - NavigationSplitView(columnVisibility: $model.columnVisibility) { - VStack(spacing: 0) { - HStack(spacing: 10) { - Image(nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark) - .resizable().frame(width: 32, height: 32).accessibilityHidden(true) - VStack(alignment: .leading) { - Text("InKCre").font(.headline) - Text("Mac Peer").font(.caption).foregroundStyle(.secondary) + Group { + switch model.pane { + case .capture: + if model.editorOwner == .quick { + ContentUnavailableView { + Label("正在快捷采集", systemImage: "rectangle.on.rectangle") + } actions: { + Button("在主窗口继续") { model.continueInMain() } } - Spacer() - }.padding() - List(selection: Binding(get: { Optional(model.pane) }, set: { - guard let value = $0 else { return } - model.pane = value - })) { - Label("采集", systemImage: "square.and.pencil").tag(CaptureModel.Pane.capture) - Label("记录", systemImage: "tray.full").tag(CaptureModel.Pane.queue) - Label("Peer 与连接", systemImage: "network").tag(CaptureModel.Pane.peer) - }.listStyle(.sidebar) - VStack(alignment: .leading, spacing: 4) { - Text(model.connection?.name ?? "未连接信息库") - Text("\(model.pendingCount) 项待收入").foregroundStyle(.secondary) - }.font(.caption).frame(maxWidth: .infinity, alignment: .leading).padding() - }.navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 260) - } detail: { - Group { - switch model.pane { - case .capture: - if model.editorOwner == .quick { - ContentUnavailableView { - Label("正在快捷采集", systemImage: "rectangle.on.rectangle") - } description: { - Text("当前草稿正在快捷面板中编辑。") - } actions: { - Button("在主窗口继续") { model.continueInMain() } - } - } else { CaptureEditor(model: model) } - case .queue: CaptureRecordsView(model: model) - case .peer: ConnectionView(model: model) - } - }.frame(minWidth: 520, maxWidth: .infinity, maxHeight: .infinity) - .navigationTitle(title) - } + } else { CaptureEditor(model: model) } + case .queue: CaptureRecordsView(model: model) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) .toolbar { ToolbarItemGroup(placement: .automatic) { if model.pane == .capture && model.editorOwner == .main { @@ -68,13 +38,7 @@ struct PeerMainView: View { .modifier(CaptureAlerts(model: model, owner: .main)) } - private var title: String { - switch model.pane { - case .capture: "采集" - case .queue: "记录" - case .peer: "Peer 与连接" - } - } + } struct QuickCaptureView: View { diff --git a/extensions/capture/Sources/CaptureMac/ConnectionView.swift b/extensions/capture/Sources/CaptureMac/ConnectionView.swift deleted file mode 100644 index 84ad790..0000000 --- a/extensions/capture/Sources/CaptureMac/ConnectionView.swift +++ /dev/null @@ -1,49 +0,0 @@ -import AppKit -import SwiftUI -@preconcurrency import ApplicationServices - -struct ConnectionView: View { - @ObservedObject var model: CaptureModel - - var body: some View { - Form { - Section("此 Mac Peer") { - LabeledContent("当前能力", value: "收集:选区、粘贴、截图、想法") - LabeledContent("信息库", value: model.connection?.name ?? "尚未配置") - if let connection = model.connection { - LabeledContent("Peer 身份") { Text(connection.peerID.uuidString).textSelection(.enabled) } - if model.checkingConnection { - HStack { ProgressView().controlSize(.small); Text("正在检查连接…") } - } else if let issue = model.peerConnectionIssue { - Text("最近检查未通过:\(issue)").foregroundStyle(.secondary) - } else if let date = model.lastContactAt { - LabeledContent("最近检查通过") { Text(date, format: .dateTime.month().day().hour().minute().second()) } - Text("信息库写入连接可用").font(.caption).foregroundStyle(.secondary) - } else { Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) } - } - LabeledContent("本机待收入", value: "\(model.pendingCount) 项") - Text("此 Mac 与其他 Peer 共享同一个信息库。关闭主窗口后仍会保留草稿、处理待办;退出 InKCre 才停止运行。") - .font(.caption).foregroundStyle(.secondary) - } - Section("连接共享信息库") { - if let target = model.connection { - LabeledContent("PostgreSQL") { Text("\(target.host):\(target.port)/\(target.database)").textSelection(.enabled) } - LabeledContent("运行角色", value: target.username) - if let web = target.webURL { - LabeledContent("Web 地址") { Text(web.absoluteString).textSelection(.enabled) } - } - } else { - Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") - .foregroundStyle(.secondary) - } - Button(model.connection == nil ? "连接信息库…" : "编辑连接…") { - model.openConnectionSettings() - } - if model.connection != nil { - Button("重新检查连接") { model.checkConnection() }.disabled(model.checkingConnection) - } - } - } - .formStyle(.grouped) - } -} diff --git a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift deleted file mode 100644 index 73c593e..0000000 --- a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift +++ /dev/null @@ -1,34 +0,0 @@ -import AppKit -import SwiftUI - -@MainActor -final class PeerMainWindow: NSWindow { - private let model: CaptureModel - - @objc func toggleSidebar(_ sender: Any?) { - withAnimation { - model.columnVisibility = model.columnVisibility == .detailOnly ? .all : .detailOnly - } - } - - override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - guard item.action == #selector(toggleSidebar(_:)) else { return super.validateUserInterfaceItem(item) } - (item as? NSMenuItem)?.title = model.columnVisibility == .detailOnly ? "显示侧栏" : "隐藏侧栏" - return attachedSheet == nil - } - - init(model: CaptureModel) { - self.model = model - super.init(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - title = "InKCre" - toolbarStyle = .unified - minSize = NSSize(width: 760, height: 520) - isReleasedWhenClosed = false - collectionBehavior = [.fullScreenPrimary] - contentViewController = NSHostingController(rootView: PeerMainView(model: model)) - if !setFrameUsingName("InKCreMainWindow") { center() } - setFrameAutosaveName("InKCreMainWindow") - } -} diff --git a/extensions/local-files/LICENSE b/extensions/local-files/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/extensions/local-files/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/local-files/Package.swift b/extensions/local-files/Package.swift new file mode 100644 index 0000000..380e80b --- /dev/null +++ b/extensions/local-files/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "InKCreLocalFiles", platforms: [.macOS(.v15)], + products: [.library(name: "LocalFiles", targets: ["LocalFiles"]), .library(name: "LocalFilesMac", targets: ["LocalFilesMac"])], + dependencies: [ + .package(path: "../../Packages/PeerCore"), + .package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.14.3"), + ], + targets: [ + .target(name: "LocalFiles", dependencies: [.product(name: "PeerCore", package: "PeerCore"), "SwiftSoup"], resources: [.copy("Resources/extension.json")]), + .target(name: "LocalFilesMac", dependencies: ["LocalFiles", .product(name: "PeerCore", package: "PeerCore")]), + ] +) diff --git a/extensions/local-files/README.md b/extensions/local-files/README.md new file mode 100644 index 0000000..7e37dab --- /dev/null +++ b/extensions/local-files/README.md @@ -0,0 +1,11 @@ +# 本地文件 Swift 分发 + +`LocalFiles` 拥有位置 URI、提取、Source 配置、对账和图更新;`LocalFilesMac` 拥有书签、FSEvents、监视生命周期及原生界面。Host 静态组合此扩展与 Capture。共享合同位于 InKCre/docs 的 `20-product-tdd/local-files-contract.md`,无需 Python 服务或新数据库迁移。 + +Source 配置在 info-base,书签及原连接绑定在 Host 的 `extensions/inkcre-local-files/directories.json`,权限 0600。密码仅存系统 Keychain。先监视再扫描;通知合并为待核对状态,不保存事件日志。每 30 秒核对共享配置、待执行 Job 和失败扫描,正常监视不会每 30 秒重新读取所有文件。每轮从原连接的 Keychain 取凭据。停止等待当前文件事务和提取结束,再释放监视与目录访问。 + +列表与扫描只读取文件元数据和正文 ID;选中文件才读取该文件正文,修订变化后更新当前正文。未变化文件不进入逐文件写事务,完整扫描仍复核来源状态并处理失效文件。 + +读取端解释 `extensions.local_files.file.v1` 与 `body`,不要注册不能执行的 Source 占位类。重连扫描当前目录,不引入未发布格式兼容、原文件复制或正文 outbox。 + +验证使用合成目录和一次性 PostgreSQL。`swift test` 包含 URI/六种格式提取;设置 `INKCRE_FILES_TEST_DATABASE`、`INKCRE_FILES_TEST_PORT`、`INKCRE_FILES_TEST_PASSWORD` 后执行真实事务、FSEvents、暂停/恢复、重启、删除保留与隐藏窗口主流程。测试运行角色是 `inkcre_core`,仅允许指定一次性数据库。`INKCRE_LAYOUT_EVIDENCE_DIR` 保存实际 AppKit 窗口的渲染图,不激活窗口。 diff --git a/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift b/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift new file mode 100644 index 0000000..f68388f --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift @@ -0,0 +1,84 @@ +import Foundation +import PeerCore + +public struct ScanProgress: Sendable { + public let completed: Int + public let total: Int + public let filename: String +} + +public enum DirectoryScanner { + public static func scan( + source: DirectorySource, client: LocalFileClient, job: SourceJob? = nil, + progress: @escaping @Sendable (ScanProgress) async -> Void = { _ in } + ) async throws -> DirectoryState { + let root = URL(fileURLWithPath: source.config.path, isDirectory: true) + let identity: String + let files: [URL] + do { + identity = try rootIdentity(root) + files = try await background { try FileExtractor.files(in: root) } + } catch is CancellationError { throw CancellationError() } catch { + throw PeerError.needsAction("无法完整读取目录:\(error.localizedDescription)") + } + let records = try await client.records(source: source) + let paths = Set(files.map(\.path)) + let byPath = Dictionary(grouping: records, by: \.path) + guard byPath.values.allSatisfy({ $0.count == 1 }) else { + throw PeerError.invalid("目录中存在重复的文件资料位置。") + } + var used: Set = [] + var errors = 0 + for (index, url) in files.enumerated() { + try await client.check(job) + var record = byPath[url.path]?.first + let previous = record?.metadata.revision + let observation = try await background { + try FileExtractor.observe(url, peer: source.config.peer, previous: previous) + } + if record == nil, let identity = observation.metadata.identity { + let candidates = records.filter { + !paths.contains($0.path) && !used.contains($0.id) && $0.metadata.identity == identity + } + if candidates.count == 1 { record = candidates[0] } + } + if let record { used.insert(record.id) } + try await client.check(job) + try await client.save(observation, replacing: record, source: source) + if observation.metadata.issue != nil { errors += 1 } + await progress( + ScanProgress(completed: index + 1, total: files.count, filename: url.lastPathComponent)) + } + try await client.check(job) + guard try rootIdentity(root) == identity else { + throw PeerError.needsAction("目录在扫描中变更,等待重新核对。") + } + let state = DirectoryState(scanned_at: Date(), errors: errors) + try await client.finishScan( + source: source, missing: records.filter { !used.contains($0.id) }, state: state) + return state + } + + private static func rootIdentity(_ root: URL) throws -> String { + let values = try URL(fileURLWithPath: root.path).resourceValues(forKeys: [ + .isDirectoryKey, .isSymbolicLinkKey, .volumeUUIDStringKey, + ]) + guard values.isDirectory == true, values.isSymbolicLink != true, + let volume = values.volumeUUIDString, + let inode = try FileManager.default.attributesOfItem(atPath: root.path)[.systemFileNumber] + as? NSNumber + else { throw PeerError.needsAction("目录当前不可访问。") } + return "\(volume):\(inode)" + } + + private static func background(_ work: @escaping @Sendable () throws -> T) + async throws -> T + { + let task = Task.detached(priority: .utility, operation: work) + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + } +} diff --git a/extensions/local-files/Sources/LocalFiles/FileExtractor.swift b/extensions/local-files/Sources/LocalFiles/FileExtractor.swift new file mode 100644 index 0000000..7bd5955 --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/FileExtractor.swift @@ -0,0 +1,162 @@ +import AppKit +import CryptoKit +import Foundation +import PDFKit +import PeerCore +import SwiftSoup +import UniformTypeIdentifiers + +public enum FileExtractor { + public static let maximumInputBytes = 64 * 1_024 * 1_024 + public static let maximumTextBytes = 8 * 1_024 * 1_024 + + public static func format(for url: URL) -> String? { + switch url.pathExtension.lowercased() { + case "pdf": "pdf" + case "docx": "docx" + case "md", "markdown": "markdown" + case "org": "org" + case "html", "htm": "html" + case "txt", "text", "": "text" + default: + UTType(filenameExtension: url.pathExtension)?.conforms(to: .text) == true ? "text" : nil + } + } + + public static func files(in directory: URL) throws -> [URL] { + guard try directory.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true else { + throw PeerError.needsAction("目录当前不可访问。") + } + let keys: Set = [.isRegularFileKey, .isSymbolicLinkKey] + var enumerationError: (any Error)? + guard + let enumerator = FileManager.default.enumerator( + at: directory, includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, error in + enumerationError = error + return false + }) + else { throw PeerError.needsAction("无法读取目录。") } + var files: [URL] = [] + for case let url as URL in enumerator { + try Task.checkCancellation() + let values = try url.resourceValues(forKeys: keys) + if values.isSymbolicLink == true { + enumerator.skipDescendants() + continue + } + if values.isRegularFile == true, format(for: url) != nil { files.append(url) } + } + if let enumerationError { throw enumerationError } + return files.sorted { $0.path < $1.path } + } + + public static func observe(_ url: URL, peer: UUID, previous: FileRevision? = nil) throws + -> FileObservation + { + try Task.checkCancellation() + guard let format = format(for: url) else { throw PeerError.invalid("不支持的文件格式。") } + var metadata = FileMetadata( + location: try PeerFileLocation(peer: peer, path: url.path).uri, + format: format, identity: nil, available: true, observed_at: Date(), revision: nil, issue: nil + ) + do { + let before = try url.resourceValues(forKeys: [ + .isSymbolicLinkKey, .isRegularFileKey, .fileSizeKey, .contentModificationDateKey, + .volumeUUIDStringKey, .creationDateKey, + ]) + guard before.isSymbolicLink != true, before.isRegularFile == true else { + throw PeerError.invalid("不是普通文件。") + } + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let volume = before.volumeUUIDString, + let number = attributes[.systemFileNumber] as? NSNumber, let created = before.creationDate + { + metadata.identity = FileIdentity( + volume: volume, number: number.uint64Value, created: created) + } + guard let size = before.fileSize, size <= maximumInputBytes else { + throw PeerError.invalid("文件超过 64 MiB。") + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let data = try handle.read(upToCount: maximumInputBytes + 1) ?? Data() + guard data.count <= maximumInputBytes else { throw PeerError.invalid("文件超过 64 MiB。") } + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + let unchanged = + previous?.sha256 == digest && previous?.extractor == LocalFilesContract.extractor + let text = unchanged ? nil : try extract(data, format: format) + if let text, text.utf8.count > maximumTextBytes { throw PeerError.invalid("提取文字超过 8 MiB。") } + let after = try URL(fileURLWithPath: url.path).resourceValues(forKeys: [ + .fileSizeKey, .contentModificationDateKey, + ]) + let afterAttributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard before.fileSize == after.fileSize, + before.contentModificationDate == after.contentModificationDate, + attributes[.systemFileNumber] as? NSNumber == afterAttributes[.systemFileNumber] + as? NSNumber + else { throw PeerError.needsAction("文件仍在变化,等待重新读取。") } + try Task.checkCancellation() + metadata.revision = + unchanged + ? previous + : FileRevision( + sha256: digest, + extractor: LocalFilesContract.extractor, extracted_at: Date(), + scope: format == "docx" ? "main_document" : format == "pdf" ? "text_layer" : "text") + return FileObservation(metadata: metadata, text: text) + } catch is CancellationError { throw CancellationError() } catch { + metadata.issue = error.localizedDescription + return FileObservation(metadata: metadata, text: nil) + } + } + + public static func extract(_ data: Data, format: String) throws -> String { + switch format { + case "pdf": + guard let document = PDFDocument(data: data) else { throw PeerError.invalid("PDF 已损坏。") } + guard !document.isLocked else { throw PeerError.needsAction("PDF 需要密码。") } + let text = (0.. String { + if data.starts(with: [0xff, 0xfe]), let text = String(data: data, encoding: .utf16) { + return text + } + if data.starts(with: [0xfe, 0xff]), let text = String(data: data, encoding: .utf16) { + return text + } + guard let text = String(data: data, encoding: .utf8), !text.contains("\0") else { + throw PeerError.invalid("文字编码无法识别;支持 UTF-8 或带 BOM 的 UTF-16。") + } + return text.hasPrefix("\u{feff}") ? String(text.dropFirst()) : text + } +} diff --git a/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift new file mode 100644 index 0000000..785b21f --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift @@ -0,0 +1,147 @@ +import Foundation +import PeerCore +import PostgresNIO + +extension LocalFileClient { + public func submitScan(_ source: DirectorySource) async throws -> Int { + let client = try await pool() + return try await client.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await locked(source.id, on: connection, expected: source.config) + return try await SourceJobs.submit(source: source.id, on: connection) + } + } + + public func pendingScans(_ source: DirectorySource) async throws -> [Int] { + let client = try await pool() + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await locked(source.id, on: connection, expected: source.config) + try await connection.query( + """ + UPDATE inkcre.jobs SET status='failed',state='{"issue":"扫描参数不符合本地文件合同。"}'::jsonb + WHERE type=\(SourceJobs.type) AND status='pending' AND parameters->'source'=to_jsonb(\(source.id)::bigint) + AND (COALESCE(parameters->'config','{}'::jsonb)<>'{}'::jsonb OR parameters-'source'-'config'<>'{}'::jsonb) + """, logger: PeerDatabase.logger) + } + return try await SourceJobs.pending(source: source.id, on: client) + } + + public func reportIssue(_ message: String, source: DirectorySource) async throws { + let client = try await pool() + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + let current = try await locked(source.id, on: connection, expected: source.config) + var state = current.state + state.issue = message + let json = try FileJSON.encode(state) + try await connection.query( + "UPDATE inkcre.sources SET state=\(json)::jsonb WHERE id=\(source.id)", + logger: PeerDatabase.logger) + } + } + + public func claim(_ id: Int, source: DirectorySource) async throws -> SourceJob? { + let client = try await pool() + return try await client.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await locked(source.id, on: connection, expected: source.config) + return try await SourceJobs.claim(id, source: source.id, on: connection) + } + } + + public func check(_ job: SourceJob?) async throws { + try Task.checkCancellation() + if let job { try await SourceJobs.check(job, on: pool()) } + } + + public func close(_ job: SourceJob, status: String, state: DirectoryState) async throws { + try await SourceJobs.close(job, status: status, state: FileJSON.encode(state), on: pool()) + } + + public func save( + _ observation: FileObservation, replacing record: FileRecord?, source: DirectorySource + ) async throws { + if let record, !observation.changes(record.metadata) { return } + let client = try await pool() + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + let source = try await locked(source.id, on: connection, expected: source.config) + guard let anchor = source.block else { throw PeerError.invalid("目录来源缺少资料锚点。") } + var metadata = observation.metadata + if let record { + let current = try await currentRecord(record.id, anchor: anchor, on: connection) + if observation.text == nil { metadata.revision = current.metadata.revision } + if let text = observation.text { + if let body = current.bodyID { + try await InfoBase.updateBlock(body, content: text, on: connection) + } else { + let body = try await InfoBase.createBlock( + resolver: "core.text.v1", content: text, on: connection) + try await InfoBase.relate(record.id, to: body, content: "body", on: connection) + } + } + try await InfoBase.updateBlock( + record.id, content: FileJSON.encode(metadata), on: connection) + } else { + // The Source lock also protects a retry after a successful commit with a lost reply. + let existing = try await connection.query( + "SELECT b.id FROM inkcre.relations r JOIN inkcre.blocks b ON b.id=r.to_ WHERE r.from_=\(anchor) AND r.content='manages' AND b.resolver=\(LocalFilesContract.resolver) AND b.content::jsonb->>'location'=\(metadata.location)", + logger: PeerDatabase.logger) + for try await _ in existing.decode(Int.self) { + throw PeerError.needsAction("资料已存在,正在重新核对目录。") + } + let root = try await InfoBase.createBlock( + resolver: LocalFilesContract.resolver, content: FileJSON.encode(metadata), on: connection) + if let text = observation.text { + let body = try await InfoBase.createBlock( + resolver: "core.text.v1", content: text, on: connection) + try await InfoBase.relate(root, to: body, content: "body", on: connection) + } + try await InfoBase.relate(anchor, to: root, content: "manages", on: connection) + } + } + } + + public func finishScan(source: DirectorySource, missing: [FileRecord], state: DirectoryState) + async throws + { + let client = try await pool() + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + let source = try await locked(source.id, on: connection, expected: source.config) + guard let anchor = source.block else { throw PeerError.invalid("目录来源缺少资料锚点。") } + for record in missing { + var current = try await currentRecord(record.id, anchor: anchor, on: connection) + guard current.metadata.available, current.metadata.location == record.metadata.location + else { continue } + current.metadata.available = false + current.metadata.observed_at = Date() + current.metadata.issue = nil + try await InfoBase.updateBlock( + record.id, content: FileJSON.encode(current.metadata), on: connection) + } + let json = try FileJSON.encode(state) + try await connection.query( + "UPDATE inkcre.sources SET state=\(json)::jsonb WHERE id=\(source.id)", + logger: PeerDatabase.logger) + } + } + + private func currentRecord(_ id: Int, anchor: Int, on connection: PostgresConnection) async throws + -> FileRecord + { + let rows = try await connection.query( + """ + SELECT b.content,t.id FROM inkcre.blocks b + JOIN inkcre.relations managed ON managed.to_=b.id AND managed.from_=\(anchor) AND managed.content='manages' + LEFT JOIN inkcre.relations body ON body.from_=b.id AND body.content='body' + LEFT JOIN inkcre.blocks t ON t.id=body.to_ AND t.resolver='core.text.v1' + WHERE b.id=\(id) AND b.resolver=\(LocalFilesContract.resolver) FOR UPDATE OF b + """, logger: PeerDatabase.logger) + var records: [FileRecord] = [] + for try await (content, body) in rows.decode((String, Int?).self) { + records.append( + FileRecord( + id: id, metadata: try FileJSON.decode(FileMetadata.self, content), bodyID: body)) + } + guard records.count == 1, let record = records.first else { + throw PeerError.invalid("文件资料关系已改变,请重新扫描。") + } + return record + } +} diff --git a/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift new file mode 100644 index 0000000..a11ff93 --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift @@ -0,0 +1,169 @@ +import Foundation +import PeerCore +import PostgresNIO + +public struct LocalFileClient: Sendable { + public let target: ConnectionTarget + private let password: String + private let database: PeerDatabase + public init(target: ConnectionTarget, password: String, database: PeerDatabase = .shared) { + self.target = target + self.password = password + self.database = database + } + func pool() async throws -> PostgresClient { + try await database.client(for: target, password: password) + } + + public func prepare() async throws { + _ = try await database.register(target, password: password) + try await database.join(target, password: password, extension: LocalFilesContract.descriptor) + let client = try await pool() + let config = + #"{"type":"object","additionalProperties":false,"properties":{"peer":{"type":"string","format":"uuid"},"path":{"type":"string","pattern":"^/"},"enabled":{"type":"boolean"}},"required":["peer","path","enabled"]}"# + try await client.query( + """ + INSERT INTO inkcre.sources_types(id,description,config_schema,collect_config_schema) + VALUES (\(LocalFilesContract.sourceType),'本地文件目录',\(config)::jsonb,'{"type":"object","additionalProperties":false}') + ON CONFLICT(id) DO UPDATE SET config_schema=EXCLUDED.config_schema,collect_config_schema=EXCLUDED.collect_config_schema + """) + let parameters = + #"{"type":"object","additionalProperties":false,"properties":{"source":{"type":"integer"},"config":{"type":"object","default":{}}},"required":["source"]}"# + try await client.query( + """ + INSERT INTO inkcre.job_types(id,description,parameters_schema,default_timeout_seconds) + VALUES (\(SourceJobs.type),'Run one ordinary collection command for a configured Source.',\(parameters)::jsonb,300) + ON CONFLICT(id) DO NOTHING + """) + } + + public func sources() async throws -> [DirectorySource] { + let client = try await pool() + let rows = try await client.query( + "SELECT id,config::text,block,state::text FROM inkcre.sources WHERE type=\(LocalFilesContract.sourceType) AND config->>'peer'=\(target.peerID.uuidString.lowercased()) ORDER BY id" + ) + var sources: [DirectorySource] = [] + for try await (id, config, block, state) in rows.decode((Int, String, Int?, String).self) { + sources.append(try decodeSource(id: id, config: config, block: block, state: state)) + } + return sources + } + + public func createDirectory(path: String) async throws -> DirectorySource { + let config = DirectoryConfig(peer: target.peerID, path: path) + _ = try PeerFileLocation(peer: config.peer, path: path) + let serialized = try FileJSON.encode(config) + let client = try await pool() + return try await client.withTransaction(logger: PeerDatabase.logger) { connection in + try await PeerDatabase.requireSelection( + LocalFilesContract.descriptor, peerID: target.peerID, on: connection) + try await connection.query( + "SELECT pg_advisory_xact_lock(hashtextextended(\(target.peerID.uuidString + path),0))", + logger: PeerDatabase.logger) + let existing = try await connection.query( + "SELECT id FROM inkcre.sources WHERE type=\(LocalFilesContract.sourceType) AND config->>'peer'=\(target.peerID.uuidString.lowercased()) AND config->>'path'=\(path)", + logger: PeerDatabase.logger) + for try await _ in existing.decode(Int.self) { throw PeerError.needsAction("这个目录已经添加。") } + let rows = try await connection.query( + "INSERT INTO inkcre.sources(type,nickname,config) VALUES (\(LocalFilesContract.sourceType),\(URL(fileURLWithPath: path).lastPathComponent),\(serialized)::jsonb) RETURNING id", + logger: PeerDatabase.logger) + for try await id in rows.decode(Int.self) { + struct Anchor: Encodable { + let id: Int + let type: String + let nickname: String + } + let content = try FileJSON.encode( + Anchor(id: id, type: LocalFilesContract.sourceType, nickname: path)) + let block = try await InfoBase.createBlock( + resolver: "core.source.v1", content: content, on: connection) + try await connection.query( + "UPDATE inkcre.sources SET block=\(block) WHERE id=\(id)", logger: PeerDatabase.logger) + return DirectorySource(id: id, config: config, block: block, state: DirectoryState()) + } + throw PeerError.connection("目录未能保存。") + } + } + + public func setEnabled(_ enabled: Bool, source: DirectorySource) async throws { + let client = try await pool() + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + var current = try await locked( + source.id, on: connection, expected: source.config, requireEnabled: false) + current.config.enabled = enabled + let config = try FileJSON.encode(current.config) + try await connection.query( + "UPDATE inkcre.sources SET config=\(config)::jsonb WHERE id=\(source.id)", + logger: PeerDatabase.logger) + } + } + + public func remove(_ source: DirectorySource) async throws { + let client = try await pool() + try await client.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await locked( + source.id, on: connection, expected: source.config, requireEnabled: false) + try await connection.query( + "DELETE FROM inkcre.sources WHERE id=\(source.id)", logger: PeerDatabase.logger) + } + } + + public func records(source: DirectorySource) async throws -> [FileRecord] { + guard source.config.peer == target.peerID else { throw PeerError.invalid("目录不属于当前 Peer。") } + guard let block = source.block else { return [] } + let client = try await pool() + let rows = try await client.query( + """ + SELECT b.id,b.content,t.id FROM inkcre.relations managed JOIN inkcre.blocks b ON b.id=managed.to_ + LEFT JOIN inkcre.relations body ON body.from_=b.id AND body.content='body' + LEFT JOIN inkcre.blocks t ON t.id=body.to_ AND t.resolver='core.text.v1' + WHERE managed.from_=\(block) AND managed.content='manages' AND b.resolver=\(LocalFilesContract.resolver) ORDER BY b.id + """) + var records: [FileRecord] = [] + var seen: Set = [] + for try await (id, content, bodyID) in rows.decode((Int, String, Int?).self) { + guard seen.insert(id).inserted else { throw PeerError.invalid("文件资料存在重复正文关系。") } + records.append( + FileRecord( + id: id, metadata: try FileJSON.decode(FileMetadata.self, content), bodyID: bodyID)) + } + return records + } + + public func text(for record: FileRecord) async throws -> String? { + guard let body = record.bodyID else { return nil } + let rows = try await pool().query( + "SELECT content FROM inkcre.blocks WHERE id=\(body) AND resolver='core.text.v1'") + for try await text in rows.decode(String.self) { return text } + throw PeerError.needsAction("正文已移除,请刷新文件列表。") + } + + func locked( + _ id: Int, on connection: PostgresConnection, expected: DirectoryConfig? = nil, + requireEnabled: Bool = true + ) async throws -> DirectorySource { + try await PeerDatabase.requireSelection( + LocalFilesContract.descriptor, peerID: target.peerID, on: connection) + let rows = try await connection.query( + "SELECT id,config::text,block,state::text FROM inkcre.sources WHERE id=\(id) AND type=\(LocalFilesContract.sourceType) FOR UPDATE", + logger: PeerDatabase.logger) + for try await (id, config, block, state) in rows.decode((Int, String, Int?, String).self) { + let source = try decodeSource(id: id, config: config, block: block, state: state) + if let expected, source.config != expected || (requireEnabled && !source.config.enabled) { + throw CancellationError() + } + return source + } + throw PeerError.needsAction("目录来源已移除。") + } + + private func decodeSource(id: Int, config: String, block: Int?, state: String) throws + -> DirectorySource + { + let config = try FileJSON.decode(DirectoryConfig.self, config) + guard config.peer == target.peerID else { throw PeerError.needsAction("目录属于另一个 Peer。") } + _ = try PeerFileLocation(peer: config.peer, path: config.path) + let status = state == "{}" ? DirectoryState() : try FileJSON.decode(DirectoryState.self, state) + return DirectorySource(id: id, config: config, block: block, state: status) + } +} diff --git a/extensions/local-files/Sources/LocalFiles/Models.swift b/extensions/local-files/Sources/LocalFiles/Models.swift new file mode 100644 index 0000000..508023a --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/Models.swift @@ -0,0 +1,150 @@ +import Foundation +import PeerCore + +public enum LocalFilesContract { + public static let descriptor = NativeExtensionDescriptor( + name: "inkcre/local-files", distribution: "swift_bundled", version: "0.1.0") + public static let sourceType = "extensions.local_files.directory.v1" + public static let resolver = "extensions.local_files.file.v1" + public static let extractor = "mac-native-text.v1" +} + +public enum FileJSON { + public static func encode(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return String(decoding: try encoder.encode(value), as: UTF8.self) + } + public static func decode(_ type: T.Type, _ text: String) throws -> T { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(type, from: Data(text.utf8)) + } +} + +public struct PeerFileLocation: Sendable, Equatable { + public let peer: UUID + public let path: String + public init(peer: UUID, path: String) throws { + guard path.hasPrefix("/"), !path.contains("\0") else { throw PeerError.invalid("文件位置需要绝对路径。") } + self.peer = peer + self.path = path + } + public init(uri: String) throws { + guard let components = URLComponents(string: uri), components.scheme == "inkcre-peer-file", + let host = components.host, let peer = UUID(uuidString: host), + components.user == nil, components.password == nil, components.port == nil, + components.query == nil, components.fragment == nil + else { throw PeerError.invalid("原文件链接无效。") } + try self.init(peer: peer, path: components.path) + } + public var uri: String { + var components = URLComponents() + components.scheme = "inkcre-peer-file" + components.host = peer.uuidString.lowercased() + components.path = path + return components.string! + } + public func localURL(for peer: UUID) throws -> URL { + guard self.peer == peer else { throw PeerError.needsAction("原件位于另一个 Peer。") } + return URL(fileURLWithPath: path) + } +} + +public struct DirectoryConfig: Codable, Sendable, Equatable { + public var peer: UUID + public var path: String + public var enabled: Bool + public init(peer: UUID, path: String, enabled: Bool = true) { + self.peer = peer + self.path = path + self.enabled = enabled + } + public func encode(to encoder: any Encoder) throws { + var values = encoder.container(keyedBy: CodingKeys.self) + try values.encode(peer.uuidString.lowercased(), forKey: .peer) + try values.encode(path, forKey: .path) + try values.encode(enabled, forKey: .enabled) + } +} + +public struct DirectorySource: Sendable, Identifiable { + public let id: Int + public var config: DirectoryConfig + public let block: Int? + public let state: DirectoryState +} + +public struct DirectoryState: Codable, Sendable { + public var scanned_at: Date? + public var issue: String? + public var errors: Int + public init(scanned_at: Date? = nil, issue: String? = nil, errors: Int = 0) { + self.scanned_at = scanned_at + self.issue = issue + self.errors = errors + } +} + +public struct FileIdentity: Codable, Sendable, Equatable { + public let volume: String + public let number: UInt64 + public let created: Date + init(volume: String, number: UInt64, created: Date) { + self.volume = volume + self.number = number + self.created = Date(timeIntervalSince1970: floor(created.timeIntervalSince1970)) + } +} + +public struct FileRevision: Codable, Sendable, Equatable { + public let sha256: String + public let extractor: String + public let extracted_at: Date + public let scope: String +} + +public struct FileMetadata: Codable, Sendable, Equatable { + public var location: String + public var format: String + public var identity: FileIdentity? + public var available: Bool + public var observed_at: Date + public var revision: FileRevision? + public var issue: String? +} + +public struct FileRecord: Sendable, Identifiable { + public let id: Int + public var metadata: FileMetadata + public let bodyID: Int? + public var path: String { + (try? PeerFileLocation(uri: metadata.location).path) ?? metadata.location + } + public var name: String { URL(fileURLWithPath: path).lastPathComponent } +} + +public struct FileObservation: Sendable { + public var metadata: FileMetadata + public let text: String? + + func changes(_ previous: FileMetadata) -> Bool { + var current = metadata + current.observed_at = previous.observed_at + if text == nil { current.revision = previous.revision } + return current != previous + } +} + +public struct DirectoryBinding: Codable, Sendable, Identifiable { + public let source: Int + public let target: ConnectionTarget + public var bookmark: Data + public var id: String { "\(target.peerID).\(source)" } + public init(source: Int, target: ConnectionTarget, bookmark: Data) { + self.source = source + self.target = target + self.bookmark = bookmark + } +} diff --git a/extensions/local-files/Sources/LocalFiles/Resources/extension.json b/extensions/local-files/Sources/LocalFiles/Resources/extension.json new file mode 100644 index 0000000..9fff6f0 --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/Resources/extension.json @@ -0,0 +1 @@ +{"name":"inkcre/local-files","distribution":"swift_bundled","version":"0.1.0","license":"MIT"} diff --git a/extensions/local-files/Sources/LocalFilesMac/DirectoryWatcher.swift b/extensions/local-files/Sources/LocalFilesMac/DirectoryWatcher.swift new file mode 100644 index 0000000..1da102b --- /dev/null +++ b/extensions/local-files/Sources/LocalFilesMac/DirectoryWatcher.swift @@ -0,0 +1,45 @@ +import CoreServices +import Foundation +import PeerCore + +@MainActor +final class DirectoryWatcher { + private var stream: FSEventStreamRef? + private let changed: () -> Void + + init(path: String, changed: @escaping () -> Void) throws { + self.changed = changed + var context = FSEventStreamContext( + version: 0, info: Unmanaged.passUnretained(self).toOpaque(), retain: nil, release: nil, + copyDescription: nil) + guard + let stream = FSEventStreamCreate( + nil, + { _, info, _, _, _, _ in + guard let info else { return } + MainActor.assumeIsolated { + Unmanaged.fromOpaque(info).takeUnretainedValue().changed() + } + }, &context, [path] as CFArray, FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 0.8, + FSEventStreamCreateFlags( + kFSEventStreamCreateFlagWatchRoot | kFSEventStreamCreateFlagFileEvents + | kFSEventStreamCreateFlagNoDefer)) + else { throw PeerError.needsAction("无法监视目录。") } + self.stream = stream + FSEventStreamSetDispatchQueue(stream, .main) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + throw PeerError.needsAction("无法启动目录监视。") + } + } + + func stop() { + guard let stream else { return } + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + self.stream = nil + } +} diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift new file mode 100644 index 0000000..d5498df --- /dev/null +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesExtension.swift @@ -0,0 +1,26 @@ +import Foundation +import LocalFiles +import PeerCore + +@MainActor +public final class LocalFilesExtension: NativeExtension { + public let descriptor = LocalFilesContract.descriptor + public let model: LocalFilesModel + public var present: (() -> Void)? + private let context: PeerHostContext + public init(context: PeerHostContext) { + self.context = context + model = LocalFilesModel(context: context) + } + public func start() throws { try model.start() } + public func stop() async throws { await model.stop() } + public func showMain() { present?() } + public func setEnabled(_ enabled: Bool) async throws { + guard let target = try await context.connections.load() else { return } + do { + try await PeerDatabase.shared.setEnabled( + enabled, target: target, + password: context.credentials.read(for: target.id), extension: descriptor) + } catch { throw PeerDatabase.failure(error) } + } +} diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift new file mode 100644 index 0000000..a011c4d --- /dev/null +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift @@ -0,0 +1,500 @@ +import AppKit +import Combine +import LocalFiles +import PeerCore + +@MainActor +public final class LocalFilesModel: ObservableObject { + @Published public private(set) var sources: [DirectorySource] = [] + @Published public private(set) var records: [FileRecord] = [] + @Published public var selectedSource: Int? { + didSet { + if oldValue != selectedSource { + selectedFile = nil + records = [] + loadRecords() + } + } + } + @Published public var selectedFile: Int? { + didSet { if oldValue != selectedFile { loadText() } } + } + @Published public private(set) var selectedText: String? + @Published public private(set) var textIssue: String? + @Published public private(set) var loadingText = false + @Published public private(set) var target: ConnectionTarget? + @Published public private(set) var issue: String? + @Published public private(set) var loading = false + @Published public private(set) var working = false + @Published public private(set) var running = false + @Published public var removal: DirectorySource? + @Published private var statuses: [String: String] = [:] + @Published private var scanning: Set = [] + public var editConnection: (() -> Void)? + + private let context: PeerHostContext + private var bindings: [DirectoryBinding] = [] + private var monitors: [String: Monitor] = [:] + private var timer: Task? + private var recordsTask: Task? + private var textTask: Task? + private var refreshTask: Task? + private var actionTask: Task? + private var directoryPanel: NSOpenPanel? + + private final class Monitor { + let source: DirectorySource + let target: ConnectionTarget + let root: URL + let scoped: Bool + var watcher: DirectoryWatcher? + var task: Task? + var dirty = false + init(source: DirectorySource, target: ConnectionTarget, root: URL, scoped: Bool) { + self.source = source + self.target = target + self.root = root + self.scoped = scoped + } + } + + public init(context: PeerHostContext) { self.context = context } + private var bindingURL: URL { + context.directory.appendingPathComponent("extensions/inkcre-local-files/directories.json") + } + public var source: DirectorySource? { sources.first { $0.id == selectedSource } } + public var file: FileRecord? { records.first { $0.id == selectedFile } } + private func key(_ source: Int, _ target: ConnectionTarget) -> String { + "\(target.peerID).\(source)" + } + public func isScanning(_ source: DirectorySource) -> Bool { + target.map { scanning.contains(key(source.id, $0)) } ?? false + } + public func status(_ source: DirectorySource) -> String { + if !source.config.enabled { return "已暂停" } + guard running else { return "扩展已停用" } + if let target, let status = statuses[key(source.id, target)] { return status } + return source.state.issue + ?? (source.state.errors > 0 ? "\(source.state.errors) 个文件需要处理" : "正在监视") + } + public func hasAccess(_ source: DirectorySource) -> Bool { + guard let target else { return false } + return bindings.contains { $0.source == source.id && $0.target.peerID == target.peerID } + } + + public func start() throws { + if FileManager.default.fileExists(atPath: bindingURL.path) { + bindings = try JSONDecoder().decode( + [DirectoryBinding].self, from: Data(contentsOf: bindingURL)) + } + running = true + timer = Task { + while !Task.isCancelled { + await refresh() + do { try await Task.sleep(for: .seconds(30)) } catch { break } + } + } + } + + public func stop() async { + running = false + directoryPanel?.cancel(nil) + directoryPanel = nil + actionTask?.cancel() + refreshTask?.cancel() + timer?.cancel() + await actionTask?.value + await refreshTask?.value + await timer?.value + timer = nil + recordsTask?.cancel() + await recordsTask?.value + textTask?.cancel() + await textTask?.value + loadingText = false + for id in Array(monitors.keys) { await stopMonitor(id) } + } + + public func refresh() async { + if let refreshTask { + await refreshTask.value + return + } + guard running else { return } + let task = Task { await performRefresh() } + refreshTask = task + await task.value + refreshTask = nil + } + + private func performRefresh() async { + guard running else { return } + loading = sources.isEmpty + defer { loading = false } + do { + let current = try await context.connections.load() + if current != target { + recordsTask?.cancel() + selectedSource = nil + selectedFile = nil + sources = [] + records = [] + } + target = current + } catch { + issue = error.localizedDescription + return + } + var targets = Dictionary( + bindings.map { ($0.target.id, $0.target) }, uniquingKeysWith: { _, newer in newer }) + if let target { targets[target.id] = target } + if target == nil { + sources = [] + records = [] + } + for connection in targets.values { + guard running, !Task.isCancelled else { return } + do { + let client = try client(connection) + try await client.prepare() + let found = try await client.sources() + guard running, !Task.isCancelled else { return } + if target?.id == connection.id { + let old = selectedSource + sources = found + selectedSource = found.contains(where: { $0.id == old }) ? old : found.first?.id + issue = nil + } + for binding in bindings.filter({ $0.target.id == connection.id }) { + guard let source = found.first(where: { $0.id == binding.source }), source.config.enabled + else { + await stopMonitor(binding.id) + continue + } + if monitors[binding.id]?.source.config != source.config { await stopMonitor(binding.id) } + if monitors[binding.id] == nil { + do { try startMonitor(binding, source: source) } catch { + statuses[binding.id] = error.localizedDescription + continue + } + } + if let monitor = monitors[binding.id], monitor.task == nil { + let jobs = try await client.pendingScans(source) + if !jobs.isEmpty || monitor.dirty { schedule(binding.id) } + } + } + for source in found where !bindings.contains(where: { $0.id == key(source.id, connection) }) + { + statuses[key(source.id, connection)] = "需要选择目录以恢复访问" + } + } catch { + let message = PeerDatabase.failure(error).localizedDescription + if target?.id == connection.id { issue = message } + for binding in bindings.filter({ $0.target.id == connection.id }) { + await stopMonitor(binding.id) + statuses[binding.id] = message + } + } + } + loadRecords() + } + + private func startMonitor(_ binding: DirectoryBinding, source: DirectorySource) throws { + guard running, !Task.isCancelled else { throw CancellationError() } + var stale = false + let root = try URL( + resolvingBookmarkData: binding.bookmark, options: [.withSecurityScope, .withoutUI], + relativeTo: nil, bookmarkDataIsStale: &stale) + guard + root.resolvingSymlinksInPath().path + == URL(fileURLWithPath: source.config.path).resolvingSymlinksInPath().path + else { throw PeerError.needsAction("目录位置已改变,请重新选择目录。") } + let scoped = root.startAccessingSecurityScopedResource() + let monitor = Monitor(source: source, target: binding.target, root: root, scoped: scoped) + do { + if stale, let index = bindings.firstIndex(where: { $0.id == binding.id }) { + bindings[index].bookmark = try root.bookmarkData( + options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) + try saveBindings() + } + monitor.watcher = try DirectoryWatcher(path: root.path) { [weak self] in + self?.schedule(binding.id) + } + monitors[binding.id] = monitor + schedule(binding.id) + } catch { + if scoped { root.stopAccessingSecurityScopedResource() } + throw error + } + } + + private func stopMonitor(_ id: String) async { + guard let monitor = monitors.removeValue(forKey: id) else { return } + monitor.watcher?.stop() + monitor.task?.cancel() + await monitor.task?.value + if monitor.scoped { monitor.root.stopAccessingSecurityScopedResource() } + scanning.remove(id) + } + + private func schedule(_ id: String) { + guard running, let monitor = monitors[id] else { return } + monitor.dirty = true + guard monitor.task == nil else { return } + monitor.task = Task { [weak self, weak monitor] in + guard let self, let monitor else { return } + defer { + monitor.task = nil + scanning.remove(id) + } + while monitor.dirty, running, !Task.isCancelled { + monitor.dirty = false + scanning.insert(id) + statuses[id] = "正在扫描…" + var job: SourceJob? + let client: LocalFileClient + do { client = try self.client(monitor.target) } catch { + statuses[id] = error.localizedDescription + monitor.dirty = true + break + } + do { + let pending = try await client.pendingScans(monitor.source) + for candidate in pending { + if let claimed = try await client.claim(candidate, source: monitor.source) { + job = claimed + break + } + } + let state = try await DirectoryScanner.scan( + source: monitor.source, client: client, job: job + ) { [weak self] progress in + await self?.report(progress, id: id) + } + if let job { + try await client.close( + job, status: state.errors == 0 ? "finished" : "failed", state: state) + } + statuses[id] = state.errors == 0 ? "正在监视" : "\(state.errors) 个文件需要处理" + let updated = try await client.sources() + if target?.peerID == monitor.source.config.peer, running, !Task.isCancelled { + sources = updated + loadRecords() + } + if !(try await client.pendingScans(monitor.source)).isEmpty { monitor.dirty = true } + } catch { + let message = + PeerDatabase.isCancellation(error) + ? "扫描已停止" : PeerDatabase.failure(error).localizedDescription + statuses[id] = message + if !PeerDatabase.isCancellation(error), running { + do { try await client.reportIssue(message, source: monitor.source) } catch { + statuses[id] = "\(message);状态暂未写回信息库" + } + } + if let job { + let status = + Date() >= job.deadline + ? "timed_out" : PeerDatabase.isCancellation(error) ? "aborted" : "failed" + let closing = Task { + try await client.close(job, status: status, state: DirectoryState(issue: message)) + } + do { try await closing.value } catch { statuses[id] = "\(message);任务终态待信息库核对" } + } + // Reconnection reconciles current files, without an outbox. + monitor.dirty = true + break + } + } + } + } + + private func report(_ progress: ScanProgress, id: String) { + statuses[id] = "\(progress.completed)/\(progress.total) · \(progress.filename)" + } + + private func client(_ target: ConnectionTarget) throws -> LocalFileClient { + let active = self.target + let connection = + active?.peerID == target.peerID && active?.host == target.host && active?.port == target.port + && active?.database == target.database ? active! : target + return LocalFileClient( + target: connection, password: try context.credentials.read(for: connection.id)) + } + + private func saveBindings() throws { + try FileManager.default.createDirectory( + at: bindingURL.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try JSONEncoder().encode(bindings).write(to: bindingURL, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bindingURL.path) + } + + private func loadRecords() { + recordsTask?.cancel() + guard let source, let target else { + records = [] + return + } + recordsTask = Task { + do { + let result = try await client(target).records(source: source) + guard !Task.isCancelled, selectedSource == source.id, self.target?.id == target.id else { + return + } + let previous = file + records = result.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + if let selectedFile, !records.contains(where: { $0.id == selectedFile }) { + self.selectedFile = nil + } else if previous?.bodyID != file?.bodyID || previous?.metadata.revision != file?.metadata.revision || textIssue != nil + || (file?.bodyID != nil && selectedText == nil && !loadingText) { + loadText() + } + } catch { if !Task.isCancelled { issue = PeerDatabase.failure(error).localizedDescription } } + } + } + + public func loadText() { + textTask?.cancel() + selectedText = nil + textIssue = nil + loadingText = false + guard let file, file.bodyID != nil, let target else { return } + loadingText = true + textTask = Task { + defer { if !Task.isCancelled { loadingText = false } } + do { + let text = try await client(target).text(for: file) + guard !Task.isCancelled else { return } + selectedText = text + } catch { + if !Task.isCancelled { textIssue = PeerDatabase.failure(error).localizedDescription } + } + } + } + + public func chooseDirectory(restoring source: DirectorySource? = nil) { + guard !working, running else { return } + guard let target else { + editConnection?() + return + } + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = source == nil ? "监视目录" : "恢复访问" + if let source { panel.directoryURL = URL(fileURLWithPath: source.config.path) } + guard let window = NSApp.keyWindow else { return } + directoryPanel = panel + panel.beginSheetModal(for: window) { [weak self] response in + guard let self else { return } + self.directoryPanel = nil + guard self.running, response == .OK, let url = panel.url else { return } + Task { await self.addDirectory(url, target: target, restoring: source) } + } + } + + public func addDirectory( + _ url: URL, target: ConnectionTarget, restoring source: DirectorySource? = nil + ) async { + guard running, !working else { return } + working = true + let task = Task { await addDirectoryImpl(url, target: target, restoring: source) } + actionTask = task + await task.value + actionTask = nil + } + + private func addDirectoryImpl( + _ url: URL, target: ConnectionTarget, restoring source: DirectorySource? + ) async { + defer { working = false } + let url = url.resolvingSymlinksInPath() + do { + if let source, source.config.path != url.path { + throw PeerError.needsAction("请选择原目录:\(source.config.path)") + } + let bookmark = try url.bookmarkData( + options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil) + let client = try client(target) + try await client.prepare() + guard running, !Task.isCancelled else { throw CancellationError() } + let directory: DirectorySource + if let source { + directory = source + } else { + directory = try await client.createDirectory(path: url.path) + } + let binding = DirectoryBinding(source: directory.id, target: target, bookmark: bookmark) + bindings.removeAll { $0.id == binding.id } + bindings.append(binding) + try saveBindings() + selectedSource = directory.id + await refresh() + } catch { issue = error.localizedDescription } + } + + public func toggle(_ source: DirectorySource) { + perform { target in + let id = self.key(source.id, target) + await self.stopMonitor(id) + try await self.client(target).setEnabled(!source.config.enabled, source: source) + } + } + + public func rescan(_ source: DirectorySource) { + perform { target in + _ = try await self.client(target).submitScan(source) + self.schedule(self.key(source.id, target)) + } + } + + public func remove(_ source: DirectorySource) { + removal = nil + perform { target in + let id = self.key(source.id, target) + await self.stopMonitor(id) + try await self.client(target).remove(source) + self.bindings.removeAll { $0.id == id } + try self.saveBindings() + } + } + + private func perform(_ action: @escaping @MainActor (ConnectionTarget) async throws -> Void) { + guard running, !working, let target else { return } + working = true + actionTask = Task { + defer { + working = false + actionTask = nil + } + var failure: String? + do { + try Task.checkCancellation() + try await action(target) + } catch { + failure = + PeerDatabase.isCancellation(error) + ? "目录状态已改变,请重新核对。" : PeerDatabase.failure(error).localizedDescription + } + await refresh() + if let failure { issue = failure } + } + } + + public func openOriginal(_ record: FileRecord, reveal: Bool = false) { + do { + guard let target else { return } + let url = try PeerFileLocation(uri: record.metadata.location).localURL(for: target.peerID) + guard FileManager.default.fileExists(atPath: url.path) else { + throw PeerError.needsAction("原件已不在这个位置。") + } + if reveal { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } else if !NSWorkspace.shared.open(url) { + throw PeerError.needsAction("没有可打开此文件的应用。") + } + } catch { issue = error.localizedDescription } + } +} diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift new file mode 100644 index 0000000..748f41d --- /dev/null +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift @@ -0,0 +1,186 @@ +import LocalFiles +import SwiftUI + +public struct LocalFilesView: View { + @ObservedObject var model: LocalFilesModel + public init(model: LocalFilesModel) { self.model = model } + + public var body: some View { + VStack(spacing: 0) { + if let issue = model.issue { + HStack(alignment: .top) { + Label(issue, systemImage: "exclamationmark.triangle").textSelection(.enabled) + Spacer() + Button("重试") { Task { await model.refresh() } }.disabled(model.loading) + }.padding().background(.quaternary) + } + if !model.running { + empty("本地文件扩展已停用", symbol: "folder") + } else if model.loading && model.sources.isEmpty { + ProgressView("正在读取目录…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.target == nil { + ContentUnavailableView { + Label("未连接信息库", systemImage: "network") + } actions: { + Button("连接信息库…") { model.editConnection?() } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.sources.isEmpty { + ContentUnavailableView { + Label("尚未添加目录", systemImage: "folder.badge.plus") + } actions: { + Button("添加目录…") { model.chooseDirectory() } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + directoryHeader + Divider() + VSplitView { + fileList.frame(minHeight: 150, maxHeight: .infinity) + if let file = model.file { + FileDetail( + record: file, text: model.selectedText, loading: model.loadingText, + issue: model.textIssue, retry: { model.loadText() }, open: { model.openOriginal(file) }, + reveal: { model.openOriginal(file, reveal: true) } + ) + .frame(minHeight: 180, idealHeight: 280, maxHeight: .infinity) + } + } + } + } + .frame(minWidth: 520, maxWidth: .infinity, minHeight: 400, maxHeight: .infinity) + .toolbar { + ToolbarItemGroup { + if let source = model.source { + Button( + source.config.enabled ? "暂停监视" : "恢复监视", + systemImage: source.config.enabled ? "pause" : "play" + ) { model.toggle(source) } + .help(source.config.enabled ? "暂停监视" : "恢复监视") + Button("重新扫描", systemImage: "arrow.clockwise") { model.rescan(source) } + .help("重新扫描,包括失败文件") + .disabled( + !source.config.enabled || model.isScanning(source) || !model.hasAccess(source)) + Menu { + Button("重新选择目录…") { model.chooseDirectory(restoring: source) } + Button("移除目录…", role: .destructive) { model.removal = source } + } label: { + Label("目录操作", systemImage: "ellipsis.circle") + } + } + Button("添加目录…", systemImage: "folder.badge.plus") { model.chooseDirectory() }.help("添加目录") + .disabled(!model.running) + } + } + .disabled(model.working) + .alert( + "移除这个目录?", + isPresented: Binding(get: { model.removal != nil }, set: { if !$0 { model.removal = nil } }), + presenting: model.removal + ) { source in + Button("移除目录", role: .destructive) { model.remove(source) } + Button("取消", role: .cancel) { model.removal = nil } + } message: { source in + Text("停止监视 \(source.config.path)。已收入资料保留。") + } + } + + private var directoryHeader: some View { + VStack(alignment: .leading, spacing: 8) { + Picker("目录", selection: $model.selectedSource) { + ForEach(model.sources) { source in + Text(source.config.path).help(source.config.path).tag(Optional(source.id)) + } + }.labelsHidden().accessibilityLabel("监视目录").frame(maxWidth: .infinity, alignment: .leading) + if let source = model.source { + HStack { + if model.isScanning(source) { ProgressView().controlSize(.small) } + Text(model.status(source)).lineLimit(2).help(model.status(source)) + Spacer(minLength: 12) + if let date = source.state.scanned_at, !model.isScanning(source) { + Text(date, format: .dateTime.month().day().hour().minute()).foregroundStyle(.secondary) + .help("最近完整扫描") + } + if !model.hasAccess(source) { + Button("选择目录…") { model.chooseDirectory(restoring: source) } + } + }.font(.callout) + } + }.padding() + } + + @ViewBuilder private var fileList: some View { + if model.records.isEmpty { + empty(model.source.map(model.isScanning) == true ? "正在扫描目录" : "没有可收入的文件", symbol: "doc") + } else { + Table(model.records, selection: $model.selectedFile) { + TableColumn("文件") { record in + Text(record.name).lineLimit(1).truncationMode(.middle).help(record.path) + }.width(min: 140, ideal: 250) + TableColumn("状态") { record in + Text(record.metadata.issue != nil ? "提取失败" : record.metadata.available ? "已收入" : "原件失效") + .foregroundStyle(record.metadata.issue == nil ? Color.secondary : Color.orange) + }.width(90) + TableColumn("正文更新") { record in + if let date = record.metadata.revision?.extracted_at { + Text(date, format: .dateTime.month().day().hour().minute()) + } else { + Text("—") + } + }.width(min: 100, ideal: 140) + } + } + } + + private func empty(_ title: String, symbol: String) -> some View { + ContentUnavailableView(title, systemImage: symbol).frame( + maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct FileDetail: View { + let record: FileRecord + let text: String? + let loading: Bool + let issue: String? + let retry: () -> Void + let open: () -> Void + let reveal: () -> Void + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline) { + Text(record.name).font(.headline).textSelection(.enabled) + Spacer() + Button("打开原件", action: open).disabled(!record.metadata.available) + Button("在 Finder 中显示", action: reveal).disabled(!record.metadata.available) + } + Text(record.path).font(.callout).foregroundStyle(.secondary).textSelection(.enabled) + if let issue = record.metadata.issue { + Label(issue, systemImage: "exclamationmark.triangle").foregroundStyle(.orange) + .textSelection(.enabled) + if record.bodyID != nil { Text("以下为上次成功提取的正文").font(.callout) } + } else if !record.metadata.available { + Label("原件已失效;已收入正文保留", systemImage: "doc.badge.ellipsis") + } + if record.metadata.revision?.scope == "main_document" { + Text("DOCX 主文档文字(不含脚注、批注)").font(.caption).foregroundStyle(.secondary) + } else if record.metadata.revision?.scope == "text_layer" { + Text("PDF 文字层").font(.caption).foregroundStyle(.secondary) + } + Divider() + if loading { + ProgressView("正在读取正文…").frame(maxWidth: .infinity, minHeight: 100) + } else if let issue { + Label(issue, systemImage: "exclamationmark.triangle") + Button("重试", action: retry) + } else if let text { + Text(text.isEmpty ? "空白文本" : text).textSelection(.enabled).frame( + maxWidth: .infinity, alignment: .leading) + } else { + Text("尚无已收入正文").foregroundStyle(.secondary) + } + }.padding().frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 5817595..d40c472 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -28,6 +28,7 @@ mkdir -p "$app/Contents/Resources" cp "$bin_path/InKCre" "$app/Contents/MacOS/InKCre" cp -R "$bin_path/InKCreCapture_Capture.bundle" "$app/Contents/Resources/" cp -R "$bin_path/InKCreCapture_CaptureMac.bundle" "$app/Contents/Resources/" +cp -R "$bin_path/InKCreLocalFiles_LocalFiles.bundle" "$app/Contents/Resources/" for resource in "$bin_path"/swift-*.bundle; do [[ -d "$resource" ]] || continue cp -R "$resource" "$app/Contents/Resources/" @@ -46,7 +47,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion13 +CFBundleVersion16 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From b681f1ac31a93d27280abdc700cd89e44318431a Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 25 Oct 2026 13:13:53 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat(mac):=20=E4=BF=9D=E5=AD=98=E5=B9=B6?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E5=A4=9A=E4=B8=AA=E5=91=BD=E5=90=8D=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留各连接身份与 Keychain 凭据引用,并恢复上次选择 - 切换时刷新前台目录,冻结待办和已有目录继续绑定原库 - 增加持久化与宿主切换检查,更新 build 17 和使用文档 --- .../Sources/PeerCore/ConnectionSettings.swift | 57 +++++++++++++++++-- .../PeerMacUI/PeerConnectionEditor.swift | 5 +- README.md | 6 +- Sources/InKCreApp/AppMain.swift | 43 +++++++++++--- Sources/InKCreApp/PeerMainView.swift | 2 +- Sources/InKCreApp/PeerSettingsWindow.swift | 46 +++++++++------ .../ConnectionSettingsTests.swift | 42 ++++++++++++++ Tests/HostTests/HostInterfaceTests.swift | 37 ++++++++++++ .../LocalFilesMac/LocalFilesModel.swift | 25 +++++++- scripts/build-app.sh | 2 +- 10 files changed, 227 insertions(+), 38 deletions(-) create mode 100644 Tests/CaptureTests/ConnectionSettingsTests.swift diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift index ce35d2d..92cda7d 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -3,21 +3,66 @@ import Foundation public actor ConnectionSettings { private let url: URL public init(url: URL) { self.url = url } - public func load() throws -> ConnectionTarget? { - guard FileManager.default.fileExists(atPath: url.path) else { return nil } - return try JSONDecoder().decode(ConnectionTarget.self, from: Data(contentsOf: url)) + public struct SavedConnections: Codable, Sendable { + public var selected: UUID? + public var connections: [ConnectionTarget] + public var current: ConnectionTarget? { connections.first { $0.id == selected } } } + + public func loadAll() throws -> SavedConnections { + guard FileManager.default.fileExists(atPath: url.path) else { + return SavedConnections(selected: nil, connections: []) + } + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + // Preserve the existing single connection, including its Keychain and Peer identities. + if let target = try? decoder.decode(ConnectionTarget.self, from: data) { + try target.validate() + return SavedConnections(selected: target.id, connections: [target]) + } + let saved = try decoder.decode(SavedConnections.self, from: data) + guard Set(saved.connections.map(\.id)).count == saved.connections.count, + saved.selected == nil || saved.current != nil else { + throw PeerError.invalid("保存的连接列表无效。") + } + for target in saved.connections { try target.validate() } + return saved + } + + public func load() throws -> ConnectionTarget? { try loadAll().current } + + public func select(_ id: UUID) throws -> ConnectionTarget { + var saved = try loadAll() + guard let target = saved.connections.first(where: { $0.id == id }) else { + throw PeerError.invalid("连接不存在。") + } + saved.selected = id + try persist(saved) + return target + } + public func save(_ target: ConnectionTarget) throws { try target.validate() + var saved = try loadAll() + if let index = saved.connections.firstIndex(where: { $0.id == target.id }) { + saved.connections[index] = target + } else { + saved.connections.append(target) + } + saved.selected = target.id + try persist(saved) + } + + private func persist(_ saved: SavedConnections) throws { try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) - try JSONEncoder().encode(target).write(to: url, options: .atomic) + try JSONEncoder().encode(saved).write(to: url, options: .atomic) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } public func saveEdited( host: String, port: String, database: String, username: String, tls: ConnectionTarget.TLS, - web: String, password: String, previous: ConnectionTarget?, credentials: CredentialStore + web: String, password: String, name: String = "", previous: ConnectionTarget?, credentials: CredentialStore ) throws -> ConnectionTarget { let host = host.trimmingCharacters(in: .whitespacesAndNewlines) guard let port = Int(port) else { throw PeerError.invalid("数据库端口无效。") } @@ -29,7 +74,7 @@ public actor ConnectionSettings { let target = ConnectionTarget( id: sameCredentials ? previous!.id : UUID(), peerID: same ? previous!.peerID : UUID(), host: host, port: port, database: database, username: username, tls: tls, webURL: webURL, - name: host) + name: name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? host : name.trimmingCharacters(in: .whitespacesAndNewlines)) try target.validate() if !password.isEmpty { try credentials.save(password, for: target.id) } _ = try credentials.read(for: target.id, allowInteraction: true) diff --git a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift index 16393f9..d617c93 100644 --- a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -9,6 +9,7 @@ public struct PeerConnectionEditor: View { private let target: ConnectionTarget? private let saveTitle: String @Environment(\.dismiss) private var dismiss + @State private var name: String @State private var server: String @State private var port: String @State private var database: String @@ -32,6 +33,7 @@ public struct PeerConnectionEditor: View { self.saveTitle = saveTitle self.onCancel = onCancel self.onSaved = onSaved + _name = State(initialValue: target?.name ?? "") _server = State(initialValue: target?.host ?? "") _port = State(initialValue: String(target?.port ?? 5432)) _database = State(initialValue: target?.database ?? "inkcre") @@ -44,6 +46,7 @@ public struct PeerConnectionEditor: View { VStack(alignment: .leading, spacing: 16) { Text("连接共享信息库").font(.title2).bold() Form { + TextField("连接名称", text: $name) TextField("PostgreSQL 主机", text: $server).focused($addressFocused) TextField("端口", text: $port) TextField("数据库", text: $database) @@ -72,7 +75,7 @@ public struct PeerConnectionEditor: View { do { let saved = try await settings.saveEdited( host: server, port: port, database: database, username: username, tls: tls, - web: web, password: secret, previous: target, credentials: credentials) + web: web, password: secret, name: name, previous: target, credentials: credentials) onSaved(saved) secret = "" dismiss() diff --git a/README.md b/README.md index 8efaf93..88a4b8c 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,13 @@ open dist/InKCre.app 连接设置使用 PostgreSQL 主机、端口、库名、独立运行角色及密码,可选 Web 地址。远端使用 TLS 并校验证书;只有回环开发地址允许关闭 TLS。密码存于 Keychain,角色拥有已准入数据库协议权限,不使用迁移管理员或 JWT 签名密钥。 +在“连接”页可以添加命名连接、编辑当前连接,并用“当前连接”切换;保存会同时使用该连接,重新打开应用会恢复上次选择。已有单连接保留原 Peer 身份和 Keychain 引用。切回连接不生成新身份;编辑数据库或运行角色时沿用既有的新连接规则,原条目仍可选择。连接名称只用于本机辨认,不修改共享 Peer 名称。 + +切换只改变当前操作的信息库。冻结 Capture 待办继续向原目标恢复,已经授权的目录继续监视并收入其原信息库;不会搬迁、清空材料或将旧目录改投新库。前台目录列表在切换时清空并重新加载,切换完成前禁用其操作。 + 凭据使用 macOS file-based Keychain。后台读取禁止系统授权弹窗;密钥串锁定或应用访问被拒绝时,显示需要处理的连接错误,只有主动保存连接时允许交互。访问策略属于进程,因此共享凭据入口串行执行读写并恢复原策略。开发验收应由签名应用保存测试密码,不能用 `security add-generic-password -T` 预置后假定签名分区已正确授权。 -`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页只展示状态,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 +`⌘,` 打开唯一的原生设置窗口,工具栏提供“连接”“扩展”“采集”三页;采集页集中管理快捷键和系统权限。主窗口 Peer 页提供同一连接选择入口,当前连接编辑打开设置窗口中的原生 sheet;无效输入在表单内提示并保留,可用 Esc 取消、Return 保存。编辑期间不能通过新建或全局采集切走表单。保存后通知正在运行的 Capture 更新连接,并在后台检查,不重启扩展或丢弃当前草稿。最近检查须同时通过 Peer 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。 共享数据库需要本分支的 Capture 回执表与原生分发参与合同。Capture Swift 分发在本 Peer 内构图,用同一 PostgreSQL 事务写图、原图和回执;Python Peer 与 PostgREST 均无需在线。图、材料指纹和收入语义属于 first-party `inkcre/capture`,共享合同见 [Capture Extension 合同](https://github.com/InKCre/docs/blob/main/20-product-tdd/capture-contract.md)。 diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 6fb5683..7952ca8 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -63,6 +63,8 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI private var presence: Task? @Published var connectionIssue: String? @Published var connection: ConnectionTarget? + @Published var savedConnections: [ConnectionTarget] = [] + @Published var changingConnection = false private var manager: PeerSettingsWindow? func applicationDidFinishLaunching(_ notification: Notification) { @@ -83,7 +85,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI presence = Task { while !Task.isCancelled { do { - connection = try await context.connections.load() + try await reloadConnections() if let target = connection { _ = try await PeerDatabase.shared.register( target, password: context.credentials.read(for: target.id)) @@ -188,7 +190,7 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI NSApp.activate(ignoringOtherApps: true) manager?.makeKeyAndOrderFront(nil) Task { - do { connection = try await context.connections.load() } + do { try await reloadConnections() } catch { connectionIssue = error.localizedDescription } } } @@ -207,8 +209,32 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI private func updateStatus() { objectWillChange.send() } + func reloadConnections() async throws { + let saved = try await context.connections.loadAll() + savedConnections = saved.connections + connection = saved.current + } + + func selectConnection(_ id: UUID) async { + guard !changingConnection, capture.canOpenSettings else { return } + changingConnection = true + defer { changingConnection = false } + do { + let target = try await context.connections.select(id) + await connectionDidChange(target) + } catch { connectionIssue = error.localizedDescription } + } + + private func connectionDidChange(_ target: ConnectionTarget) async { + connection = target + connectionIssue = nil + capture.connectionDidChange(target) + do { try await reloadConnections() } catch { connectionIssue = error.localizedDescription } + await localFiles.model.connectionDidChange(target) + } + func editConnection(_ target: ConnectionTarget?, onFinished: (@MainActor () -> Void)? = nil) { - guard capture.canOpenSettings else { return } + guard !changingConnection, capture.canOpenSettings else { return } showSettings(.connection) guard let manager, manager.attachedSheet == nil else { return } let sheet = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) @@ -221,11 +247,12 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI settings: context.connections, credentials: context.credentials, target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, onSaved: { [weak self] target in - self?.connection = target - self?.connectionIssue = nil - self?.capture.connectionDidChange(target) - Task { await self?.localFiles.model.refresh() } - close() + self?.changingConnection = true + Task { + await self?.connectionDidChange(target) + self?.changingConnection = false + close() + } })) sheet.contentView = content sheet.setContentSize(content.fittingSize) diff --git a/Sources/InKCreApp/PeerMainView.swift b/Sources/InKCreApp/PeerMainView.swift index e381be6..ce168b4 100644 --- a/Sources/InKCreApp/PeerMainView.swift +++ b/Sources/InKCreApp/PeerMainView.swift @@ -20,7 +20,7 @@ struct PeerMainView: View { case .capture, .records: host.capture.pageView() case .files: if host.filesRuntime.running { - LocalFilesView(model: host.localFiles.model) + LocalFilesView(model: host.localFiles.model).disabled(host.changingConnection) } else { ContentUnavailableView { Label("本地文件扩展已停用", systemImage: "folder") diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift index 38ab6f5..7960472 100644 --- a/Sources/InKCreApp/PeerSettingsWindow.swift +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -62,27 +62,37 @@ final class PeerSettingsWindow: NSWindow, NSWindowDelegate { struct ConnectionSettingsView: View { @ObservedObject var host: PeerHost var body: some View { - Group { - if let target = host.connection { - Form { - Section("共享信息库") { - LabeledContent("信息库", value: target.name) - LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") - LabeledContent("运行角色", value: target.username) - LabeledContent("Peer", value: target.peerID.uuidString.lowercased()).textSelection(.enabled) - if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } + Form { + Section("连接") { + if !host.savedConnections.isEmpty { + Picker("当前连接", selection: Binding( + get: { host.connection?.id }, + set: { id in if let id { Task { await host.selectConnection(id) } } } + )) { + ForEach(host.savedConnections) { target in + Text(target.name).tag(Optional(target.id)) + } + } + } + HStack { + Button("添加连接…") { host.editConnection(nil) } + if let target = host.connection { Button("编辑连接…") { host.editConnection(target) } - if let issue = host.connectionIssue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } } - }.formStyle(.grouped) - } else { - ContentUnavailableView { - Label("未连接信息库", systemImage: "network") - } actions: { - Button("连接信息库…") { host.editConnection(nil) } - }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + }.disabled(host.changingConnection) + if let target = host.connection { + Section("共享信息库") { + LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent("运行角色", value: target.username) + LabeledContent("Peer", value: target.peerID.uuidString.lowercased()).textSelection(.enabled) + if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } + } + } + if let issue = host.connectionIssue { + Text(issue).foregroundStyle(.red).textSelection(.enabled) } - } + }.formStyle(.grouped) } } diff --git a/Tests/CaptureTests/ConnectionSettingsTests.swift b/Tests/CaptureTests/ConnectionSettingsTests.swift new file mode 100644 index 0000000..fe6db39 --- /dev/null +++ b/Tests/CaptureTests/ConnectionSettingsTests.swift @@ -0,0 +1,42 @@ +import Foundation +import PeerCore +import Testing + +struct ConnectionSettingsTests { + @Test func savesMultipleConnectionsAndRestoresSelectionWithoutChangingIdentity() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("connection.json") + let settings = ConnectionSettings(url: url) + let first = ConnectionTarget(host: "one.example", database: "first", username: "peer", name: "个人") + let second = ConnectionTarget(host: "two.example", database: "second", username: "peer", name: "工作") + try await settings.save(first) + try await settings.save(second) + #expect(try await settings.load() == second) + #expect(try await settings.select(first.id) == first) + let reopened = ConnectionSettings(url: url) + #expect(try await reopened.load() == first) + #expect(try await reopened.loadAll().connections == [first, second]) + var renamed = first + renamed.name = "私人信息库" + try await reopened.save(renamed) + #expect(try await reopened.loadAll().connections == [renamed, second]) + #expect(try await reopened.select(second.id) == second) + await #expect(throws: (any Error).self) { try await reopened.select(UUID()) } + #expect(try await reopened.load() == second) + } + + @Test func preservesExistingSingleConnectionAndRejectsCorruptLists() async throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: url) } + let original = ConnectionTarget(host: "one.example", database: "first", username: "peer") + try JSONEncoder().encode(original).write(to: url) + let settings = ConnectionSettings(url: url) + #expect(try await settings.loadAll().connections == [original]) + #expect(try await settings.select(original.id) == original) + #expect(try await ConnectionSettings(url: url).load() == original) + try Data("{\"connections\":42}".utf8).write(to: url) + await #expect(throws: (any Error).self) { try await settings.loadAll() } + #expect(try String(contentsOf: url, encoding: .utf8) == "{\"connections\":42}") + } +} diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift index d842d31..ef2db46 100644 --- a/Tests/HostTests/HostInterfaceTests.swift +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -7,6 +7,43 @@ import Testing @Suite(.serialized) @MainActor struct HostInterfaceTests { + @Test func switchingSavedConnectionUpdatesHostAndDirectorySelection() async throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let domain = "dev.inkcre.connections-test.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: domain)) + defer { defaults.removePersistentDomain(forName: domain); try? FileManager.default.removeItem(at: directory) } + defaults.set(false, forKey: "extensions.inkcre/capture.enabled") + defaults.set(false, forKey: "extensions.inkcre/local-files.enabled") + let context = PeerHostContext(identity: domain, directory: directory) + let first = ConnectionTarget(host: "one.example", database: "one", username: "peer", name: "个人") + let second = ConnectionTarget(host: "two.example", database: "two", username: "peer", name: "工作") + try await context.connections.save(first) + try await context.connections.save(second) + let host = PeerHost(context: context, defaults: defaults) + try await host.reloadConnections() + #expect(host.savedConnections == [first, second]) + await host.selectConnection(first.id) + #expect(host.connection == first) + #expect(host.localFiles.model.target == first) + await host.selectConnection(second.id) + #expect(host.connection == second) + #expect(host.localFiles.model.target == second) + #expect(host.localFiles.model.sources.isEmpty) + #expect(!host.changingConnection) + if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { + let window = PeerSettingsWindow(host: host) + defer { window.orderOut(nil) } + window.select(.connection) + let view = try #require(window.contentView?.superview) + view.layoutSubtreeIfNeeded() + let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) + view.cacheDisplay(in: view.bounds, to: bitmap) + try #require(bitmap.representation(using: .png, properties: [:])).write(to: + URL(fileURLWithPath: path).appendingPathComponent("multiple-connections.png")) + } + #expect(try await ConnectionSettings(url: directory.appendingPathComponent("connection.json")).load() == second) + } + @Test func nativeSidebarShortcutAndSettingsTabsRemainAvailableWithoutCapture() async throws { _ = NSApplication.shared let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift index a011c4d..b7c86f5 100644 --- a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift @@ -115,16 +115,37 @@ public final class LocalFilesModel: ObservableObject { for id in Array(monitors.keys) { await stopMonitor(id) } } + public func connectionDidChange(_ connection: ConnectionTarget) async { + directoryPanel?.cancel(nil) + directoryPanel = nil + removal = nil + recordsTask?.cancel() + textTask?.cancel() + target = connection + selectedSource = nil + selectedFile = nil + selectedText = nil + textIssue = nil + sources = [] + records = [] + issue = nil + // A coalesced old refresh must settle before the new selection is refreshed. + await refreshTask?.value + await refresh() + } + public func refresh() async { if let refreshTask { await refreshTask.value return } guard running else { return } - let task = Task { await performRefresh() } + let task = Task { + defer { refreshTask = nil } + await performRefresh() + } refreshTask = task await task.value - refreshTask = nil } private func performRefresh() async { diff --git a/scripts/build-app.sh b/scripts/build-app.sh index d40c472..57eb750 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -47,7 +47,7 @@ cat > "$app/Contents/Info.plist" <CFBundleIconFileAppIcon.icns CFBundlePackageTypeAPPL CFBundleShortVersionString$version -CFBundleVersion16 +CFBundleVersion17 LSMinimumSystemVersion15.0 NSHighResolutionCapable NSPrincipalClassNSApplication From 52690c754769610451189df4e2307a141028811f Mon Sep 17 00:00:00 2001 From: Lanzhijiang Date: Sun, 25 Oct 2026 14:31:29 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix(checks):=20=E4=B8=BA=20SwiftPM=20?= =?UTF-8?q?=E5=80=99=E9=80=89=E7=BB=9F=E4=B8=80=E6=A0=BC=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=20CI=20=E9=97=A8=E7=A6=81=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 覆盖 Host、PeerCore 与 first-party 扩展并保留协议字段合同 --- .github/workflows/ci.yml | 23 ++ .swift-format | 4 + AGENTS.md | 2 +- Packages/PeerCore/Package.swift | 4 +- .../PeerCore/BundledParticipation.swift | 148 +++++------ .../Sources/PeerCore/ConnectionSettings.swift | 6 +- .../PeerCore/Sources/PeerCore/InfoBase.swift | 2 + .../Sources/PeerCore/PeerDatabase.swift | 4 +- .../PeerMacUI/PeerConnectionEditor.swift | 9 +- README.md | 2 +- Sources/InKCreApp/AppMain.swift | 85 +++--- Sources/InKCreApp/PeerSettingsWindow.swift | 57 ++-- Tests/CaptureMacTests/AppIconTests.swift | 10 +- .../CaptureInteractionTests.swift | 175 +++++++++---- .../CaptureMacTests/CaptureLayoutTests.swift | 163 ++++++++---- .../CaptureMacTests/RegionOverlayTests.swift | 204 ++++++++++----- Tests/CaptureMacTests/ShortcutTests.swift | 99 ++++--- Tests/CaptureTests/CaptureContractTests.swift | 16 +- .../CaptureTests/CaptureLifecycleTests.swift | 3 +- Tests/CaptureTests/CaptureStoreTests.swift | 57 ++-- Tests/CaptureTests/LivePeerTests.swift | 244 +++++++++--------- Tests/HostTests/HostInterfaceTests.swift | 40 ++- extensions/capture/Package.swift | 3 +- .../Sources/Capture/CaptureClient.swift | 21 +- .../Sources/Capture/CaptureDigest.swift | 3 +- .../Sources/Capture/CaptureGraph.swift | 6 +- .../Sources/Capture/CaptureQueue.swift | 7 +- .../Sources/Capture/CaptureStore.swift | 8 +- .../capture/Sources/Capture/Models.swift | 13 +- .../Sources/CaptureMac/BrandAssets.swift | 13 +- .../Sources/CaptureMac/CaptureEditor.swift | 62 +++-- .../Sources/CaptureMac/CaptureExtension.swift | 52 ++-- .../CaptureMac/CaptureImagePreview.swift | 14 +- .../Sources/CaptureMac/CaptureModel.swift | 50 ++-- .../CaptureMac/CaptureRecordSettings.swift | 3 +- .../CaptureMac/CaptureRecordsView.swift | 68 +++-- .../CaptureMac/CaptureSettingsView.swift | 15 +- .../Sources/CaptureMac/ClipboardReader.swift | 16 +- .../Sources/CaptureMac/ConnectionView.swift | 28 +- .../Sources/CaptureMac/GlobalShortcuts.swift | 113 +++++--- .../Sources/CaptureMac/KeyboardShortcut.swift | 73 ++++-- .../Sources/CaptureMac/PeerMainView.swift | 51 ++-- .../Sources/CaptureMac/PeerMainWindow.swift | 11 +- .../Sources/CaptureMac/RegionCapture.swift | 45 +++- .../Sources/CaptureMac/SelectedText.swift | 62 +++-- .../CaptureMac/ShortcutSettingsView.swift | 9 +- scripts/check.sh | 13 + 47 files changed, 1396 insertions(+), 720 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-format create mode 100755 scripts/check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a495954 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: Mac checks + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mac-checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: impl-swift checks + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode_26.2.app/Contents/Developer + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: 检查格式、lint 与 SwiftPM 测试 + run: bash scripts/check.sh diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..f57bc26 --- /dev/null +++ b/.swift-format @@ -0,0 +1,4 @@ +{ + "version": 1, + "indentation": { "spaces": 4 } +} diff --git a/AGENTS.md b/AGENTS.md index 4036584..0615da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,4 +12,4 @@ Capture 和此次 Mac 分发尚未发布,直接修订候选 schema,不增加 同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。 -运行 `swift test` 和 `bash scripts/build-app.sh`。编译不证明系统权限、真实选区、焦点或多屏行为;真实应用验证使用合成内容,按实际结果记录。开发包使用 `dev.inkcre.capture`;发布身份、签名、公证只能使用显式提供的输入。 +运行 `bash scripts/check.sh` 和 `bash scripts/build-app.sh`。前者与 CI 共用 Xcode 26.2 的 Swift 格式/lint 检查和测试入口,覆盖 Host、PeerCore 与 first-party 扩展;固定来源的 Vendor 代码按上游格式维护。编译不证明系统权限、真实选区、焦点或多屏行为;真实应用验证使用合成内容,按实际结果记录。开发包使用 `dev.inkcre.capture`;发布身份、签名、公证只能使用显式提供的输入。 diff --git a/Packages/PeerCore/Package.swift b/Packages/PeerCore/Package.swift index 0b53b6b..2867f8a 100644 --- a/Packages/PeerCore/Package.swift +++ b/Packages/PeerCore/Package.swift @@ -9,7 +9,9 @@ let package = Package( ], dependencies: [.package(url: "https://github.com/vapor/postgres-nio.git", from: "1.32.0")], targets: [ - .target(name: "PeerCore", dependencies: [.product(name: "PostgresNIO", package: "postgres-nio")]), + .target( + name: "PeerCore", dependencies: [.product(name: "PostgresNIO", package: "postgres-nio")] + ), .target(name: "PeerMacUI", dependencies: ["PeerCore"]), ] ) diff --git a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift index 54af37f..7e623ee 100644 --- a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift +++ b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift @@ -2,82 +2,84 @@ import Foundation import PostgresNIO extension PeerDatabase { - /// First joining may create a selection; subsequent reconnects never recreate removed participation. - public func join( - _ target: ConnectionTarget, password: String, extension descriptor: NativeExtensionDescriptor - ) async throws { - let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! - let key = "\(target.peerID).\(descriptor.name)" - let firstJoin = !defaults.bool(forKey: key) - let client = try client(for: target, password: password) - let issue: String? = try await client.withTransaction(logger: Self.logger) { connection in - if firstJoin { - try await connection.query( - "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", - logger: Self.logger) - try await connection.query( - """ - INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) - VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) - ON CONFLICT (name,peer_id) DO NOTHING - """, logger: Self.logger) - } - do { - try await Self.requireSelection(descriptor, peerID: target.peerID, on: connection) - return nil - } catch PeerError.needsAction(let message) { return message } + /// First joining may create a selection; subsequent reconnects never recreate removed participation. + public func join( + _ target: ConnectionTarget, password: String, + extension descriptor: NativeExtensionDescriptor + ) async throws { + let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! + let key = "\(target.peerID).\(descriptor.name)" + let firstJoin = !defaults.bool(forKey: key) + let client = try client(for: target, password: password) + let issue: String? = try await client.withTransaction(logger: Self.logger) { connection in + if firstJoin { + try await connection.query( + "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", + logger: Self.logger) + try await connection.query( + """ + INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) + VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) + ON CONFLICT (name,peer_id) DO NOTHING + """, logger: Self.logger) + } + do { + try await Self.requireSelection(descriptor, peerID: target.peerID, on: connection) + return nil + } catch PeerError.needsAction(let message) { return message } + } + defaults.set(true, forKey: key) + if let issue { throw PeerError.needsAction(issue) } } - defaults.set(true, forKey: key) - if let issue { throw PeerError.needsAction(issue) } - } - public static func requireSelection( - _ descriptor: NativeExtensionDescriptor, peerID: UUID, on connection: PostgresConnection - ) async throws { - let rows = try await connection.query( - """ - SELECT distribution,version,enabled FROM inkcre.extension_peer_participations - WHERE name=\(descriptor.name) AND peer_id=\(peerID) FOR SHARE - """, logger: Self.logger) - for try await (distribution, version, enabled) in rows.decode((String?, String, Bool).self) { - guard distribution == descriptor.distribution, version == descriptor.version else { - throw PeerError.needsAction("此 Peer 选择的扩展版本不在当前应用中,请核对扩展选择。") - } - guard enabled else { throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在线明确启用后继续。") } - return + public static func requireSelection( + _ descriptor: NativeExtensionDescriptor, peerID: UUID, on connection: PostgresConnection + ) async throws { + let rows = try await connection.query( + """ + SELECT distribution,version,enabled FROM inkcre.extension_peer_participations + WHERE name=\(descriptor.name) AND peer_id=\(peerID) FOR SHARE + """, logger: Self.logger) + for try await (distribution, version, enabled) in rows.decode((String?, String, Bool).self) + { + guard distribution == descriptor.distribution, version == descriptor.version else { + throw PeerError.needsAction("此 Peer 选择的扩展版本不在当前应用中,请核对扩展选择。") + } + guard enabled else { throw PeerError.needsAction("信息库中的扩展参与已停用;本机材料保留,请在线明确启用后继续。") } + return + } + throw PeerError.needsAction("此 Peer 的扩展参与已移除;不会在重连时自动重建。") } - throw PeerError.needsAction("此 Peer 的扩展参与已移除;不会在重连时自动重建。") - } - public func setEnabled( - _ enabled: Bool, target: ConnectionTarget, password: String, - extension descriptor: NativeExtensionDescriptor - ) async throws { - _ = try await register(target, password: password) - let client = try client(for: target, password: password) - try await client.withTransaction(logger: Self.logger) { connection in - if enabled { - try await connection.query( - "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", - logger: Self.logger) - try await connection.query( - """ - INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) - VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) - ON CONFLICT (name,peer_id) DO NOTHING - """, logger: Self.logger) - } - let rows = try await connection.query( - """ - UPDATE inkcre.extension_peer_participations SET enabled=\(enabled) - WHERE name=\(descriptor.name) AND peer_id=\(target.peerID) - AND distribution=\(descriptor.distribution) AND version=\(descriptor.version) - RETURNING name - """, logger: Self.logger) - for try await _ in rows.decode(String.self) { return } - if enabled { throw PeerError.needsAction("信息库中没有与本机匹配的扩展选择。") } + public func setEnabled( + _ enabled: Bool, target: ConnectionTarget, password: String, + extension descriptor: NativeExtensionDescriptor + ) async throws { + _ = try await register(target, password: password) + let client = try client(for: target, password: password) + try await client.withTransaction(logger: Self.logger) { connection in + if enabled { + try await connection.query( + "INSERT INTO inkcre.extensions(name) VALUES (\(descriptor.name)) ON CONFLICT (name) DO NOTHING", + logger: Self.logger) + try await connection.query( + """ + INSERT INTO inkcre.extension_peer_participations(name,peer_id,distribution,version,enabled) + VALUES (\(descriptor.name),\(target.peerID),\(descriptor.distribution),\(descriptor.version),true) + ON CONFLICT (name,peer_id) DO NOTHING + """, logger: Self.logger) + } + let rows = try await connection.query( + """ + UPDATE inkcre.extension_peer_participations SET enabled=\(enabled) + WHERE name=\(descriptor.name) AND peer_id=\(target.peerID) + AND distribution=\(descriptor.distribution) AND version=\(descriptor.version) + RETURNING name + """, logger: Self.logger) + for try await _ in rows.decode(String.self) { return } + if enabled { throw PeerError.needsAction("信息库中没有与本机匹配的扩展选择。") } + } + let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! + defaults.set(true, forKey: "\(target.peerID).\(descriptor.name)") } - let defaults = UserDefaults(suiteName: "dev.inkcre.native-participation")! - defaults.set(true, forKey: "\(target.peerID).\(descriptor.name)") - } } diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift index 2f5d097..e82f6a3 100644 --- a/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -24,11 +24,13 @@ public actor ConnectionSettings { let web = web.trimmingCharacters(in: .whitespacesAndNewlines) let webURL = web.isEmpty ? nil : URL(string: web) if !web.isEmpty && webURL == nil { throw PeerError.invalid("Web 地址无效。") } - let same = previous?.host == host && previous?.port == port && previous?.database == database + let same = + previous?.host == host && previous?.port == port && previous?.database == database let sameCredentials = same && previous?.username == username let target = ConnectionTarget( id: sameCredentials ? previous!.id : UUID(), peerID: same ? previous!.peerID : UUID(), - host: host, port: port, database: database, username: username, tls: tls, webURL: webURL, + host: host, port: port, database: database, username: username, tls: tls, + webURL: webURL, name: host) try target.validate() if !password.isEmpty { try credentials.save(password, for: target.id) } diff --git a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift index 9fcbcf2..a8aabb8 100644 --- a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift +++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift @@ -11,6 +11,8 @@ public struct GraphBlock: Sendable, Encodable { self.content = content } } +// 数据库图协议直接使用 from_/to_ 字段。 +// swift-format-ignore: AlwaysUseLowerCamelCase public struct GraphRelation: Sendable, Encodable { public let from_: Int public let to_: Int diff --git a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift index f7a8d95..32c90d2 100644 --- a/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift +++ b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift @@ -54,7 +54,9 @@ public actor PeerDatabase { """, logger: Self.logger) let rows = try await connection.query( "SELECT inkcre.renew_peer_lease(\(target.peerID),120)", logger: Self.logger) - for try await expiry in rows.decode(Date.self) { return expiry.addingTimeInterval(-120) } + for try await expiry in rows.decode(Date.self) { + return expiry.addingTimeInterval(-120) + } throw PeerError.connection("信息库未返回 Peer 联络结果。") } } diff --git a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift index 16393f9..cdef355 100644 --- a/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -71,15 +71,18 @@ public struct PeerConnectionEditor: View { defer { saving = false } do { let saved = try await settings.saveEdited( - host: server, port: port, database: database, username: username, tls: tls, - web: web, password: secret, previous: target, credentials: credentials) + host: server, port: port, database: database, username: username, + tls: tls, + web: web, password: secret, previous: target, + credentials: credentials) onSaved(saved) secret = "" dismiss() } catch { issue = error.localizedDescription } } }.keyboardShortcut(.defaultAction) - .disabled(saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .disabled( + saving || server.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } }.padding(20).frame(width: 520) .interactiveDismissDisabled(saving) diff --git a/README.md b/README.md index bc5214f..d2dbcd5 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 需要 Xcode 26.2 / Swift 6.2。PostgresNIO 及依赖通过 SwiftPM 解析,精确修订固定在 Package.resolved。 ```sh -swift test +bash scripts/check.sh bash scripts/build-app.sh open dist/InKCre.app ``` diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift index 48557d1..a53bb11 100644 --- a/Sources/InKCreApp/AppMain.swift +++ b/Sources/InKCreApp/AppMain.swift @@ -15,14 +15,16 @@ enum InKCreApplication { } @MainActor -final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserInterfaceValidations { +final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserInterfaceValidations +{ let context: PeerHostContext let runtime: BundledExtensionRuntime let capture: CaptureExtension private var module: any NativeExtension { runtime.module } override convenience init() { let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" - let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let directory = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent(identity) self.init(context: PeerHostContext(identity: identity, directory: directory)) } @@ -33,7 +35,9 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI runtime = BundledExtensionRuntime(module: capture, defaults: defaults) super.init() capture.showPreferences = { [weak self] in self?.showSettings(.capture) } - capture.editConnection = { [weak self] target, completion in self?.editConnection(target, onFinished: completion) } + capture.editConnection = { [weak self] target, completion in + self?.editConnection(target, onFinished: completion) + } } private var presence: Task? @Published var connectionIssue: String? @@ -78,24 +82,31 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI } let app = addMenu("InKCre") app.addItem( - withTitle: "关于 InKCre", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + withTitle: "关于 InKCre", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") - app.addItem(withTitle: "设置…", action: #selector(showPreferences(_:)), keyEquivalent: ",").target = self - app.addItem(withTitle: "Peer 连接…", action: #selector(showConnection(_:)), keyEquivalent: "").target = + app.addItem(withTitle: "设置…", action: #selector(showPreferences(_:)), keyEquivalent: ",") + .target = self + app.addItem(withTitle: "Peer 连接…", action: #selector(showConnection(_:)), keyEquivalent: "") + .target = self - app.addItem(withTitle: "开源许可与致谢…", action: #selector(showLicenses(_:)), keyEquivalent: "").target = + app.addItem(withTitle: "开源许可与致谢…", action: #selector(showLicenses(_:)), keyEquivalent: "") + .target = self app.addItem(.separator()) - app.addItem(withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + app.addItem( + withTitle: "隐藏 InKCre", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") let others = app.addItem( withTitle: "隐藏其他应用", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") others.keyEquivalentModifierMask = [.command, .option] app.addItem( - withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + withTitle: "显示全部", action: #selector(NSApplication.unhideAllApplications(_:)), + keyEquivalent: "") app.addItem(.separator()) app.addItem( - withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") let edit = addMenu("编辑") for (title, action, key) in [ ("撤销", Selector(("undo:")), "z"), ("剪切", #selector(NSText.cut(_:)), "x"), @@ -111,17 +122,23 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s" ).keyEquivalentModifierMask = [.command, .option] - view.addItem(withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") - .keyEquivalentModifierMask = [.command, .control] + view.addItem( + withTitle: "进入全屏", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f" + ) + .keyEquivalentModifierMask = [.command, .control] let window = addMenu("窗口") window.addItem( - withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") - window.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") - window.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") + withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m" + ) + window.addItem( + withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + window.addItem( + withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w") window.addItem(withTitle: "扩展设置…", action: #selector(showExtensions(_:)), keyEquivalent: "") .target = self window.addItem( - withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "") + withTitle: "前置全部窗口", action: #selector(NSApplication.arrangeInFront(_:)), + keyEquivalent: "") NSApp.windowsMenu = window NSApp.mainMenu = main } @@ -131,7 +148,10 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI @objc private func showConnection(_ sender: Any?) { showSettings(.connection) } func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - if [#selector(showPreferences(_:)), #selector(showConnection(_:)), #selector(showExtensions(_:))].contains(item.action) { + if [ + #selector(showPreferences(_:)), #selector(showConnection(_:)), + #selector(showExtensions(_:)), + ].contains(item.action) { return manager?.attachedSheet != nil || capture.canOpenSettings } return true @@ -145,8 +165,9 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI NSApp.activate(ignoringOtherApps: true) manager?.makeKeyAndOrderFront(nil) Task { - do { connection = try await context.connections.load() } - catch { connectionIssue = error.localizedDescription } + do { connection = try await context.connections.load() } catch { + connectionIssue = error.localizedDescription + } } } @@ -166,21 +187,23 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI guard capture.canOpenSettings else { return } showSettings(.connection) guard let manager, manager.attachedSheet == nil else { return } - let sheet = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + let sheet = NSWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) let close: @MainActor @Sendable () -> Void = { [weak manager, weak sheet] in guard let manager, let sheet else { return } manager.endSheet(sheet) sheet.orderOut(nil) } - let content = NSHostingView(rootView: PeerConnectionEditor( - settings: context.connections, credentials: context.credentials, - target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, - onSaved: { [weak self] target in - self?.connection = target - self?.connectionIssue = nil - self?.capture.connectionDidChange(target) - close() - })) + let content = NSHostingView( + rootView: PeerConnectionEditor( + settings: context.connections, credentials: context.credentials, + target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, + onSaved: { [weak self] target in + self?.connection = target + self?.connectionIssue = nil + self?.capture.connectionDidChange(target) + close() + })) sheet.contentView = content sheet.setContentSize(content.fittingSize) manager.beginSheet(sheet) { [weak manager] _ in @@ -204,7 +227,9 @@ final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserI alert.runModal() } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) + -> Bool + { if runtime.running { module.showMain() } else { showExtensions(nil) } return false } diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift index d7b1a89..dcf4112 100644 --- a/Sources/InKCreApp/PeerSettingsWindow.swift +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -9,20 +9,24 @@ final class PeerSettingsWindow: NSWindow, NSWindowDelegate { private let tabs = NSTabViewController() init(host: PeerHost) { - super.init(contentRect: NSRect(x: 0, y: 0, width: 640, height: 580), - styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + super.init( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 580), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) self.host = host delegate = self title = "InKCre 设置" isReleasedWhenClosed = false minSize = NSSize(width: 600, height: 540) tabs.tabStyle = .toolbar - let connection = NSTabViewItem(viewController: NSHostingController(rootView: ConnectionSettingsView(host: host))) + let connection = NSTabViewItem( + viewController: NSHostingController(rootView: ConnectionSettingsView(host: host))) connection.label = "连接" connection.image = NSImage(systemSymbolName: "network", accessibilityDescription: "连接") - let extensions = NSTabViewItem(viewController: NSHostingController(rootView: ExtensionSettingsView(host: host))) + let extensions = NSTabViewItem( + viewController: NSHostingController(rootView: ExtensionSettingsView(host: host))) extensions.label = "扩展" - extensions.image = NSImage(systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "扩展") + extensions.image = NSImage( + systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "扩展") let capture = NSTabViewItem(viewController: captureController(host: host)) capture.label = "采集" capture.image = NSImage(systemSymbolName: "viewfinder", accessibilityDescription: "采集") @@ -53,9 +57,12 @@ final class PeerSettingsWindow: NSWindow, NSWindowDelegate { } private func captureController(host: PeerHost) -> NSViewController { - host.capture.makeSettingsViewController() ?? NSHostingController(rootView: - ContentUnavailableView("采集扩展已停用", systemImage: "viewfinder", - description: Text("在“扩展”中启用后,可设置采集快捷键和系统权限。"))) + host.capture.makeSettingsViewController() + ?? NSHostingController( + rootView: + ContentUnavailableView( + "采集扩展已停用", systemImage: "viewfinder", + description: Text("在“扩展”中启用后,可设置采集快捷键和系统权限。"))) } } @@ -66,15 +73,22 @@ private struct ConnectionSettingsView: View { Section("共享信息库") { if let target = host.connection { LabeledContent("信息库", value: target.name) - LabeledContent("PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent( + "PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") LabeledContent("运行角色", value: target.username) - if let web = target.webURL { LabeledContent("Web 地址", value: web.absoluteString) } + if let web = target.webURL { + LabeledContent("Web 地址", value: web.absoluteString) + } } else { Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") .foregroundStyle(.secondary) } - Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { host.editConnection(host.connection) } - if let issue = host.connectionIssue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { + host.editConnection(host.connection) + } + if let issue = host.connectionIssue { + Text(issue).foregroundStyle(.red).textSelection(.enabled) + } } Text("连接由此 Mac 的各扩展共享;密码保存在系统钥匙串中。") .font(.caption).foregroundStyle(.secondary) @@ -87,11 +101,20 @@ private struct ExtensionSettingsView: View { var body: some View { Form { Section("随手收集") { - Toggle("在此 Mac 启用", isOn: Binding(get: { host.runtime.enabled }, set: { host.changeEnabled($0) })) - .disabled(host.runtime.applying) - Text(host.runtime.applying ? "正在应用…" : host.runtime.syncIssue ?? - (host.runtime.running ? "本机运行中" : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留")) - .foregroundStyle(.secondary) + Toggle( + "在此 Mac 启用", + isOn: Binding(get: { host.runtime.enabled }, set: { host.changeEnabled($0) }) + ) + .disabled(host.runtime.applying) + Text( + host.runtime.applying + ? "正在应用…" + : host.runtime.syncIssue + ?? (host.runtime.running + ? "本机运行中" + : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留") + ) + .foregroundStyle(.secondary) LabeledContent("版本", value: host.capture.descriptor.version) Text("随应用交付,提供选区、粘贴、截图与想法采集。") .font(.caption).foregroundStyle(.secondary) diff --git a/Tests/CaptureMacTests/AppIconTests.swift b/Tests/CaptureMacTests/AppIconTests.swift index 4e6bb08..e29a684 100644 --- a/Tests/CaptureMacTests/AppIconTests.swift +++ b/Tests/CaptureMacTests/AppIconTests.swift @@ -5,7 +5,8 @@ struct AppIconTests { @Test func iconsetHasTransparentMacMarginsAtEveryScale() throws { let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent() - let folder = root.appendingPathComponent("extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset") + let folder = root.appendingPathComponent( + "extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset") for (name, pixels, fraction) in [ ("16x16", 16, 0.875), ("16x16@2x", 32, 0.875), ("32x32", 32, 0.875), ("32x32@2x", 64, 0.875), @@ -13,12 +14,15 @@ struct AppIconTests { ("256x256", 256, 0.8046875), ("256x256@2x", 512, 0.8046875), ("512x512", 512, 0.8046875), ("512x512@2x", 1024, 0.8046875), ] { - let bitmap = try #require(NSBitmapImageRep(data: Data(contentsOf: folder.appendingPathComponent("icon_\(name).png")))) + let bitmap = try #require( + NSBitmapImageRep( + data: Data(contentsOf: folder.appendingPathComponent("icon_\(name).png")))) #expect(bitmap.pixelsWide == pixels && bitmap.pixelsHigh == pixels) let inset = Double(pixels) * (1 - fraction) / 2 for vertical in [false, true] { let visible = (0.. 0.05 } let first = try #require(visible.first) diff --git a/Tests/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift index 3b856b1..70eebdf 100644 --- a/Tests/CaptureMacTests/CaptureInteractionTests.swift +++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift @@ -1,16 +1,19 @@ import AppKit import Capture import PeerCore -import Testing import Security +import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor struct CaptureInteractionTests { @Test func emptyCaptureShortcutDoesNotOpenConnectionOrStartSubmission() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } var mainOpened = false model.editConnection = { _, _ in mainOpened = true } @@ -23,16 +26,23 @@ struct CaptureInteractionTests { } @Test func backgroundQueueRefreshCannotReplaceNewDraftFeedbackWithAnOldRecord() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } let old = CaptureRecord(payload: CapturePayload(materials: [Material(text: "旧的已收入记录")])) try await model.store.saveDraft(old) - _ = try await model.store.freeze(old, target: ConnectionTarget(host: "fixture.test", database: "inkcre", username: "inkcre_core")) - try await model.store.confirm(old.id, result: CaptureResult( - submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available - )) + _ = try await model.store.freeze( + old, + target: ConnectionTarget( + host: "fixture.test", database: "inkcre", username: "inkcre_core")) + try await model.store.confirm( + old.id, + result: CaptureResult( + submissionID: old.id, rootBlockID: 42, committedAt: Date(), availability: .available + )) model.newThought() for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } let newDraftID = model.draft.id @@ -44,9 +54,11 @@ struct CaptureInteractionTests { } @Test func quickCaptureDoesNotChangeMainWindowNavigationAndUsesTheSameDraft() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } model.thought = "主窗口中未提交的草稿" let previousID = model.draft.id @@ -59,7 +71,9 @@ struct CaptureInteractionTests { #expect(model.pane == .peer) #expect(presented == .quick) #expect(model.draft.id == payload.submissionID) - #expect(try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿") + #expect( + try await model.store.record(previousID)?.payload?.materials.first?.text == "主窗口中未提交的草稿" + ) let currentID = model.draft.id model.editorOwner = .main #expect(model.draft.id == currentID) @@ -74,11 +88,14 @@ struct CaptureInteractionTests { @Test(arguments: [false, true]) func missingConnectionReturnsToTheOriginalDraftWithoutSubmitting(saved: Bool) async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } - await model.receive(CapturePayload(materials: [Material(text: "连接后继续收入的原文")], annotation: "原附注")) + await model.receive( + CapturePayload(materials: [Material(text: "连接后继续收入的原文")], annotation: "原附注")) let original = model.draft model.editorOwner = .quick var completion: (@MainActor () -> Void)? @@ -91,7 +108,10 @@ struct CaptureInteractionTests { model.presentEditor = { returnedTo = model.editorOwner } model.submit() #expect(returnedTo == nil) - if saved { model.connection = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") } + if saved { + model.connection = ConnectionTarget( + host: "fixture.test", database: "inkcre", username: "test") + } let finish = try #require(completion) finish() #expect(returnedTo == .quick) @@ -107,17 +127,22 @@ struct CaptureInteractionTests { } @Test func passwordRepairRetriesTheFrozenTargetWhileKeepingTheActiveConnection() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) let identity = "dev.inkcre.password.\(UUID().uuidString)" defer { - SecItemDelete([kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "\(identity).connections"] as CFDictionary) + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(identity).connections", + ] as CFDictionary) try? FileManager.default.removeItem(at: directory) } let transport = JourneyTransport() let model = try CaptureModel(directory: directory, identity: identity, client: transport) defer { model.cancelWork() } - let originalTarget = ConnectionTarget(host: "original.test", database: "original", username: "test") + let originalTarget = ConnectionTarget( + host: "original.test", database: "original", username: "test") let active = ConnectionTarget(host: "active.test", database: "active", username: "test") model.connection = active try await model.connections.save(active) @@ -128,27 +153,37 @@ struct CaptureInteractionTests { await model.sendPending(force: true) #expect(await transport.calls.isEmpty) try await model.updatePassword("new-fixture-password", for: cancelled) - for _ in 0..<100 where model.records.first?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } + for _ in 0..<100 where model.records.first?.state != .confirmed { + try await Task.sleep(for: .milliseconds(10)) + } #expect(model.connection == active) #expect(try await model.connections.load() == active) let calls = await transport.calls #expect(calls.map(\.kind) == ["recover", "submit"]) for call in calls { - #expect(call.record.id == draft.id && call.record.target == originalTarget && call.secret == "new-fixture-password") - #expect(try CaptureJSON.encode(call.record.payload) == CaptureJSON.encode(draft.payload)) + #expect( + call.record.id == draft.id && call.record.target == originalTarget + && call.secret == "new-fixture-password") + #expect( + try CaptureJSON.encode(call.record.payload) == CaptureJSON.encode(draft.payload)) } #expect(model.records.first?.state == .confirmed) } @Test func submittedFeedbackRemainsFindableWhileWritingTheNextDraft() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) let identity = "dev.inkcre.feedback.\(UUID().uuidString)" defer { - SecItemDelete([kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "\(identity).connections"] as CFDictionary) + SecItemDelete( + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "\(identity).connections", + ] as CFDictionary) try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: identity, client: JourneyTransport()) + let model = try CaptureModel( + directory: directory, identity: identity, client: JourneyTransport()) defer { model.cancelWork() } let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "test") model.connection = target @@ -156,7 +191,9 @@ struct CaptureInteractionTests { model.thought = "第一条想法" let submittedID = model.draft.id model.submit() - for _ in 0..<100 where model.submittedRecord?.state != .confirmed { try await Task.sleep(for: .milliseconds(10)) } + for _ in 0..<100 where model.submittedRecord?.state != .confirmed { + try await Task.sleep(for: .milliseconds(10)) + } let record = try #require(model.submittedRecord) #expect(record.id == submittedID && record.state == .confirmed) model.thought = "下一条尚未收入的想法" @@ -172,60 +209,78 @@ struct CaptureInteractionTests { } @Test func historicalWebAddressUpdatesOnlyThatRecordsDisplayMetadata() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.web.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.web.\(UUID().uuidString)") defer { model.cancelWork() } - let original = ConnectionTarget(host: "original.test", database: "original", username: "test") + let original = ConnectionTarget( + host: "original.test", database: "original", username: "test") let active = ConnectionTarget(host: "active.test", database: "active", username: "test") model.connection = active try await model.connections.save(active) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "历史记录")])) _ = try await model.store.freeze(draft, target: original) - let receipt = CaptureResult(submissionID: draft.id, rootBlockID: 42, committedAt: Date(), availability: .available) + let receipt = CaptureResult( + submissionID: draft.id, rootBlockID: 42, committedAt: Date(), availability: .available) try await model.store.confirm(draft.id, result: receipt) let confirmed = try #require(try await model.store.record(draft.id)) let updated = try await model.updateWebAddress("https://original-web.test", for: confirmed) var expected = original expected.webURL = URL(string: "https://original-web.test") - #expect(updated.target == expected && updated.id == confirmed.id && updated.result == receipt) + #expect( + updated.target == expected && updated.id == confirmed.id && updated.result == receipt) #expect(updated.payload == nil && updated.updatedAt == confirmed.updatedAt) #expect(model.connection == active) #expect(try await model.connections.load() == active) #expect(model.resultURL(for: updated)?.host == "original-web.test") model.connection = original #expect(model.resultURL(for: updated)?.host == "original-web.test") - await #expect(throws: PeerError.self) { _ = try await model.updateWebAddress("javascript:alert(1)", for: confirmed) } + await #expect(throws: PeerError.self) { + _ = try await model.updateWebAddress("javascript:alert(1)", for: confirmed) + } #expect(try await model.store.record(draft.id)?.target == expected) } @Test func configuringWebAddressMakesExistingResultsOpenableWithoutRetargetingThem() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.result.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.result.\(UUID().uuidString)") defer { model.cancelWork() } - let target = ConnectionTarget(host: "fixture.test", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "fixture.test", database: "inkcre", username: "inkcre_core") var record = CaptureRecord() record.target = target record.state = .confirmed - record.result = CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + record.result = CaptureResult( + submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) #expect(model.resultURL(for: record) == nil) var configured = target configured.webURL = URL(string: "https://fixture.test") model.connection = configured - #expect(model.resultURL(for: record)?.absoluteString == "https://fixture.test/info-base/list/blocks/42/content") + #expect( + model.resultURL(for: record)?.absoluteString + == "https://fixture.test/info-base/list/blocks/42/content") #expect(record.target == target) - model.connection = ConnectionTarget(host: "other.test", database: "other", username: "inkcre_core", webURL: URL(string: "https://other.test")) + model.connection = ConnectionTarget( + host: "other.test", database: "other", username: "inkcre_core", + webURL: URL(string: "https://other.test")) #expect(model.resultURL(for: record) == nil) model.connection = configured - record.result = CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .deleted) + record.result = CaptureResult( + submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .deleted) #expect(model.resultURL(for: record) == nil) } @Test func commandAvailabilityFollowsEditorContentAndPresentation() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.commands.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.commands.\(UUID().uuidString)") defer { model.cancelWork() } #expect(!model.canPerform(.submit, isEditor: true, isQuickPanel: false)) model.thought = "待收入的内容" @@ -244,13 +299,16 @@ struct CaptureInteractionTests { } @Test func materialRemovalRequiresConfirmationAndPreservesOtherContent() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } let material = Material(text: "误采的材料") let kept = Material(text: "保留的材料") - model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [material, kept], annotation: "附注")) + model.draft = CaptureRecord( + payload: CapturePayload(entry: .paste, materials: [material, kept], annotation: "附注")) try await model.flush() model.requestRemoveMaterial(material.id) #expect(model.draft.payload?.materials == [material, kept]) @@ -265,9 +323,11 @@ struct CaptureInteractionTests { } @Test func deletingBeforeDebounceRequiresConfirmationAndDoesNotReappear() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } model.thought = "尚未等到自动保存的中文 👩🏽‍💻" model.annotation = "最后一次附注" @@ -291,9 +351,11 @@ struct CaptureInteractionTests { } @Test func cancellingQuickCaptureOnlyDiscardsContentAfterConfirmation() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } var closed = false model.dismissQuickCapture = { closed = true } @@ -313,9 +375,11 @@ struct CaptureInteractionTests { } @Test func deletingAnotherRecordKeepsTheCurrentUnsavedDraft() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.test.\(UUID().uuidString)") defer { model.cancelWork() } let other = CaptureRecord(payload: CapturePayload(materials: [Material(text: "另一份草稿")])) try await model.store.saveDraft(other) @@ -325,7 +389,9 @@ struct CaptureInteractionTests { model.confirmRemoval(try #require(model.removalRequest)) for _ in 0..<100 where model.submitting { try await Task.sleep(for: .milliseconds(10)) } #expect(model.draft.id == currentID) - #expect(try await model.store.record(currentID)?.payload?.materials.first?.text == "当前尚未等到自动保存的内容") + #expect( + try await model.store.record(currentID)?.payload?.materials.first?.text + == "当前尚未等到自动保存的内容") #expect(try await model.store.record(other.id) == nil) #expect(model.error == nil) } @@ -344,6 +410,7 @@ private actor JourneyTransport: CaptureTransport { } func submit(_ record: CaptureRecord, secret: String) async throws -> CaptureResult { calls.append(Call(kind: "submit", record: record, secret: secret)) - return CaptureResult(submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) + return CaptureResult( + submissionID: record.id, rootBlockID: 42, committedAt: Date(), availability: .available) } } diff --git a/Tests/CaptureMacTests/CaptureLayoutTests.swift b/Tests/CaptureMacTests/CaptureLayoutTests.swift index c816bcd..7c4214c 100644 --- a/Tests/CaptureMacTests/CaptureLayoutTests.swift +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -1,24 +1,28 @@ import AppKit import Capture -import SwiftUI import PeerCore import PeerMacUI +import SwiftUI import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor struct CaptureLayoutTests { @Test func emptyRecordsContentIsCenteredAcrossDetailWidths() throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") defer { model.cancelWork() } for width in [520, 550, 790] { - let renderer = ImageRenderer(content: CaptureRecordsView(model: model) - .environment(\.colorScheme, .light) - .frame(width: CGFloat(width), height: 517) - .background(.white)) + let renderer = ImageRenderer( + content: CaptureRecordsView(model: model) + .environment(\.colorScheme, .light) + .frame(width: CGFloat(width), height: 517) + .background(.white)) renderer.scale = 1 let image = try #require(renderer.cgImage) let bitmap = NSBitmapImageRep(cgImage: image) @@ -29,8 +33,12 @@ struct CaptureLayoutTests { for y in 0..= minX) #expect(abs(Double(minX + maxX) / 2 - Double(width - 1) / 2) < 1) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - let output = URL(fileURLWithPath: path).appendingPathComponent("records-empty-\(width).png") + let output = URL(fileURLWithPath: path).appendingPathComponent( + "records-empty-\(width).png") try #require(bitmap.representation(using: .png, properties: [:])).write(to: output) } } } @Test func nativeConnectionEditorRendersWithoutOpeningAWindow() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.layout.\(UUID().uuidString)") defer { model.cancelWork() } - let view = NSHostingView(rootView: PeerConnectionEditor(settings: model.connections, credentials: model.credentials, target: nil, onSaved: { _ in }).environment(\.colorScheme, .light).background(Color.white)) - let window = NSWindow(contentRect: NSRect(x:0,y:0,width:520,height:440), styleMask: [.titled], backing:.buffered, defer:false) - window.appearance = NSAppearance(named:.aqua) + let view = NSHostingView( + rootView: PeerConnectionEditor( + settings: model.connections, credentials: model.credentials, target: nil, + onSaved: { _ in } + ).environment(\.colorScheme, .light).background(Color.white)) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 440), styleMask: [.titled], + backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .aqua) window.contentView = view defer { window.orderOut(nil) } try await Task.sleep(for: .milliseconds(100)) @@ -66,21 +83,26 @@ struct CaptureLayoutTests { let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent("native-connection.png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: URL(fileURLWithPath: path).appendingPathComponent("native-connection.png")) } } @Test func mainWindowSidebarAndPrimarySurfacesRenderOffscreen() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") defer { model.cancelWork() } let window = PeerMainWindow(model: model) window.setFrameAutosaveName("") window.setFrame(NSRect(x: 0, y: 0, width: 760, height: 520), display: false) defer { window.orderOut(nil) } - let item = NSMenuItem(title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), keyEquivalent: "s") + let item = NSMenuItem( + title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), + keyEquivalent: "s") #expect(window.validateUserInterfaceItem(item)) #expect(item.title == "隐藏侧栏") #expect(window.tryToPerform(item.action!, with: item)) @@ -101,17 +123,20 @@ struct CaptureLayoutTests { try await snapshot(window, named: "records-annotation-\(appearance.rawValue)") model.records = [] } - let auxiliary = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 600, height: 560), - styleMask: [.titled, .closable], backing: .buffered, defer: false) + let auxiliary = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 560), + styleMask: [.titled, .closable], backing: .buffered, defer: false) defer { auxiliary.orderOut(nil) } for appearance in [NSAppearance.Name.aqua, .darkAqua] { auxiliary.appearance = NSAppearance(named: appearance) - auxiliary.contentViewController = NSHostingController(rootView: CaptureSettingsView(model: model)) + auxiliary.contentViewController = NSHostingController( + rootView: CaptureSettingsView(model: model)) auxiliary.setContentSize(NSSize(width: 600, height: 560)) try await snapshot(auxiliary, named: "capture-settings-\(appearance.rawValue)") model.editorOwner = .quick model.thought = "用于界面验收的合成想法,不读取剪贴板或屏幕。" - auxiliary.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) + auxiliary.contentViewController = NSHostingController( + rootView: QuickCaptureView(model: model)) auxiliary.setContentSize(NSSize(width: 500, height: 500)) try await snapshot(auxiliary, named: "quick-\(appearance.rawValue)") } @@ -120,20 +145,31 @@ struct CaptureLayoutTests { @Test func captureJourneysRenderAtMinimumSizeWithReadableActions() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let model = try CaptureModel(directory: directory, identity: "dev.inkcre.journey-layout.\(UUID().uuidString)") + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.journey-layout.\(UUID().uuidString)") defer { model.cancelWork() } - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 520, height: 480), - styleMask: [.titled, .closable], backing: .buffered, defer: false) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 480), + styleMask: [.titled, .closable], backing: .buffered, defer: false) defer { window.orderOut(nil) } let source = NSImage(size: NSSize(width: 1200, height: 800), flipped: false) { rect in - NSColor.white.setFill(); rect.fill() - NSColor.systemBlue.setFill(); NSRect(x: 40, y: 600, width: 1120, height: 150).fill() - ("截图原文 · 合成内容" as NSString).draw(at: NSPoint(x: 80, y: 650), - withAttributes: [.font: NSFont.systemFont(ofSize: 48), .foregroundColor: NSColor.white]) - ("原图 1200 × 800,可用快速查看阅读细节。" as NSString).draw(at: NSPoint(x: 80, y: 500), - withAttributes: [.font: NSFont.systemFont(ofSize: 28), .foregroundColor: NSColor.black]) + NSColor.white.setFill() + rect.fill() + NSColor.systemBlue.setFill() + NSRect(x: 40, y: 600, width: 1120, height: 150).fill() + ("截图原文 · 合成内容" as NSString).draw( + at: NSPoint(x: 80, y: 650), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 48), .foregroundColor: NSColor.white, + ]) + ("原图 1200 × 800,可用快速查看阅读细节。" as NSString).draw( + at: NSPoint(x: 80, y: 500), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 28), .foregroundColor: NSColor.black, + ]) return true } let tiff = try #require(source.tiffRepresentation) @@ -143,52 +179,81 @@ struct CaptureLayoutTests { let preview = try CaptureImagePreview.makePreviewFile(material) defer { try? FileManager.default.removeItem(at: preview) } #expect(try Data(contentsOf: preview) == data) - #expect((try FileManager.default.attributesOfItem(atPath: preview.path)[.posixPermissions] as? NSNumber)?.intValue == 0o600) - let target = ConnectionTarget(host: "original.test", database: "inkcre", username: "inkcre_core", name: "original.test/inkcre") + #expect( + (try FileManager.default.attributesOfItem(atPath: preview.path)[.posixPermissions] + as? NSNumber)?.intValue == 0o600) + let target = ConnectionTarget( + host: "original.test", database: "inkcre", username: "inkcre_core", + name: "original.test/inkcre") for appearance in [NSAppearance.Name.aqua, .darkAqua] { window.appearance = NSAppearance(named: appearance) model.editorOwner = .quick - model.draft = CaptureRecord(payload: CapturePayload(materials: [Capture.Material(text: "手写想法只有一个主要输入区,可以持续输入并自动保留。")])) - window.contentViewController = NSHostingController(rootView: QuickCaptureView(model: model)) + model.draft = CaptureRecord( + payload: CapturePayload(materials: [ + Capture.Material(text: "手写想法只有一个主要输入区,可以持续输入并自动保留。") + ])) + window.contentViewController = NSHostingController( + rootView: QuickCaptureView(model: model)) window.setContentSize(NSSize(width: 500, height: 500)) try await snapshot(window, named: "journey-manual-\(appearance.rawValue)") - model.draft = CaptureRecord(payload: CapturePayload(entry: .screenshot, materials: [material], annotation: "这张图说明了什么?")) + model.draft = CaptureRecord( + payload: CapturePayload( + entry: .screenshot, materials: [material], annotation: "这张图说明了什么?")) try await snapshot(window, named: "journey-image-\(appearance.rawValue)") - model.draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: [Capture.Material(text: String(repeating: "原文内容可以滚动阅读;自己的想法在下面单独输入。\n", count: 16))])) + model.draft = CaptureRecord( + payload: CapturePayload( + entry: .paste, + materials: [ + Capture.Material( + text: String(repeating: "原文内容可以滚动阅读;自己的想法在下面单独输入。\n", count: 16)) + ])) try await snapshot(window, named: "journey-long-source-\(appearance.rawValue)") model.connection = target model.submit() - for _ in 0..<100 where model.submittedRecord?.state != .needsAction { try await Task.sleep(for: .milliseconds(10)) } + for _ in 0..<100 where model.submittedRecord?.state != .needsAction { + try await Task.sleep(for: .milliseconds(10)) + } let failed = try #require(model.submittedRecord) try await snapshot(window, named: "journey-feedback-\(appearance.rawValue)") model.showRecord(failed) - window.contentViewController = NSHostingController(rootView: CaptureRecordsView(model: model)) + window.contentViewController = NSHostingController( + rootView: CaptureRecordsView(model: model)) window.setContentSize(NSSize(width: 520, height: 480)) try await snapshot(window, named: "journey-failure-record-\(appearance.rawValue)") let payload = try #require(failed.payload) - window.contentViewController = NSHostingController(rootView: - ScrollView { CaptureRecordMaterials(payload: payload).padding() }) + window.contentViewController = NSHostingController( + rootView: + ScrollView { CaptureRecordMaterials(payload: payload).padding() }) window.setContentSize(NSSize(width: 520, height: 480)) try await snapshot(window, named: "journey-all-materials-\(appearance.rawValue)") for field in [CaptureRecordSettings.Field.password, .webAddress] { - window.contentViewController = NSHostingController(rootView: CaptureRecordSettings(model: model, record: failed, field: field)) + window.contentViewController = NSHostingController( + rootView: CaptureRecordSettings(model: model, record: failed, field: field)) window.setContentSize(NSSize(width: 520, height: 400)) - try await snapshot(window, named: "journey-\(field)-\(appearance.rawValue)", minimumSize: NSSize(width: 480, height: 250)) + try await snapshot( + window, named: "journey-\(field)-\(appearance.rawValue)", + minimumSize: NSSize(width: 480, height: 250)) } } #expect(!window.isVisible) } - private func snapshot(_ window: NSWindow, named name: String, minimumSize: NSSize = NSSize(width: 500, height: 400)) async throws { + private func snapshot( + _ window: NSWindow, named name: String, + minimumSize: NSSize = NSSize(width: 500, height: 400) + ) async throws { try await Task.sleep(for: .milliseconds(200)) let view = try #require(window.contentView?.superview) view.layoutSubtreeIfNeeded() let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) - #expect(bitmap.pixelsWide >= Int(minimumSize.width) && bitmap.pixelsHigh >= Int(minimumSize.height)) + #expect( + bitmap.pixelsWide >= Int(minimumSize.width) + && bitmap.pixelsHigh >= Int(minimumSize.height)) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: path).appendingPathComponent("\(name).png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: + URL(fileURLWithPath: path).appendingPathComponent("\(name).png")) } } diff --git a/Tests/CaptureMacTests/RegionOverlayTests.swift b/Tests/CaptureMacTests/RegionOverlayTests.swift index 511a506..18d6060 100644 --- a/Tests/CaptureMacTests/RegionOverlayTests.swift +++ b/Tests/CaptureMacTests/RegionOverlayTests.swift @@ -3,6 +3,7 @@ import AppKit import ImageIO import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor @@ -11,12 +12,18 @@ struct RegionOverlayTests { _ = NSApplication.shared let windows = NSApp.windows.count var checks = 0 - let capture = RegionCapture(hasPermission: { checks += 1; return false }) + let capture = RegionCapture(hasPermission: { + checks += 1 + return false + }) var denied = 0 for _ in 0..<3 { capture.begin { result in - if case .failure(let error) = result, error is ScreenRecordingPermissionError { denied += 1 } - else { Issue.record("拒绝访问不得进入框选或截取。") } + if case .failure(let error) = result, error is ScreenRecordingPermissionError { + denied += 1 + } else { + Issue.record("拒绝访问不得进入框选或截取。") + } } } #expect(checks == 3) @@ -25,10 +32,14 @@ struct RegionOverlayTests { #expect(NSApp.windows.count == windows) } - private func event(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws -> NSEvent { - try #require(NSEvent.mouseEvent(with: type, location: view.convert(point, to: nil), modifierFlags: [], - timestamp: 0, windowNumber: view.window!.windowNumber, - context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) + private func event(_ type: NSEvent.EventType, at point: CGPoint, in view: NSView) throws + -> NSEvent + { + try #require( + NSEvent.mouseEvent( + with: type, location: view.convert(point, to: nil), modifierFlags: [], + timestamp: 0, windowNumber: view.window!.windowNumber, + context: nil, eventNumber: 1, clickCount: 1, pressure: 1)) } private func drag(_ view: SelectionView, from start: CGPoint, to end: CGPoint) throws { @@ -39,8 +50,9 @@ struct RegionOverlayTests { private func window() -> NSWindow { _ = NSApplication.shared - let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 800, height: 520), styleMask: .borderless, - backing: .buffered, defer: false) + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 800, height: 520), styleMask: .borderless, + backing: .buffered, defer: false) window.isReleasedWhenClosed = false return window } @@ -49,8 +61,9 @@ struct RegionOverlayTests { let window = window() defer { window.close() } var results: [CGRect] = [] - let view = SelectionView(frame: window.contentView!.bounds, scaleFactor: 2, - onSelect: { results.append($0) }, onCancel: { Issue.record("不应取消。") }) + let view = SelectionView( + frame: window.contentView!.bounds, scaleFactor: 2, + onSelect: { results.append($0) }, onCancel: { Issue.record("不应取消。") }) window.contentView = view #expect(view.acceptsFirstMouse(for: nil)) try drag(view, from: CGPoint(x: 500, y: 400), to: CGPoint(x: 100, y: 120)) @@ -66,12 +79,17 @@ struct RegionOverlayTests { let buttonRect = view.confirmButton.convert(view.confirmButton.bounds, to: view) #expect(view.bounds.contains(buttonRect)) #expect(!view.confirmButton.isHidden && view.confirmButton.isEnabled) - #expect(view.hitTest(CGPoint(x: buttonRect.midX, y: buttonRect.midY)) === view.confirmButton) + #expect( + view.hitTest(CGPoint(x: buttonRect.midX, y: buttonRect.midY)) === view.confirmButton) let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) let scale = CGFloat(bitmap.pixelsWide) / view.bounds.width - let edge = try #require(bitmap.colorAt(x: Int(rect.minX * scale), y: Int((view.bounds.height - rect.midY) * scale))) - let inside = try #require(bitmap.colorAt(x: Int(rect.midX * scale), y: Int((view.bounds.height - rect.midY) * scale))) + let edge = try #require( + bitmap.colorAt( + x: Int(rect.minX * scale), y: Int((view.bounds.height - rect.midY) * scale))) + let inside = try #require( + bitmap.colorAt( + x: Int(rect.midX * scale), y: Int((view.bounds.height - rect.midY) * scale))) let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(250 * scale))) #expect(edge.alphaComponent > 0.9) #expect(inside.alphaComponent < 0.05) @@ -88,8 +106,9 @@ struct RegionOverlayTests { let window = window() defer { window.close() } var cancelled = 0 - let view = SelectionView(frame: window.contentView!.bounds, scaleFactor: 1, - onSelect: { _ in Issue.record("取消不得产图。") }, onCancel: { cancelled += 1 }) + let view = SelectionView( + frame: window.contentView!.bounds, scaleFactor: 1, + onSelect: { _ in Issue.record("取消不得产图。") }, onCancel: { cancelled += 1 }) window.contentView = view try drag(view, from: CGPoint(x: 200, y: 200), to: CGPoint(x: 201, y: 201)) #expect(view.selection == nil) @@ -98,27 +117,39 @@ struct RegionOverlayTests { try drag(view, from: CGPoint(x: 40, y: 40), to: CGPoint(x: 300, y: 260)) #expect(view.confirmButton.isEnabled) if escape { - let key = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, - windowNumber: window.windowNumber, context: nil, - characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, keyCode: 53)) + let key = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, + characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, keyCode: 53)) view.keyDown(with: key) - } else { view.cancelButton.performClick(nil) } + } else { + view.cancelButton.performClick(nil) + } view.cancelSelection() #expect(cancelled == 1) } } - @Test func stopClosesActualOverlayAndDiscardsPendingResultWithoutPresentingWindows() async throws { + @Test func stopClosesActualOverlayAndDiscardsPendingResultWithoutPresentingWindows() + async throws + { _ = NSApplication.shared let screen = try #require(NSScreen.screens.first) - let number = try #require(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) - let frame = FrozenScreenFrame(displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), - image: try pixels(width: 8, height: 6)) + let number = try #require( + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + let frame = FrozenScreenFrame( + displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), + image: try pixels(width: 8, height: 6)) var windows: [NSWindow] = [] let closing = WindowCloseObserver() - let overlay = RegionSelectionOverlay(present: { windows = $0; windows.forEach { $0.delegate = closing } }) - let capture = RegionCapture(hasPermission: { true }, freezeScreens: { [frame] }, overlay: overlay) + let overlay = RegionSelectionOverlay(present: { + windows = $0 + for window in windows { window.delegate = closing } + }) + let capture = RegionCapture( + hasPermission: { true }, freezeScreens: { [frame] }, overlay: overlay) var callbacks = 0 capture.begin { _ in callbacks += 1 } for _ in 0..<100 where windows.isEmpty { await Task.yield() } @@ -136,9 +167,11 @@ struct RegionOverlayTests { @Test func confirmedRegionReturnsOnlyFrozenPngThroughCaptureCoordinator() async throws { _ = NSApplication.shared let screen = try #require(NSScreen.screens.first) - let number = try #require(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) - let frame = FrozenScreenFrame(displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), - image: try pixels(width: 8, height: 6)) + let number = try #require( + screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber) + let frame = FrozenScreenFrame( + displayID: number.uint32Value, pointsRect: CGDisplayBounds(number.uint32Value), + image: try pixels(width: 8, height: 6)) var freezes = 0 let overlay = RegionSelectionOverlay(present: { windows in let view = windows[0].contentView!.subviews[0] as! SelectionView @@ -146,16 +179,23 @@ struct RegionOverlayTests { view.endDrag(at: CGPoint(x: screen.frame.width * 3 / 4, y: screen.frame.height * 3 / 4)) view.confirmButton.performClick(nil) }) - let capture = RegionCapture(hasPermission: { true }, freezeScreens: { freezes += 1; return [frame] }, overlay: overlay) + let capture = RegionCapture( + hasPermission: { true }, + freezeScreens: { + freezes += 1 + return [frame] + }, overlay: overlay) let result: Result = await withCheckedContinuation { continuation in capture.begin { continuation.resume(returning: $0) } } let data = try #require(try result.get()) let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) - let expected = try frame.croppedImage(in: CGRect(x: frame.pointsRect.minX + frame.pointsRect.width / 4, - y: frame.pointsRect.minY + frame.pointsRect.height / 4, - width: frame.pointsRect.width / 2, height: frame.pointsRect.height / 2)) + let expected = try frame.croppedImage( + in: CGRect( + x: frame.pointsRect.minX + frame.pointsRect.width / 4, + y: frame.pointsRect.minY + frame.pointsRect.height / 4, + width: frame.pointsRect.width / 2, height: frame.pointsRect.height / 2)) #expect(rgba(image) == rgba(expected)) #expect(freezes == 1) #expect(!capture.isCapturing) @@ -171,39 +211,59 @@ struct RegionOverlayTests { NSColor(calibratedRed: 0.91, green: 0.93, blue: 0.96, alpha: 1).setFill() NSBezierPath(rect: CGRect(origin: .zero, size: image.size)).fill() NSColor.white.setFill() - NSBezierPath(roundedRect: CGRect(x: 80, y: 80, width: 640, height: 360), xRadius: 12, yRadius: 12).fill() - ("InKCre · 合成截图验收" as NSString).draw(at: CGPoint(x: 120, y: 365), withAttributes: [ - .font: NSFont.systemFont(ofSize: 26, weight: .semibold), .foregroundColor: NSColor.black]) + NSBezierPath( + roundedRect: CGRect(x: 80, y: 80, width: 640, height: 360), xRadius: 12, yRadius: 12 + ).fill() + ("InKCre · 合成截图验收" as NSString).draw( + at: CGPoint(x: 120, y: 365), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 26, weight: .semibold), + .foregroundColor: NSColor.black, + ]) ("选中眼前的信息,随手收入。\n\n框选后仍可移动、调整大小,再确认保存。\n这段文字与背景均为测试生成的合成内容。" as NSString).draw( - in: CGRect(x: 120, y: 170, width: 550, height: 160), withAttributes: [ - .font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.darkGray]) + in: CGRect(x: 120, y: 170, width: 550, height: 160), + withAttributes: [ + .font: NSFont.systemFont(ofSize: 18), .foregroundColor: NSColor.darkGray, + ]) image.unlockFocus() - let backdrop = FrozenBackdropView(image: try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)), - frame: window.contentView!.bounds) - let view = SelectionView(frame: backdrop.bounds, scaleFactor: 2, onSelect: { _ in }, onCancel: {}) + let backdrop = FrozenBackdropView( + image: try #require(image.cgImage(forProposedRect: nil, context: nil, hints: nil)), + frame: window.contentView!.bounds) + let view = SelectionView( + frame: backdrop.bounds, scaleFactor: 2, onSelect: { _ in }, onCancel: {}) backdrop.addSubview(view) window.contentView = backdrop for state in ["ready", "selected", "edge"] { - if state == "selected" { try drag(view, from: CGPoint(x: 100, y: 130), to: CGPoint(x: 690, y: 415)) } - if state == "edge" { try drag(view, from: CGPoint(x: 740, y: 10), to: CGPoint(x: 790, y: 70)) } + if state == "selected" { + try drag(view, from: CGPoint(x: 100, y: 130), to: CGPoint(x: 690, y: 415)) + } + if state == "edge" { + try drag(view, from: CGPoint(x: 740, y: 10), to: CGPoint(x: 790, y: 70)) + } backdrop.layoutSubtreeIfNeeded() let button = state == "ready" ? view.cancelButton : view.confirmButton let rect = button.convert(button.bounds, to: view) #expect(rect.width > 35 && rect.height > 15) #expect(view.bounds.contains(rect)) #expect(view.hitTest(CGPoint(x: rect.midX, y: rect.midY)) === button) - let bitmap = try #require(backdrop.bitmapImageRepForCachingDisplay(in: backdrop.bounds)) + let bitmap = try #require( + backdrop.bitmapImageRepForCachingDisplay(in: backdrop.bounds)) backdrop.cacheDisplay(in: backdrop.bounds, to: bitmap) let scale = CGFloat(bitmap.pixelsWide) / backdrop.bounds.width - let outside = try #require(bitmap.colorAt(x: Int(40 * scale), y: Int(260 * scale))?.usingColorSpace(.deviceRGB)) + let outside = try #require( + bitmap.colorAt(x: Int(40 * scale), y: Int(260 * scale))?.usingColorSpace( + .deviceRGB)) #expect(outside.redComponent > 0.5 && outside.redComponent < 0.75) if state == "selected" { - let inside = try #require(bitmap.colorAt(x: Int(670 * scale), y: Int(370 * scale))?.usingColorSpace(.deviceRGB)) + let inside = try #require( + bitmap.colorAt(x: Int(670 * scale), y: Int(370 * scale))?.usingColorSpace( + .deviceRGB)) #expect(inside.redComponent > 0.95 && inside.alphaComponent > 0.95) } if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { let file = "capture-\(name.rawValue)-\(state).png" - try #require(bitmap.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path).appendingPathComponent(file)) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: URL(fileURLWithPath: path).appendingPathComponent(file)) } } } @@ -211,7 +271,8 @@ struct RegionOverlayTests { @Test func frozenCropUsesOriginalPixelsAtEachDisplayScaleAndGlobalOrigin() throws { let image = try pixels(width: 8, height: 6) - let frame = FrozenScreenFrame(displayID: 2, pointsRect: CGRect(x: -4, y: -3, width: 4, height: 3), image: image) + let frame = FrozenScreenFrame( + displayID: 2, pointsRect: CGRect(x: -4, y: -3, width: 4, height: 3), image: image) let region = CGRect(x: -3, y: -2.5, width: 2, height: 1.5) let cropped = try frame.croppedImage(in: region) #expect(cropped.width == 4 && cropped.height == 3) @@ -223,43 +284,58 @@ struct RegionOverlayTests { #expect(bytes[i + 1] == UInt8((y + 1) * 30)) } } - let source = try #require(CGImageSourceCreateWithData(try frame.png(in: region) as CFData, nil)) + let source = try #require( + CGImageSourceCreateWithData(try frame.png(in: region) as CFData, nil)) let png = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) #expect(rgba(png) == bytes) - #expect(RegionGeometry.pointsRect(global: CGRect(x: -3, y: 101, width: 2, height: 1.5), primaryHeight: 100) == region) - let single = FrozenScreenFrame(displayID: 3, pointsRect: CGRect(x: 100, y: 50, width: 8, height: 6), image: image) + #expect( + RegionGeometry.pointsRect( + global: CGRect(x: -3, y: 101, width: 2, height: 1.5), primaryHeight: 100) == region) + let single = FrozenScreenFrame( + displayID: 3, pointsRect: CGRect(x: 100, y: 50, width: 8, height: 6), image: image) let singleCrop = try single.croppedImage(in: CGRect(x: 102, y: 51, width: 4, height: 3)) #expect(singleCrop.width == 4 && singleCrop.height == 3) #expect(throws: (any Error).self) { try frame.croppedImage(in: .zero) } - #expect(throws: (any Error).self) { try frame.croppedImage(in: CGRect(x: -5, y: -3, width: 2, height: 2)) } + #expect(throws: (any Error).self) { + try frame.croppedImage(in: CGRect(x: -5, y: -3, width: 2, height: 2)) + } } @Test func resizingAcrossOppositeEdgeAndMovingStaysWithinDisplay() { let selection = CGRect(x: 100, y: 100, width: 200, height: 100) let screen = CGRect(x: 0, y: 0, width: 1000, height: 800) - #expect(RegionAdjustment.apply(.left, delta: CGSize(width: 250, height: 0), to: selection, within: screen) + #expect( + RegionAdjustment.apply( + .left, delta: CGSize(width: 250, height: 0), to: selection, within: screen) == CGRect(x: 300, y: 100, width: 50, height: 100)) - #expect(RegionAdjustment.apply(.move, delta: CGSize(width: 900, height: -10), to: selection, within: screen) + #expect( + RegionAdjustment.apply( + .move, delta: CGSize(width: 900, height: -10), to: selection, within: screen) == CGRect(x: 800, y: 90, width: 200, height: 100)) } private func pixels(width: Int, height: Int) throws -> CGImage { var bytes = [UInt8]() - for y in 0.. [UInt8] { var bytes = [UInt8](repeating: 0, count: image.width * image.height * 4) bytes.withUnsafeMutableBytes { buffer in - let context = CGContext(data: buffer.baseAddress, width: image.width, height: image.height, - bitsPerComponent: 8, bytesPerRow: image.width * 4, - space: CGColorSpace(name: CGColorSpace.sRGB)!, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let context = CGContext( + data: buffer.baseAddress, width: image.width, height: image.height, + bitsPerComponent: 8, bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) } return bytes diff --git a/Tests/CaptureMacTests/ShortcutTests.swift b/Tests/CaptureMacTests/ShortcutTests.swift index 4db4782..39ca299 100644 --- a/Tests/CaptureMacTests/ShortcutTests.swift +++ b/Tests/CaptureMacTests/ShortcutTests.swift @@ -1,8 +1,9 @@ import AppKit -import Carbon.HIToolbox import Capture +import Carbon.HIToolbox import PeerCore import Testing + @testable import CaptureMac @Suite(.serialized) @MainActor @@ -12,18 +13,25 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - let window = NSWindow(contentRect: .init(x: 0, y: 0, width: 500, height: 400), - styleMask: [.titled], backing: .buffered, defer: false) - let sheet = NSWindow(contentRect: .init(x: 0, y: 0, width: 200, height: 100), - styleMask: [.titled], backing: .buffered, defer: false) + let window = NSWindow( + contentRect: .init(x: 0, y: 0, width: 500, height: 400), + styleMask: [.titled], backing: .buffered, defer: false) + let sheet = NSWindow( + contentRect: .init(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], backing: .buffered, defer: false) window.beginSheet(sheet) - defer { window.endSheet(sheet); window.orderOut(nil) } + defer { + window.endSheet(sheet) + window.orderOut(nil) + } shortcuts.isCaptureWindow = { $0 === window } var performed = false shortcuts.perform = { _ in performed = true } - let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, - timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: window.windowNumber, context: nil, characters: "\r", + charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) #expect(window.attachedSheet === sheet) #expect(shortcuts.handle(event) === event) #expect(!performed) @@ -39,7 +47,8 @@ struct ShortcutTests { let cg = try #require(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)) let characters = Array(text.utf16) characters.withUnsafeBufferPointer { buffer in - cg.keyboardSetUnicodeString(stringLength: buffer.count, unicodeString: buffer.baseAddress) + cg.keyboardSetUnicodeString( + stringLength: buffer.count, unicodeString: buffer.baseAddress) } let event = try #require(NSEvent(cgEvent: cg)) let original = try #require(event.characters) @@ -48,7 +57,8 @@ struct ShortcutTests { #expect(event.characters == original) var count = 0 var result = [UniChar](repeating: 0, count: 256) - cg.keyboardGetUnicodeString(maxStringLength: result.count, actualStringLength: &count, unicodeString: &result) + cg.keyboardGetUnicodeString( + maxStringLength: result.count, actualStringLength: &count, unicodeString: &result) #expect(String(utf16CodeUnits: result, count: count) == original) } @@ -62,9 +72,11 @@ struct ShortcutTests { var performed = 0 shortcuts.canPerform = { _, _ in enabled } shortcuts.perform = { _ in performed += 1 } - let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command, - timestamp: 0, windowNumber: 0, context: nil, characters: "\r", - charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: .command, + timestamp: 0, windowNumber: 0, context: nil, characters: "\r", + charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: UInt16(kVK_Return))) #expect(shortcuts.handle(event) === event) #expect(performed == 0) enabled = true @@ -75,8 +87,10 @@ struct ShortcutTests { @Test func invalidSavedBindingDoesNotPreventOpeningSettingsOrDisableOtherActions() throws { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } - var saved = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) - saved[.hide] = RecordedShortcut(keyCode: UInt16(kVK_ANSI_W), modifiers: .command, keyLabel: "W") + var saved = Dictionary( + uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) + saved[.hide] = RecordedShortcut( + keyCode: UInt16(kVK_ANSI_W), modifiers: .command, keyLabel: "W") let data = try JSONEncoder().encode(saved) defaults.set(data, forKey: GlobalShortcuts.settingsKey) let shortcuts = try GlobalShortcuts(defaults: defaults) @@ -93,10 +107,12 @@ struct ShortcutTests { for (key, label, modifiers) in [ (kVK_ANSI_W, "W", NSEvent.ModifierFlags.command), (kVK_ANSI_Q, "Q", .command), (kVK_ANSI_Comma, ",", .command), (kVK_ANSI_V, "V", .command), - (kVK_ANSI_S, "S", [.command, .option]) + (kVK_ANSI_S, "S", [.command, .option]), ] { #expect(throws: PeerError.self) { - try shortcuts.set(RecordedShortcut(keyCode: UInt16(key), modifiers: modifiers, keyLabel: label), for: .hide) + try shortcuts.set( + RecordedShortcut(keyCode: UInt16(key), modifiers: modifiers, keyLabel: label), + for: .hide) } } #expect(shortcuts.bindings[.hide] == ShortcutAction.hide.defaultShortcut) @@ -112,7 +128,8 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - let replacement = RecordedShortcut(keyCode: UInt16(kVK_ANSI_K), modifiers: [.control, .option], keyLabel: "K") + let replacement = RecordedShortcut( + keyCode: UInt16(kVK_ANSI_K), modifiers: [.control, .option], keyLabel: "K") try shortcuts.set(replacement, for: .capture) try shortcuts.set(nil, for: .screenshot) let reopened = try GlobalShortcuts(defaults: defaults) @@ -128,9 +145,13 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - #expect(throws: PeerError.self) { try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) } #expect(throws: PeerError.self) { - try shortcuts.set(RecordedShortcut(keyCode: UInt16(kVK_ANSI_A), modifiers: .shift, keyLabel: "A"), for: .submit) + try shortcuts.set(ShortcutAction.submit.defaultShortcut, for: .capture) + } + #expect(throws: PeerError.self) { + try shortcuts.set( + RecordedShortcut(keyCode: UInt16(kVK_ANSI_A), modifiers: .shift, keyLabel: "A"), + for: .submit) } #expect(shortcuts.bindings[.capture] == ShortcutAction.capture.defaultShortcut) #expect(shortcuts.bindings[.submit] == ShortcutAction.submit.defaultShortcut) @@ -142,23 +163,29 @@ struct ShortcutTests { let (defaults, domain) = defaults() defer { defaults.removePersistentDomain(forName: domain) } let shortcuts = try GlobalShortcuts(defaults: defaults) - let old = RecordedShortcut(keyCode: UInt16(kVK_F17), modifiers: [.control, .option, .command], keyLabel: "F17") - let blocked = RecordedShortcut(keyCode: UInt16(kVK_F18), modifiers: [.control, .option, .command], keyLabel: "F18") + let old = RecordedShortcut( + keyCode: UInt16(kVK_F17), modifiers: [.control, .option, .command], keyLabel: "F17") + let blocked = RecordedShortcut( + keyCode: UInt16(kVK_F18), modifiers: [.control, .option, .command], keyLabel: "F18") try shortcuts.set(old, for: .capture) try shortcuts.set(nil, for: .screenshot) try shortcuts.start() defer { shortcuts.stop() } #expect(shortcuts.message == nil) var occupied: EventHotKeyRef? - #expect(RegisterEventHotKey(UInt32(blocked.keyCode), blocked.carbonModifiers, - EventHotKeyID(signature: 0x54455354, id: 77), GetApplicationEventTarget(), 0, &occupied) == noErr) + #expect( + RegisterEventHotKey( + UInt32(blocked.keyCode), blocked.carbonModifiers, + EventHotKeyID(signature: 0x5445_5354, id: 77), GetApplicationEventTarget(), 0, + &occupied) == noErr) defer { if let occupied { UnregisterEventHotKey(occupied) } } #expect(throws: PeerError.self) { try shortcuts.set(blocked, for: .capture) } #expect(shortcuts.bindings[.capture] == old) #expect(try GlobalShortcuts(defaults: defaults).bindings[.capture] == old) var probe: EventHotKeyRef? - let result = RegisterEventHotKey(UInt32(old.keyCode), old.carbonModifiers, - EventHotKeyID(signature: 0x54455354, id: 78), GetApplicationEventTarget(), 0, &probe) + let result = RegisterEventHotKey( + UInt32(old.keyCode), old.carbonModifiers, + EventHotKeyID(signature: 0x5445_5354, id: 78), GetApplicationEventTarget(), 0, &probe) defer { if let probe { UnregisterEventHotKey(probe) } } #expect(result != noErr) } @@ -170,16 +197,22 @@ struct ShortcutTests { var performed = false shortcuts.perform = { _ in performed = true } shortcuts.beginRecording(.paste) - let event = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .option], - timestamp: 0, windowNumber: 0, context: nil, characters: "p", charactersIgnoringModifiers: "p", - isARepeat: false, keyCode: UInt16(kVK_ANSI_P))) + let event = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [.command, .option], + timestamp: 0, windowNumber: 0, context: nil, characters: "p", + charactersIgnoringModifiers: "p", + isARepeat: false, keyCode: UInt16(kVK_ANSI_P))) #expect(shortcuts.handle(event) == nil) #expect(shortcuts.bindings[.paste]?.keyCode == UInt16(kVK_ANSI_P)) #expect(!performed && shortcuts.recording == nil) shortcuts.beginRecording(.submit) - let escape = try #require(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], - timestamp: 0, windowNumber: 0, context: nil, characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", - isARepeat: false, keyCode: UInt16(kVK_Escape))) + let escape = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: 0, context: nil, characters: "\u{1b}", + charactersIgnoringModifiers: "\u{1b}", + isARepeat: false, keyCode: UInt16(kVK_Escape))) #expect(shortcuts.handle(escape) == nil) #expect(shortcuts.recording == nil) #expect(shortcuts.bindings[.submit] == ShortcutAction.submit.defaultShortcut) diff --git a/Tests/CaptureTests/CaptureContractTests.swift b/Tests/CaptureTests/CaptureContractTests.swift index 9b78d5d..549f09d 100644 --- a/Tests/CaptureTests/CaptureContractTests.swift +++ b/Tests/CaptureTests/CaptureContractTests.swift @@ -10,7 +10,8 @@ import Testing let digest: String } let url = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixtures = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url)) for fixture in fixtures { #expect(try fixture.payload.canonicalDigest() == fixture.digest) @@ -26,7 +27,8 @@ import Testing let digest: String } let url = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1] let data = try CaptureJSON.encode(CaptureCommit(fixture.payload)) #expect(data == (try CaptureJSON.encode(CaptureCommit(fixture.payload)))) @@ -45,7 +47,9 @@ import Testing #expect(blocks[1]["content"] as? String == fixture.payload.materials[0].text) #expect(blocks[4]["content"] as? String == fixture.payload.annotation) #expect( - relations.map { $0["content"] as? String } == ["material:0", "content", "material:1", "annotation"]) + relations.map { $0["content"] as? String } == [ + "material:0", "content", "material:1", "annotation", + ]) #expect(blobs.count == 1 && blobs[0]["block_id"] as? Int == -4) #expect( Data(base64Encoded: try #require(blobs[0]["data_base64"] as? String)) @@ -55,9 +59,11 @@ import Testing @Test func nativeValidationRejectsMismatchedImagesAndCountsUTF8Bytes() throws { struct Fixture: Decodable { let payload: CapturePayload } let url = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) let image = try #require( - CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data) + CaptureJSON.decode([Fixture].self, from: Data(contentsOf: url))[1].payload.materials[1].data + ) #expect(throws: PeerError.self) { try CapturePayload(materials: [Material(image: image, mimeType: "image/jpeg")]).validate() } diff --git a/Tests/CaptureTests/CaptureLifecycleTests.swift b/Tests/CaptureTests/CaptureLifecycleTests.swift index 3e80f6f..f997990 100644 --- a/Tests/CaptureTests/CaptureLifecycleTests.swift +++ b/Tests/CaptureTests/CaptureLifecycleTests.swift @@ -46,7 +46,8 @@ private actor PausedRecovery: CaptureTransport { let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) for text in ["第一份", "第二份"] { _ = try await store.freeze( - CaptureRecord(payload: CapturePayload(materials: [Material(text: text)])), target: target) + CaptureRecord(payload: CapturePayload(materials: [Material(text: text)])), + target: target) } let transport = PausedRecovery() let queue = CaptureQueue(store: store, credentials: credentials, client: transport) diff --git a/Tests/CaptureTests/CaptureStoreTests.swift b/Tests/CaptureTests/CaptureStoreTests.swift index 49e96c8..60f2df7 100644 --- a/Tests/CaptureTests/CaptureStoreTests.swift +++ b/Tests/CaptureTests/CaptureStoreTests.swift @@ -7,7 +7,8 @@ import Testing struct CaptureStoreTests { @Test func deletedDraftIsAbsentAfterRestartAndLateAutosaveCannotRecreateIt() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) @@ -18,7 +19,8 @@ struct CaptureStoreTests { await #expect(throws: PeerError.self) { _ = try await store.freeze( draft, - target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core")) + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core")) } let reopened = try CaptureStore(url: url) #expect(try await reopened.record(draft.id) == nil) @@ -26,10 +28,12 @@ struct CaptureStoreTests { } @Test func cancelledIntentNeverAutomaticallyDispatches() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let target = ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "尚未发送")])) _ = try await store.freeze(draft, target: target) try await store.cancel(draft.id) @@ -48,12 +52,15 @@ struct CaptureStoreTests { } @Test func cancellationKeepsUnknownOutcomeButDeletionIgnoresLateCallbacks() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "在途材料")])) _ = try await store.freeze( - draft, target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + draft, + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") ) _ = try await store.markDispatch(draft.id) try await store.cancel(draft.id) @@ -73,7 +80,8 @@ struct CaptureStoreTests { } @Test func clearingDraftAndContinuingToTypeKeepsTheSameIdentity() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) var draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "清空前")])) @@ -86,7 +94,8 @@ struct CaptureStoreTests { } @Test func failedFreezeRetainsTheSavedDraftAndCannotDispatch() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) @@ -106,7 +115,8 @@ struct CaptureStoreTests { await #expect(throws: PeerError.self) { _ = try await store.freeze( draft, - target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core")) + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core")) } let retained = try #require(try await store.record(draft.id)) #expect(retained.state == .draft) @@ -115,11 +125,13 @@ struct CaptureStoreTests { } @Test func frozenMaterialSurvivesReopeningAndTargetChanges() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) - let target = ConnectionTarget(host: "one.example", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "one.example", database: "inkcre", username: "inkcre_core") let image = try #require( Data( base64Encoded: @@ -129,7 +141,8 @@ struct CaptureStoreTests { payload: CapturePayload( entry: .paste, materials: [ - Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), Material(image: image, mimeType: "image/png"), + Material(text: "原文\n中文 👩🏽‍💻 e\u{301}"), + Material(image: image, mimeType: "image/png"), ], annotation: "自己的想法" )) @@ -146,21 +159,27 @@ struct CaptureStoreTests { #expect(recovered.payload?.materials[1].data == image) #expect(recovered.payload?.annotation == "自己的想法") await #expect(throws: PeerError.self) { try await reopened.saveDraft(draft) } - await #expect(throws: PeerError.self) { _ = try await reopened.freeze(draft, target: target) } + await #expect(throws: PeerError.self) { + _ = try await reopened.freeze(draft, target: target) + } } @Test func confirmationAtomicallyRetainsIdentityAndClearsMaterial() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("capture.sqlite") let store = try CaptureStore(url: url) let draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "一次意图")])) _ = try await store.freeze( - draft, target: ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + draft, + target: ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") ) _ = try await store.markDispatch(draft.id) let result = CaptureResult( - submissionID: draft.id, rootBlockID: 123, committedAt: Date(timeIntervalSince1970: 1_780_000_000), + submissionID: draft.id, rootBlockID: 123, + committedAt: Date(timeIntervalSince1970: 1_780_000_000), availability: .deleted ) try await store.confirm(draft.id, result: result) @@ -173,10 +192,12 @@ struct CaptureStoreTests { } @Test func emptyDraftCannotEraseAnAlreadyFrozenIntention() async throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - let target = ConnectionTarget(host: "example.test", database: "inkcre", username: "inkcre_core") + let target = ConnectionTarget( + host: "example.test", database: "inkcre", username: "inkcre_core") await #expect(throws: PeerError.self) { _ = try await store.freeze(CaptureRecord(), target: target) } diff --git a/Tests/CaptureTests/LivePeerTests.swift b/Tests/CaptureTests/LivePeerTests.swift index 4bff258..f2c872c 100644 --- a/Tests/CaptureTests/LivePeerTests.swift +++ b/Tests/CaptureTests/LivePeerTests.swift @@ -6,131 +6,135 @@ import Testing @Test(.enabled(if: ProcessInfo.processInfo.environment["INKCRE_CAPTURE_TEST_DATABASE"] != nil)) func nativeTransactionCommitsOriginalImageOnceAndRecoversAfterRestart() async throws { - let env = ProcessInfo.processInfo.environment - let target = ConnectionTarget( - host: "127.0.0.1", port: Int(env["INKCRE_CAPTURE_TEST_PORT"] ?? "5432")!, - database: try #require(env["INKCRE_CAPTURE_TEST_DATABASE"]), username: "inkcre_core", - tls: .disable) - let password = try #require(env["INKCRE_CAPTURE_TEST_PASSWORD"]) - let database = PeerDatabase() - let client = CaptureClient(database: database) - try await client.checkConnection(target, secret: password) - let pg = try await database.client(for: target, password: password) - struct Fixture: Decodable { let payload: CapturePayload } - let fixtureURL = try #require( - Bundle.module.url(forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) - let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] - let payload = CapturePayload( - entry: .paste, origin: fixture.payload.origin, materials: fixture.payload.materials, - annotation: fixture.payload.annotation) - var record = CaptureRecord(payload: payload) - record.target = target - let submission = record - async let a = client.submit(submission, secret: password) - async let b = client.submit(submission, secret: password) - async let c = client.submit(submission, secret: password) - let results = try await [a, b, c] - let result = try #require(results.first) - #expect(results.allSatisfy { $0 == result }) - let rows = try await pg.query( - """ - WITH RECURSIVE captured(id) AS (SELECT \(result.rootBlockID)::integer UNION - SELECT to_ FROM inkcre.relations JOIN captured ON from_=captured.id) - SELECT b.resolver,b.content,s.data FROM inkcre.blocks b JOIN captured USING(id) - LEFT JOIN inkcre.storage_blobs s ON s.id=CASE WHEN b.storage=-4 THEN (b.content::jsonb->>'blob_id')::uuid END - """) - var blockCount = 0 - for try await (resolver, content, bytes) in rows.decode((String, String, Data?).self) { - blockCount += 1 - if resolver == "core.image.v1" { #expect(bytes == payload.materials[1].data) } - if resolver == "core.text.v1" { - #expect([payload.materials[0].text, payload.annotation].contains(content)) + let env = ProcessInfo.processInfo.environment + let target = ConnectionTarget( + host: "127.0.0.1", port: Int(env["INKCRE_CAPTURE_TEST_PORT"] ?? "5432")!, + database: try #require(env["INKCRE_CAPTURE_TEST_DATABASE"]), username: "inkcre_core", + tls: .disable) + let password = try #require(env["INKCRE_CAPTURE_TEST_PASSWORD"]) + let database = PeerDatabase() + let client = CaptureClient(database: database) + try await client.checkConnection(target, secret: password) + let pg = try await database.client(for: target, password: password) + struct Fixture: Decodable { let payload: CapturePayload } + let fixtureURL = try #require( + Bundle.module.url( + forResource: "capture-v1", withExtension: "json", subdirectory: "Fixtures")) + let fixture = try CaptureJSON.decode([Fixture].self, from: Data(contentsOf: fixtureURL))[1] + let payload = CapturePayload( + entry: .paste, origin: fixture.payload.origin, materials: fixture.payload.materials, + annotation: fixture.payload.annotation) + var record = CaptureRecord(payload: payload) + record.target = target + let submission = record + async let a = client.submit(submission, secret: password) + async let b = client.submit(submission, secret: password) + async let c = client.submit(submission, secret: password) + let results = try await [a, b, c] + let result = try #require(results.first) + #expect(results.allSatisfy { $0 == result }) + let rows = try await pg.query( + """ + WITH RECURSIVE captured(id) AS (SELECT \(result.rootBlockID)::integer UNION + SELECT to_ FROM inkcre.relations JOIN captured ON from_=captured.id) + SELECT b.resolver,b.content,s.data FROM inkcre.blocks b JOIN captured USING(id) + LEFT JOIN inkcre.storage_blobs s ON s.id=CASE WHEN b.storage=-4 THEN (b.content::jsonb->>'blob_id')::uuid END + """) + var blockCount = 0 + for try await (resolver, content, bytes) in rows.decode((String, String, Data?).self) { + blockCount += 1 + if resolver == "core.image.v1" { #expect(bytes == payload.materials[1].data) } + if resolver == "core.text.v1" { + #expect([payload.materials[0].text, payload.annotation].contains(content)) + } } - } - #expect(blockCount == 5) + #expect(blockCount == 5) - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let service = "dev.inkcre.native.acceptance.\(UUID().uuidString)" - let credentials = CredentialStore(service: service) - try credentials.save(password, for: target.id) - defer { - SecItemDelete( - [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] - as CFDictionary) - try? FileManager.default.removeItem(at: directory) - } - // Commit has happened but the local process has not observed its response. - let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - _ = try await store.freeze(CaptureRecord(payload: payload), target: target) - _ = try await store.markDispatch(payload.submissionID) - let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) - try await CaptureQueue(store: reopened, credentials: credentials, client: client).process( - force: true) - let recovered = try #require(try await reopened.record(payload.submissionID)) - #expect(recovered.state == .confirmed && recovered.payload == nil) - #expect(recovered.result == result) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let service = "dev.inkcre.native.acceptance.\(UUID().uuidString)" + let credentials = CredentialStore(service: service) + try credentials.save(password, for: target.id) + defer { + SecItemDelete( + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service] + as CFDictionary) + try? FileManager.default.removeItem(at: directory) + } + // Commit has happened but the local process has not observed its response. + let store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + _ = try await store.freeze(CaptureRecord(payload: payload), target: target) + _ = try await store.markDispatch(payload.submissionID) + let reopened = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) + try await CaptureQueue(store: reopened, credentials: credentials, client: client).process( + force: true) + let recovered = try #require(try await reopened.record(payload.submissionID)) + #expect(recovered.state == .confirmed && recovered.payload == nil) + #expect(recovered.result == result) - var changed = payload - changed.annotation = "另一份材料" - var conflict = CaptureRecord(payload: changed) - conflict.target = target - await #expect(throws: PeerError.self) { _ = try await client.submit(conflict, secret: password) } + var changed = payload + changed.annotation = "另一份材料" + var conflict = CaptureRecord(payload: changed) + conflict.target = target + await #expect(throws: PeerError.self) { + _ = try await client.submit(conflict, secret: password) + } - let before = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { - _ in true - } - await #expect(throws: (any Error).self) { - try await pg.withTransaction(logger: PeerDatabase.logger) { connection in - _ = try await InfoBase.persist( - blocks: [GraphBlock(id: -1, resolver: "core.text.v1", content: "must rollback")], - relations: [GraphRelation(from_: -1, to_: -999, content: "invalid")], blobs: [:], - on: connection) + let before = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { + _ in true } - } - let after = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { _ in - true - } - #expect(before == after) - try await pg.query("DELETE FROM inkcre.blocks WHERE id=\(result.rootBlockID)") - #expect(try await client.submit(record, secret: password).availability == .deleted) - try await database.setEnabled( - false, target: target, password: password, extension: CaptureClient.descriptor) - do { - _ = try await client.submit(record, secret: password) - Issue.record("停用后不得继续共享写入") - } catch PeerError.needsAction(let message) { #expect(message.contains("停用")) } - await #expect(throws: PeerError.self) { - try await client.checkConnection(target, secret: password) - } - try await pg.query( - "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)") - await #expect(throws: PeerError.self) { + await #expect(throws: (any Error).self) { + try await pg.withTransaction(logger: PeerDatabase.logger) { connection in + _ = try await InfoBase.persist( + blocks: [GraphBlock(id: -1, resolver: "core.text.v1", content: "must rollback")], + relations: [GraphRelation(from_: -1, to_: -999, content: "invalid")], blobs: [:], + on: connection) + } + } + let after = try await pg.query("SELECT count(*) FROM inkcre.blocks").decode(Int.self).first { + _ in + true + } + #expect(before == after) + try await pg.query("DELETE FROM inkcre.blocks WHERE id=\(result.rootBlockID)") + #expect(try await client.submit(record, secret: password).availability == .deleted) + try await database.setEnabled( + false, target: target, password: password, extension: CaptureClient.descriptor) + do { + _ = try await client.submit(record, secret: password) + Issue.record("停用后不得继续共享写入") + } catch PeerError.needsAction(let message) { #expect(message.contains("停用")) } + await #expect(throws: PeerError.self) { + try await client.checkConnection(target, secret: password) + } + try await pg.query( + "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)") + await #expect(throws: PeerError.self) { + try await client.checkConnection(target, secret: password) + } + let removed = try await pg.query( + "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)" + ).decode(Int.self).first { _ in true } + #expect(removed == 0) + // An explicit enable is allowed to create a first participation before Capture starts. + try await database.setEnabled( + true, target: target, password: password, extension: CaptureClient.descriptor) try await client.checkConnection(target, secret: password) - } - let removed = try await pg.query( - "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(target.peerID)" - ).decode(Int.self).first { _ in true } - #expect(removed == 0) - // An explicit enable is allowed to create a first participation before Capture starts. - try await database.setEnabled( - true, target: target, password: password, extension: CaptureClient.descriptor) - try await client.checkConnection(target, secret: password) - let first = ConnectionTarget( - host: target.host, port: target.port, database: target.database, username: target.username, - tls: target.tls) - try await database.setEnabled( - true, target: first, password: password, extension: CaptureClient.descriptor) - try await pg.query( - "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)") - await #expect(throws: PeerError.self) { + let first = ConnectionTarget( + host: target.host, port: target.port, database: target.database, username: target.username, + tls: target.tls) + try await database.setEnabled( + true, target: first, password: password, extension: CaptureClient.descriptor) + try await pg.query( + "DELETE FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)") + await #expect(throws: PeerError.self) { + try await client.checkConnection(first, secret: password) + } + let removedBeforeFirstJoin = try await pg.query( + "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)" + ).decode(Int.self).first { _ in true } + #expect(removedBeforeFirstJoin == 0) + try await database.setEnabled( + true, target: first, password: password, extension: CaptureClient.descriptor) try await client.checkConnection(first, secret: password) - } - let removedBeforeFirstJoin = try await pg.query( - "SELECT count(*) FROM inkcre.extension_peer_participations WHERE peer_id=\(first.peerID)" - ).decode(Int.self).first { _ in true } - #expect(removedBeforeFirstJoin == 0) - try await database.setEnabled( - true, target: first, password: password, extension: CaptureClient.descriptor) - try await client.checkConnection(first, secret: password) - await database.shutdown() + await database.shutdown() } diff --git a/Tests/HostTests/HostInterfaceTests.swift b/Tests/HostTests/HostInterfaceTests.swift index ce9b41b..5eb9811 100644 --- a/Tests/HostTests/HostInterfaceTests.swift +++ b/Tests/HostTests/HostInterfaceTests.swift @@ -1,15 +1,17 @@ import AppKit import PeerCore import SwiftUI -@testable import CaptureMac import Testing + +@testable import CaptureMac @testable import InKCreApp @Suite(.serialized) @MainActor struct HostInterfaceTests { @Test func nativeSidebarShortcutAndSettingsTabsRemainAvailableWithoutCapture() async throws { _ = NSApplication.shared - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) let domain = "dev.inkcre.host-tests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: domain)) defer { @@ -17,18 +19,26 @@ struct HostInterfaceTests { try? FileManager.default.removeItem(at: directory) } defaults.set(false, forKey: "extensions.inkcre/capture.enabled") - let host = PeerHost(context: PeerHostContext(identity: domain, directory: directory), defaults: defaults) + let host = PeerHost( + context: PeerHostContext(identity: domain, directory: directory), defaults: defaults) let previousMenu = NSApp.mainMenu let previousWindowsMenu = NSApp.windowsMenu - defer { NSApp.mainMenu = previousMenu; NSApp.windowsMenu = previousWindowsMenu } + defer { + NSApp.mainMenu = previousMenu + NSApp.windowsMenu = previousWindowsMenu + } host.installHostMenu() let menu = try #require(NSApp.mainMenu) let viewMenu = try #require(menu.items.first(where: { $0.title == "显示" })?.submenu) - let sidebar = try #require(viewMenu.items.first(where: { $0.action == #selector(NSSplitViewController.toggleSidebar(_:)) })) + let sidebar = try #require( + viewMenu.items.first(where: { + $0.action == #selector(NSSplitViewController.toggleSidebar(_:)) + })) #expect(sidebar.keyEquivalent == "s") #expect(sidebar.keyEquivalentModifierMask == [.command, .option]) #expect(sidebar.target == nil) - let settings = try #require(menu.items.first?.submenu?.items.first(where: { $0.keyEquivalent == "," })) + let settings = try #require( + menu.items.first?.submenu?.items.first(where: { $0.keyEquivalent == "," })) #expect(settings.keyEquivalentModifierMask == .command) #expect(host.validateUserInterfaceItem(settings)) let window = PeerSettingsWindow(host: host) @@ -48,14 +58,18 @@ struct HostInterfaceTests { let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: path).appendingPathComponent("settings-\(pane)-\(appearance.rawValue).png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: + URL(fileURLWithPath: path).appendingPathComponent( + "settings-\(pane)-\(appearance.rawValue).png")) } } } - let captureModel = try CaptureModel(directory: directory.appendingPathComponent("capture"), identity: domain) + let captureModel = try CaptureModel( + directory: directory.appendingPathComponent("capture"), identity: domain) defer { captureModel.cancelWork() } - window.replaceCaptureSettings(with: NSHostingController(rootView: CaptureSettingsView(model: captureModel))) + window.replaceCaptureSettings( + with: NSHostingController(rootView: CaptureSettingsView(model: captureModel))) window.select(.capture) for appearance in [NSAppearance.Name.aqua, .darkAqua] { window.appearance = NSAppearance(named: appearance) @@ -66,8 +80,10 @@ struct HostInterfaceTests { let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds)) view.cacheDisplay(in: view.bounds, to: bitmap) if let path = ProcessInfo.processInfo.environment["INKCRE_LAYOUT_EVIDENCE_DIR"] { - try #require(bitmap.representation(using: .png, properties: [:])).write(to: - URL(fileURLWithPath: path).appendingPathComponent("settings-capture-enabled-\(appearance.rawValue).png")) + try #require(bitmap.representation(using: .png, properties: [:])).write( + to: + URL(fileURLWithPath: path).appendingPathComponent( + "settings-capture-enabled-\(appearance.rawValue).png")) } } #expect(!window.isVisible) diff --git a/extensions/capture/Package.swift b/extensions/capture/Package.swift index 0df7def..a2afa13 100644 --- a/extensions/capture/Package.swift +++ b/extensions/capture/Package.swift @@ -11,7 +11,8 @@ let package = Package( targets: [ .systemLibrary(name: "CSQLite"), .target( - name: "Capture", dependencies: ["CSQLite", .product(name: "PeerCore", package: "PeerCore")], + name: "Capture", + dependencies: ["CSQLite", .product(name: "PeerCore", package: "PeerCore")], resources: [.copy("Resources/extension.json")]), .target( name: "CaptureMac", diff --git a/extensions/capture/Sources/Capture/CaptureClient.swift b/extensions/capture/Sources/Capture/CaptureClient.swift index c191340..fc9cf54 100644 --- a/extensions/capture/Sources/Capture/CaptureClient.swift +++ b/extensions/capture/Sources/Capture/CaptureClient.swift @@ -12,7 +12,9 @@ public struct CaptureClient: CaptureTransport { public static let descriptor: NativeExtensionDescriptor = { let bundle: Bundle if Bundle.main.bundleURL.pathExtension == "app" { - guard let url = Bundle.main.url(forResource: "InKCreCapture_Capture", withExtension: "bundle"), + guard + let url = Bundle.main.url( + forResource: "InKCreCapture_Capture", withExtension: "bundle"), let packaged = Bundle(url: url) else { preconditionFailure("Missing packaged Capture distribution") @@ -38,7 +40,8 @@ public struct CaptureClient: CaptureTransport { try await database.join(target, password: secret, extension: Self.descriptor) let client = try await database.client(for: target, password: secret) _ = try await client.query( - "SELECT submission_id,payload_digest,graph_digest FROM inkcre.capture_receipts LIMIT 0") + "SELECT submission_id,payload_digest,graph_digest FROM inkcre.capture_receipts LIMIT 0" + ) } catch { throw PeerDatabase.failure(error, dispatched: false) } } @@ -48,7 +51,8 @@ public struct CaptureClient: CaptureTransport { } do { let client = try await database.client(for: target, password: secret) - return try await Self.receipt(record.id, digest: payload.canonicalDigest(), client: client) + return try await Self.receipt( + record.id, digest: payload.canonicalDigest(), client: client) } catch { throw PeerDatabase.failure(error, dispatched: false) } } @@ -79,7 +83,8 @@ public struct CaptureClient: CaptureTransport { }.joined() do { let client = try await database.client(for: target, password: secret) - let result = try await client.withTransaction(logger: PeerDatabase.logger) { connection in + let result = try await client.withTransaction(logger: PeerDatabase.logger) { + connection in try await PeerDatabase.requireSelection( Self.descriptor, peerID: target.peerID, on: connection) // The UUID lock covers receipt lookup, graph writes and the receipt itself. @@ -104,10 +109,14 @@ public struct CaptureClient: CaptureTransport { let ids = try await InfoBase.persist( blocks: command.graph.blocks, relations: command.graph.relations, blobs: Dictionary( - uniqueKeysWithValues: command.graph.blobs.map { ($0.block_id, $0.data_base64) }), + uniqueKeysWithValues: command.graph.blobs.map { + ($0.block_id, $0.data_base64) + }), on: connection ) - guard let root = ids[command.graph.root_local_id] else { throw PeerError.invalid("采集图缺少根。") } + guard let root = ids[command.graph.root_local_id] else { + throw PeerError.invalid("采集图缺少根。") + } let inserted = try await connection.query( """ INSERT INTO inkcre.capture_receipts(submission_id,payload_digest,graph_digest,root_block_id) diff --git a/extensions/capture/Sources/Capture/CaptureDigest.swift b/extensions/capture/Sources/Capture/CaptureDigest.swift index 1ae1660..13af159 100644 --- a/extensions/capture/Sources/Capture/CaptureDigest.swift +++ b/extensions/capture/Sources/Capture/CaptureDigest.swift @@ -29,7 +29,8 @@ extension CapturePayload { throw PeerError.invalid("图片材料格式不正确。") } return [ - "kind": "image", "mime_type": mime, "filename": material.filename as Any? ?? null, + "kind": "image", "mime_type": mime, + "filename": material.filename as Any? ?? null, "data_sha256": Self.sha256(data), ] } diff --git a/extensions/capture/Sources/Capture/CaptureGraph.swift b/extensions/capture/Sources/Capture/CaptureGraph.swift index 5284de8..89bb406 100644 --- a/extensions/capture/Sources/Capture/CaptureGraph.swift +++ b/extensions/capture/Sources/Capture/CaptureGraph.swift @@ -2,6 +2,8 @@ import Foundation import PeerCore /// Capture owns this graph; the Host supplies transaction and storage mechanics. +// Capture 的序列化字段遵循跨端图协议。 +// swift-format-ignore: AlwaysUseLowerCamelCase struct CaptureCommit: Encodable, Sendable { struct Blob: Encodable, Sendable { let block_id: Int @@ -37,7 +39,9 @@ struct CaptureCommit: Encodable, Sendable { GraphBlock( id: -1, resolver: "extensions.capture.capture.v1", content: try json( - Metadata(captured_at: payload.capturedAt, entry: payload.entry, origin: payload.origin)) + Metadata( + captured_at: payload.capturedAt, entry: payload.entry, + origin: payload.origin)) ) ]) for (index, material) in payload.materials.enumerated() { diff --git a/extensions/capture/Sources/Capture/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift index b2aba93..de7b55f 100644 --- a/extensions/capture/Sources/Capture/CaptureQueue.swift +++ b/extensions/capture/Sources/Capture/CaptureQueue.swift @@ -10,7 +10,8 @@ public actor CaptureQueue { private var idleWaiters: [CheckedContinuation] = [] public init( - store: CaptureStore, credentials: CredentialStore, client: any CaptureTransport = CaptureClient() + store: CaptureStore, credentials: CredentialStore, + client: any CaptureTransport = CaptureClient() ) { self.store = store self.credentials = credentials @@ -34,7 +35,9 @@ public actor CaptureQueue { for item in try await store.records() where [.pending, .unknown].contains(item.state) { guard !isStopped, !Task.isCancelled else { return } let delay = min(300.0, pow(2.0, Double(min(item.attempts, 8))) * 2) - if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { continue } + if !force && item.attempts > 0 && Date().timeIntervalSince(item.updatedAt) < delay { + continue + } guard let target = item.target else { continue } do { let secret = try credentials.read(for: target.id) diff --git a/extensions/capture/Sources/Capture/CaptureStore.swift b/extensions/capture/Sources/Capture/CaptureStore.swift index 5c71ffb..1980775 100644 --- a/extensions/capture/Sources/Capture/CaptureStore.swift +++ b/extensions/capture/Sources/Capture/CaptureStore.swift @@ -9,7 +9,8 @@ private final class SQLiteConnection: @unchecked Sendable { init(url: URL) throws { var database: OpaquePointer? let status = sqlite3_open_v2( - url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil + url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, + nil ) guard status == SQLITE_OK, let database else { if let database { sqlite3_close(database) } @@ -213,7 +214,7 @@ public actor CaptureStore { public func updateWebAddress(_ id: UUID, address: String) throws -> CaptureRecord { guard var record = try record(id), record.state == .confirmed, var target = record.target, - let url = URL(string: address.trimmingCharacters(in: .whitespacesAndNewlines)) + let url = URL(string: address.trimmingCharacters(in: .whitespacesAndNewlines)) else { throw PeerError.invalid("请输入有效的 Web 地址,且仅为已收入记录配置地址。") } // The display address can change; the frozen database destination and receipt cannot. target.webURL = url @@ -240,7 +241,8 @@ public actor CaptureStore { private func save(_ record: CaptureRecord) throws { let encoded = try CaptureJSON.encode(record) - try database.statement("INSERT OR REPLACE INTO records(id,body) VALUES (?,?)") { statement in + try database.statement("INSERT OR REPLACE INTO records(id,body) VALUES (?,?)") { + statement in sqlite3_bind_text(statement, 1, record.id.uuidString, -1, transient) let result = encoded.withUnsafeBytes { bytes in sqlite3_bind_blob(statement, 2, bytes.baseAddress, Int32(bytes.count), transient) diff --git a/extensions/capture/Sources/Capture/Models.swift b/extensions/capture/Sources/Capture/Models.swift index 2612afa..26ca56d 100644 --- a/extensions/capture/Sources/Capture/Models.swift +++ b/extensions/capture/Sources/Capture/Models.swift @@ -91,7 +91,9 @@ public struct CapturePayload: Codable, Sendable { throw PeerError.invalid("先写下想法,或加入一份材料。") } guard materials.count <= 128 else { throw PeerError.invalid("材料数量超过 128 项。") } - for (value, limit) in [(origin?.applicationID, 1024), (origin?.title, 4096), (origin?.url, 8192)] { + for (value, limit) in [ + (origin?.applicationID, 1024), (origin?.title, 4096), (origin?.url, 8192), + ] { guard (value?.unicodeScalars.count ?? 0) <= limit else { throw PeerError.invalid("来源信息过长,请缩短后收入。") } @@ -108,7 +110,8 @@ public struct CapturePayload: Codable, Sendable { } case .image: guard let bytes = material.data, !bytes.isEmpty, - ["image/png", "image/jpeg", "image/gif", "image/webp"].contains(material.mimeType), + ["image/png", "image/jpeg", "image/gif", "image/webp"].contains( + material.mimeType), material.text == nil else { throw PeerError.invalid("图片为空或格式不支持。") @@ -119,7 +122,8 @@ public struct CapturePayload: Codable, Sendable { CGImageSourceGetStatus(source) == .statusComplete, let identifier = CGImageSourceGetType(source) as String?, UTType(identifier)?.preferredMIMEType == material.mimeType, - let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) + as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int, width > 0, height > 0, width <= 89_478_485 / height, @@ -140,7 +144,8 @@ public struct CaptureResult: Codable, Sendable, Equatable { public let committedAt: Date public let availability: Availability - public init(submissionID: UUID, rootBlockID: Int, committedAt: Date, availability: Availability) { + public init(submissionID: UUID, rootBlockID: Int, committedAt: Date, availability: Availability) + { self.submissionID = submissionID self.rootBlockID = rootBlockID let formatter = ISO8601DateFormatter() diff --git a/extensions/capture/Sources/CaptureMac/BrandAssets.swift b/extensions/capture/Sources/CaptureMac/BrandAssets.swift index 1a3f3c4..44b0dd7 100644 --- a/extensions/capture/Sources/CaptureMac/BrandAssets.swift +++ b/extensions/capture/Sources/CaptureMac/BrandAssets.swift @@ -14,16 +14,21 @@ enum BrandAssets { private static let resources: Bundle = { // SwiftPM's generated accessor searches beside the executable; a macOS app packages resources in Contents/Resources. if Bundle.main.bundleURL.pathExtension == "app" { - guard let url = Bundle.main.url(forResource: "InKCreCapture_CaptureMac", withExtension: "bundle"), - let bundle = Bundle(url: url) else { preconditionFailure("Missing packaged InKCre resources") } + guard + let url = Bundle.main.url( + forResource: "InKCreCapture_CaptureMac", withExtension: "bundle"), + let bundle = Bundle(url: url) + else { preconditionFailure("Missing packaged InKCre resources") } return bundle } return Bundle.module }() private static func image(_ name: String, size: CGFloat) -> NSImage { - guard let url = resources.url(forResource: name, withExtension: "png", subdirectory: "Brand"), - let image = NSImage(contentsOf: url) else { preconditionFailure("Missing brand resource: \(name)") } + guard + let url = resources.url(forResource: name, withExtension: "png", subdirectory: "Brand"), + let image = NSImage(contentsOf: url) + else { preconditionFailure("Missing brand resource: \(name)") } image.size = NSSize(width: size, height: size) return image } diff --git a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift index 09ae65b..7de0bf0 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift @@ -53,17 +53,29 @@ struct CaptureEditor: View { ScrollView { VStack(alignment: .leading, spacing: 12) { Text("本次材料").font(.subheadline).foregroundStyle(.secondary) - ForEach(Array((model.draft.payload?.materials ?? []).enumerated()), id: \.element.id) { index, material in - if !isManual || material.id != model.draft.payload?.materials.first(where: { $0.kind == .text })?.id { + ForEach( + Array((model.draft.payload?.materials ?? []).enumerated()), + id: \.element.id + ) { index, material in + if !isManual + || material.id + != model.draft.payload?.materials.first(where: { + $0.kind == .text + })?.id + { VStack(alignment: .leading, spacing: 6) { HStack { - Text("材料 \(index + 1)").font(.caption).foregroundStyle(.secondary) + Text("材料 \(index + 1)").font(.caption).foregroundStyle( + .secondary) Spacer() - Button("移除", systemImage: "xmark") { model.requestRemoveMaterial(material.id) } - .accessibilityLabel("移除第 \(index + 1) 项材料") + Button("移除", systemImage: "xmark") { + model.requestRemoveMaterial(material.id) + } + .accessibilityLabel("移除第 \(index + 1) 项材料") } if material.kind == .text { - Text(material.text ?? "").textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) + Text(material.text ?? "").textSelection(.enabled).frame( + maxWidth: .infinity, alignment: .leading) } else { CaptureImagePreview(material: material) } @@ -82,15 +94,19 @@ struct CaptureEditor: View { } if !isManual || showsAnnotation || !model.annotation.isEmpty { DisclosureGroup("补充想法(可选)", isExpanded: $showsAnnotation) { - TextEditor(text: Binding(get: { model.annotation }, set: { model.annotation = $0 })) - .focused($focusedField, equals: .annotation) - .frame(height: 70).accessibilityLabel("用户附注,与原文分别保存") + TextEditor( + text: Binding(get: { model.annotation }, set: { model.annotation = $0 }) + ) + .focused($focusedField, equals: .annotation) + .frame(height: 70).accessibilityLabel("用户附注,与原文分别保存") } } Divider() if let record = model.submittedRecord { HStack { - Label("上次采集:\(record.statusLabel)", systemImage: record.state == .confirmed ? "checkmark.circle" : "tray") + Label( + "上次采集:\(record.statusLabel)", + systemImage: record.state == .confirmed ? "checkmark.circle" : "tray") Spacer() Button("查看记录") { model.showRecord(record) } }.font(.callout) @@ -109,8 +125,10 @@ struct CaptureEditor: View { if isQuickCapture { Button("取消本次") { model.cancelCapture() } } else { - Button("删除草稿", systemImage: "trash", role: .destructive) { model.requestDiscard(model.draft) } - .disabled(!model.hasContent) + Button("删除草稿", systemImage: "trash", role: .destructive) { + model.requestDiscard(model.draft) + } + .disabled(!model.hasContent) } Spacer() Button("导出材料") { model.export(model.draft) }.disabled(!model.hasContent) @@ -118,21 +136,31 @@ struct CaptureEditor: View { .buttonStyle(.borderedProminent).disabled(!model.hasContent || model.submitting) } }.padding().disabled(model.submitting) - .onAppear { showsAnnotation = !model.annotation.isEmpty; focusEditor() } - .onChange(of: model.draft.id) { showsAnnotation = !model.annotation.isEmpty; focusEditor() } - .onChange(of: showsAnnotation) { _, expanded in if expanded { focusedField = .annotation } } + .onAppear { + showsAnnotation = !model.annotation.isEmpty + focusEditor() + } + .onChange(of: model.draft.id) { + showsAnnotation = !model.annotation.isEmpty + focusEditor() + } + .onChange(of: showsAnnotation) { _, expanded in + if expanded { focusedField = .annotation } + } .onChange(of: model.submitting) { _, busy in if !busy { focusEditor() } } } private var standaloneImage: Capture.Material? { guard let payload = model.draft.payload, payload.materials.count == 1, - payload.materials[0].kind == .image, payload.origin == nil else { return nil } + payload.materials[0].kind == .image, payload.origin == nil + else { return nil } return payload.materials[0] } private var additionalMaterials: Bool { let materials = model.draft.payload?.materials ?? [] - return materials.contains { $0.kind == .image } || materials.filter { $0.kind == .text }.count > 1 + return materials.contains { $0.kind == .image } + || materials.filter { $0.kind == .text }.count > 1 } private func focusEditor() { diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift index e6c9b33..0243926 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift @@ -14,7 +14,9 @@ final class CapturePanel: NSPanel { } @MainActor -public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate, NSUserInterfaceValidations { +public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate, + NSUserInterfaceValidations +{ public let descriptor = CaptureClient.descriptor private let context: PeerHostContext public init(context: PeerHostContext) { @@ -62,9 +64,11 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { guard canChangeCaptureContext, let model, !model.submitting, - model.shortcuts.recording == nil else { return false } + model.shortcuts.recording == nil + else { return false } if item.action == #selector(performShortcut(_:)), let item = item as? NSMenuItem, - let action = ShortcutAction(rawValue: UInt32(item.tag)) { + let action = ShortcutAction(rawValue: UInt32(item.tag)) + { return canPerform(action, in: NSApp.keyWindow) } return model.error == nil && model.removalRequest == nil @@ -106,7 +110,9 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.dismissQuickCapture = { [weak self] in self?.hide() } model.showMain = { [weak self] in self?.showMain() } model.showQuick = { [weak self] in self?.showQuick() } - model.editConnection = { [weak self] target, completion in self?.editConnection?(target, completion) } + model.editConnection = { [weak self] target, completion in + self?.editConnection?(target, completion) + } model.takeScreenshot = { [weak self] in self?.screenshot() } model.shortcuts.isCaptureWindow = { [weak panel, weak mainWindow] window in window === panel || window === mainWindow @@ -157,22 +163,27 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } menu.addItem(.separator()) menu.addItem( - withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + withTitle: "退出 InKCre", action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q") item.menu = menu statusItem = item let commands = NSMenuItem(title: "采集", action: nil, keyEquivalent: "") let capture = NSMenu(title: "采集") - capture.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n").target = self + capture.addItem(withTitle: "新想法", action: #selector(newThought), keyEquivalent: "n") + .target = self for action in ShortcutAction.allCases { - let item = NSMenuItem(title: action.title, action: #selector(performShortcut(_:)), keyEquivalent: "") + let item = NSMenuItem( + title: action.title, action: #selector(performShortcut(_:)), keyEquivalent: "") item.target = self item.tag = Int(action.rawValue) capture.addItem(item) } capture.addItem(.separator()) - capture.addItem(withTitle: "采集记录", action: #selector(showQueue), keyEquivalent: "").target = self - capture.addItem(withTitle: "采集设置…", action: #selector(showSettings), keyEquivalent: "").target = self + capture.addItem(withTitle: "采集记录", action: #selector(showQueue), keyEquivalent: "").target = + self + capture.addItem(withTitle: "采集设置…", action: #selector(showSettings), keyEquivalent: "") + .target = self commands.submenu = capture NSApp.mainMenu?.insertItem(commands, at: 1) commandItem = commands @@ -186,9 +197,12 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate } private func canPerform(_ action: ShortcutAction, in window: NSWindow?) -> Bool { - guard canChangeCaptureContext, let model, model.shortcuts.recording == nil else { return false } + guard canChangeCaptureContext, let model, model.shortcuts.recording == nil else { + return false + } let quick = window === panel && model.editorOwner == .quick - let editor = quick || (window === mainWindow && model.pane == .capture && model.editorOwner == .main) + let editor = + quick || (window === mainWindow && model.pane == .capture && model.editorOwner == .main) return model.canPerform(action, isEditor: editor, isQuickPanel: quick) } @@ -217,7 +231,9 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate let bundleID = application.bundleIdentifier selectionTask?.cancel() selectionTask = Task { - let snapshot = await Task.detached { SelectedText.read(pid: pid, applicationID: bundleID) }.value + let snapshot = await Task.detached { + SelectedText.read(pid: pid, applicationID: bundleID) + }.value guard model.selectionRequest == request, NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } @@ -233,14 +249,17 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate else { return } if let text { guard - await Task.detached(operation: { SelectedText.isCurrent(pid: pid, snapshot: snapshot) }) - .value, + await Task.detached(operation: { + SelectedText.isCurrent(pid: pid, snapshot: snapshot) + }) + .value, model.selectionRequest == request, NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else { return } await model.receive( CapturePayload( - entry: .selection, origin: snapshot.origin, materials: [Material(text: text)]), + entry: .selection, origin: snapshot.origin, + materials: [Material(text: text)]), request: request ) } else { @@ -303,7 +322,8 @@ public final class CaptureExtension: NSObject, NativeExtension, NSWindowDelegate model.perform { await model.receive( CapturePayload( - entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]), + entry: .screenshot, + materials: [Material(image: data, mimeType: "image/png")]), request: request ) } diff --git a/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift index e0a27f4..b5a834b 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift @@ -13,8 +13,9 @@ struct CaptureImagePreview: View { var body: some View { VStack(alignment: .leading, spacing: 4) { Button { - do { previewURL = try Self.makePreviewFile(material) } - catch { issue = error.localizedDescription } + do { previewURL = try Self.makePreviewFile(material) } catch { + issue = error.localizedDescription + } } label: { VStack(alignment: .leading, spacing: 4) { Label("查看原图", systemImage: "eye").font(.caption).foregroundStyle(.tint) @@ -40,11 +41,14 @@ struct CaptureImagePreview: View { static func makePreviewFile(_ material: Capture.Material) throws -> URL { guard let data = material.data, let mime = material.mimeType, - let suffix = UTType(mimeType: mime)?.preferredFilenameExtension + let suffix = UTType(mimeType: mime)?.preferredFilenameExtension else { throw CocoaError(.fileReadCorruptFile) } let url = FileManager.default.temporaryDirectory - .appendingPathComponent("inkcre-preview-\(UUID().uuidString)").appendingPathExtension(suffix) - guard FileManager.default.createFile(atPath: url.path, contents: data, attributes: [.posixPermissions: 0o600]) + .appendingPathComponent("inkcre-preview-\(UUID().uuidString)").appendingPathExtension( + suffix) + guard + FileManager.default.createFile( + atPath: url.path, contents: data, attributes: [.posixPermissions: 0o600]) else { throw CocoaError(.fileWriteUnknown) } return url } diff --git a/extensions/capture/Sources/CaptureMac/CaptureModel.swift b/extensions/capture/Sources/CaptureMac/CaptureModel.swift index 53999c9..2951e52 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureModel.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift @@ -78,10 +78,13 @@ final class CaptureModel: ObservableObject { private var actions: [UUID: Task] = [:] private var stopping = false - init(directory: URL, identity: String, connections: ConnectionSettings? = nil, - client: any CaptureTransport = CaptureClient()) throws { + init( + directory: URL, identity: String, connections: ConnectionSettings? = nil, + client: any CaptureTransport = CaptureClient() + ) throws { self.connections = - connections ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + connections + ?? ConnectionSettings(url: directory.appendingPathComponent("connection.json")) shortcuts = try GlobalShortcuts() store = try CaptureStore(url: directory.appendingPathComponent("capture.sqlite")) credentials = CredentialStore(service: "\(identity).connections") @@ -307,18 +310,23 @@ final class CaptureModel: ObservableObject { guard let connection else { let draftID = draft.id let owner = editorOwner - editConnection?(nil, { [weak self] in - guard let self, !self.stopping, self.draft.id == draftID else { return } - self.editorOwner = owner - self.captureNotice = self.connection == nil - ? "连接信息库后即可收入,也可以继续编辑。" - : "连接已配置,请确认内容后点击收入。" - self.revealEditor() - }) + editConnection?( + nil, + { [weak self] in + guard let self, !self.stopping, self.draft.id == draftID else { return } + self.editorOwner = owner + self.captureNotice = + self.connection == nil + ? "连接信息库后即可收入,也可以继续编辑。" + : "连接已配置,请确认内容后点击收入。" + self.revealEditor() + }) return } // An input method owns Return until its marked text is committed. - if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.hasMarkedText() { return } + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.hasMarkedText() { + return + } submitting = true saveTask?.cancel() perform { [self] in @@ -455,8 +463,8 @@ final class CaptureModel: ObservableObject { func updatePassword(_ password: String, for record: CaptureRecord) async throws { guard !password.isEmpty, - let current = try await store.record(record.id), - let target = current.target, current.state != .draft, current.state != .confirmed + let current = try await store.record(record.id), + let target = current.target, current.state != .draft, current.state != .confirmed else { throw PeerError.invalid("这条记录已变更,请关闭后重新查看。") } try credentials.save(password, for: target.id) try await store.retry(current.id) @@ -464,7 +472,9 @@ final class CaptureModel: ObservableObject { perform { [self] in await sendPending(force: true) } } - func updateWebAddress(_ address: String, for record: CaptureRecord) async throws -> CaptureRecord { + func updateWebAddress(_ address: String, for record: CaptureRecord) async throws + -> CaptureRecord + { let updated = try await store.updateWebAddress(record.id, address: address) try await refresh() return updated @@ -516,7 +526,11 @@ final class CaptureModel: ObservableObject { checkingConnection = true let revision = connectionRevision connectionCheck = Task { - defer { if !Task.isCancelled && connectionRevision == revision { checkingConnection = false } } + defer { + if !Task.isCancelled && connectionRevision == revision { + checkingConnection = false + } + } do { try await CaptureClient().checkConnection(target, secret: secret) guard !Task.isCancelled, connectionRevision == revision else { return } @@ -530,7 +544,9 @@ final class CaptureModel: ObservableObject { } func resultURL(for record: CaptureRecord) -> URL? { - guard let result = record.result, result.availability == .available, let target = record.target else { return nil } + guard let result = record.result, result.availability == .available, + let target = record.target + else { return nil } let base = target.webURL ?? (connection?.peerID == target.peerID ? connection?.webURL : nil) guard var url = base else { return nil } for part in ["info-base", "list", "blocks", String(result.rootBlockID), "content"] { diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift index 17a4ca3..1708403 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift @@ -29,7 +29,8 @@ struct CaptureRecordSettings: View { Text("密码保存到原连接的 Keychain 项,并继续收入这条记录。数据库目标和当前使用的信息库不会改变。") .font(.caption).foregroundStyle(.secondary) } else { - TextField("Web 地址,例如 https://inkcre.example.com", text: $value).focused($inputFocused) + TextField("Web 地址,例如 https://inkcre.example.com", text: $value).focused( + $inputFocused) Text("填写该信息库对应的 InKCre Web 地址,用于打开已收入内容。此操作不会更换当前连接。") .font(.caption).foregroundStyle(.secondary) } diff --git a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift index e4e2f4e..fa32e45 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift @@ -10,9 +10,11 @@ struct CaptureRecordsView: View { var body: some View { Group { if model.records.isEmpty { - ContentUnavailableView("还没有采集记录", systemImage: "tray", - description: Text("草稿、待收入和已收入的记录会显示在这里。")) - .frame(maxWidth: .infinity, maxHeight: .infinity) + ContentUnavailableView( + "还没有采集记录", systemImage: "tray", + description: Text("草稿、待收入和已收入的记录会显示在这里。") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollViewReader { proxy in ScrollView { @@ -22,15 +24,24 @@ struct CaptureRecordsView: View { HStack { Text(record.statusLabel).font(.headline) Spacer() - Text(record.updatedAt, format: .dateTime.month().day().hour().minute()).font(.caption).foregroundStyle(.secondary) + Text( + record.updatedAt, + format: .dateTime.month().day().hour().minute() + ).font(.caption).foregroundStyle(.secondary) } - if let text = record.payload?.materials.first(where: { $0.kind == .text })?.text { + if let text = record.payload?.materials.first(where: { + $0.kind == .text + })?.text { Text(text).lineLimit(3) - } else if let material = record.payload?.materials.first(where: { $0.kind == .image }), - material.data != nil { + } else if let material = record.payload?.materials.first( + where: { $0.kind == .image }), + material.data != nil + { CaptureImagePreview(material: material, height: 100) } - if let annotation = record.payload?.annotation, !annotation.isEmpty { + if let annotation = record.payload?.annotation, + !annotation.isEmpty + { Text(annotation).lineLimit(3).foregroundStyle(.secondary) } if let payload = record.payload, record.state != .draft { @@ -38,40 +49,61 @@ struct CaptureRecordsView: View { CaptureRecordMaterials(payload: payload) } } - if let message = record.message { Text(message).font(.caption).foregroundStyle(.secondary) } - if let target = record.target { Text(target.name).font(.caption).foregroundStyle(.secondary) } - if let result = record.result { Text("Block \(result.rootBlockID)").font(.caption).textSelection(.enabled) } + if let message = record.message { + Text(message).font(.caption).foregroundStyle(.secondary) + } + if let target = record.target { + Text(target.name).font(.caption).foregroundStyle(.secondary) + } + if let result = record.result { + Text("Block \(result.rootBlockID)").font(.caption) + .textSelection(.enabled) + } HStack { if record.state == .draft { Button("继续编辑") { model.resume(record) } } else if record.state == .confirmed { if record.result?.availability == .deleted { - Text("共享内容已删除").font(.caption).foregroundStyle(.secondary) + Text("共享内容已删除").font(.caption).foregroundStyle( + .secondary) } else if model.resultURL(for: record) == nil { Button("配置 Web 地址…") { webRecord = record } } else { Button("打开") { model.openResult(record) } } } else { - Button(record.state == .cancelled ? "继续收入" : "立即重试") { model.retry(record) } + Button(record.state == .cancelled ? "继续收入" : "立即重试") { + model.retry(record) + } if record.state != .cancelled { Button("取消收入") { model.cancel(record) } } Button("更新密码…") { passwordRecord = record } } - Button("删除记录", role: .destructive) { model.requestDiscard(record) } - if record.payload != nil { Button("导出材料") { model.export(record) } } + Button("删除记录", role: .destructive) { + model.requestDiscard(record) + } + if record.payload != nil { + Button("导出材料") { model.export(record) } + } } } .padding(8) - .background(model.selectedRecordID == record.id ? Color.accentColor.opacity(0.08) : Color.clear) + .background( + model.selectedRecordID == record.id + ? Color.accentColor.opacity(0.08) : Color.clear + ) .id(record.id) Divider() } }.padding() } - .onAppear { if let id = model.selectedRecordID { proxy.scrollTo(id, anchor: .top) } } - .onChange(of: model.selectedRecordID) { _, id in if let id { proxy.scrollTo(id, anchor: .top) } } + .onAppear { + if let id = model.selectedRecordID { proxy.scrollTo(id, anchor: .top) } + } + .onChange(of: model.selectedRecordID) { _, id in + if let id { proxy.scrollTo(id, anchor: .top) } + } } } }.disabled(model.submitting) diff --git a/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift index d9ca02b..dd5fd85 100644 --- a/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift +++ b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI @preconcurrency import ApplicationServices +import SwiftUI struct CaptureSettingsView: View { @ObservedObject var model: CaptureModel @@ -16,13 +16,18 @@ struct CaptureSettingsView: View { LabeledContent("读取选区", value: selectionAllowed ? "已允许" : "尚未允许") if !selectionAllowed { Button("允许读取选区(辅助功能)") { - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + let options = + [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] + as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } } LabeledContent("屏幕录制", value: screenAllowed ? "已允许" : "尚未允许") if !screenAllowed { - Button("请求屏幕录制权限") { model.requestScreenRecordingAccess(); refreshPermissions() } + Button("请求屏幕录制权限") { + model.requestScreenRecordingAccess() + refreshPermissions() + } } Button("打开屏幕录制权限设置") { model.openScreenRecordingSettings() } Text("选区与截图分别需要系统授权;输入和明确粘贴不需要这两项权限。") @@ -32,7 +37,9 @@ struct CaptureSettingsView: View { .formStyle(.grouped) .onDisappear { model.shortcuts.cancelRecording() } .onAppear { refreshPermissions() } - .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in refreshPermissions() } + .onReceive( + NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification) + ) { _ in refreshPermissions() } } private func refreshPermissions() { diff --git a/extensions/capture/Sources/CaptureMac/ClipboardReader.swift b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift index 8b37c0e..909d7fb 100644 --- a/extensions/capture/Sources/CaptureMac/ClipboardReader.swift +++ b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift @@ -9,7 +9,8 @@ enum ClipboardReader { var result: [Material] = [] let supported: [(NSPasteboard.PasteboardType, String)] = [ (.png, "image/png"), (.init(UTType.jpeg.identifier), "image/jpeg"), - (.init(UTType.gif.identifier), "image/gif"), (.init(UTType.webP.identifier), "image/webp"), + (.init(UTType.gif.identifier), "image/gif"), + (.init(UTType.webP.identifier), "image/webp"), ] for item in NSPasteboard.general.pasteboardItems ?? [] { var image: Material? @@ -19,17 +20,22 @@ enum ClipboardReader { break } } - if let image { result.append(image); continue } + if let image { + result.append(image) + continue + } if let data = item.data(forType: .tiff), - let bitmap = NSBitmapImageRep(data: data), - let png = bitmap.representation(using: .png, properties: [:]) { + let bitmap = NSBitmapImageRep(data: data), + let png = bitmap.representation(using: .png, properties: [:]) + { result.append(Material(image: png, mimeType: "image/png")) continue } if item.types.contains(.fileURL) { throw PeerError.invalid("首版不导入文件。请粘贴文字、链接或图片本身。") } - if let text = item.string(forType: .string) ?? item.string(forType: .URL), !text.isEmpty { + if let text = item.string(forType: .string) ?? item.string(forType: .URL), !text.isEmpty + { result.append(Material(text: text)) } } diff --git a/extensions/capture/Sources/CaptureMac/ConnectionView.swift b/extensions/capture/Sources/CaptureMac/ConnectionView.swift index 84ad790..98ba147 100644 --- a/extensions/capture/Sources/CaptureMac/ConnectionView.swift +++ b/extensions/capture/Sources/CaptureMac/ConnectionView.swift @@ -1,6 +1,6 @@ import AppKit -import SwiftUI @preconcurrency import ApplicationServices +import SwiftUI struct ConnectionView: View { @ObservedObject var model: CaptureModel @@ -11,15 +11,24 @@ struct ConnectionView: View { LabeledContent("当前能力", value: "收集:选区、粘贴、截图、想法") LabeledContent("信息库", value: model.connection?.name ?? "尚未配置") if let connection = model.connection { - LabeledContent("Peer 身份") { Text(connection.peerID.uuidString).textSelection(.enabled) } + LabeledContent("Peer 身份") { + Text(connection.peerID.uuidString).textSelection(.enabled) + } if model.checkingConnection { - HStack { ProgressView().controlSize(.small); Text("正在检查连接…") } + HStack { + ProgressView().controlSize(.small) + Text("正在检查连接…") + } } else if let issue = model.peerConnectionIssue { Text("最近检查未通过:\(issue)").foregroundStyle(.secondary) } else if let date = model.lastContactAt { - LabeledContent("最近检查通过") { Text(date, format: .dateTime.month().day().hour().minute().second()) } + LabeledContent("最近检查通过") { + Text(date, format: .dateTime.month().day().hour().minute().second()) + } Text("信息库写入连接可用").font(.caption).foregroundStyle(.secondary) - } else { Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) } + } else { + Text("等待首次联络;已配置连接不代表在线。").foregroundStyle(.secondary) + } } LabeledContent("本机待收入", value: "\(model.pendingCount) 项") Text("此 Mac 与其他 Peer 共享同一个信息库。关闭主窗口后仍会保留草稿、处理待办;退出 InKCre 才停止运行。") @@ -27,10 +36,15 @@ struct ConnectionView: View { } Section("连接共享信息库") { if let target = model.connection { - LabeledContent("PostgreSQL") { Text("\(target.host):\(target.port)/\(target.database)").textSelection(.enabled) } + LabeledContent("PostgreSQL") { + Text("\(target.host):\(target.port)/\(target.database)").textSelection( + .enabled) + } LabeledContent("运行角色", value: target.username) if let web = target.webURL { - LabeledContent("Web 地址") { Text(web.absoluteString).textSelection(.enabled) } + LabeledContent("Web 地址") { + Text(web.absoluteString).textSelection(.enabled) + } } } else { Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") diff --git a/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift index 367dc28..6a1b2b0 100644 --- a/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift +++ b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift @@ -1,8 +1,8 @@ import AppKit -import Carbon.HIToolbox import Capture -import PeerCore +import Carbon.HIToolbox import Combine +import PeerCore @MainActor final class GlobalShortcuts: ObservableObject { @@ -24,13 +24,13 @@ final class GlobalShortcuts: ObservableObject { if let data = defaults.data(forKey: Self.settingsKey) { bindings = try JSONDecoder().decode([ShortcutAction: RecordedShortcut].self, from: data) } else { - bindings = Dictionary(uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) + bindings = Dictionary( + uniqueKeysWithValues: ShortcutAction.allCases.map { ($0, $0.defaultShortcut) }) } var failures: [String] = [] for (action, shortcut) in bindings { - do { try validate(shortcut, for: action) } - catch { + do { try validate(shortcut, for: action) } catch { bindings[action] = nil failures.append("已停用无效的“\(action.title)”快捷键:\(error.localizedDescription)") } @@ -40,19 +40,24 @@ final class GlobalShortcuts: ObservableObject { func start() throws { guard handler == nil else { return } - var type = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) - let status = InstallEventHandler(GetApplicationEventTarget(), { _, event, context in - guard let event, let context else { return OSStatus(eventNotHandledErr) } - var identity = EventHotKeyID() - let status = GetEventParameter(event, EventParamName(kEventParamDirectObject), - EventParamType(typeEventHotKeyID), nil, - MemoryLayout.size, nil, &identity) - guard status == noErr, identity.signature == 0x494e4b43, - let action = ShortcutAction(rawValue: identity.id) else { return OSStatus(eventNotHandledErr) } - let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() - MainActor.assumeIsolated { _ = owner.execute(action, in: nil) } - return noErr - }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) + var type = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + let status = InstallEventHandler( + GetApplicationEventTarget(), + { _, event, context in + guard let event, let context else { return OSStatus(eventNotHandledErr) } + var identity = EventHotKeyID() + let status = GetEventParameter( + event, EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), nil, + MemoryLayout.size, nil, &identity) + guard status == noErr, identity.signature == 0x494e_4b43, + let action = ShortcutAction(rawValue: identity.id) + else { return OSStatus(eventNotHandledErr) } + let owner = Unmanaged.fromOpaque(context).takeUnretainedValue() + MainActor.assumeIsolated { _ = owner.execute(action, in: nil) } + return noErr + }, 1, &type, Unmanaged.passUnretained(self).toOpaque(), &handler) guard status == noErr else { throw PeerError.needsAction("无法启用快捷键事件,请重新打开 InKCre。") } monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in let consumed = MainActor.assumeIsolated { @@ -70,9 +75,18 @@ final class GlobalShortcuts: ObservableObject { func stop() { for key in keys.values { UnregisterEventHotKey(key) } keys.removeAll() - if let handler { RemoveEventHandler(handler); self.handler = nil } - if let monitor { NSEvent.removeMonitor(monitor); self.monitor = nil } - if let resignObserver { NotificationCenter.default.removeObserver(resignObserver); self.resignObserver = nil } + if let handler { + RemoveEventHandler(handler) + self.handler = nil + } + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + self.resignObserver = nil + } recording = nil } @@ -82,9 +96,13 @@ final class GlobalShortcuts: ObservableObject { changed[action] = shortcut let data = try JSONEncoder().encode(changed) if handler != nil, recording == nil, action.isGlobal, - shortcut != bindings[action] || keys[action] == nil { - if let shortcut { try register(shortcut, for: action) } - else if let old = keys.removeValue(forKey: action) { UnregisterEventHotKey(old) } + shortcut != bindings[action] || keys[action] == nil + { + if let shortcut { + try register(shortcut, for: action) + } else if let old = keys.removeValue(forKey: action) { + UnregisterEventHotKey(old) + } } defaults.set(data, forKey: Self.settingsKey) bindings = changed @@ -93,8 +111,7 @@ final class GlobalShortcuts: ObservableObject { func update(_ shortcut: RecordedShortcut?, for action: ShortcutAction) { cancelRecording() - do { try set(shortcut, for: action) } - catch { message = error.localizedDescription } + do { try set(shortcut, for: action) } catch { message = error.localizedDescription } } func beginRecording(_ action: ShortcutAction) { @@ -114,7 +131,10 @@ final class GlobalShortcuts: ObservableObject { if let action = recording { guard !event.isARepeat else { return nil } let shortcut = RecordedShortcut(event: event) - if event.keyCode == kVK_Escape, shortcut.modifierBits == 0 { cancelRecording(); return nil } + if event.keyCode == kVK_Escape, shortcut.modifierBits == 0 { + cancelRecording() + return nil + } do { try validate(shortcut, for: action) cancelRecording() @@ -123,13 +143,17 @@ final class GlobalShortcuts: ObservableObject { return nil } guard isCaptureWindow?(event.window) == true, - NSApp.modalWindow == nil, - event.window?.attachedSheet == nil, - (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() != true else { return event } + NSApp.modalWindow == nil, + event.window?.attachedSheet == nil, + (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() != true + else { return event } let modifiers = event.modifierFlags.intersection(RecordedShortcut.allowedModifiers).rawValue - guard let action = ShortcutAction.allCases.first(where: { - !$0.isGlobal && bindings[$0]?.keyCode == event.keyCode && bindings[$0]?.modifierBits == modifiers - }) else { return event } + guard + let action = ShortcutAction.allCases.first(where: { + !$0.isGlobal && bindings[$0]?.keyCode == event.keyCode + && bindings[$0]?.modifierBits == modifiers + }) + else { return event } guard canPerform?(action, event.window) == true else { return event } if !event.isARepeat { _ = execute(action, in: event.window) } return nil @@ -137,25 +161,31 @@ final class GlobalShortcuts: ObservableObject { @discardableResult func execute(_ action: ShortcutAction, in window: NSWindow?) -> Bool { - guard recording == nil, canPerform?(action, window) == true, let perform else { return false } + guard recording == nil, canPerform?(action, window) == true, let perform else { + return false + } perform(action) return true } private func validate(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { try shortcut.validate(for: action) - if let other = ShortcutAction.allCases.first(where: { $0 != action && bindings[$0] == shortcut }) { + if let other = ShortcutAction.allCases.first(where: { + $0 != action && bindings[$0] == shortcut + }) { throw PeerError.invalid("\(shortcut.label) 已用于“\(other.title)”,请换一个组合。原设置未更改。") } } private func register(_ shortcut: RecordedShortcut, for action: ShortcutAction) throws { var reference: EventHotKeyRef? - let status = RegisterEventHotKey(UInt32(shortcut.keyCode), shortcut.carbonModifiers, - EventHotKeyID(signature: 0x494e4b43, id: action.rawValue), - GetApplicationEventTarget(), 0, &reference) + let status = RegisterEventHotKey( + UInt32(shortcut.keyCode), shortcut.carbonModifiers, + EventHotKeyID(signature: 0x494e_4b43, id: action.rawValue), + GetApplicationEventTarget(), 0, &reference) guard status == noErr, let reference else { - throw PeerError.needsAction("“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") + throw PeerError.needsAction( + "“\(action.title)”的 \(shortcut.label) 无法注册,可能已被系统或其他应用占用。请换一个组合。") } // Register the replacement first; failure must not remove the working shortcut. if let old = keys.updateValue(reference, forKey: action) { UnregisterEventHotKey(old) } @@ -166,8 +196,9 @@ final class GlobalShortcuts: ObservableObject { var failures: [String] = [] for action in ShortcutAction.allCases where action.isGlobal && keys[action] == nil { guard let shortcut = bindings[action] else { continue } - do { try register(shortcut, for: action) } - catch { failures.append(error.localizedDescription) } + do { try register(shortcut, for: action) } catch { + failures.append(error.localizedDescription) + } } if !failures.isEmpty { message = failures.joined(separator: "\n") } } diff --git a/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift index 53bd313..c5048b3 100644 --- a/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift +++ b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift @@ -1,10 +1,11 @@ import AppKit -import Carbon.HIToolbox import Capture +import Carbon.HIToolbox import PeerCore enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable { - case capture = 1, screenshot, paste, submit, hide + case capture = 1 + case screenshot, paste, submit, hide var id: UInt32 { rawValue } var isGlobal: Bool { self == .capture || self == .screenshot } var title: String { @@ -18,10 +19,17 @@ enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable { } var defaultShortcut: RecordedShortcut { switch self { - case .capture: RecordedShortcut(keyCode: UInt16(kVK_ANSI_I), modifiers: [.control, .option], keyLabel: "I") - case .screenshot: RecordedShortcut(keyCode: UInt16(kVK_ANSI_S), modifiers: [.control, .option], keyLabel: "S") - case .paste: RecordedShortcut(keyCode: UInt16(kVK_ANSI_V), modifiers: [.command, .shift], keyLabel: "V") - case .submit: RecordedShortcut(keyCode: UInt16(kVK_Return), modifiers: .command, keyLabel: "↩") + case .capture: + RecordedShortcut( + keyCode: UInt16(kVK_ANSI_I), modifiers: [.control, .option], keyLabel: "I") + case .screenshot: + RecordedShortcut( + keyCode: UInt16(kVK_ANSI_S), modifiers: [.control, .option], keyLabel: "S") + case .paste: + RecordedShortcut( + keyCode: UInt16(kVK_ANSI_V), modifiers: [.command, .shift], keyLabel: "V") + case .submit: + RecordedShortcut(keyCode: UInt16(kVK_Return), modifiers: .command, keyLabel: "↩") case .hide: RecordedShortcut(keyCode: UInt16(kVK_Escape), modifiers: [], keyLabel: "Esc") } } @@ -40,13 +48,19 @@ struct RecordedShortcut: Codable, Equatable, Sendable { } init(event: NSEvent) { - let special: [Int: String] = [kVK_Return: "↩", kVK_Escape: "Esc", kVK_Tab: "⇥", kVK_Space: "空格", + let special: [Int: String] = [ + kVK_Return: "↩", kVK_Escape: "Esc", kVK_Tab: "⇥", kVK_Space: "空格", kVK_Delete: "⌫", kVK_ForwardDelete: "⌦", kVK_LeftArrow: "←", kVK_RightArrow: "→", kVK_UpArrow: "↑", kVK_DownArrow: "↓", kVK_Home: "↖", kVK_End: "↘", - kVK_PageUp: "⇞", kVK_PageDown: "⇟"] - let functionKeys = [kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10, - kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, kVK_F20] - let label = special[Int(event.keyCode)] + kVK_PageUp: "⇞", kVK_PageDown: "⇟", + ] + let functionKeys = [ + kVK_F1, kVK_F2, kVK_F3, kVK_F4, kVK_F5, kVK_F6, kVK_F7, kVK_F8, kVK_F9, kVK_F10, + kVK_F11, kVK_F12, kVK_F13, kVK_F14, kVK_F15, kVK_F16, kVK_F17, kVK_F18, kVK_F19, + kVK_F20, + ] + let label = + special[Int(event.keyCode)] ?? functionKeys.firstIndex(of: Int(event.keyCode)).map { "F\($0 + 1)" } ?? event.characters(byApplyingModifiers: [])?.uppercased() ?? "按键 \(event.keyCode)" self.init(keyCode: event.keyCode, modifiers: event.modifierFlags, keyLabel: label) @@ -58,35 +72,48 @@ struct RecordedShortcut: Codable, Equatable, Sendable { .filter { modifiers.contains($0.0) }.map(\.1).joined() + keyLabel } var menuKeyEquivalent: String { - let special = ["↩": "\r", "Esc": "\u{1b}", "⇥": "\t", "空格": " ", "⌫": "\u{8}", "⌦": "\u{7f}", - "←": "\u{f702}", "→": "\u{f703}", "↑": "\u{f700}", "↓": "\u{f701}", - "↖": "\u{f729}", "↘": "\u{f72b}", "⇞": "\u{f72c}", "⇟": "\u{f72d}"] + let special = [ + "↩": "\r", "Esc": "\u{1b}", "⇥": "\t", "空格": " ", "⌫": "\u{8}", "⌦": "\u{7f}", + "←": "\u{f702}", "→": "\u{f703}", "↑": "\u{f700}", "↓": "\u{f701}", + "↖": "\u{f729}", "↘": "\u{f72b}", "⇞": "\u{f72c}", "⇟": "\u{f72d}", + ] if let key = special[keyLabel] { return key } - if keyLabel.hasPrefix("F"), let number = Int(keyLabel.dropFirst()), (1...20).contains(number), - let scalar = UnicodeScalar(0xf703 + number) { return String(scalar) } + if keyLabel.hasPrefix("F"), let number = Int(keyLabel.dropFirst()), + (1...20).contains(number), + let scalar = UnicodeScalar(0xf703 + number) + { + return String(scalar) + } return keyLabel.lowercased() } var carbonModifiers: UInt32 { - [(NSEvent.ModifierFlags.control, controlKey), (.option, optionKey), (.shift, shiftKey), (.command, cmdKey)] - .reduce(0) { $0 | (modifiers.contains($1.0) ? UInt32($1.1) : 0) } + [ + (NSEvent.ModifierFlags.control, controlKey), (.option, optionKey), (.shift, shiftKey), + (.command, cmdKey), + ] + .reduce(0) { $0 | (modifiers.contains($1.0) ? UInt32($1.1) : 0) } } static func == (left: Self, right: Self) -> Bool { left.keyCode == right.keyCode && left.modifierBits == right.modifierBits } func validate(for action: ShortcutAction) throws { // Preserve the app's window, navigation and text-editing commands. - let commandKeys = [kVK_ANSI_Q, kVK_ANSI_W, kVK_ANSI_H, kVK_ANSI_M, kVK_ANSI_N, - kVK_ANSI_Comma, kVK_ANSI_C, kVK_ANSI_V, kVK_ANSI_X, kVK_ANSI_A, kVK_ANSI_Z] - let reserved = modifiers == .command && commandKeys.contains(Int(keyCode)) + let commandKeys = [ + kVK_ANSI_Q, kVK_ANSI_W, kVK_ANSI_H, kVK_ANSI_M, kVK_ANSI_N, + kVK_ANSI_Comma, kVK_ANSI_C, kVK_ANSI_V, kVK_ANSI_X, kVK_ANSI_A, kVK_ANSI_Z, + ] + let reserved = + modifiers == .command && commandKeys.contains(Int(keyCode)) || modifiers == [.command, .shift] && [kVK_ANSI_Z, kVK_ANSI_W].contains(Int(keyCode)) || modifiers == [.command, .option] && [kVK_ANSI_H, kVK_ANSI_S].contains(Int(keyCode)) || modifiers == [.command, .control] && keyCode == kVK_ANSI_F guard !reserved else { throw PeerError.invalid("\(label) 是应用的标准命令,请使用其他组合。原设置未更改。") } let plainEscape = action == .hide && keyCode == kVK_Escape && modifierBits == 0 guard keyCode <= 127, !keyLabel.isEmpty, - modifierBits & ~Self.allowedModifiers.rawValue == 0, - plainEscape || !modifiers.intersection([.command, .control, .option]).isEmpty else { + modifierBits & ~Self.allowedModifiers.rawValue == 0, + plainEscape || !modifiers.intersection([.command, .control, .option]).isEmpty + else { throw PeerError.invalid("请同时按住 ⌘、⌃ 或 ⌥,避免影响正常输入。") } } diff --git a/extensions/capture/Sources/CaptureMac/PeerMainView.swift b/extensions/capture/Sources/CaptureMac/PeerMainView.swift index 4708306..a1a7b38 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainView.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainView.swift @@ -8,18 +8,24 @@ struct PeerMainView: View { NavigationSplitView(columnVisibility: $model.columnVisibility) { VStack(spacing: 0) { HStack(spacing: 10) { - Image(nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark) - .resizable().frame(width: 32, height: 32).accessibilityHidden(true) + Image( + nsImage: colorScheme == .dark ? BrandAssets.darkMark : BrandAssets.lightMark + ) + .resizable().frame(width: 32, height: 32).accessibilityHidden(true) VStack(alignment: .leading) { Text("InKCre").font(.headline) Text("Mac Peer").font(.caption).foregroundStyle(.secondary) } Spacer() }.padding() - List(selection: Binding(get: { Optional(model.pane) }, set: { - guard let value = $0 else { return } - model.pane = value - })) { + List( + selection: Binding( + get: { Optional(model.pane) }, + set: { + guard let value = $0 else { return } + model.pane = value + }) + ) { Label("采集", systemImage: "square.and.pencil").tag(CaptureModel.Pane.capture) Label("记录", systemImage: "tray.full").tag(CaptureModel.Pane.queue) Label("Peer 与连接", systemImage: "network").tag(CaptureModel.Pane.peer) @@ -41,7 +47,9 @@ struct PeerMainView: View { } actions: { Button("在主窗口继续") { model.continueInMain() } } - } else { CaptureEditor(model: model) } + } else { + CaptureEditor(model: model) + } case .queue: CaptureRecordsView(model: model) case .peer: ConnectionView(model: model) } @@ -61,8 +69,10 @@ struct PeerMainView: View { .help("框选截图") .disabled(model.submitting) } - Button("快捷采集", systemImage: "rectangle.and.pencil.and.ellipsis") { model.showQuick?() } - .help("打开快捷采集面板") + Button("快捷采集", systemImage: "rectangle.and.pencil.and.ellipsis") { + model.showQuick?() + } + .help("打开快捷采集面板") } } .modifier(CaptureAlerts(model: model, owner: .main)) @@ -127,15 +137,18 @@ private struct CaptureAlerts: ViewModifier { } func body(content: Content) -> some View { - content.alert(presentedAlert?.title ?? "需要处理", isPresented: Binding( - get: { presentedAlert != nil && model.editorOwner == owner }, - set: { - if !$0 && model.editorOwner == owner { - model.error = nil - model.removalRequest = nil + content.alert( + presentedAlert?.title ?? "需要处理", + isPresented: Binding( + get: { presentedAlert != nil && model.editorOwner == owner }, + set: { + if !$0 && model.editorOwner == owner { + model.error = nil + model.removalRequest = nil + } } - } - ), presenting: presentedAlert) { alert in + ), presenting: presentedAlert + ) { alert in switch alert { case .error: Button("知道了", role: .cancel) { model.error = nil } @@ -143,6 +156,8 @@ private struct CaptureAlerts: ViewModifier { Button(request.actionTitle, role: .destructive) { model.confirmRemoval(request) } Button("取消", role: .cancel) { model.removalRequest = nil } } - } message: { alert in Text(alert.message) } + } message: { alert in + Text(alert.message) + } } } diff --git a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift index 73c593e..5b04307 100644 --- a/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift +++ b/extensions/capture/Sources/CaptureMac/PeerMainWindow.swift @@ -12,16 +12,19 @@ final class PeerMainWindow: NSWindow { } override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { - guard item.action == #selector(toggleSidebar(_:)) else { return super.validateUserInterfaceItem(item) } + guard item.action == #selector(toggleSidebar(_:)) else { + return super.validateUserInterfaceItem(item) + } (item as? NSMenuItem)?.title = model.columnVisibility == .detailOnly ? "显示侧栏" : "隐藏侧栏" return attachedSheet == nil } init(model: CaptureModel) { self.model = model - super.init(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) + super.init( + contentRect: NSRect(x: 0, y: 0, width: 1000, height: 720), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) title = "InKCre" toolbarStyle = .unified minSize = NSSize(width: 760, height: 520) diff --git a/extensions/capture/Sources/CaptureMac/RegionCapture.swift b/extensions/capture/Sources/CaptureMac/RegionCapture.swift index 9d2d5b5..aec2f03 100644 --- a/extensions/capture/Sources/CaptureMac/RegionCapture.swift +++ b/extensions/capture/Sources/CaptureMac/RegionCapture.swift @@ -23,7 +23,8 @@ final class RegionCapture { init( hasPermission: @escaping () -> Bool = CGPreflightScreenCaptureAccess, - freezeScreens: @escaping () async throws -> [FrozenScreenFrame] = RegionCapture.freezeScreens, + freezeScreens: @escaping () async throws -> [FrozenScreenFrame] = RegionCapture + .freezeScreens, overlay: RegionSelectionOverlay = RegionSelectionOverlay() ) { self.hasPermission = hasPermission @@ -50,20 +51,26 @@ final class RegionCapture { case .cancelled: finish(.success(nil)) case .region(let selection): - guard let frame = frames.first(where: { $0.displayID == selection.displayID }) else { + guard let frame = frames.first(where: { $0.displayID == selection.displayID }) + else { throw PeerError.invalid("找不到所选画面,请重新截图。") } let data = try await Task.detached(priority: .userInitiated) { try frame.png(in: selection.pointsRect) }.value guard !Task.isCancelled else { return } - try CapturePayload(entry: .screenshot, materials: [Material(image: data, mimeType: "image/png")]).validate() + try CapturePayload( + entry: .screenshot, + materials: [Material(image: data, mimeType: "image/png")] + ).validate() finish(.success(data)) } } catch { guard !Task.isCancelled else { return } let failure = error as NSError - if failure.domain == SCStreamErrorDomain, failure.code == SCStreamError.Code.userDeclined.rawValue { + if failure.domain == SCStreamErrorDomain, + failure.code == SCStreamError.Code.userDeclined.rawValue + { finish(.failure(ScreenRecordingPermissionError())) } else { finish(.failure(error)) @@ -88,23 +95,35 @@ final class RegionCapture { } private static func freezeScreens() async throws -> [FrozenScreenFrame] { - let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true) - let own = content.applications.filter { $0.processID == ProcessInfo.processInfo.processIdentifier } + let content = try await SCShareableContent.excludingDesktopWindows( + false, onScreenWindowsOnly: true) + let own = content.applications.filter { + $0.processID == ProcessInfo.processInfo.processIdentifier + } var frames: [FrozenScreenFrame] = [] for screen in NSScreen.screens { try Task.checkCancellation() - guard let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber, - let display = content.displays.first(where: { $0.displayID == number.uint32Value }) else { + guard + let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] + as? NSNumber, + let display = content.displays.first(where: { $0.displayID == number.uint32Value }) + else { throw PeerError.invalid("屏幕配置已改变,请重新截图。") } - let filter = SCContentFilter(display: display, excludingApplications: own, exceptingWindows: []) + let filter = SCContentFilter( + display: display, excludingApplications: own, exceptingWindows: []) let configuration = SCStreamConfiguration() - configuration.width = max(1, Int((filter.contentRect.width * CGFloat(filter.pointPixelScale)).rounded())) - configuration.height = max(1, Int((filter.contentRect.height * CGFloat(filter.pointPixelScale)).rounded())) + configuration.width = max( + 1, Int((filter.contentRect.width * CGFloat(filter.pointPixelScale)).rounded())) + configuration.height = max( + 1, Int((filter.contentRect.height * CGFloat(filter.pointPixelScale)).rounded())) configuration.showsCursor = false configuration.captureResolution = .best - let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: configuration) - frames.append(FrozenScreenFrame(displayID: display.displayID, pointsRect: display.frame, image: image)) + let image = try await SCScreenshotManager.captureImage( + contentFilter: filter, configuration: configuration) + frames.append( + FrozenScreenFrame( + displayID: display.displayID, pointsRect: display.frame, image: image)) } return frames } diff --git a/extensions/capture/Sources/CaptureMac/SelectedText.swift b/extensions/capture/Sources/CaptureMac/SelectedText.swift index f1db9fb..8599373 100644 --- a/extensions/capture/Sources/CaptureMac/SelectedText.swift +++ b/extensions/capture/Sources/CaptureMac/SelectedText.swift @@ -4,8 +4,8 @@ import AppKit import ApplicationServices import Capture -import PeerCore import Carbon.HIToolbox +import PeerCore struct SelectionSnapshot: Sendable { var text: String? @@ -28,7 +28,9 @@ enum SelectedText { var snapshot = SelectionSnapshot(origin: CaptureOrigin(applicationID: applicationID)) let application = AXUIElementCreateApplication(pid) AXUIElementSetMessagingTimeout(application, 0.12) - guard let focused = element(application, kAXFocusedUIElementAttribute) else { return snapshot } + guard let focused = element(application, kAXFocusedUIElementAttribute) else { + return snapshot + } AXUIElementSetMessagingTimeout(focused, 0.12) snapshot.secure = string(focused, kAXSubroleAttribute) == kAXSecureTextFieldSubrole guard !snapshot.secure else { return snapshot } @@ -41,7 +43,8 @@ enum SelectedText { snapshot.text = string(focused, kAXSelectedTextAttribute).flatMap { $0.isEmpty ? nil : $0 } var selectedRange: CFRange? if let value = attribute(focused, kAXSelectedTextRangeAttribute), - CFGetTypeID(value) == AXValueGetTypeID() { + CFGetTypeID(value) == AXValueGetTypeID() + { var range = CFRange() if AXValueGetValue(unsafeDowncast(value, to: AXValue.self), .cfRange, &range) { snapshot.rangeLength = range.length @@ -50,7 +53,8 @@ enum SelectedText { } // A delayed AX reply must not be attached to a different focused element. guard let current = element(application, kAXFocusedUIElementAttribute), - CFEqual(current, focused) else { + CFEqual(current, focused) + else { return SelectionSnapshot(origin: CaptureOrigin(applicationID: applicationID)) } snapshot.focus = SelectionFocus(element: focused, window: window, range: selectedRange) @@ -62,26 +66,34 @@ enum SelectedText { let application = AXUIElementCreateApplication(pid) AXUIElementSetMessagingTimeout(application, 0.12) guard let current = element(application, kAXFocusedUIElementAttribute), - CFEqual(current, focus.element) else { return false } + CFEqual(current, focus.element) + else { return false } AXUIElementSetMessagingTimeout(current, 0.12) if let window = focus.window { guard let currentWindow = element(application, kAXFocusedWindowAttribute), - CFEqual(window, currentWindow) else { return false } + CFEqual(window, currentWindow) + else { return false } } if let expected = focus.range { guard let value = attribute(current, kAXSelectedTextRangeAttribute), - CFGetTypeID(value) == AXValueGetTypeID() else { return false } + CFGetTypeID(value) == AXValueGetTypeID() + else { return false } var actual = CFRange() guard AXValueGetValue(unsafeDowncast(value, to: AXValue.self), .cfRange, &actual), - actual.location == expected.location, actual.length == expected.length else { return false } + actual.location == expected.location, actual.length == expected.length + else { return false } + } + if let text = snapshot.text, string(current, kAXSelectedTextAttribute) != text { + return false } - if let text = snapshot.text, string(current, kAXSelectedTextAttribute) != text { return false } return string(current, kAXSubroleAttribute) != kAXSecureTextFieldSubrole } private static func attribute(_ element: AXUIElement, _ name: String) -> CFTypeRef? { var value: CFTypeRef? - guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { return nil } + guard AXUIElementCopyAttributeValue(element, name as CFString, &value) == .success else { + return nil + } return value } @@ -92,16 +104,21 @@ enum SelectedText { } private static func element(_ parent: AXUIElement, _ name: String) -> AXUIElement? { - guard let value = attribute(parent, name), CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil } + guard let value = attribute(parent, name), CFGetTypeID(value) == AXUIElementGetTypeID() + else { return nil } return unsafeDowncast(value, to: AXUIElement.self) } @MainActor - static func copyFallback(pid: pid_t, snapshot selection: SelectionSnapshot, isActive: () -> Bool) async -> String? { + static func copyFallback( + pid: pid_t, snapshot selection: SelectionSnapshot, isActive: () -> Bool + ) async -> String? { let expectedLength = selection.rangeLength guard expectedLength > 0, !IsSecureEventInputEnabled(), - isActive(), await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, - isActive() else { return nil } + isActive(), + await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, + isActive() + else { return nil } let board = NSPasteboard.general let originalCount = board.changeCount var snapshot: [[NSPasteboard.PasteboardType: Data]] = [] @@ -117,8 +134,11 @@ enum SelectedText { snapshot.append(representations) } guard board.changeCount == originalCount, isActive(), - let down = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true), - let up = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false) else { return nil } + let down = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: true), + let up = CGEvent( + keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_ANSI_C), keyDown: false) + else { return nil } down.flags = .maskCommand up.flags = .maskCommand down.postToPid(pid) @@ -128,7 +148,8 @@ enum SelectedText { guard isActive() else { return nil } if board.changeCount != originalCount { let copiedCount = board.changeCount - guard let text = board.string(forType: .string), text.utf16.count == expectedLength else { return nil } + guard let text = board.string(forType: .string), text.utf16.count == expectedLength + else { return nil } // Restore only the copy we observed; a later user copy always wins. if board.changeCount == copiedCount { let items = snapshot.map { representations in @@ -139,8 +160,11 @@ enum SelectedText { board.clearContents() if !items.isEmpty { board.writeObjects(items) } } - guard await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }).value, - isActive() else { return nil } + guard + await Task.detached(operation: { isCurrent(pid: pid, snapshot: selection) }) + .value, + isActive() + else { return nil } return text } } diff --git a/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift index 839abf1..7ed899c 100644 --- a/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift +++ b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift @@ -12,12 +12,17 @@ struct ShortcutSettingsView: View { .font(.caption).foregroundStyle(.secondary) } Spacer() - Button(shortcuts.recording == action ? "按下快捷键…" : shortcuts.bindings[action]?.label ?? "未设置") { + Button( + shortcuts.recording == action + ? "按下快捷键…" : shortcuts.bindings[action]?.label ?? "未设置" + ) { shortcuts.beginRecording(action) } .frame(minWidth: 110) .accessibilityLabel("\(action.title)快捷键") - .accessibilityValue(shortcuts.recording == action ? "正在录入" : shortcuts.bindings[action]?.label ?? "未设置") + .accessibilityValue( + shortcuts.recording == action + ? "正在录入" : shortcuts.bindings[action]?.label ?? "未设置") Button("清除") { shortcuts.update(nil, for: action) } .disabled(shortcuts.bindings[action] == nil) .accessibilityLabel("清除\(action.title)快捷键") diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..901683f --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +for script in scripts/*.sh; do + bash -n "$script" +done +git ls-files --cached --others --exclude-standard -z -- \ + Package.swift 'Sources/**/*.swift' 'Tests/**/*.swift' \ + 'Packages/**/*.swift' 'extensions/**/*.swift' \ + ':!extensions/capture/Sources/CaptureMac/Vendor/**' \ + | xargs -0 swift format lint --strict +swift test From 7e2724f723fd24109057abcd298663d2313e9058 Mon Sep 17 00:00:00 2001 From: Lanzhijiang Date: Sun, 25 Oct 2026 14:46:20 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix(checks):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=94=81=E5=AE=9A=E7=89=88=E6=9C=AC=E8=BF=90=E8=A1=8C=20SwiftP?= =?UTF-8?q?M=20=E9=97=A8=E7=A6=81=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 防止格式化包清单触发无关依赖升级 --- scripts/check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check.sh b/scripts/check.sh index 901683f..c125bca 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,4 +10,4 @@ git ls-files --cached --others --exclude-standard -z -- \ 'Packages/**/*.swift' 'extensions/**/*.swift' \ ':!extensions/capture/Sources/CaptureMac/Vendor/**' \ | xargs -0 swift format lint --strict -swift test +swift test --disable-automatic-resolution From 37d819e0328e171172837c63b098ecb076f00f21 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 25 Oct 2026 17:39:44 +0800 Subject: [PATCH 11/14] =?UTF-8?q?docs(brand):=20=E5=9B=BA=E5=AE=9A=20UI=20?= =?UTF-8?q?=E5=80=99=E9=80=89=20Logo=20Pack=20=E5=8F=96=E5=BE=97=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 记录 UI PR #67 的精确候选提交与独立 ZIP 来源 - 补齐明暗面板及菜单资源映射,保留原生图标适配边界 --- README.md | 2 +- .../Sources/CaptureMac/Resources/Brand/README.md | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c0b63e1..50f12b0 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ PDF 读取文字层;DOCX 读取主文档与表格文字,遗漏脚注、批 Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 -应用图标、菜单栏 template 图标和明暗面板标识来自用户提供的 2026-10-21 logo pack。应用 ICNS 在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。资源选择、原始出处、小尺寸/高 DPI 几何与再生步骤见 `extensions/capture/Sources/CaptureMac/Resources/Brand/README.md`,构建不依赖 Downloads 中的原始目录。 +应用图标、菜单栏 template 图标和明暗面板标识使用 InKCre UI 的 2026-10-21 Logo Pack,当前固定到尚未发布的 UI PR #67 候选提交。完整 ZIP 可直接用于原生应用,无需安装 Web 依赖。应用 ICNS 在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。取得与固定方式、资源选择、原始出处、小尺寸/高 DPI 几何与再生步骤见 [Mac 标识资源](extensions/capture/Sources/CaptureMac/Resources/Brand/README.md),构建不依赖 Downloads 中的原始目录。 发布构建显式设置稳定 `BUNDLE_ID` 与 `SIGNING_IDENTITY`;可用 `NOTARY_PROFILE` 指定已配置的 notarytool Keychain profile。脚本不会寻找或创建发布凭据。发布后保持 bundle ID 与签名团队不变,以保留系统权限、Keychain 和待办身份。 diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md index 341425c..1b7aa76 100644 --- a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md +++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md @@ -1,6 +1,18 @@ # InKCre Mac 标识资源 -来自用户提供的 `InKCre-logo-pack-20261021`。来源与固定仓库版本见同目录 `PROVENANCE.json`;面板与菜单 PNG 保持资产包原字节,应用图标另做 macOS 平台画布适配。 +来自 InKCre UI 交付的 `InKCre-logo-pack-20261021`。当前固定到尚未发布的 [UI PR #67](https://github.com/InKCre/ui/pull/67) 候选提交 `b7fea4612999003e6976ce3649ef97fff4ad55d2`;[完整 ZIP](https://github.com/InKCre/ui/blob/b7fea4612999003e6976ce3649ef97fff4ad55d2/assets/InKCre-logo-pack-20261021.zip) 无需 Vue、npm 或渲染器即可取得原生资源。资产本身的原始来源与固定版本见同目录 `PROVENANCE.json`;面板与菜单 PNG 保持资产包原字节,应用图标另做 macOS 平台画布适配。 + +从仓库根目录取得并更新这三张资源: + +```sh +curl -fL https://raw.githubusercontent.com/InKCre/ui/b7fea4612999003e6976ce3649ef97fff4ad55d2/assets/InKCre-logo-pack-20261021.zip -o /tmp/InKCre-logo-pack-20261021.zip +unzip -q -o /tmp/InKCre-logo-pack-20261021.zip -d /tmp +cp /tmp/InKCre-logo-pack-20261021/png/2x/original-transparent/inkcre-original-transparent-24px@2x.png extensions/capture/Sources/CaptureMac/Resources/Brand/MarkLight.png +cp /tmp/InKCre-logo-pack-20261021/png/2x/reverse-transparent/inkcre-reverse-transparent-24px@2x.png extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png +cp /tmp/InKCre-logo-pack-20261021/png/2x/black-transparent/inkcre-black-transparent-20px@2x.png extensions/capture/Sources/CaptureMac/Resources/Brand/MenuTemplate.png +``` + +本次 ZIP 的 SHA-256 为 `ec4c2390f785d64db23c73f46c3c7a976cb6269d6724e5fa0a807467be79e25f`,三个 PNG 与此前用户提供的本地包逐字节一致。更新交付版本时先阅读该版本的 Logo 说明,按逻辑尺寸选择几何与像素倍率;不跟随移动分支。 `MarkLight.png` 和 `MarkDark.png` 分别取自原色透明、反白透明的 24 px @2x 版本,用于采集面板并按界面深浅切换。`MenuTemplate.png` 取自纯黑透明的 20 px @2x 版本,由 macOS 作为 template 图像适配菜单栏。 From 00bac5b1aec5c119db1a84ce0f95051af0cbc20f Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 26 Oct 2026 14:02:01 +0800 Subject: [PATCH 12/14] =?UTF-8?q?ref(brand):=20=E9=80=9A=E8=BF=87=20SwiftP?= =?UTF-8?q?M=20=E6=B6=88=E8=B4=B9=20UI=20=E6=A0=87=E8=AF=86=E8=B5=84?= =?UTF-8?q?=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 锁定 UI #67 候选资源 product,移除三张本地 PNG 副本 - 显式嵌入资源 bundle,保留原生尺寸、template 与 AppIcon 边界 --- Package.resolved | 10 +++++++- README.md | 2 +- extensions/capture/Package.swift | 8 ++++++- .../Sources/CaptureMac/BrandAssets.swift | 22 +++++++++++------- .../CaptureMac/Resources/Brand/MarkDark.png | Bin 360 -> 0 bytes .../CaptureMac/Resources/Brand/MarkLight.png | Bin 380 -> 0 bytes .../Resources/Brand/MenuTemplate.png | Bin 384 -> 0 bytes .../CaptureMac/Resources/Brand/README.md | 22 +++++++++--------- scripts/build-app.sh | 1 + 9 files changed, 43 insertions(+), 22 deletions(-) delete mode 100644 extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png delete mode 100644 extensions/capture/Sources/CaptureMac/Resources/Brand/MarkLight.png delete mode 100644 extensions/capture/Sources/CaptureMac/Resources/Brand/MenuTemplate.png diff --git a/Package.resolved b/Package.resolved index bb0bc1f..97b8504 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "5f09cb124b915dafd85dbbdc3963e066f1ead16665778dfb8bd2759d053cb364", + "originHash" : "0a4331c716f6315492dafb5e2f8d12e5cb5ec29e9cb08562420b8a9a237b33f3", "pins" : [ { "identity" : "postgres-nio", @@ -126,6 +126,14 @@ "revision" : "e7f85f4d3dd91ca9a1e94d3328aa1ed5120a8c0f", "version" : "2.14.3" } + }, + { + "identity" : "ui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/InKCre/ui.git", + "state" : { + "revision" : "15487b91b68ee3999ee6302276f621d0374757e6" + } } ], "version" : 3 diff --git a/README.md b/README.md index 50f12b0..4353b4d 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ PDF 读取文字层;DOCX 读取主文档与表格文字,遗漏脚注、批 Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 -应用图标、菜单栏 template 图标和明暗面板标识使用 InKCre UI 的 2026-10-21 Logo Pack,当前固定到尚未发布的 UI PR #67 候选提交。完整 ZIP 可直接用于原生应用,无需安装 Web 依赖。应用 ICNS 在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。取得与固定方式、资源选择、原始出处、小尺寸/高 DPI 几何与再生步骤见 [Mac 标识资源](extensions/capture/Sources/CaptureMac/Resources/Brand/README.md),构建不依赖 Downloads 中的原始目录。 +菜单栏 template 图标和明暗面板标识通过 InKCre UI 的 SwiftPM product `InKCreLogoAssets` 取得,Git revision 当前固定到尚未发布的 UI PR #67 候选。SwiftPM 解析资源,打包脚本将资源 bundle 嵌入最终 `.app`,普通构建无需解压 ZIP、安装 Web 依赖或运行渲染器。应用 ICNS 继续由本仓库维护,在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。依赖与资源路径、原始出处、小尺寸/高 DPI 几何与图标再生步骤见 [Mac 标识资源](extensions/capture/Sources/CaptureMac/Resources/Brand/README.md)。 发布构建显式设置稳定 `BUNDLE_ID` 与 `SIGNING_IDENTITY`;可用 `NOTARY_PROFILE` 指定已配置的 notarytool Keychain profile。脚本不会寻找或创建发布凭据。发布后保持 bundle ID 与签名团队不变,以保留系统权限、Keychain 和待办身份。 diff --git a/extensions/capture/Package.swift b/extensions/capture/Package.swift index a2afa13..ec83a6b 100644 --- a/extensions/capture/Package.swift +++ b/extensions/capture/Package.swift @@ -7,7 +7,12 @@ let package = Package( .library(name: "Capture", targets: ["Capture"]), .library(name: "CaptureMac", targets: ["CaptureMac"]), ], - dependencies: [.package(path: "../../Packages/PeerCore")], + dependencies: [ + .package(path: "../../Packages/PeerCore"), + .package( + url: "https://github.com/InKCre/ui.git", + revision: "15487b91b68ee3999ee6302276f621d0374757e6"), + ], targets: [ .systemLibrary(name: "CSQLite"), .target( @@ -19,6 +24,7 @@ let package = Package( dependencies: [ "Capture", .product(name: "PeerCore", package: "PeerCore"), .product(name: "PeerMacUI", package: "PeerCore"), + .product(name: "InKCreLogoAssets", package: "ui"), ], resources: [.copy("Resources/Brand")]), ] ) diff --git a/extensions/capture/Sources/CaptureMac/BrandAssets.swift b/extensions/capture/Sources/CaptureMac/BrandAssets.swift index 44b0dd7..94f040b 100644 --- a/extensions/capture/Sources/CaptureMac/BrandAssets.swift +++ b/extensions/capture/Sources/CaptureMac/BrandAssets.swift @@ -1,11 +1,16 @@ import AppKit +import InKCreLogoAssets @MainActor enum BrandAssets { - static let lightMark = image("MarkLight", size: 24) - static let darkMark = image("MarkDark", size: 24) + static let lightMark = image( + "inkcre-original-transparent-24px@2x", subdirectory: "png/2x/original-transparent", size: 24 + ) + static let darkMark = image( + "inkcre-reverse-transparent-24px@2x", subdirectory: "png/2x/reverse-transparent", size: 24) static let menuIcon: NSImage = { - let icon = image("MenuTemplate", size: 20) + let icon = image( + "inkcre-black-transparent-20px@2x", subdirectory: "png/2x/black-transparent", size: 20) icon.isTemplate = true icon.accessibilityDescription = "InKCre" return icon @@ -16,17 +21,18 @@ enum BrandAssets { if Bundle.main.bundleURL.pathExtension == "app" { guard let url = Bundle.main.url( - forResource: "InKCreCapture_CaptureMac", withExtension: "bundle"), + forResource: "InKCreUI_InKCreLogoAssets", withExtension: "bundle"), let bundle = Bundle(url: url) - else { preconditionFailure("Missing packaged InKCre resources") } + else { preconditionFailure("Missing packaged InKCre Logo assets") } return bundle } - return Bundle.module + return InKCreLogoAssets.bundle }() - private static func image(_ name: String, size: CGFloat) -> NSImage { + private static func image(_ name: String, subdirectory: String, size: CGFloat) -> NSImage { guard - let url = resources.url(forResource: name, withExtension: "png", subdirectory: "Brand"), + let url = resources.url( + forResource: name, withExtension: "png", subdirectory: subdirectory), let image = NSImage(contentsOf: url) else { preconditionFailure("Missing brand resource: \(name)") } image.size = NSSize(width: size, height: size) diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png deleted file mode 100644 index 6c314f081420230d718f3267a57b933fe4c91b8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?3TAX~PbvH$7ERG^?_ ziEBhjaDG}zd16s2LwR|*US?i)adKios$PCk`s{Z$Qb0vtJzX3_G8*5`Jji=kfycFe z72hS_S-w_W8zk>8nSH_8Vu_b)gG61%UzK>Jl!`+STxL3PNX+D}W^@l^Gz))pCoJiY zi_MXx!v6Q=_~MqN$vLYqW? z*7V54xc=kSY`i7)b>`!o)PMZnyiO#p+&gE1!5_&V+Kk;#Uq!RazH}>VzVPtO($)jc z7U@EnjQj~==VmT(Ij}wIh@cm1&Fs`G#(zwAZD%&Ny05kL4ddWoJSI@t z!@u1sQ!ypVreX9=AO`1(Q3Sl$4NjJIu$w#E-feF;1^^yF8kns^u0g}=)7wZC! z;sVxOtp)}#YWkC0><_C=3_vw7Ni4N@zl2=C8W*spdx7TnfcCi5@{Nn^DI%{a=7cKQ zNj41;p@@tt?b)%G|9AI*_N_9JNT$rbu^|5kTYI2%ji#JVYmYN6drK&iL{8KwriAYI z!2bvU7y)n|Eep$~_C=H5OIC) z%rmJ;&dtLtk%7xBfko=u!5o1qF1Lhc@5zT)P8WX4Nmh|R8I)xCZKAz-=LDCNYwC0j zTt7CwmTB(rkiOKns{@Pci4x>Kz|B-v#CU3#e&&o+Bpr%75}StrbhTvQ~l}r;{(ld-QyQ=#`*7 a52X9vY?i)|`JfgUdJLYfelF{r5}E*tvY5mG diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md index 1b7aa76..9956d5a 100644 --- a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md +++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md @@ -1,20 +1,20 @@ # InKCre Mac 标识资源 -来自 InKCre UI 交付的 `InKCre-logo-pack-20261021`。当前固定到尚未发布的 [UI PR #67](https://github.com/InKCre/ui/pull/67) 候选提交 `b7fea4612999003e6976ce3649ef97fff4ad55d2`;[完整 ZIP](https://github.com/InKCre/ui/blob/b7fea4612999003e6976ce3649ef97fff4ad55d2/assets/InKCre-logo-pack-20261021.zip) 无需 Vue、npm 或渲染器即可取得原生资源。资产本身的原始来源与固定版本见同目录 `PROVENANCE.json`;面板与菜单 PNG 保持资产包原字节,应用图标另做 macOS 平台画布适配。 +采集面板和菜单栏标识来自 InKCre UI 的 SwiftPM product `InKCreLogoAssets`,由 `extensions/capture/Package.swift` 声明 Git 依赖,并随应用的 `Package.resolved` 锁定解析结果。当前 revision 指向尚未发布的 [UI PR #67](https://github.com/InKCre/ui/pull/67) 候选,用于接入验收,不表示已有正式 Swift 版本发行。 -从仓库根目录取得并更新这三张资源: +`BrandAssets` 直接使用该 product 提供的 PNG,主题选择、逻辑尺寸与 `NSImage.isTemplate` 由原生界面负责,不在本仓库另存面板或菜单 PNG: -```sh -curl -fL https://raw.githubusercontent.com/InKCre/ui/b7fea4612999003e6976ce3649ef97fff4ad55d2/assets/InKCre-logo-pack-20261021.zip -o /tmp/InKCre-logo-pack-20261021.zip -unzip -q -o /tmp/InKCre-logo-pack-20261021.zip -d /tmp -cp /tmp/InKCre-logo-pack-20261021/png/2x/original-transparent/inkcre-original-transparent-24px@2x.png extensions/capture/Sources/CaptureMac/Resources/Brand/MarkLight.png -cp /tmp/InKCre-logo-pack-20261021/png/2x/reverse-transparent/inkcre-reverse-transparent-24px@2x.png extensions/capture/Sources/CaptureMac/Resources/Brand/MarkDark.png -cp /tmp/InKCre-logo-pack-20261021/png/2x/black-transparent/inkcre-black-transparent-20px@2x.png extensions/capture/Sources/CaptureMac/Resources/Brand/MenuTemplate.png -``` +| 用途 | Bundle 内路径 | 原生显示 | +| --- | --- | --- | +| 浅色面板 | `png/2x/original-transparent/inkcre-original-transparent-24px@2x.png` | 24 × 24 pt | +| 深色面板 | `png/2x/reverse-transparent/inkcre-reverse-transparent-24px@2x.png` | 24 × 24 pt | +| 菜单栏 | `png/2x/black-transparent/inkcre-black-transparent-20px@2x.png` | 20 × 20 pt,template | -本次 ZIP 的 SHA-256 为 `ec4c2390f785d64db23c73f46c3c7a976cb6269d6724e5fa0a807467be79e25f`,三个 PNG 与此前用户提供的本地包逐字节一致。更新交付版本时先阅读该版本的 Logo 说明,按逻辑尺寸选择几何与像素倍率;不跟随移动分支。 +普通 SwiftPM 运行使用公开的 `InKCreLogoAssets.bundle`。自组装应用由 `scripts/build-app.sh` 将 `InKCreUI_InKCreLogoAssets.bundle` 放入 `.app/Contents/Resources`,`BrandAssets` 在应用内只从该嵌入位置加载,缺失时直接报错,不回退到本机 `.build`。普通构建不需要解压 ZIP、执行资源生成器或安装 Web 依赖。 -`MarkLight.png` 和 `MarkDark.png` 分别取自原色透明、反白透明的 24 px @2x 版本,用于采集面板并按界面深浅切换。`MenuTemplate.png` 取自纯黑透明的 20 px @2x 版本,由 macOS 作为 template 图像适配菜单栏。 +更新 UI 依赖时阅读该 revision 的 Logo 说明,按逻辑尺寸选择几何与像素倍率,并更新解析锁定;24 pt @2x 的文件是 48 × 48 物理像素,不能改选常规 48 px 的标准几何。资源嵌入验收应将 `.app` 移出构建目录,确认实际界面仍能显示标识。 + +应用图标继续由本仓库维护。当前几何来自原 `InKCre-logo-pack-20261021`,原始来源与固定版本见同目录 `PROVENANCE.json`;该历史来源记录不承担运行时 SwiftPM 依赖的版本锁定。 `AppIcon.svg` 与 `AppIconSmall.svg` 保留资产包的标准 256 和小尺寸 32 几何与配色,在白色圆角底板外围加入透明留白。沿用 `nearby-audio` 已修复的 legacy ICNS 光学尺度:常规底板占 824/1024,小尺寸占 28/32;圆角半径为底板宽度的 18.75%。不能把通用满版白底 PNG 直接打包成 Mac 图标,ICNS 不会自动补这层留白。 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 57eb750..be68262 100644 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -28,6 +28,7 @@ mkdir -p "$app/Contents/Resources" cp "$bin_path/InKCre" "$app/Contents/MacOS/InKCre" cp -R "$bin_path/InKCreCapture_Capture.bundle" "$app/Contents/Resources/" cp -R "$bin_path/InKCreCapture_CaptureMac.bundle" "$app/Contents/Resources/" +cp -R "$bin_path/InKCreUI_InKCreLogoAssets.bundle" "$app/Contents/Resources/" cp -R "$bin_path/InKCreLocalFiles_LocalFiles.bundle" "$app/Contents/Resources/" for resource in "$bin_path"/swift-*.bundle; do [[ -d "$resource" ]] || continue From 91e989c3181b0098a53d2b8d26c12c25463fbce6 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 26 Oct 2026 14:30:44 +0800 Subject: [PATCH 13/14] =?UTF-8?q?fix(packaging):=20=E4=BF=9D=E7=95=99=20UI?= =?UTF-8?q?=20=E5=93=81=E7=89=8C=E6=9D=A5=E6=BA=90=E5=B9=B6=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E5=BA=94=E7=94=A8=E6=89=93=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从固定 UI checkout 收集品牌使用说明与来源记录 - 保留其他依赖缺失许可时的失败边界,并加入打包回归检查 --- THIRD_PARTY_NOTICES.md | 2 +- .../CaptureMac/Resources/Brand/README.md | 2 +- scripts/check.sh | 1 + scripts/collect-licenses.py | 3 + scripts/tests/test_collect_licenses.py | 57 +++++++++++++++++++ 5 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 scripts/tests/test_collect_licenses.py diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e5b5843..0d9da0e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,7 +8,7 @@ BetterShot 由 Kartik Labhshetwar 及贡献者开发。CaptureMac/Vendor/BetterS PostgresNIO 及传递依赖使用各自许可。Package.resolved 固定来源与修订;scripts/collect-licenses.py 从实际解析的源码收集完整 LICENSE/NOTICE,包括 BoringSSL 的内嵌许可。缺失许可会使打包失败。生成结果随应用保存在 Contents/Resources/THIRD_PARTY_NOTICES.txt,可从应用菜单打开。 -用户提供的 InKCre logo pack 用于本项目品牌;代码 MIT 许可不授予 InKCre 商标或品牌资源的使用权。品牌目录保留出处记录。 +InKCre UI 的 `InKCreLogoAssets` 用于本项目品牌;代码 MIT 许可不授予 InKCre 商标或品牌资源的使用权。打包从锁定的 UI checkout 收集 `assets/logo/README.zh-CN.md` 与 `PROVENANCE.json`,保留资源来源和使用边界;任一文件缺失仍使打包失败。其他依赖继续要求完整许可。应用图标的历史来源另保留于本机品牌目录。 [SwiftSoup 2.14.3](https://github.com/scinfu/SwiftSoup/tree/2.14.3)(e7f85f4d3dd91ca9a1e94d3328aa1ed5120a8c0f)用于本地 HTML 文字解析,使用 MIT。构建收集上游完整 LICENSE,保留原 jsoup 作者与 Swift port 作者署名。 diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md index 9956d5a..806acb4 100644 --- a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md +++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md @@ -1,6 +1,6 @@ # InKCre Mac 标识资源 -采集面板和菜单栏标识来自 InKCre UI 的 SwiftPM product `InKCreLogoAssets`,由 `extensions/capture/Package.swift` 声明 Git 依赖,并随应用的 `Package.resolved` 锁定解析结果。当前 revision 指向尚未发布的 [UI PR #67](https://github.com/InKCre/ui/pull/67) 候选,用于接入验收,不表示已有正式 Swift 版本发行。 +采集面板和菜单栏标识来自 InKCre UI 的 SwiftPM product `InKCreLogoAssets`,由 `extensions/capture/Package.swift` 声明 Git 依赖,并随应用的 `Package.resolved` 锁定解析结果。[UI PR #67](https://github.com/InKCre/ui/pull/67) 已合并;当前固定 revision 与该合并提交的源码树相同,不表示已有正式 Swift 版本发行。 `BrandAssets` 直接使用该 product 提供的 PNG,主题选择、逻辑尺寸与 `NSImage.isTemplate` 由原生界面负责,不在本仓库另存面板或菜单 PNG: diff --git a/scripts/check.sh b/scripts/check.sh index c125bca..77056ae 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,6 +5,7 @@ cd "$(dirname "$0")/.." for script in scripts/*.sh; do bash -n "$script" done +python3 -B -m unittest discover -s scripts/tests git ls-files --cached --others --exclude-standard -z -- \ Package.swift 'Sources/**/*.swift' 'Tests/**/*.swift' \ 'Packages/**/*.swift' 'extensions/**/*.swift' \ diff --git a/scripts/collect-licenses.py b/scripts/collect-licenses.py index 956797c..26c9f4f 100644 --- a/scripts/collect-licenses.py +++ b/scripts/collect-licenses.py @@ -13,6 +13,9 @@ checkout = root / ".build/checkouts" / pin["identity"] files = sorted(p for p in checkout.rglob("*") if p.is_file() and p.name.upper().startswith(("LICENSE", "LICENCE", "NOTICE", "COPYING"))) + if pin["location"] == "https://github.com/InKCre/ui.git": + # 本项目品牌资源保留来源与使用边界,不把代码 MIT 许可扩展为品牌授权。 + files += [checkout / "assets/logo/README.zh-CN.md", checkout / "assets/logo/PROVENANCE.json"] if not files: raise SystemExit(f"Missing license for {pin['identity']}") parts.append(f"{pin['identity']} {pin['state'].get('version', '')}\n{pin['location']}\nrevision {pin['state']['revision']}") diff --git a/scripts/tests/test_collect_licenses.py b/scripts/tests/test_collect_licenses.py new file mode 100644 index 0000000..3b3e0e3 --- /dev/null +++ b/scripts/tests/test_collect_licenses.py @@ -0,0 +1,57 @@ +"""验证最终通知包含固定依赖的许可,以及本项目品牌的来源与使用边界。""" +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class CollectLicensesTests(unittest.TestCase): + def test_bundles_brand_provenance_without_relaxing_missing_license_checks(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "scripts").mkdir() + script = root / "scripts/collect-licenses.py" + shutil.copyfile(Path(__file__).resolve().parents[1] / script.name, script) + (root / "LICENSE").write_text("Host license") + (root / "THIRD_PARTY_NOTICES.md").write_text("Host notices") + logo = root / ".build/checkouts/ui/assets/logo" + logo.mkdir(parents=True) + (logo / "README.zh-CN.md").write_text("品牌资源不另行授予商标使用权限。") + provenance = logo / "PROVENANCE.json" + provenance.write_text('{"source": "fixed brand source"}') + dependency = root / ".build/checkouts/dependency" + dependency.mkdir() + (dependency / "LICENSE").write_text("Dependency full license") + pins = [ + {"identity": "ui", "location": "https://github.com/InKCre/ui.git", + "state": {"revision": "fixed-ui-revision"}}, + {"identity": "dependency", "location": "https://example.com/dependency.git", + "state": {"revision": "fixed-dependency-revision"}}, + ] + (root / "Package.resolved").write_text(json.dumps({"pins": pins})) + output = root / "notices.txt" + + def collect(): + return subprocess.run(["python3", str(script), str(output)], capture_output=True, text=True) + + result = collect() + self.assertEqual(result.returncode, 0, result.stderr) + bundled = output.read_text() + for value in ("Dependency full license", "品牌资源不另行授予商标使用权限。", + "fixed brand source", "fixed-ui-revision", "fixed-dependency-revision"): + self.assertIn(value, bundled) + + pins[0]["location"] = "https://example.com/ui.git" + (root / "Package.resolved").write_text(json.dumps({"pins": pins})) + result = collect() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Missing license for ui", result.stderr) + + pins[0]["location"] = "https://github.com/InKCre/ui.git" + (root / "Package.resolved").write_text(json.dumps({"pins": pins})) + provenance.unlink() + result = collect() + self.assertNotEqual(result.returncode, 0) + self.assertIn("PROVENANCE.json", result.stderr) From e13a177a1b749e7407fbfa28fb8e10fd311ea50e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 26 Oct 2026 14:34:31 +0800 Subject: [PATCH 14/14] =?UTF-8?q?chore(brand):=20=E5=9B=BA=E5=AE=9A=20UI?= =?UTF-8?q?=202.6.0=20=E6=AD=A3=E5=BC=8F=E8=B5=84=E6=BA=90=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 使用 exact 版本与发行提交锁定 SwiftPM 资源 - 更新正式版本说明并复验应用内独立资源加载 --- Package.resolved | 3 ++- README.md | 2 +- extensions/capture/Package.swift | 4 +--- .../capture/Sources/CaptureMac/Resources/Brand/README.md | 4 ++-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Package.resolved b/Package.resolved index 97b8504..9f650c2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -132,7 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/InKCre/ui.git", "state" : { - "revision" : "15487b91b68ee3999ee6302276f621d0374757e6" + "revision" : "e5858e1092617eb5acf6588460e597fbc43c662a", + "version" : "2.6.0" } } ], diff --git a/README.md b/README.md index 4353b4d..c82c5c9 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ PDF 读取文字层;DOCX 读取主文档与表格文字,遗漏脚注、批 Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 -菜单栏 template 图标和明暗面板标识通过 InKCre UI 的 SwiftPM product `InKCreLogoAssets` 取得,Git revision 当前固定到尚未发布的 UI PR #67 候选。SwiftPM 解析资源,打包脚本将资源 bundle 嵌入最终 `.app`,普通构建无需解压 ZIP、安装 Web 依赖或运行渲染器。应用 ICNS 继续由本仓库维护,在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。依赖与资源路径、原始出处、小尺寸/高 DPI 几何与图标再生步骤见 [Mac 标识资源](extensions/capture/Sources/CaptureMac/Resources/Brand/README.md)。 +菜单栏 template 图标和明暗面板标识通过 InKCre UI 的 SwiftPM product `InKCreLogoAssets` 取得,当前依赖正式版本 `2.6.0`,使用 exact 约束并由 `Package.resolved` 锁定提交。SwiftPM 解析资源,打包脚本将资源 bundle 嵌入最终 `.app`,普通构建无需解压 ZIP、安装 Web 依赖或运行渲染器。应用 ICNS 继续由本仓库维护,在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。依赖与资源路径、原始出处、小尺寸/高 DPI 几何与图标再生步骤见 [Mac 标识资源](extensions/capture/Sources/CaptureMac/Resources/Brand/README.md)。 发布构建显式设置稳定 `BUNDLE_ID` 与 `SIGNING_IDENTITY`;可用 `NOTARY_PROFILE` 指定已配置的 notarytool Keychain profile。脚本不会寻找或创建发布凭据。发布后保持 bundle ID 与签名团队不变,以保留系统权限、Keychain 和待办身份。 diff --git a/extensions/capture/Package.swift b/extensions/capture/Package.swift index ec83a6b..7b4b483 100644 --- a/extensions/capture/Package.swift +++ b/extensions/capture/Package.swift @@ -9,9 +9,7 @@ let package = Package( ], dependencies: [ .package(path: "../../Packages/PeerCore"), - .package( - url: "https://github.com/InKCre/ui.git", - revision: "15487b91b68ee3999ee6302276f621d0374757e6"), + .package(url: "https://github.com/InKCre/ui.git", exact: "2.6.0"), ], targets: [ .systemLibrary(name: "CSQLite"), diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md index 806acb4..01da7ad 100644 --- a/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md +++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md @@ -1,6 +1,6 @@ # InKCre Mac 标识资源 -采集面板和菜单栏标识来自 InKCre UI 的 SwiftPM product `InKCreLogoAssets`,由 `extensions/capture/Package.swift` 声明 Git 依赖,并随应用的 `Package.resolved` 锁定解析结果。[UI PR #67](https://github.com/InKCre/ui/pull/67) 已合并;当前固定 revision 与该合并提交的源码树相同,不表示已有正式 Swift 版本发行。 +采集面板和菜单栏标识来自 InKCre UI 的 SwiftPM product `InKCreLogoAssets`,由 `extensions/capture/Package.swift` 声明 [UI `2.6.0`](https://github.com/InKCre/ui/tree/2.6.0) 的 exact 依赖,并随应用的 `Package.resolved` 锁定解析提交。升级时同步修改版本约束与锁文件,不跟随移动分支。 `BrandAssets` 直接使用该 product 提供的 PNG,主题选择、逻辑尺寸与 `NSImage.isTemplate` 由原生界面负责,不在本仓库另存面板或菜单 PNG: @@ -12,7 +12,7 @@ 普通 SwiftPM 运行使用公开的 `InKCreLogoAssets.bundle`。自组装应用由 `scripts/build-app.sh` 将 `InKCreUI_InKCreLogoAssets.bundle` 放入 `.app/Contents/Resources`,`BrandAssets` 在应用内只从该嵌入位置加载,缺失时直接报错,不回退到本机 `.build`。普通构建不需要解压 ZIP、执行资源生成器或安装 Web 依赖。 -更新 UI 依赖时阅读该 revision 的 Logo 说明,按逻辑尺寸选择几何与像素倍率,并更新解析锁定;24 pt @2x 的文件是 48 × 48 物理像素,不能改选常规 48 px 的标准几何。资源嵌入验收应将 `.app` 移出构建目录,确认实际界面仍能显示标识。 +更新 UI 依赖时阅读该版本的 Logo 说明,按逻辑尺寸选择几何与像素倍率,并更新解析锁定;24 pt @2x 的文件是 48 × 48 物理像素,不能改选常规 48 px 的标准几何。资源嵌入验收应将 `.app` 移出构建目录,确认实际界面仍能显示标识。 应用图标继续由本仓库维护。当前几何来自原 `InKCre-logo-pack-20261021`,原始来源与固定版本见同目录 `PROVENANCE.json`;该历史来源记录不承担运行时 SwiftPM 依赖的版本锁定。