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/.gitignore b/.gitignore
new file mode 100644
index 0000000..a9c2649
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.build/
+.swiftpm/
+dist/
+.DS_Store
+*.xcuserstate
+xcuserdata/
+.local-development-signing-identity
+**/Package.resolved
+!/Package.resolved
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
new file mode 100644
index 0000000..3e1398c
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,15 @@
+# InKCre Mac Peer
+
+使用中文与人沟通。未明确要求时不提交或推送。
+
+本仓库拥有原生 macOS 采集 Peer。共享状态属于 info-base;Mac 自己校验材料、构图并通过共享数据库协议原子写入,不依赖其他 Peer 执行采集。Mac 承载随签名应用交付的原生扩展,不启动 Python 子进程,不把本机 SQLite 当成共享知识权威。
+
+本体是 regular Mac 应用,拥有 Dock 图标、普通主窗口和应用菜单。快捷采集 NSPanel 是辅助入口;不得再把应用整体收窄为 accessory/LSUIElement 菜单栏工具。主窗口导航与快捷采集编辑位置分开,草稿和队列只有一个所有者。
+
+`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、旧回执或旧本机格式兼容。保留原开发数据文件,不自动导入或清空。第三方引用必须保留许可证、固定来源、修改说明和署名;打包必须包含完整依赖许可。
+
+同次提交不得换 UUID、变更材料或改投另一个信息库。只有经过指纹核对的共享回执可以清理本机材料。选区失败不能读旧剪贴板充数;截图只持久化明确选择的区域。
+
+运行 `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/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 [](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 7c6f44d..0000000
Binary files a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 1.png and /dev/null differ
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 7c6f44d..0000000
Binary files a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 2.png and /dev/null differ
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 10cb4b9..0000000
Binary files a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appicon light.png and /dev/null differ
diff --git a/D3LibMin/Assets.xcassets/Contents.json b/D3LibMin/Assets.xcassets/Contents.json
deleted file mode 100644
index 73c0059..0000000
--- a/D3LibMin/Assets.xcassets/Contents.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
diff --git a/D3LibMin/Assets.xcassets/Library.imageset/Contents.json b/D3LibMin/Assets.xcassets/Library.imageset/Contents.json
deleted file mode 100644
index 19f1d9f..0000000
--- a/D3LibMin/Assets.xcassets/Library.imageset/Contents.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "images" : [
- {
- "filename" : "Library.png",
- "idiom" : "universal",
- "scale" : "1x"
- },
- {
- "idiom" : "universal",
- "scale" : "2x"
- },
- {
- "idiom" : "universal",
- "scale" : "3x"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
\ No newline at end of file
diff --git a/D3LibMin/Assets.xcassets/Library.imageset/Library.png b/D3LibMin/Assets.xcassets/Library.imageset/Library.png
deleted file mode 100644
index e860ef6..0000000
Binary files a/D3LibMin/Assets.xcassets/Library.imageset/Library.png and /dev/null differ
diff --git a/D3LibMin/Assets.xcassets/Profile.imageset/Contents.json b/D3LibMin/Assets.xcassets/Profile.imageset/Contents.json
deleted file mode 100644
index aa56205..0000000
--- a/D3LibMin/Assets.xcassets/Profile.imageset/Contents.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "images" : [
- {
- "filename" : "Profile.png",
- "idiom" : "universal",
- "scale" : "1x"
- },
- {
- "idiom" : "universal",
- "scale" : "2x"
- },
- {
- "idiom" : "universal",
- "scale" : "3x"
- }
- ],
- "info" : {
- "author" : "xcode",
- "version" : 1
- }
-}
\ No newline at end of file
diff --git a/D3LibMin/Assets.xcassets/Profile.imageset/Profile.png b/D3LibMin/Assets.xcassets/Profile.imageset/Profile.png
deleted file mode 100644
index ece57aa..0000000
Binary files a/D3LibMin/Assets.xcassets/Profile.imageset/Profile.png and /dev/null differ
diff --git a/D3LibMin/COLOR_SYSTEM_UPDATE.md b/D3LibMin/COLOR_SYSTEM_UPDATE.md
deleted file mode 100644
index 1aa7406..0000000
--- a/D3LibMin/COLOR_SYSTEM_UPDATE.md
+++ /dev/null
@@ -1,160 +0,0 @@
-# 颜色系统更新总结
-
-## 概述
-
-为了统一D3LibMin应用的色调,已将primary color设置为纯黑色,创建了完整的自定义颜色系统。
-
-## 更新内容
-
-### 1. 创建ColorExtension.swift
-
-**文件位置**: `D3LibMin/ColorExtension.swift`
-
-**主要功能**:
-- 定义自定义颜色常量
-- 重写Color.primary为纯黑色
-- 提供一致的颜色管理
-
-**颜色定义**:
-```swift
-// 纯黑色作为primary color
-static let customPrimary = Color.black
-
-// 深灰色作为secondary color
-static let customSecondary = Color(red: 0.4, green: 0.4, blue: 0.4)
-
-// 浅灰色作为背景色
-static let customBackground = Color(red: 0.95, green: 0.95, blue: 0.95)
-
-// 白色作为卡片背景
-static let customCardBackground = Color.white
-```
-
-### 2. 更新AccentColor.colorset
-
-**文件位置**: `D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json`
-
-**更改内容**:
-- 从系统默认的`labelColor`改为纯黑色
-- RGB值: (0, 0, 0)
-- Alpha值: 1.0
-
-**更新前**:
-```json
-{
- "color" : {
- "platform" : "universal",
- "reference" : "labelColor"
- }
-}
-```
-
-**更新后**:
-```json
-{
- "color" : {
- "color-space" : "srgb",
- "components" : {
- "alpha" : "1.000",
- "blue" : "0.000",
- "green" : "0.000",
- "red" : "0.000"
- }
- }
-}
-```
-
-## 影响范围
-
-### 自动应用的颜色
-
-通过重写`Color.primary`,以下所有使用`.primary`的地方都会自动应用纯黑色:
-
-1. **文本颜色** - 所有标题、正文文本
-2. **图标颜色** - 系统图标和自定义图标
-3. **边框颜色** - 按钮边框、卡片边框
-4. **背景颜色** - 按钮背景、选中状态背景
-5. **填充颜色** - 进度条、指示器等
-
-### 涉及的文件
-
-- `HomeView.swift` - 主页面的所有primary颜色
-- `UserProfileView.swift` - 用户资料页面的所有primary颜色
-- `InfoDetailView.swift` - 信息详情页面的所有primary颜色
-- `AddDeviceView.swift` - 添加设备页面的所有primary颜色
-- `NotificationsView.swift` - 通知设置页面的所有primary颜色
-- `PrivacyView.swift` - 隐私设置页面的所有primary颜色
-- `HelpSupportView.swift` - 帮助支持页面的所有primary颜色
-- `CustomToastView.swift` - 自定义Toast视图的primary颜色
-- `PixelWaveAnimationView.swift` - 像素波动画视图的primary颜色
-
-## 设计优势
-
-### 1. 统一性
-- 整个应用使用一致的纯黑色作为主色调
-- 消除了不同系统主题下的颜色差异
-- 提供更加专业的视觉效果
-
-### 2. 可维护性
-- 通过ColorExtension集中管理颜色
-- 易于未来颜色方案的调整
-- 避免硬编码颜色值
-
-### 3. 用户体验
-- 纯黑色提供最佳的对比度
-- 符合现代设计趋势
-- 在各种设备上都有良好的显示效果
-
-### 4. 扩展性
-- 可以轻松添加新的自定义颜色
-- 支持深色模式适配
-- 便于品牌色彩的应用
-
-## 技术实现
-
-### 颜色扩展机制
-```swift
-extension Color {
- static var primary: Color {
- return .customPrimary
- }
-}
-```
-
-这种实现方式确保了:
-- 所有现有的`.primary`引用都会自动使用新颜色
-- 无需修改现有代码
-- 向后兼容性良好
-
-### 编译验证
-✅ 项目成功编译
-✅ 所有颜色引用正常工作
-✅ 无编译错误或警告
-
-## 未来扩展
-
-### 可能的改进
-1. **深色模式支持** - 为深色模式定义不同的颜色值
-2. **品牌色彩** - 添加品牌特定的颜色定义
-3. **语义化颜色** - 定义更多语义化的颜色名称
-4. **颜色主题** - 支持多种颜色主题切换
-
-### 建议的使用方式
-```swift
-// 使用自定义颜色
-.foregroundColor(.customPrimary)
-.background(Color.customCardBackground)
-
-// 或继续使用系统颜色(会自动应用自定义primary)
-.foregroundColor(.primary)
-```
-
-## 总结
-
-通过这次颜色系统更新,D3LibMin应用现在拥有了:
-- 🎨 统一的纯黑色主色调
-- 🔧 可维护的颜色管理系统
-- 📱 更好的视觉一致性
-- 🚀 为未来扩展做好准备
-
-整个应用现在呈现出更加专业和统一的视觉效果,同时保持了良好的代码结构和可维护性。
\ No newline at end of file
diff --git a/D3LibMin/ColorExtension.swift b/D3LibMin/ColorExtension.swift
deleted file mode 100644
index d64cd46..0000000
--- a/D3LibMin/ColorExtension.swift
+++ /dev/null
@@ -1,29 +0,0 @@
-//
-// ColorExtension.swift
-// D3LibMin
-//
-// Created by Jackson Guo on 2025/7/27.
-//
-
-import SwiftUI
-
-extension Color {
- // 定义纯黑色作为primary color
- static let customPrimary = Color.black
-
- // 定义深灰色作为secondary color
- static let customSecondary = Color(red: 0.4, green: 0.4, blue: 0.4)
-
- // 定义浅灰色作为背景色
- static let customBackground = Color(red: 0.95, green: 0.95, blue: 0.95)
-
- // 定义白色作为卡片背景
- static let customCardBackground = Color.white
-}
-
-// 扩展Color以支持自定义primary color
-extension Color {
- static var primary: Color {
- return .customPrimary
- }
-}
\ No newline at end of file
diff --git a/D3LibMin/ContentView.swift b/D3LibMin/ContentView.swift
deleted file mode 100644
index e57456c..0000000
--- a/D3LibMin/ContentView.swift
+++ /dev/null
@@ -1,222 +0,0 @@
-//
-// ContentView.swift
-// D3LibMin
-//
-// Created by Jackson Guo on 2025/7/26.
-//
-
-import SwiftUI
-import Photos
-import UIKit
-
-struct ContentView: View {
- @State private var showLatestImage = false
- @State private var latestImage: UIImage?
- @State private var showPixelWave = false
- @State private var showCenterPixelWave = false
- @State private var showToast = false
- @Environment(\.scenePhase) private var scenePhase
-
- var body: some View {
- ZStack {
- TabView {
- HomeView()
- .tabItem {
- Image(systemName: "house")
- Text("Library")
- }
-
- NavigationView {
- UserProfileView()
- }
- .navigationViewStyle(StackNavigationViewStyle())
- .tabItem {
- Image(systemName: "person")
- Text("Profile")
- }
- }
- .accentColor(.primary)
- .animation(nil, value: UUID())
- .onAppear {
- checkClipboardAndShowImage()
- }
- .onChange(of: scenePhase) { newPhase in
- if newPhase == .active {
- checkClipboardAndShowImage()
- }
- }
- .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
- checkClipboardAndShowImage()
- }
-
- // 中央波纹动画覆盖层
- if showCenterPixelWave {
- PixelWaveAnimationView(centerPosition: CGPoint(
- x: UIScreen.main.bounds.width / 2,
- y: UIScreen.main.bounds.height / 2
- ))
- .ignoresSafeArea()
- }
-
- // 图片显示覆盖层(只有旧图片会显示)
- if showLatestImage, let image = latestImage {
- ImageWithBorderView(image: image, showLatestImage: $showLatestImage)
- .padding(.top, 100)
- }
-
- // 像素波纹动画覆盖层
- if showPixelWave {
- PixelWaveAnimationView()
- .ignoresSafeArea()
- .onAppear {
- // 1秒后隐藏动画并显示Toast
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- withAnimation(.easeOut(duration: 0.3)) {
- showPixelWave = false
- }
-
- // 动画结束后显示Toast
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
- withAnimation(.easeInOut(duration: 0.4)) {
- showToast = true
- }
-
- // 2秒后自动隐藏Toast
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
- withAnimation(.easeOut(duration: 0.3)) {
- showToast = false
- }
- }
- }
- }
- }
- }
-
- // 自定义Toast覆盖层
- CustomToastView(message: "云端解析中", isShowing: $showToast)
- .opacity(showToast ? 1 : 0)
- .allowsHitTesting(showToast)
- }
- }
-
- private func checkClipboardAndShowImage() {
- let pasteboard = UIPasteboard.general
-
- if let clipboardString = pasteboard.string,
- clipboardString == "D3lib_addpic" {
-
- pasteboard.string = ""
-
- requestPhotoLibraryPermission { granted in
- if granted {
- fetchLatestPhoto()
- }
- }
- }
- }
-
- private func requestPhotoLibraryPermission(completion: @escaping (Bool) -> 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 859f704..0000000
Binary files a/D3LibMin/SpaceGrotesk-Medium.ttf and /dev/null differ
diff --git a/D3LibMin/UserProfileView.swift b/D3LibMin/UserProfileView.swift
deleted file mode 100644
index f1f8b4d..0000000
--- a/D3LibMin/UserProfileView.swift
+++ /dev/null
@@ -1,284 +0,0 @@
-//
-// UserProfileView.swift
-// D3LibMin
-//
-// Created by Jackson Guo on 2025/7/26.
-//
-
-import SwiftUI
-
-struct UserProfileView: View {
- @Environment(\.presentationMode) var presentationMode
- @State private var userProfile = UserProfile()
-
- var body: some View {
- VStack(spacing: 0) {
- // Header
- HStack {
- HStack(spacing: 12) {
- Image("Profile")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 24, height: 24)
-
- Text("Profile")
- .font(.spaceGroteskMedium(size: 24))
- .foregroundColor(.primary)
- }
-
- Spacer()
- }
- .padding(.horizontal, 20)
- .padding(.top, 10)
- .padding(.bottom, 20)
-
- ScrollView {
- VStack(spacing: 30) {
- // User Info Section
- VStack(spacing: 16) {
- // Avatar
- Image(systemName: userProfile.avatar)
- .font(.system(size: 60))
- .foregroundColor(.primary)
- .frame(width: 80, height: 80)
- .background(Color(.systemGray6))
- .clipShape(Circle())
-
- // Name and Username
- VStack(spacing: 4) {
- Text(userProfile.name)
- .font(.spaceGroteskMedium(size: 20))
- .foregroundColor(.primary)
-
- Text(userProfile.username)
- .font(.system(size: 14, weight: .regular))
- .foregroundColor(.secondary)
- }
- }
-
- // Memory Usage Section
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Image(systemName: "internaldrive")
- .font(.system(size: 16))
- .foregroundColor(.primary)
-
- Text("Memory Usage")
- .font(.system(size: 16, weight: .medium))
- .foregroundColor(.primary)
-
- Spacer()
-
- Text("\(userProfile.memoryUsed, specifier: "%.1f") GB / \(userProfile.memoryTotal, specifier: "%.1f") GB")
- .font(.system(size: 14, weight: .regular))
- .foregroundColor(.secondary)
- }
-
- // Dot Matrix Progress Bar
- VStack(alignment: .leading, spacing: 8) {
- DotMatrixProgressView(progress: userProfile.memoryUsagePercentage / 100)
-
- Text("\(Int(userProfile.memoryUsagePercentage))% of storage used")
- .font(.system(size: 12, weight: .regular))
- .foregroundColor(.secondary)
- }
- }
- .padding(20)
- .background(Color.white)
- .cornerRadius(12)
-
- // Connected Devices Section
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Image(systemName: "antenna.radiowaves.left.and.right")
- .font(.system(size: 16))
- .foregroundColor(.primary)
-
- Text("Connected Devices")
- .font(.system(size: 16, weight: .medium))
- .foregroundColor(.primary)
- }
-
- ForEach(userProfile.connectedDevices) { device in
- DeviceRow(device: device)
- }
-
- // Add Device Button
- NavigationLink(destination: AddDeviceView()) {
- HStack {
- Image(systemName: "plus")
- .font(.system(size: 14, weight: .medium))
- .foregroundColor(.primary)
-
- Text("Add Device")
- .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)
-
- // App Settings Section
- VStack(alignment: .leading, spacing: 0) {
- HStack {
- Image(systemName: "gearshape")
- .font(.system(size: 16))
- .foregroundColor(.primary)
-
- Text("App Settings")
- .font(.system(size: 16, weight: .medium))
- .foregroundColor(.primary)
- }
- .padding(.bottom, 16)
-
- VStack(spacing: 16) {
- NavigationLink(destination: NotificationsView()) {
- SettingsRow(title: "Notifications", hasChevron: true)
- }
- NavigationLink(destination: PrivacyView()) {
- SettingsRow(title: "Privacy", hasChevron: true)
- }
- NavigationLink(destination: HelpSupportView()) {
- SettingsRow(title: "Help & Support", hasChevron: true)
- }
- }
- }
- .padding(20)
- .background(Color.white)
- .cornerRadius(12)
- }
- .padding(.horizontal, 20)
- }
-
- Spacer()
- }
- .background(Color(.systemGroupedBackground))
- .navigationBarHidden(true)
- }
-}
-
-struct DeviceRow: View {
- let device: ConnectedDevice
-
- private func formatLastConnected(_ date: Date?) -> 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..] = []
+ 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
+ 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()
+ }
+ }
+ 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..7e623ee
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift
@@ -0,0 +1,85 @@
+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..7264e00
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift
@@ -0,0 +1,103 @@
+import Foundation
+
+public actor ConnectionSettings {
+ private let url: URL
+ public init(url: URL) { self.url = 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(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, name: 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: 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)
+ 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..8d5ac51
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerCore/Credentials.swift
@@ -0,0 +1,59 @@
+import Foundation
+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("请输入数据库密码。")
+ }
+ 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
+ status = SecItemAdd(item as CFDictionary, nil)
+ }
+ guard status == errSecSuccess else { throw PeerError.storage("Keychain 保存失败(\(status))。") }
+ }
+
+ 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,
+ kSecAttrAccount as String: connection.uuidString,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+ 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 {
+ 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..f69cbe7
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift
@@ -0,0 +1,32 @@
+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,
+ 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..ce8f9bb
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift
@@ -0,0 +1,89 @@
+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
+ }
+}
+// 数据库图协议直接使用 from_/to_ 字段。
+// swift-format-ignore: AlwaysUseLowerCamelCase
+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 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
+ ) 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..32c90d2
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerCore/PeerDatabase.swift
@@ -0,0 +1,63 @@
+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/PeerCore/SourceJobs.swift b/Packages/PeerCore/Sources/PeerCore/SourceJobs.swift
new file mode 100644
index 0000000..7308472
--- /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/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift
new file mode 100644
index 0000000..703ad9e
--- /dev/null
+++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift
@@ -0,0 +1,94 @@
+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?
+ 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
+ @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?,
+ 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
+ _name = State(initialValue: target?.name ?? "")
+ _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("连接名称", text: $name)
+ 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 ? "正在保存…" : saveTitle) {
+ 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, name: name, 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/Profile.png b/Profile.png
deleted file mode 100644
index ece57aa..0000000
Binary files a/Profile.png and /dev/null differ
diff --git a/README.md b/README.md
index f63ea4e..c82c5c9 100644
--- a/README.md
+++ b/README.md
@@ -1,71 +1,93 @@
-# D3LibMin - Library Notes App
+# InKCre for Mac
-一款基于SwiftUI开发的极简笔记收集应用,采用Dieter Rams风格设计,灵感来自Nothing和Teenage Engineering品牌的设计理念。
+正常运行于 Dock 和应用切换器中的原生 Mac Peer,首版提供收集能力。macOS 15 或更新版本,可采集当前选区、明确粘贴的文字/链接/图片、单屏框选截图与主动输入的想法。原材料、来源与补充想法分别保存;收入不依赖 OCR 或模型处理。
-## 功能特性
+启动或点击 Dock 图标打开普通主窗口,侧栏提供采集、记录、本地文件、Peer 与连接。主窗口可缩放、最小化,记住尺寸位置;关闭主窗口后进程、菜单栏入口和队列继续运行,`⌘Q` 才退出。Peer 页展示当前信息库、连接对应的 Peer 身份与连接问题;配置连接本身不表示在线。
-### 主要页面
-- **Library页面**: 垂直卡片列表,显示重要信息的标题和简短描述
-- **Profile页面**: 用户头像、内存使用情况、连接设备状态
+主窗口使用系统 `NavigationSplitView` 和原生工具栏,标题随当前页面更新。采集页工具栏提供新想法、粘贴材料、框选截图,采集与记录页保留快捷面板入口;记录和 Peer 页不显示采集动作。侧栏开关固定在窗口控制按钮旁,系统分隔线跟随侧栏宽度,内容区不另放重复顶栏。窗口通过 `NSHostingController` 与 `fullSizeContentView` 承载 SwiftUI,不用弹性占位或自制侧栏控制器修补位置。显示菜单以 `⌘⌥S` 切换侧栏,与系统工具栏按钮共用 `NavigationSplitView` 的可见状态;命令沿窗口响应链执行,设置窗口和快捷面板不会误切主窗口侧栏。全屏仍使用系统命令。
-### 核心功能
-- **信息分类**: 支持四种类型的信息筛选
- - Numbers (数字)
- - Profiles (联系人)
- - Files (文件)
- - Phrases (短语)
+全局入口打开独立快捷采集面板。主窗口与面板共用同一草稿、记录和队列,同时只有一份可编辑采集视图。“在主窗口继续”转回主窗口采集页;关闭或收起快捷面板保留草稿并归还前一个应用的焦点。主窗口菜单提供新想法、设置、关闭、最小化和窗口重新打开等普通 Mac 操作。
-- **详情管理**: 点击卡片进入详情页面
- - 查看Essential Information
- - 编辑和重新排列内容
- - 删除不需要的信息
+## 构建与打开
-- **快速添加**: 主页底部的"+"按钮
- - 点击后弹出三个选项:文本、图片、文件
- - 带有优雅的动画效果和背景遮罩
+需要 Xcode 26.2 / Swift 6.2。PostgresNIO 及依赖通过 SwiftPM 解析,精确修订固定在 Package.resolved。
-### 设备连接
-- **AR设备支持**: 连接Rokid AR Pro眼镜
-- **实时状态显示**: 显示设备连接状态和最后连接时间
+```sh
+bash scripts/check.sh
+bash scripts/build-app.sh
+open dist/InKCre.app
+```
-### 设计特色
-- **Bento风格**: 简洁的卡片式布局
-- **Dieter Rams美学**: 极简主义设计原则
-- **现代化界面**: 清晰的视觉层次和优雅的交互
-- **流畅动画**: 自然的过渡效果和微交互
+构建产物是 universal `.app` 和 `dist/InKCre-0.1.0-universal.dmg`。未指定开发签名身份时使用 ad-hoc 签名;两种开发包都不能当作已公证的发行版。将 `.app` 放在固定位置,并保持稳定的开发签名身份后再授权。
-## 技术栈
-- SwiftUI
-- iOS 18.5+
-- Xcode 16
+首版尚未发布,应用版本当前为 `0.1.0`。`CFBundleShortVersionString` 保存应用版本,`CFBundleVersion` 单独记录构建序号;交付说明写作 `0.1.0(build 15)`,不能将构建序号称作 `v15`。首版发布前最多递增 patch 或 build,不提升 minor/major;一般开发构建只递增 build,正式版本准备遵循组织的 Release 流程。
-## 项目结构
-```
-D3LibMin/
-├── Models.swift # 数据模型
-├── ContentView.swift # 主视图容器
-├── HomeView.swift # 主页视图
-├── UserProfileView.swift # 用户资料页面
-├── InfoDetailView.swift # 信息详情页面
-└── D3LibMinApp.swift # 应用入口
+需要反复开发和实测系统采集时,使用本人明确指定的 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 和知识查询不属于此版本。目录文件由独立的本地文件扩展处理。
+
+截图在应用内使用 BetterShot 选区组件的固定版本改编,先冻结各屏画面,再拖动框选。松手后可拖动区域移动、拖动八个手柄缩放,或在区域外重新框选;附近的原生“截取”/“取消”按钮始终留在屏幕内。回车或双击选区确认,Esc、右键和“取消”退出并保留原草稿。确认只从看到的冻结画面裁剪原图 PNG,不重新拍摄,不调用 Apple 截图工具栏。全屏冻结帧仅在本次会话内存中存在,只持久化明确确认的单屏区域。
+
+截图入口只静默检查授权;未授权时在面板内显示说明,不反复调用系统授权请求或弹出应用警告。只有明确点击“请求屏幕录制权限”才发起授权。授权后按系统要求完全退出并重新打开应用,不能仅凭系统开关亮起就断言当前进程已获得权限。[Apple 的 ScreenCaptureKit 示例说明](https://developer.apple.com/documentation/screencapturekit/capturing-screen-content-in-macos)。
+
+移除材料、删除草稿和删除记录均先显示系统确认框;取消确认不会改变内容,确认后直接删除,无法撤销。快捷面板的“取消本次”在有内容时先确认,确认后删除本次草稿并关闭面板;空面板直接关闭。“收起”只隐藏面板并保留草稿。删除已收入记录只影响本机列表,共享内容仍属于 info-base。不提供回收站或“撤销移除”。
+
+手写想法使用单一主要输入区;采集原文与可选附注分别呈现,附注按需展开,已有附注默认可见,编辑区不嵌套在原文滚动区内。图片可用原生 Quick Look 查看原始尺寸,预览临时文件只对当前用户可读写,关闭预览或离开内容时移除。未收入记录可以展开查看完整原文、全部图片、来源和附注,无须导出 JSON 才能核对材料。上次收入状态与当前草稿保存状态分别显示,“查看记录”定位到对应条目,继续写下一条想法不会丢失入口。
+
+## 连接信息库
+
+连接设置使用 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 注册与直接提交回执合同访问;配置已保存或单次注册成功都不等于采集能力可用。权限页分别显示辅助功能与屏幕录制的实际许可状态。未配置连接时点击“收入”会打开连接表单;保存或取消都返回原草稿,保存后由用户再次确认收入。
+
+共享数据库需要本分支的 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,支持 PNG/JPEG/GIF/WebP,原生数据库通过 bytea 参数保存原图。失败材料可在“待收入”中导出 JSON,导出不含连接凭据。暂不提供修改已经发送的材料或自动舍弃失败项。
+
+失败记录的“更新密码…”仅更新原连接的 Keychain 项,并按原 UUID、材料与数据库目标重试,不切换当前连接。已收入记录缺少打开地址时,“配置 Web 地址…”仅修改该条记录的展示地址,保存后打开共享内容;记录自身地址优先,同一 Peer 当前连接的 Web 地址仅作补充。
+
+待收入记录可“取消收入”,停止后续自动发送并保留原材料、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 回退层。
+
+`Sources/InKCreApp` 拥有主窗口、导航、菜单与设置。`Packages/PeerCore` 提供数据库、标准图/存储、Source Job、凭据和原生模块生命周期;`extensions/capture` 与 `extensions/local-files` 是独立 Swift 包,各自拥有业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。Capture 在实际提交时办理首次准入并校验共享选择,不另设连接状态页或连接轮询。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。本地停用先保存本机停用状态、撤下入口并等待当前工作收束,再尝试更新共享意图;连接或凭据错误只报告共享状态未同步,不阻止停止,材料保留;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。
+
+Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。
+
+菜单栏 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 和待办身份。
+
+```sh
+BUNDLE_ID=你的稳定BundleID SIGNING_IDENTITY='Developer ID Application: …' \
+ NOTARY_PROFILE=你的Profile bash scripts/build-app.sh
```
-## 运行项目
-1. 使用Xcode打开 `D3LibMin.xcodeproj`
-2. 选择iOS模拟器或真机
-3. 点击运行按钮或使用 `Cmd+R`
-
-## 示例数据
-应用包含以下示例数据:
-- "605-1-3064" - Package pick up
-- "Moonlight" - Piano scores
-- "John Smith" - Business contact
-- "Project Alpha" - Design files
-- "Meeting Notes" - Weekly team sync
-
-## 设计参考
-项目设计参考了paraflow文件夹中的原型设计,实现了:
-- 简洁的筛选按钮
-- 卡片式信息展示
-- 用户资料页面布局
-- 信息详情管理界面
\ No newline at end of file
+Swift 测试覆盖冻结包重开、目标绑定、原子确认、原生构图、原生数据库事务、共享材料指纹与扩展生命周期。原生权限、多应用选区、多屏、Intel 实机与公证安装还需对应环境验证,不能由 universal 编译结果推断通过。
+
+
+原创 Swift 代码使用 MIT,Cida 选区读取与采集协调改编保留 Apache-2.0,BetterShot 选区与冻结图组件保留 BSD-3-Clause。见 [开源许可与致谢](THIRD_PARTY_NOTICES.md)。构建脚本从本次解析的依赖收集完整许可,随开发包交付;菜单“开源许可与致谢…”可打开通知。
diff --git a/Sources/InKCreApp/AppMain.swift b/Sources/InKCreApp/AppMain.swift
new file mode 100644
index 0000000..3762ebb
--- /dev/null
+++ b/Sources/InKCreApp/AppMain.swift
@@ -0,0 +1,337 @@
+import AppKit
+import CaptureMac
+import LocalFilesMac
+import PeerCore
+import PeerMacUI
+import SwiftUI
+
+@main
+enum InKCreApplication {
+ @MainActor static func main() {
+ let application = NSApplication.shared
+ let delegate = PeerHost()
+ application.delegate = delegate
+ withExtendedLifetime(delegate) { application.run() }
+ }
+}
+
+@MainActor
+final class PeerHost: NSObject, NSApplicationDelegate, ObservableObject, NSUserInterfaceValidations
+{
+ let context: PeerHostContext
+ let runtime: BundledExtensionRuntime
+ let capture: CaptureExtension
+ 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]
+ .appendingPathComponent(identity)
+ 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)
+ 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)
+ }
+ }
+ 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) {
+ NSApp.setActivationPolicy(.regular)
+ if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"),
+ let icon = NSImage(contentsOf: url)
+ {
+ 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 {
+ try await reloadConnections()
+ if let target = connection {
+ _ = 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 }
+ }
+ }
+ }
+
+ 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() {
+ 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
+ }
+ let app = addMenu("InKCre")
+ app.addItem(
+ 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 =
+ 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(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]
+ let view = addMenu("显示")
+ view.addItem(
+ withTitle: "显示/隐藏侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)),
+ keyEquivalent: "s"
+ ).keyEquivalentModifierMask = [.command, .option]
+ 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: "")
+ 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: "")
+ NSApp.windowsMenu = window
+ NSApp.mainMenu = main
+ }
+
+ @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
+ }
+ 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 { try await reloadConnections() } catch {
+ connectionIssue = error.localizedDescription
+ }
+ }
+ }
+
+ 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()
+ }
+ }
+
+ 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 !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)
+ 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?.changingConnection = true
+ Task {
+ await self?.connectionDidChange(target)
+ self?.changingConnection = false
+ close()
+ }
+ }))
+ sheet.contentView = content
+ sheet.setContentSize(content.fittingSize)
+ manager.beginSheet(sheet) { [weak manager] _ in
+ if let onFinished {
+ manager?.orderOut(nil)
+ onFinished()
+ }
+ }
+ }
+
+ @objc private func showLicenses(_ sender: Any?) {
+ if let url = Bundle.main.url(forResource: "THIRD_PARTY_NOTICES", withExtension: "txt") {
+ NSWorkspace.shared.open(url)
+ }
+ }
+
+ 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()
+ return false
+ }
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ presence?.cancel()
+ Task {
+ 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)
+ } catch {
+ show(error)
+ sender.reply(toApplicationShouldTerminate: false)
+ }
+ }
+ return .terminateLater
+ }
+}
diff --git a/Sources/InKCreApp/PeerMainView.swift b/Sources/InKCreApp/PeerMainView.swift
new file mode 100644
index 0000000..f92adb8
--- /dev/null
+++ b/Sources/InKCreApp/PeerMainView.swift
@@ -0,0 +1,39 @@
+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).disabled(
+ host.changingConnection)
+ } 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..0910031
--- /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
new file mode 100644
index 0000000..09267c0
--- /dev/null
+++ b/Sources/InKCreApp/PeerSettingsWindow.swift
@@ -0,0 +1,155 @@
+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("在“扩展”中启用后,可设置采集快捷键和系统权限。")))
+ }
+}
+
+struct ConnectionSettingsView: View {
+ @ObservedObject var host: PeerHost
+ var body: some View {
+ 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) }
+ }
+ }
+ }.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)
+ }
+}
+
+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)
+ }
+ 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/SpaceGrotesk-Medium.ttf b/SpaceGrotesk-Medium.ttf
deleted file mode 100644
index 859f704..0000000
Binary files a/SpaceGrotesk-Medium.ttf and /dev/null differ
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..0d9da0e
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,15 @@
+# 开源许可与来源
+
+InKCre Swift 原创实现使用 MIT。参考或改写的 Cida 文件使用 Apache-2.0,保留其独立许可。完整许可随源码与应用交付。
+
+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,可从应用菜单打开。
+
+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 作者署名。
+
+[Hister](https://github.com/asciimoo/hister) 的目录监视与客户端提取行为为本地文件功能提供启发。参考版本 f1fff29683c8c8006a35d3387384d9b1e123cebe,项目使用 AGPL-3.0-or-later。本实现未复制或改写其源码,不运行其服务;本致谢不表示上游参与或背书。
diff --git a/Tests/CaptureMacTests/AppIconTests.swift b/Tests/CaptureMacTests/AppIconTests.swift
new file mode 100644
index 0000000..e29a684
--- /dev/null
+++ b/Tests/CaptureMacTests/AppIconTests.swift
@@ -0,0 +1,37 @@
+import AppKit
+import Testing
+
+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")
+ 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/CaptureMacTests/CaptureInteractionTests.swift b/Tests/CaptureMacTests/CaptureInteractionTests.swift
new file mode 100644
index 0000000..5921dcf
--- /dev/null
+++ b/Tests/CaptureMacTests/CaptureInteractionTests.swift
@@ -0,0 +1,416 @@
+import AppKit
+import Capture
+import PeerCore
+import Security
+import Testing
+
+@testable import CaptureMac
+
+@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.cancelWork() }
+ var mainOpened = false
+ model.editConnection = { _, _ in mainOpened = true }
+ model.submit()
+ #expect(!model.hasContent)
+ #expect(!model.submitting)
+ #expect(!mainOpened)
+ #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.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
+ ))
+ 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.cancelWork() }
+ model.thought = "主窗口中未提交的草稿"
+ let previousID = model.draft.id
+ 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 == .queue)
+ #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(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() }
+ await model.receive(
+ CapturePayload(materials: [Material(text: "连接后继续收入的原文")], annotation: "原附注"))
+ let original = model.draft
+ model.editorOwner = .quick
+ 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(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 {
+ 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) }
+ 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: "附注"))
+ 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.cancelWork() }
+ 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.cancelWork() }
+ 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.cancelWork() }
+ 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)
+ }
+}
+
+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
new file mode 100644
index 0000000..13be957
--- /dev/null
+++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift
@@ -0,0 +1,250 @@
+import AppKit
+import Capture
+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)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ 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))
+ 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)
+ }
+ }
+ }
+ @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"))
+ }
+ }
+
+ @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 = 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) }
+ for appearance in [NSAppearance.Name.aqua, .darkAqua] {
+ window.appearance = NSAppearance(named: appearance)
+ for pane in [CaptureModel.Pane.capture, .queue] {
+ 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: 500))
+ try await snapshot(auxiliary, named: "quick-\(appearance.rawValue)")
+ }
+ #expect(!window.isVisible && !auxiliary.isVisible)
+ }
+
+ @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 >= 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/Tests/CaptureMacTests/RegionOverlayTests.swift b/Tests/CaptureMacTests/RegionOverlayTests.swift
new file mode 100644
index 0000000..18d6060
--- /dev/null
+++ b/Tests/CaptureMacTests/RegionOverlayTests.swift
@@ -0,0 +1,348 @@
+// 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
+
+@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))
+ }
+
+ 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: 800, height: 520), styleMask: .borderless,
+ backing: .buffered, defer: false)
+ 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
+ #expect(view.acceptsFirstMouse(for: nil))
+ 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(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.cancelSelection()
+ #expect(results == [rect])
+ #expect(!window.isVisible)
+ }
+
+ @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 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
+ 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() }
+ #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/Tests/CaptureMacTests/ShortcutTests.swift b/Tests/CaptureMacTests/ShortcutTests.swift
new file mode 100644
index 0000000..39ca299
--- /dev/null
+++ b/Tests/CaptureMacTests/ShortcutTests.swift
@@ -0,0 +1,220 @@
+import AppKit
+import Capture
+import Carbon.HIToolbox
+import PeerCore
+import Testing
+
+@testable import CaptureMac
+
+@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)
+ }
+
+ @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)
+ }
+
+ @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 duplicateAndPlainTypingCannotReplaceOldSettings() throws {
+ 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)
+ }
+ #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: 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: 0x5445_5354, 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/Tests/CaptureTests/CaptureContractTests.swift b/Tests/CaptureTests/CaptureContractTests.swift
new file mode 100644
index 0000000..549f09d
--- /dev/null
+++ b/Tests/CaptureTests/CaptureContractTests.swift
@@ -0,0 +1,79 @@
+import Foundation
+import PeerCore
+import Testing
+
+@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)
+ var changed = fixture.payload
+ changed.annotation = "不同的意图"
+ #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: PeerError.self) {
+ try CapturePayload(materials: [Material(image: image, mimeType: "image/jpeg")]).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: 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..c6f515f
--- /dev/null
+++ b/Tests/CaptureTests/CaptureLifecycleTests.swift
@@ -0,0 +1,64 @@
+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/CaptureTests/CaptureStoreTests.swift b/Tests/CaptureTests/CaptureStoreTests.swift
new file mode 100644
index 0000000..6ccad47
--- /dev/null
+++ b/Tests/CaptureTests/CaptureStoreTests.swift
@@ -0,0 +1,217 @@
+import CSQLite
+import Foundation
+import PeerCore
+import Testing
+
+@testable import Capture
+
+struct CaptureStoreTests {
+ @Test func deletedDraftIsAbsentAfterRestartAndLateAutosaveCannotRecreateIt() 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: "最后一次编辑")], annotation: "保留附注"))
+ try await store.deleteRecord(draft.id)
+ try await store.saveDraft(draft)
+ 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)
+ #expect(try await reopened.records().isEmpty)
+ }
+
+ @Test func cancelledIntentNeverAutomaticallyDispatches() 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(
+ 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: 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)")
+ #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 {
+ 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")
+ )
+ _ = try await store.markDispatch(draft.id)
+ try await store.cancel(draft.id)
+ try await store.report(draft.id, state: .unknown, message: "迟到的失败")
+ let cancelled = try #require(try await store.record(draft.id))
+ #expect(cancelled.state == .cancelled)
+ #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: 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)
+ #expect(try await store.records().isEmpty)
+ }
+
+ @Test func clearingDraftAndContinuingToTypeKeepsTheSameIdentity() 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"))
+ var draft = CaptureRecord(payload: CapturePayload(materials: [Material(text: "清空前")]))
+ try await store.saveDraft(draft)
+ try await store.discardDraft(draft.id)
+ try await store.discardDraft(draft.id)
+ draft.payload?.materials = [Material(text: "清空后继续输入")]
+ try await store.saveDraft(draft)
+ #expect(try await store.record(draft.id)?.payload?.materials.first?.text == "清空后继续输入")
+ }
+
+ @Test func failedFreezeRetainsTheSavedDraftAndCannotDispatch() 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: "保存失败仍须保留")]))
+ try await store.saveDraft(draft)
+ var connection: OpaquePointer?
+ #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: 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: PeerError.self) { _ = try await store.markDispatch(draft.id) }
+ }
+
+ @Test func frozenMaterialSurvivesReopeningAndTargetChanges() 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 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 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)
+ #expect(recovered.target == target)
+ #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: PeerError.self) { try await reopened.saveDraft(draft) }
+ 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)
+ 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")
+ )
+ _ = try await store.markDispatch(draft.id)
+ let result = CaptureResult(
+ submissionID: draft.id, rootBlockID: 123,
+ committedAt: Date(timeIntervalSince1970: 1_780_000_000),
+ availability: .deleted
+ )
+ try await store.confirm(draft.id, result: result)
+ let reopened = try CaptureStore(url: url)
+ 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) }
+ }
+
+ @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(
+ 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: "相同内容")]))
+ let second = CaptureRecord(payload: CapturePayload(materials: [Material(text: "相同内容")]))
+ _ = try await store.freeze(first, target: target)
+ _ = try await store.freeze(second, target: target)
+ #expect(try await store.records().count == 2)
+ #expect(first.id != second.id)
+ }
+}
diff --git a/Tests/CaptureTests/ConnectionSettingsTests.swift b/Tests/CaptureTests/ConnectionSettingsTests.swift
new file mode 100644
index 0000000..f2a7cae
--- /dev/null
+++ b/Tests/CaptureTests/ConnectionSettingsTests.swift
@@ -0,0 +1,45 @@
+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/CaptureTests/CredentialStoreTests.swift b/Tests/CaptureTests/CredentialStoreTests.swift
new file mode 100644
index 0000000..8762820
--- /dev/null
+++ b/Tests/CaptureTests/CredentialStoreTests.swift
@@ -0,0 +1,54 @@
+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/Fixtures/capture-v1.json b/Tests/CaptureTests/Fixtures/capture-v1.json
new file mode 100644
index 0000000..d23e627
--- /dev/null
+++ b/Tests/CaptureTests/Fixtures/capture-v1.json
@@ -0,0 +1,44 @@
+[
+ {
+ "payload": {
+ "submission_id": "00000000-0000-4000-8000-000000000001",
+ "captured_at": "2026-10-21T01:02:03.123Z",
+ "entry": "paste",
+ "origin": null,
+ "materials": [
+ {
+ "kind": "text",
+ "text": "中文 👩🏽💻 é\n/ "
+ ),
+ ("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/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/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
new file mode 100644
index 0000000..a54f023
--- /dev/null
+++ b/extensions/capture/Legal/Cida/ATTRIBUTION.txt
@@ -0,0 +1,10 @@
+Cida(辞达)
+上游:https://github.com/Xuanwo/cida
+作者:Xuanwo 及 Cida 贡献者
+核对版本:3099e0dcbc2bafe0a86e6a04d1941b78afacbfd6
+许可:Apache License 2.0,完整文本见同目录 LICENSE。
+
+InKCre 的 SelectedText.swift 与 RegionCapture.swift 的采集协调骨架参考与改写了 Cida 的选区读取、剪贴板保护和系统截图调用。
+原选框已删除,选区 UI 现改用独立注明 BSD-3-Clause 的 BetterShot 组件。
+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..7b4b483
--- /dev/null
+++ b/extensions/capture/Package.swift
@@ -0,0 +1,28 @@
+// 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"),
+ .package(url: "https://github.com/InKCre/ui.git", exact: "2.6.0"),
+ ],
+ 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"),
+ .product(name: "InKCreLogoAssets", package: "ui"),
+ ], resources: [.copy("Resources/Brand")]),
+ ]
+)
diff --git a/extensions/capture/README.md b/extensions/capture/README.md
new file mode 100644
index 0000000..e79e306
--- /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;BetterShot 应用内选区与冻结图组件的 BSD-3-Clause 许可、固定版本和修改说明见 Legal/BetterShot。冻结帧只在截图会话内存中存在,确认后裁剪原图 PNG;停用关闭选区并丢弃会话,不调用系统截图工具栏。原创代码见 LICENSE。
diff --git a/extensions/capture/Sources/CSQLite/module.modulemap b/extensions/capture/Sources/CSQLite/module.modulemap
new file mode 100644
index 0000000..0a291b5
--- /dev/null
+++ b/extensions/capture/Sources/CSQLite/module.modulemap
@@ -0,0 +1,5 @@
+module CSQLite [system] {
+ header "shim.h"
+ link "sqlite3"
+ export *
+}
diff --git a/extensions/capture/Sources/CSQLite/shim.h b/extensions/capture/Sources/CSQLite/shim.h
new file mode 100644
index 0000000..f52e1f0
--- /dev/null
+++ b/extensions/capture/Sources/CSQLite/shim.h
@@ -0,0 +1 @@
+#include
diff --git a/extensions/capture/Sources/Capture/CaptureClient.swift b/extensions/capture/Sources/Capture/CaptureClient.swift
new file mode 100644
index 0000000..90d2cae
--- /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 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 {
+ _ = 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(
+ 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/extensions/capture/Sources/Capture/CaptureDigest.swift b/extensions/capture/Sources/Capture/CaptureDigest.swift
new file mode 100644
index 0000000..13af159
--- /dev/null
+++ b/extensions/capture/Sources/Capture/CaptureDigest.swift
@@ -0,0 +1,53 @@
+import CryptoKit
+import Foundation
+import PeerCore
+
+extension CapturePayload {
+ /// Matches Capture v1's sorted UTF-8 JSON, including explicit null optionals.
+ 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()
+ 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 PeerError.invalid("文字材料格式不正确。") }
+ return ["kind": "text", "text": text]
+ case .image:
+ guard let data = material.data, let mime = material.mimeType else {
+ throw PeerError.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/extensions/capture/Sources/Capture/CaptureGraph.swift b/extensions/capture/Sources/Capture/CaptureGraph.swift
new file mode 100644
index 0000000..89bb406
--- /dev/null
+++ b/extensions/capture/Sources/Capture/CaptureGraph.swift
@@ -0,0 +1,77 @@
+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
+ 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/extensions/capture/Sources/Capture/CaptureQueue.swift b/extensions/capture/Sources/Capture/CaptureQueue.swift
new file mode 100644
index 0000000..fcb7c2d
--- /dev/null
+++ b/extensions/capture/Sources/Capture/CaptureQueue.swift
@@ -0,0 +1,77 @@
+import Foundation
+import PeerCore
+
+public actor CaptureQueue {
+ private let store: CaptureStore
+ private let credentials: CredentialStore
+ 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: 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) } }
+ }
+
+ @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.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 }
+ 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)
+ changed = true
+ continue
+ }
+ guard !isStopped, !Task.isCancelled else { return changed }
+ let dispatched = try await store.markDispatch(item.id)
+ 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 {
+ 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)
+ 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
new file mode 100644
index 0000000..f29586f
--- /dev/null
+++ b/extensions/capture/Sources/Capture/CaptureStore.swift
@@ -0,0 +1,272 @@
+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 {
+ 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 PeerError.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() -> PeerError {
+ .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 == 0 || version == 1 else {
+ throw PeerError.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);
+ PRAGMA user_version=1;
+ """)
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
+ }
+
+ public func records() throws -> [CaptureRecord] {
+ 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)
+ 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 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 PeerError.invalid("草稿状态不正确。")
+ }
+ try payload.validate()
+ try target.validate()
+ try database.execute("BEGIN IMMEDIATE")
+ do {
+ if let old = try record(draft.id), old.state != .draft {
+ throw PeerError.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 PeerError.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 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 PeerError.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 PeerError.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 PeerError.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 PeerError.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 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 {
+ throw PeerError.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() }
+ }
+ }
+
+ 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 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/extensions/capture/Sources/Capture/Models.swift b/extensions/capture/Sources/Capture/Models.swift
new file mode 100644
index 0000000..26ca56d
--- /dev/null
+++ b/extensions/capture/Sources/Capture/Models.swift
@@ -0,0 +1,226 @@
+import Foundation
+import ImageIO
+import PeerCore
+import UniformTypeIdentifiers
+
+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 PeerError.invalid("先写下想法,或加入一份材料。")
+ }
+ 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 PeerError.invalid("来源信息过长,请缩短后收入。")
+ }
+ }
+ let size = materials.reduce(annotation?.utf8.count ?? 0) { $0 + $1.byteCount }
+ guard size <= 32 * 1024 * 1024 else {
+ 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 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 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 PeerError.invalid("图片内容与格式不符、文件损坏或解码尺寸过大。")
+ }
+ }
+ }
+ }
+}
+
+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
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ self.committedAt = formatter.date(from: formatter.string(from: 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 PeerError.invalid("时间格式无效。")
+ }
+ return date
+ }
+ return try decoder.decode(type, from: data)
+ }
+}
diff --git a/extensions/capture/Sources/Capture/Resources/extension.json b/extensions/capture/Sources/Capture/Resources/extension.json
new file mode 100644
index 0000000..5c81b81
--- /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 AND BSD-3-Clause"}
diff --git a/extensions/capture/Sources/CaptureMac/BrandAssets.swift b/extensions/capture/Sources/CaptureMac/BrandAssets.swift
new file mode 100644
index 0000000..94f040b
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/BrandAssets.swift
@@ -0,0 +1,41 @@
+import AppKit
+import InKCreLogoAssets
+
+@MainActor
+enum BrandAssets {
+ 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(
+ "inkcre-black-transparent-20px@2x", subdirectory: "png/2x/black-transparent", 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: "InKCreUI_InKCreLogoAssets", withExtension: "bundle"),
+ let bundle = Bundle(url: url)
+ else { preconditionFailure("Missing packaged InKCre Logo assets") }
+ return bundle
+ }
+ return InKCreLogoAssets.bundle
+ }()
+
+ private static func image(_ name: String, subdirectory: String, size: CGFloat) -> NSImage {
+ guard
+ 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)
+ return image
+ }
+}
diff --git a/extensions/capture/Sources/CaptureMac/CaptureEditor.swift b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift
new file mode 100644
index 0000000..7de0bf0
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureEditor.swift
@@ -0,0 +1,170 @@
+import AppKit
+import Capture
+import PeerCore
+import SwiftUI
+
+struct CaptureEditor: View {
+ @ObservedObject var model: CaptureModel
+ 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: 12) {
+ 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))
+ }
+ 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)
+ }
+ }.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(height: 70).accessibilityLabel("用户附注,与原文分别保存")
+ }
+ }
+ Divider()
+ 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() }
+ } 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 {
+ 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 = isManual ? .thought : (showsAnnotation ? .annotation : nil)
+ }
+}
diff --git a/extensions/capture/Sources/CaptureMac/CaptureExtension.swift b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift
new file mode 100644
index 0000000..630c264
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureExtension.swift
@@ -0,0 +1,451 @@
+import AppKit
+import ApplicationServices
+import Capture
+import Combine
+import PeerCore
+import SwiftUI
+
+public enum CapturePage: Hashable { case capture, queue }
+
+@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()
+ }
+ public var showPreferences: (() -> Void)?
+ public var editConnection: ((ConnectionTarget?, (@MainActor () -> Void)?) -> Void)?
+
+ public func makeSettingsViewController() -> NSViewController? {
+ model.map { NSHostingController(rootView: CaptureSettingsView(model: $0)) }
+ }
+
+ public func connectionDidChange(_ target: ConnectionTarget) { model?.connection = 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?
+ 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?
+ 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 {
+ 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 {
+ stopping = false
+ if let model {
+ try model.shortcuts.start()
+ configureMenu()
+ model.start(restore: false)
+ return
+ }
+ do {
+ let model = try CaptureModel(
+ directory: context.directory.appendingPathComponent("extensions/inkcre-capture"),
+ identity: context.identity, connections: context.connections)
+ self.model = model
+ 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.contentMinSize = NSSize(width: 500, height: 500)
+ 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.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
+ }
+ 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 else { return }
+ switch action {
+ case .capture: self.showCapture()
+ case .screenshot: self.quickScreenshot()
+ case .paste: model.pasteMaterial()
+ case .submit: model.submit()
+ case .hide: self.hide()
+ }
+ }
+ try model.shortcuts.start()
+ if let message = model.shortcuts.message { model.captureNotice = message }
+ configureMenu()
+ model.start()
+ } catch {
+ model?.cancelWork()
+ model?.shortcuts.stop()
+ panel?.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)),
+ ("采集设置…", #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: "采集")
+ 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
+ || (showingCapturePage && 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() {
+ 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 }
+ showPreferences?()
+ }
+ @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 let model { navigate?(model.pane) }
+ presentMain?()
+ }
+
+ 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 mainWindowBecameKey() {
+ if 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)
+ if let statusItem { NSStatusBar.system.removeStatusItem(statusItem) }
+ statusItem = nil
+ if let commandItem { NSApp.mainMenu?.removeItem(commandItem) }
+ commandItem = nil
+ shortcutObservation = nil
+ try await model.flush()
+ panel = nil
+ self.model = nil
+ }
+}
diff --git a/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift
new file mode 100644
index 0000000..b5a834b
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureImagePreview.swift
@@ -0,0 +1,55 @@
+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
new file mode 100644
index 0000000..58ee467
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureModel.swift
@@ -0,0 +1,519 @@
+import AppKit
+import Capture
+import Combine
+import Foundation
+import PeerCore
+import SwiftUI
+
+@MainActor
+final class CaptureModel: ObservableObject {
+ typealias Pane = CapturePage
+ 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 error: String?
+ @Published var saveStatus = "随时写下想法,或加入材料"
+ @Published var submitting = 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)?
+ var editConnection: ((ConnectionTarget?, (@MainActor () -> Void)?) -> Void)?
+ let store: CaptureStore
+ let credentials: CredentialStore
+ let connections: ConnectionSettings
+ private let queue: CaptureQueue
+ private var saveTask: Task?
+ private var heartbeat: Task?
+ @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,
+ 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, client: client)
+ }
+
+ 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
+ }
+ 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(restore: Bool = true) {
+ stopping = false
+ submitting = true
+ heartbeat = Task {
+ 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
+ while !Task.isCancelled {
+ await sendPending()
+ do { try await Task.sleep(for: .seconds(10)) } catch { break }
+ }
+ }
+ }
+
+ func cancelWork() {
+ stopping = true
+ selectionRequest = UUID()
+ heartbeat?.cancel()
+ saveTask?.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 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 }
+ 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
+ try await store.discardDraft(id)
+ if draft.id == id, revision == editRevision { saveStatus = "随时写下想法,或加入材料" }
+ try await refreshRecord(id)
+ 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 refreshRecord(value.id)
+ }
+
+ func newThought(notice: String? = nil) {
+ guard !submitting else { return }
+ submitting = true
+ selectionRequest = UUID()
+ perform { [self] in
+ defer { submitting = false }
+ do {
+ saveTask?.cancel()
+ try await flush()
+ draft = CaptureRecord()
+ captureNotice = notice
+ saveStatus = "写下此刻的想法"
+ revealEditor()
+ } catch { self.error = error.localizedDescription }
+ }
+ }
+
+ func pasteMaterial() {
+ guard !submitting else { return }
+ submitting = true
+ selectionRequest = UUID()
+ perform { [self] in
+ defer { submitting = false }
+ do {
+ let materials = try ClipboardReader.materials()
+ guard !materials.isEmpty else { throw PeerError.invalid("剪贴板没有可用的文字、链接或图片。") }
+ saveTask?.cancel()
+ try await flush()
+ draft = CaptureRecord(payload: CapturePayload(entry: .paste, materials: materials))
+ 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)
+ captureNotice = notice
+ try await flush()
+ revealEditor()
+ } catch {
+ self.error = error.localizedDescription
+ presentEditor?()
+ }
+ }
+
+ func submit() {
+ guard !submitting, hasContent else { return }
+ 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()
+ })
+ 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()
+ perform { [self] in
+ do {
+ let frozen = try await store.freeze(draft, target: connection)
+ draft = CaptureRecord()
+ submissionStatusID = frozen.id
+ captureNotice = nil
+ saveStatus = "随时写下想法,或加入材料"
+ try await refresh()
+ submitting = false
+ await sendPending(force: true)
+ } catch {
+ submitting = false
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ func sendPending(force: Bool = false) async {
+ do {
+ if try await queue.process(force: force) { try await refresh() }
+ } catch { self.error = error.localizedDescription }
+ }
+
+ func refresh() async throws {
+ 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
+ selectionRequest = UUID()
+ 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 PeerError.invalid("这份草稿已变更,请刷新记录后再打开。")
+ }
+ draft = current
+ saveStatus = "已恢复本机草稿"
+ revealEditor()
+ } catch { self.error = error.localizedDescription }
+ }
+ }
+
+ func retry(_ record: CaptureRecord) {
+ 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 }
+ 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) {
+ perform { [self] in
+ 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()
+ perform { [self] in
+ 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()
+ captureNotice = nil
+ saveStatus = "本机草稿已删除"
+ }
+ try await refresh()
+ if close { dismissQuickCapture?() }
+ } catch { self.error = error.localizedDescription }
+ }
+ }
+
+ 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() {
+ 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 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)
+ 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)
+ }
+
+ 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/extensions/capture/Sources/CaptureMac/CapturePageView.swift b/extensions/capture/Sources/CaptureMac/CapturePageView.swift
new file mode 100644
index 0000000..ab71555
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CapturePageView.swift
@@ -0,0 +1,120 @@
+import SwiftUI
+
+struct CapturePageView: View {
+ @ObservedObject var model: CaptureModel
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ Group {
+ switch model.pane {
+ case .capture:
+ if model.editorOwner == .quick {
+ ContentUnavailableView {
+ Label("正在快捷采集", systemImage: "rectangle.on.rectangle")
+ } actions: {
+ Button("在主窗口继续") { model.continueInMain() }
+ }
+ } 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 {
+ 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))
+ }
+
+}
+
+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: 500)
+ .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/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift
new file mode 100644
index 0000000..1708403
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureRecordSettings.swift
@@ -0,0 +1,72 @@
+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
new file mode 100644
index 0000000..fa32e45
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureRecordsView.swift
@@ -0,0 +1,147 @@
+import AppKit
+import Capture
+import PeerCore
+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 {
+ ContentUnavailableView(
+ "还没有采集记录", systemImage: "tray",
+ description: Text("草稿、待收入和已收入的记录会显示在这里。")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ 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)
+ }
+ }
+ 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("删除记录", 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()
+ }
+ }.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/CaptureSettingsView.swift b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift
new file mode 100644
index 0000000..dd5fd85
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/CaptureSettingsView.swift
@@ -0,0 +1,49 @@
+import AppKit
+@preconcurrency import ApplicationServices
+import SwiftUI
+
+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/ClipboardReader.swift b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift
new file mode 100644
index 0000000..909d7fb
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/ClipboardReader.swift
@@ -0,0 +1,45 @@
+import AppKit
+import Capture
+import PeerCore
+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 PeerError.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/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift
new file mode 100644
index 0000000..6a1b2b0
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/GlobalShortcuts.swift
@@ -0,0 +1,205 @@
+import AppKit
+import Capture
+import Carbon.HIToolbox
+import Combine
+import PeerCore
+
+@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 canPerform: ((ShortcutAction, NSWindow?) -> Bool)?
+ 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) })
+
+ }
+ 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 {
+ 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 == 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 {
+ 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 }
+ 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
+ }) {
+ 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: 0x494e_4b43, id: action.rawValue),
+ GetApplicationEventTarget(), 0, &reference)
+ guard status == noErr, let reference else {
+ 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) }
+ }
+
+ 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/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift
new file mode 100644
index 0000000..c5048b3
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/KeyboardShortcut.swift
@@ -0,0 +1,120 @@
+import AppKit
+import Capture
+import Carbon.HIToolbox
+import PeerCore
+
+enum ShortcutAction: UInt32, CaseIterable, Codable, Identifiable, Sendable {
+ case capture = 1
+ case 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 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) }
+ }
+ 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))
+ || 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 {
+ throw PeerError.invalid("请同时按住 ⌘、⌃ 或 ⌥,避免影响正常输入。")
+ }
+ }
+}
diff --git a/extensions/capture/Sources/CaptureMac/RegionCapture.swift b/extensions/capture/Sources/CaptureMac/RegionCapture.swift
new file mode 100644
index 0000000..aec2f03
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/RegionCapture.swift
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: Apache-2.0
+// 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
+import ScreenCaptureKit
+
+struct ScreenRecordingPermissionError: LocalizedError {
+ var errorDescription: String? {
+ "系统尚未允许当前 InKCre 读取屏幕。请在设置中授权,随后完全退出并重新打开应用。"
+ }
+}
+
+@MainActor
+final class RegionCapture {
+ private var captureTask: Task?
+ 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,
+ 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 }
+ // Requesting access belongs to the explicit settings action, never a screenshot retry.
+ guard hasPermission() else {
+ completion(.failure(ScreenRecordingPermissionError()))
+ return
+ }
+ self.completion = completion
+ 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))
+ }
+ }
+ }
+ }
+
+ func stop() async {
+ completion = nil
+ captureTask?.cancel()
+ overlay.cancel()
+ await captureTask?.value
+ captureTask = nil
+ }
+
+ private func finish(_ result: Result) {
+ let callback = completion
+ completion = nil
+ captureTask = nil
+ callback?(result)
+ }
+
+ 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))
+ }
+ return frames
+ }
+}
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128.png
new file mode 100644
index 0000000..e5bfc1b
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png
new file mode 100644
index 0000000..7a7111a
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_128x128@2x.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16.png
new file mode 100644
index 0000000..2a45c29
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png
new file mode 100644
index 0000000..57e7d2e
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_16x16@2x.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256.png
new file mode 100644
index 0000000..7a7111a
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png
new file mode 100644
index 0000000..c55a5e6
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_256x256@2x.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32.png
new file mode 100644
index 0000000..57e7d2e
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png
new file mode 100644
index 0000000..c221809
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_32x32@2x.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512.png
new file mode 100644
index 0000000..c55a5e6
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png
new file mode 100644
index 0000000..7a47cc6
Binary files /dev/null and b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.iconset/icon_512x512@2x.png differ
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.svg b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.svg
new file mode 100644
index 0000000..e6836fe
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIcon.svg
@@ -0,0 +1,14 @@
+
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIconSmall.svg b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIconSmall.svg
new file mode 100644
index 0000000..3445213
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/AppIconSmall.svg
@@ -0,0 +1,14 @@
+
diff --git a/extensions/capture/Sources/CaptureMac/Resources/Brand/PROVENANCE.json b/extensions/capture/Sources/CaptureMac/Resources/Brand/PROVENANCE.json
new file mode 100644
index 0000000..c67995a
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/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/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md
new file mode 100644
index 0000000..01da7ad
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/Resources/Brand/README.md
@@ -0,0 +1,21 @@
+# InKCre Mac 标识资源
+
+采集面板和菜单栏标识来自 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:
+
+| 用途 | 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 |
+
+普通 SwiftPM 运行使用公开的 `InKCreLogoAssets.bundle`。自组装应用由 `scripts/build-app.sh` 将 `InKCreUI_InKCreLogoAssets.bundle` 放入 `.app/Contents/Resources`,`BrandAssets` 在应用内只从该嵌入位置加载,缺失时直接报错,不回退到本机 `.build`。普通构建不需要解压 ZIP、执行资源生成器或安装 Web 依赖。
+
+更新 UI 依赖时阅读该版本的 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 不会自动补这层留白。
+
+16、32 逻辑尺寸(包括对应 @2x)使用小尺寸母版,其余使用标准母版。更改 SVG 后运行 `bash scripts/build-macos-icon.sh`,通过本机已有的 `rsvg-convert` 生成整套 PNG;不要放大小尺寸位图。普通应用构建只用系统 `iconutil` 打包已生成的 iconset,无需额外安装渲染器。SwiftPM 资源 bundle 与 ICNS 在签名前装入应用。
diff --git a/extensions/capture/Sources/CaptureMac/SelectedText.swift b/extensions/capture/Sources/CaptureMac/SelectedText.swift
new file mode 100644
index 0000000..8599373
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/SelectedText.swift
@@ -0,0 +1,173 @@
+// 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 Capture
+import Carbon.HIToolbox
+import PeerCore
+
+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/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift
new file mode 100644
index 0000000..7ed899c
--- /dev/null
+++ b/extensions/capture/Sources/CaptureMac/ShortcutSettingsView.swift
@@ -0,0 +1,37 @@
+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/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/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..cb5c2a0
--- /dev/null
+++ b/extensions/local-files/Package.swift
@@ -0,0 +1,23 @@
+// 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..6370f5f
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFiles/DirectoryScanner.swift
@@ -0,0 +1,87 @@
+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..ddfe106
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFiles/FileExtractor.swift
@@ -0,0 +1,169 @@
+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..82f6dcf
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFiles/FileReconciliation.swift
@@ -0,0 +1,152 @@
+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..d15d434
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift
@@ -0,0 +1,177 @@
+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..6d09ecb
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFiles/Models.swift
@@ -0,0 +1,158 @@
+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 {
+ // 序列化字段遵循 Local Files 跨端合同。
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ 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
+ // 序列化字段遵循 Local Files 跨端合同。
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ 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
+ // 序列化字段遵循 Local Files 跨端合同。
+ // swift-format-ignore: AlwaysUseLowerCamelCase
+ 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..00b4dd8
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFilesMac/DirectoryWatcher.swift
@@ -0,0 +1,46 @@
+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..4031b6b
--- /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..69bf525
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift
@@ -0,0 +1,543 @@
+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 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 {
+ defer { refreshTask = nil }
+ await performRefresh()
+ }
+ refreshTask = task
+ await task.value
+ }
+
+ 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..d7dac1a
--- /dev/null
+++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift
@@ -0,0 +1,197 @@
+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/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 @@
-
-
-
-
-
-
-
-
605-1-3064
Package pick up
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 @@
-
-
-
-
-
-
-
-
Glasses
Last connected: Today, 3:45 PM
-
\ 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 @@
-
-
-
-
-
-
-
-
Complete project proposal
Prepare presentation slides
-
\ 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 157aec7..0000000
Binary files a/profilehead.png and /dev/null differ
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
new file mode 100644
index 0000000..be68262
--- /dev/null
+++ b/scripts/build-app.sh
@@ -0,0 +1,82 @@
+#!/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"
+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/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
+ 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" <
+
+
+CFBundleExecutableInKCre
+CFBundleIdentifier$bundle_id
+CFBundleNameInKCre
+CFBundleDisplayNameInKCre
+CFBundleIconFileAppIcon.icns
+CFBundlePackageTypeAPPL
+CFBundleShortVersionString$version
+CFBundleVersion17
+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..299303d
--- /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=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
+ 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
diff --git a/scripts/check.sh b/scripts/check.sh
new file mode 100755
index 0000000..77056ae
--- /dev/null
+++ b/scripts/check.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+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' \
+ ':!extensions/capture/Sources/CaptureMac/Vendor/**' \
+ | xargs -0 swift format lint --strict
+swift test --disable-automatic-resolution
diff --git a/scripts/collect-licenses.py b/scripts/collect-licenses.py
new file mode 100644
index 0000000..26c9f4f
--- /dev/null
+++ b/scripts/collect-licenses.py
@@ -0,0 +1,25 @@
+"""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 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']}")
+ 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))
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)