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..0615da5 --- /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、凭据、Host 配置和原生扩展生命周期。`extensions/capture` 是 first-party Swift 分发,拥有材料、系统采集、界面、SQLite 草稿/待办、构图与收入恢复。`Sources/InKCreApp` 只负责应用 Host。跨平台业务合同属于 Capture 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 [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Get%20over%20170%20free%20design%20blocks%20based%20on%20Bootstrap%204&url=https://froala.com/design-blocks&via=froala&hashtags=bootstrap,design,templates,blocks,developers) -### Present Apple-like alert & toast in SwiftUI - -

- -

- -## 🌄 Example - -

- -

- -

- -

- -## 🔭 Overview - -Currently in SwiftUI, the only way to inform the user about some process that finished for example, is by using `Alert`. Sometimes, you just want to display a message that tells the user that something has completed or that their message was sent. Apple doesn't provide any method other than using Alert, even though they use various types of pop-ups. This results in poor UX, as the user must tap 'OK' or 'Dismiss' for every piece of information they should be notified about - -Alert Toast is an open-source library in Github to use with SwiftUI. It allows you to present popups that don't need any user action to dismiss or to validate. Some great usage examples: `Message Sent`, `Poor Network Connection`, `Profile Updated`, `Logged In/Out`, `Favorited`, `Loading` and so on... - -          - -* Built with pure SwiftUI. -* 3 Display modes: `Alert` (pop at the center), `HUD` (drop from the top) and `Banner` (pop/slide from the bottom). -* `Complete`, `Error` `SystemImage`, `Image`, `Loading`, and `Regular` (Only Text). -* Supports Light & Dark Mode. -* Works with **any** kind of view builder. -* Localization support. -* Font & Background customization. - -#### If you like the project, don't forget to `put star 🌟`. - -       - -₿ Bitcoin donation address: -``` -0xec48bfa813a773fa2394aec23f97da5cb4d5ff02 -``` -- Send only BTC to this deposit address. -- Ensure the network is BNB Smart Chain (BEP20). - -## Navigation - -- [Installation](#-installation) - - [CocoaPods](#cocoapods) - - [Swift Package Manager](#swift-package-manager) - - [Manually](#manually) -- [Usage](#-usage) - - [Usage example with regular alert](#usage-example-with-regular-alert) - - [Complete Modifier Example](#complete-modifier-example) - - [Alert Toast Parameters](#alert-toast-parameters) - - [Article](#-article) - - [Contributing](#-contributing) - - [Author](#-author) - - [License](#-license) - -## 💻 Installation - -### Cocoapods - -[AlertToast Cocapods Website](https://cocoapods.org/pods/AlertToast) - -CocoaPods is a dependency manager for Cocoa projects. For usage and installation instructions, visit their website. To integrate `AlertToast` into your Xcode project using CocoaPods, specify it in your Podfile: - -```ruby -pod 'AlertToast' -``` - ------- - -### Swift Package Manager - -The [Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. It’s integrated with the Swift build system to automate the process of downloading, compiling, and linking dependencies. - -To integrate `AlertToast` into your Xcode project using Xcode 12, specify it in `File > Swift Packages > Add Package Dependency...`: - -```ogdl -https://github.com/elai950/AlertToast.git, :branch="master" -``` - -For Xcode 13, please refer [this article](https://iiroalhonen.medium.com/adding-a-swift-package-dependency-in-xcode-13-937b2caaf218) to install `AlertToast` - ------- - -### Manually - -If you prefer not to use any of dependency managers, you can integrate `AlertToast` into your project manually. Put `Sources/AlertToast` folder in your Xcode project. Make sure to enable `Copy items if needed` and `Create groups`. - -## 🧳 Requirements - -- iOS 13.0+ | macOS 11+ -- Xcode 12.0+ | Swift 5+ - -## 🛠 Usage - -First, add `import AlertToast` on every `swift` file you would like to use `AlertToast`. - -Then, use the `.toast` view modifier: - -**Parameters:** - -- `isPresenting`: (MUST) assign a `Binding` to show or dismiss alert. -- `duration`: default is 2, set 0 to disable auto dismiss. -- `tapToDismiss`: default is `true`, set `false` to disable. -- `alert`: (MUST) expects `AlertToast`. - -#### Usage example with regular alert - -```swift -import AlertToast -import SwiftUI - -struct ContentView: View{ - - @State private var showToast = false - - var body: some View{ - VStack{ - - Button("Show Toast"){ - showToast.toggle() - } - } - .toast(isPresenting: $showToast){ - - // `.alert` is the default displayMode - AlertToast(type: .regular, title: "Message Sent!") - - //Choose .hud to toast alert from the top of the screen - //AlertToast(displayMode: .hud, type: .regular, title: "Message Sent!") - - //Choose .banner to slide/pop alert from the bottom of the screen - //AlertToast(displayMode: .banner(.slide), type: .regular, title: "Message Sent!") - } - } -} -``` - -#### Complete Modifier Example - -```swift -.toast(isPresenting: $showAlert, duration: 2, tapToDismiss: true, alert: { - //AlertToast goes here -}, onTap: { - //onTap would call either if `tapToDismis` is true/false - //If tapToDismiss is true, onTap would call and then dismis the alert -}, completion: { - //Completion block after dismiss -}) -``` - -### Alert Toast Parameters - -```swift -AlertToast(displayMode: DisplayMode, - type: AlertType, - title: Optional(String), - subTitle: Optional(String), - style: Optional(AlertStyle)) - -//This is the available customizations parameters: -AlertStyle(backgroundColor: Color?, - titleColor: Color?, - subTitleColor: Color?, - titleFont: Font?, - subTitleFont: Font?) -``` - -#### Available Alert Types: -- **Regular:** text only (Title and Subtitle). -- **Complete:** animated checkmark. -- **Error:** animated xmark. -- **System Image:** name image from `SFSymbols`. -- **Image:** name image from Assets. -- **Loading:** Activity Indicator (Spinner). - -#### Alert dialog view modifier (with default settings): -```swift -.toast(isPresenting: Binding, duration: Double = 2, tapToDismiss: true, alert: () -> AlertToast , onTap: () -> (), completion: () -> () ) -``` - -#### Simple Text Alert: -```swift -AlertToast(type: .regular, title: Optional(String), subTitle: Optional(String)) -``` - -#### Complete/Error Alert: -```swift -AlertToast(type: .complete(Color)/.error(Color), title: Optional(String), subTitle: Optional(String)) -``` - -#### System Image Alert: -```swift -AlertToast(type: .systemImage(String, Color), title: Optional(String), subTitle: Optional(String)) -``` - -#### Image Alert: -```swift -AlertToast(type: .image(String), title: Optional(String), subTitle: Optional(String)) -``` - -#### Loading Alert: -```swift -//When using loading, duration won't auto dismiss and tapToDismiss is set to false -AlertToast(type: .loading, title: Optional(String), subTitle: Optional(String)) -``` - -You can add many `.toast` on a single view. - -## 📖 Article - -I wrote an article that contains more usage examples. - -[Medium - How to toast an alert in SwiftUI](https://elaizuberman.medium.com/presenting-apples-music-alerts-in-swiftui-7f5c32cebed6) - -## 👨‍💻 Contributors - -All issue reports, feature requests, pull requests and GitHub stars are welcomed and much appreciated. - -- [@barnard-b](https://github.com/barnard-b) - -## ✍️ Author - -Elai Zuberman - -## 📃 License - -`AlertToast` is available under the MIT license. See the [LICENSE](https://github.com/elai950/AlertToast/blob/master/LICENSE.md) file for more info. - ---- - -- [Jump Up](#-overview) diff --git a/D3LibMin.xcodeproj/project.pbxproj b/D3LibMin.xcodeproj/project.pbxproj deleted file mode 100644 index d4a361c..0000000 --- a/D3LibMin.xcodeproj/project.pbxproj +++ /dev/null @@ -1,579 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXContainerItemProxy section */ - 9F3F9F732E34B511004F4170 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 9F3F9F5D2E34B50F004F4170 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 9F3F9F642E34B50F004F4170; - remoteInfo = D3LibMin; - }; - 9F3F9F7D2E34B511004F4170 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 9F3F9F5D2E34B50F004F4170 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 9F3F9F642E34B50F004F4170; - remoteInfo = D3LibMin; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXFileReference section */ - 9F3F9F652E34B50F004F4170 /* D3LibMin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = D3LibMin.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 9F3F9F722E34B511004F4170 /* D3LibMinTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = D3LibMinTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 9F3F9F7C2E34B511004F4170 /* D3LibMinUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = D3LibMinUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 9F3F9F672E34B50F004F4170 /* D3LibMin */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = D3LibMin; - sourceTree = ""; - }; - 9F3F9F752E34B511004F4170 /* D3LibMinTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = D3LibMinTests; - sourceTree = ""; - }; - 9F3F9F7F2E34B511004F4170 /* D3LibMinUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = D3LibMinUITests; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 9F3F9F622E34B50F004F4170 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F6F2E34B511004F4170 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F792E34B511004F4170 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 9F3F9F5C2E34B50F004F4170 = { - isa = PBXGroup; - children = ( - 9F3F9F672E34B50F004F4170 /* D3LibMin */, - 9F3F9F752E34B511004F4170 /* D3LibMinTests */, - 9F3F9F7F2E34B511004F4170 /* D3LibMinUITests */, - 9F3F9F662E34B50F004F4170 /* Products */, - ); - sourceTree = ""; - }; - 9F3F9F662E34B50F004F4170 /* Products */ = { - isa = PBXGroup; - children = ( - 9F3F9F652E34B50F004F4170 /* D3LibMin.app */, - 9F3F9F722E34B511004F4170 /* D3LibMinTests.xctest */, - 9F3F9F7C2E34B511004F4170 /* D3LibMinUITests.xctest */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 9F3F9F642E34B50F004F4170 /* D3LibMin */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9F3F9F862E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMin" */; - buildPhases = ( - 9F3F9F612E34B50F004F4170 /* Sources */, - 9F3F9F622E34B50F004F4170 /* Frameworks */, - 9F3F9F632E34B50F004F4170 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 9F3F9F672E34B50F004F4170 /* D3LibMin */, - ); - name = D3LibMin; - packageProductDependencies = ( - ); - productName = D3LibMin; - productReference = 9F3F9F652E34B50F004F4170 /* D3LibMin.app */; - productType = "com.apple.product-type.application"; - }; - 9F3F9F712E34B511004F4170 /* D3LibMinTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9F3F9F892E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinTests" */; - buildPhases = ( - 9F3F9F6E2E34B511004F4170 /* Sources */, - 9F3F9F6F2E34B511004F4170 /* Frameworks */, - 9F3F9F702E34B511004F4170 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 9F3F9F742E34B511004F4170 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 9F3F9F752E34B511004F4170 /* D3LibMinTests */, - ); - name = D3LibMinTests; - packageProductDependencies = ( - ); - productName = D3LibMinTests; - productReference = 9F3F9F722E34B511004F4170 /* D3LibMinTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 9F3F9F7B2E34B511004F4170 /* D3LibMinUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 9F3F9F8C2E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinUITests" */; - buildPhases = ( - 9F3F9F782E34B511004F4170 /* Sources */, - 9F3F9F792E34B511004F4170 /* Frameworks */, - 9F3F9F7A2E34B511004F4170 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 9F3F9F7E2E34B511004F4170 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 9F3F9F7F2E34B511004F4170 /* D3LibMinUITests */, - ); - name = D3LibMinUITests; - packageProductDependencies = ( - ); - productName = D3LibMinUITests; - productReference = 9F3F9F7C2E34B511004F4170 /* D3LibMinUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 9F3F9F5D2E34B50F004F4170 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1640; - LastUpgradeCheck = 1640; - TargetAttributes = { - 9F3F9F642E34B50F004F4170 = { - CreatedOnToolsVersion = 16.4; - }; - 9F3F9F712E34B511004F4170 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = 9F3F9F642E34B50F004F4170; - }; - 9F3F9F7B2E34B511004F4170 = { - CreatedOnToolsVersion = 16.4; - TestTargetID = 9F3F9F642E34B50F004F4170; - }; - }; - }; - buildConfigurationList = 9F3F9F602E34B50F004F4170 /* Build configuration list for PBXProject "D3LibMin" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 9F3F9F5C2E34B50F004F4170; - minimizedProjectReferenceProxies = 1; - packageReferences = ( - 9F3F9FD72E350CF7004F4170 /* XCLocalSwiftPackageReference "../AlertToast-master" */, - ); - preferredProjectObjectVersion = 77; - productRefGroup = 9F3F9F662E34B50F004F4170 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 9F3F9F642E34B50F004F4170 /* D3LibMin */, - 9F3F9F712E34B511004F4170 /* D3LibMinTests */, - 9F3F9F7B2E34B511004F4170 /* D3LibMinUITests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 9F3F9F632E34B50F004F4170 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F702E34B511004F4170 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F7A2E34B511004F4170 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 9F3F9F612E34B50F004F4170 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F6E2E34B511004F4170 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 9F3F9F782E34B511004F4170 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 9F3F9F742E34B511004F4170 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 9F3F9F642E34B50F004F4170 /* D3LibMin */; - targetProxy = 9F3F9F732E34B511004F4170 /* PBXContainerItemProxy */; - }; - 9F3F9F7E2E34B511004F4170 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 9F3F9F642E34B50F004F4170 /* D3LibMin */; - targetProxy = 9F3F9F7D2E34B511004F4170 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin XCBuildConfiguration section */ - 9F3F9F842E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 9F3F9F852E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 9F3F9F872E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMin; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 9F3F9F882E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to display the latest image when triggered."; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMin; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 9F3F9F8A2E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/D3LibMin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/D3LibMin"; - }; - name = Debug; - }; - 9F3F9F8B2E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/D3LibMin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/D3LibMin"; - }; - name = Release; - }; - 9F3F9F8D2E34B511004F4170 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = D3LibMin; - }; - name = Debug; - }; - 9F3F9F8E2E34B511004F4170 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = Q8S3VVCGHQ; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.dullBlade.D3LibMinUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = D3LibMin; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 9F3F9F602E34B50F004F4170 /* Build configuration list for PBXProject "D3LibMin" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F842E34B511004F4170 /* Debug */, - 9F3F9F852E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 9F3F9F862E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMin" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F872E34B511004F4170 /* Debug */, - 9F3F9F882E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 9F3F9F892E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F8A2E34B511004F4170 /* Debug */, - 9F3F9F8B2E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 9F3F9F8C2E34B511004F4170 /* Build configuration list for PBXNativeTarget "D3LibMinUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 9F3F9F8D2E34B511004F4170 /* Debug */, - 9F3F9F8E2E34B511004F4170 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 9F3F9FD72E350CF7004F4170 /* XCLocalSwiftPackageReference "../AlertToast-master" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "../AlertToast-master"; - }; -/* End XCLocalSwiftPackageReference section */ - }; - rootObject = 9F3F9F5D2E34B50F004F4170 /* Project object */; -} diff --git a/D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/D3LibMin.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist b/D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index ce76cce..0000000 --- a/D3LibMin.xcodeproj/xcuserdata/jacksonguo.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,14 +0,0 @@ - - - - - SchemeUserState - - D3LibMin.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/D3LibMin/AddDeviceView.swift b/D3LibMin/AddDeviceView.swift deleted file mode 100644 index f75bfcf..0000000 --- a/D3LibMin/AddDeviceView.swift +++ /dev/null @@ -1,334 +0,0 @@ -// -// AddDeviceView.swift -// D3LibMin -// -// Created by Jackson Guo on 2025/7/27. -// - -import SwiftUI - -struct AddDeviceView: View { - @Environment(\.presentationMode) var presentationMode - @State private var searchText = "" - @State private var selectedDeviceType: DeviceType = .ar - @State private var isScanning = false - @State private var discoveredDevices: [DiscoveredDevice] = [] - - enum DeviceType: String, CaseIterable { - case ar = "AR Glasses" - case vr = "VR Headset" - case mobile = "Mobile Device" - case desktop = "Desktop Computer" - - var icon: String { - switch self { - case .ar: return "eyeglasses" - case .vr: return "viewfinder" - case .mobile: return "iphone" - case .desktop: return "desktopcomputer" - } - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Button(action: { - presentationMode.wrappedValue.dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.primary) - } - - Spacer() - - Text("Add Device") - .font(.spaceGroteskMedium(size: 20)) - .foregroundColor(.primary) - - Spacer() - - // Placeholder for balance - Image(systemName: "chevron.left") - .font(.system(size: 20, weight: .medium)) - .foregroundColor(.clear) - } - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 20) - - ScrollView { - VStack(spacing: 24) { - // Device Type Selection - VStack(alignment: .leading, spacing: 16) { - Text("Device Type") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 12) { - ForEach(DeviceType.allCases, id: \.self) { deviceType in - DeviceTypeCard( - type: deviceType, - isSelected: selectedDeviceType == deviceType, - action: { - selectedDeviceType = deviceType - } - ) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Search Section - VStack(alignment: .leading, spacing: 16) { - Text("Search for Devices") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - HStack { - Image(systemName: "magnifyingglass") - .font(.system(size: 16)) - .foregroundColor(.secondary) - - TextField("Search for devices...", text: $searchText) - .font(.system(size: 16)) - - if !searchText.isEmpty { - Button(action: { - searchText = "" - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 16)) - .foregroundColor(.secondary) - } - } - } - .padding(12) - .background(Color(.systemGray6)) - .cornerRadius(8) - - // Scan Button - Button(action: { - startScanning() - }) { - HStack { - if isScanning { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } else { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 16, weight: .medium)) - } - - Text(isScanning ? "Scanning..." : "Scan for Devices") - .font(.system(size: 16, weight: .medium)) - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(isScanning ? Color.gray : Color.primary) - .cornerRadius(8) - } - .disabled(isScanning) - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - - // Discovered Devices - if !discoveredDevices.isEmpty { - VStack(alignment: .leading, spacing: 16) { - Text("Available Devices") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - ForEach(discoveredDevices) { device in - DiscoveredDeviceRow(device: device) { - connectToDevice(device) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - } - - // Manual Connection - VStack(alignment: .leading, spacing: 16) { - Text("Manual Connection") - .font(.spaceGroteskMedium(size: 16)) - .foregroundColor(.primary) - - Text("If your device doesn't appear in the list above, you can manually enter the device information or use a QR code.") - .font(.system(size: 14)) - .foregroundColor(.secondary) - - HStack(spacing: 12) { - Button(action: { - // Manual entry action - }) { - HStack { - Image(systemName: "keyboard") - .font(.system(size: 16, weight: .medium)) - Text("Manual Entry") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - - Button(action: { - // QR code action - }) { - HStack { - Image(systemName: "qrcode") - .font(.system(size: 16, weight: .medium)) - Text("QR Code") - .font(.system(size: 14, weight: .medium)) - } - .foregroundColor(.primary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - } - .padding(20) - .background(Color.white) - .cornerRadius(12) - } - .padding(.horizontal, 20) - } - } - .background(Color(.systemGroupedBackground)) - .navigationBarHidden(true) - } - - private func startScanning() { - isScanning = true - discoveredDevices = [] - - // Simulate device discovery - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - discoveredDevices = [ - DiscoveredDevice(name: "Rokid AR Pro", type: .ar, signalStrength: 0.9, isPaired: false), - DiscoveredDevice(name: "Even Realities G1", type: .ar, signalStrength: 0.7, isPaired: false), - DiscoveredDevice(name: "Meta Quest 3", type: .vr, signalStrength: 0.8, isPaired: false), - DiscoveredDevice(name: "iPhone 15 Pro", type: .mobile, signalStrength: 0.6, isPaired: true) - ] - isScanning = false - } - } - - private func connectToDevice(_ device: DiscoveredDevice) { - // Handle device connection - print("Connecting to \(device.name)") - } -} - -struct DeviceTypeCard: View { - let type: AddDeviceView.DeviceType - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 12) { - Image(systemName: type.icon) - .font(.system(size: 24)) - .foregroundColor(isSelected ? .white : .primary) - - Text(type.rawValue) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(isSelected ? .white : .primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .background(isSelected ? Color.primary : Color(.systemGray6)) - .cornerRadius(8) - } - .buttonStyle(PlainButtonStyle()) - } -} - -struct DiscoveredDevice: Identifiable { - let id = UUID() - let name: String - let type: AddDeviceView.DeviceType - let signalStrength: Double - let isPaired: Bool -} - -struct DiscoveredDeviceRow: View { - let device: DiscoveredDevice - let onConnect: () -> Void - - var body: some View { - HStack { - Image(systemName: device.type.icon) - .font(.system(size: 20)) - .foregroundColor(.primary) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text(device.name) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - - HStack(spacing: 8) { - Text(device.type.rawValue) - .font(.system(size: 12, weight: .regular)) - .foregroundColor(.secondary) - - if device.isPaired { - Text("Paired") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.green) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.green.opacity(0.1)) - .cornerRadius(4) - } - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 4) { - // Signal strength indicator - HStack(spacing: 2) { - ForEach(0..<4, id: \.self) { index in - Rectangle() - .fill(index < Int(device.signalStrength * 4) ? Color.green : Color(.systemGray4)) - .frame(width: 3, height: CGFloat(index + 1) * 3) - .cornerRadius(1.5) - } - } - - Button(action: onConnect) { - Text(device.isPaired ? "Reconnect" : "Connect") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.primary) - .cornerRadius(6) - } - } - } - .padding(.vertical, 8) - } -} - -#Preview { - AddDeviceView() -} \ No newline at end of file diff --git a/D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json b/D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 8ec08b9..0000000 --- a/D3LibMin/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json b/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a345538..0000000 --- a/D3LibMin/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "images" : [ - { - "filename" : "appicon light.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "filename" : "appIcon 1.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "filename" : "appIcon 2.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 1.png b/D3LibMin/Assets.xcassets/AppIcon.appiconset/appIcon 1.png deleted file mode 100644 index 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 + do { try await module.setEnabled(enabled) } catch PeerError.connection(let message) { + syncIssue = message + } + defaults.set(enabled, forKey: key) + if enabled { try startIfEnabled() } else { try await stop() } + } + public func waitUntilIdle() async { + if applying { await withCheckedContinuation { idleWaiters.append($0) } } + } + + /// Process shutdown changes runtime facts, not durable enabled intent. + public func stop() async throws { + defer { running = false } + try await module.stop() + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift b/Packages/PeerCore/Sources/PeerCore/BundledParticipation.swift new file mode 100644 index 0000000..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..e82f6a3 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionSettings.swift @@ -0,0 +1,55 @@ +import Foundation + +public actor ConnectionSettings { + private let url: URL + public init(url: URL) { self.url = url } + public func load() throws -> ConnectionTarget? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + return try JSONDecoder().decode(ConnectionTarget.self, from: Data(contentsOf: url)) + } + public func save(_ target: ConnectionTarget) throws { + try target.validate() + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + try JSONEncoder().encode(target).write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + public func saveEdited( + host: String, port: String, database: String, username: String, tls: ConnectionTarget.TLS, + web: String, password: String, previous: ConnectionTarget?, credentials: CredentialStore + ) throws -> ConnectionTarget { + let host = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard let port = Int(port) else { throw PeerError.invalid("数据库端口无效。") } + let web = web.trimmingCharacters(in: .whitespacesAndNewlines) + let webURL = web.isEmpty ? nil : URL(string: web) + if !web.isEmpty && webURL == nil { throw PeerError.invalid("Web 地址无效。") } + let same = + previous?.host == host && previous?.port == port && previous?.database == database + let sameCredentials = same && previous?.username == username + let target = ConnectionTarget( + id: sameCredentials ? previous!.id : UUID(), peerID: same ? previous!.peerID : UUID(), + host: host, port: port, database: database, username: username, tls: tls, + webURL: webURL, + name: host) + try target.validate() + if !password.isEmpty { try credentials.save(password, for: target.id) } + _ = try credentials.read(for: target.id) + try save(target) + return target + } + +} + +public struct PeerHostContext: Sendable { + public let identity: String + public let directory: URL + public let connections: ConnectionSettings + public let credentials: CredentialStore + public init(identity: String, directory: URL) { + self.identity = identity + self.directory = directory + connections = ConnectionSettings(url: directory.appendingPathComponent("connection.json")) + credentials = CredentialStore(service: "\(identity).connections") + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift b/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift new file mode 100644 index 0000000..a7b7346 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/ConnectionTarget.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct ConnectionTarget: Codable, Sendable, Equatable, Identifiable { + public enum TLS: String, Codable, Sendable, CaseIterable { case require, disable } + public let id: UUID + public let peerID: UUID + public var host: String + public var port: Int + public var database: String + public var username: String + public var tls: TLS + public var webURL: URL? + public var name: String + + public init( + id: UUID = UUID(), peerID: UUID = UUID(), host: String, port: Int = 5432, + database: String, username: String, tls: TLS = .require, + webURL: URL? = nil, name: String = "我的信息库" + ) { + self.id = id + self.peerID = peerID + self.host = host + self.port = port + self.database = database + self.username = username + self.tls = tls + self.webURL = webURL + self.name = name + } + + public func validate() throws { + guard !host.isEmpty, !host.contains(where: { $0.isWhitespace }), !host.contains("/"), + (1...65535).contains(port), !database.isEmpty, !username.isEmpty, + ![host, database, username].contains(where: { $0.contains("\0") }) + else { + throw PeerError.invalid("请输入有效的数据库主机、端口、库名与运行角色。") + } + if tls == .disable && !["localhost", "127.0.0.1", "::1"].contains(host) { + throw PeerError.invalid("远端数据库必须使用 TLS;仅本机回环开发连接可关闭 TLS。") + } + if let url = webURL { + guard ["http", "https"].contains(url.scheme), url.host != nil, url.user == nil, + url.password == nil, url.query == nil, url.fragment == nil + else { + throw PeerError.invalid("Web 地址须为不含凭据的 HTTP(S) 地址。") + } + } + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/Credentials.swift b/Packages/PeerCore/Sources/PeerCore/Credentials.swift new file mode 100644 index 0000000..e5fbbbf --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/Credentials.swift @@ -0,0 +1,46 @@ +import Foundation +import Security + +public struct CredentialStore: Sendable { + private let service: String + + public init(service: String) { self.service = service } + + public func save(_ secret: String, for connection: UUID) throws { + guard !secret.isEmpty else { + throw PeerError.invalid("请输入数据库密码。") + } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + ] + let data = Data(secret.utf8) + var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + if status == errSecItemNotFound { + var item = query + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + status = SecItemAdd(item as CFDictionary, nil) + } + guard status == errSecSuccess else { throw PeerError.storage("Keychain 保存失败(\(status))。") } + } + + public func read(for connection: UUID) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: connection.uuidString, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data, + let secret = String(data: data, encoding: .utf8) + else { + throw PeerError.needsAction("无法读取此信息库的凭据,请更新数据库密码。") + } + return secret + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift new file mode 100644 index 0000000..edd8621 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/DatabaseError.swift @@ -0,0 +1,22 @@ +import Foundation +import PostgresNIO + +extension PeerDatabase { + public static func failure(_ error: Error, dispatched: Bool = false) -> PeerError { + if let transaction = error as? PostgresTransactionError, + transaction.rollbackError == nil, transaction.commitError == nil, + let cause = transaction.closureError + { + return failure(cause, dispatched: dispatched) + } + if let error = error as? PeerError { return error } + if let pg = error as? PSQLError, let code = pg.serverInfo?[.sqlState] { + switch String(code.prefix(2)) { + case "28", "42": return .needsAction("数据库角色权限或协议不符,请检查运行凭据与数据库初始化。") + case "22", "23": return .needsAction("数据库拒绝了写入参数或约束。") + default: break + } + } + return dispatched ? .outcomeUnknown : .connection("暂时无法连接 PostgreSQL,请检查网络和连接配置。") + } +} diff --git a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift new file mode 100644 index 0000000..a8aabb8 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift @@ -0,0 +1,61 @@ +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 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/PeerMacUI/PeerConnectionEditor.swift b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift new file mode 100644 index 0000000..cdef355 --- /dev/null +++ b/Packages/PeerCore/Sources/PeerMacUI/PeerConnectionEditor.swift @@ -0,0 +1,91 @@ +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 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 + _server = State(initialValue: target?.host ?? "") + _port = State(initialValue: String(target?.port ?? 5432)) + _database = State(initialValue: target?.database ?? "inkcre") + _username = State(initialValue: target?.username ?? "") + _tls = State(initialValue: target?.tls ?? .require) + _web = State(initialValue: target?.webURL?.absoluteString ?? "") + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("连接共享信息库").font(.title2).bold() + Form { + TextField("PostgreSQL 主机", text: $server).focused($addressFocused) + TextField("端口", text: $port) + TextField("数据库", text: $database) + TextField("运行角色", text: $username) + Picker("TLS", selection: $tls) { + Text("校验证书").tag(ConnectionTarget.TLS.require) + Text("关闭(仅本机开发)").tag(ConnectionTarget.TLS.disable) + } + TextField("Web 地址(可选)", text: $web) + SecureField("数据库密码", text: $secret) + }.textFieldStyle(.roundedBorder).disabled(saving) + Text("凭据保存在本机 Keychain。已有连接留空可沿用原凭据;更换库或运行角色会建立新连接,已有待办保留原目标。") + .font(.caption).foregroundStyle(.secondary) + if let issue { Text(issue).foregroundStyle(.red).textSelection(.enabled) } + HStack { + Spacer() + Button("取消") { + dismiss() + onCancel() + }.keyboardShortcut(.cancelAction).disabled(saving) + Button(saving ? "正在保存…" : 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, 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..d2dbcd5 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,75 @@ -# 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 +需要反复开发和实测系统采集时,使用本人明确指定的 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)。 -## 项目结构 -``` -D3LibMin/ -├── Models.swift # 数据模型 -├── ContentView.swift # 主视图容器 -├── HomeView.swift # 主页视图 -├── UserProfileView.swift # 用户资料页面 -├── InfoDetailView.swift # 信息详情页面 -└── D3LibMinApp.swift # 应用入口 +默认 `⌃⌥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 页只展示状态,当前连接编辑打开设置窗口中的原生 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 和目标;“继续收入”先查原回执。已发出的请求可能已完成,取消不能撤回共享内容。确认删除待办会停止后续发送并删除本机记录和材料;已经发出的请求仍可能完成,迟到响应不会重新创建本机记录。 + +## 本地数据与发布 + +开发包的 Host 配置位于 `~/Library/Application Support/dev.inkcre.capture/connection.json`;Capture 数据位于同目录 `extensions/inkcre-capture/capture.sqlite`,使用 WAL/FULL。确认后清理本机材料,保留共享引用。WAL 和空闲页不提供安全擦除保证。此前未发布开发版的数据文件原样保留,本版没有旧格式导入或 HTTP 回退层。 + +`Packages/PeerCore` 提供数据库、标准图/存储、凭据和原生模块生命周期;`extensions/capture` 是独立 Swift 包,拥有采集业务与 Mac 界面。宿主菜单提供独立的“Peer 连接…”,Capture 停用后仍能编辑连接,宿主继续维护 Peer 在线状态。设置中的“扩展”页展示实际携带的分发版本和本机运行状态,可独立启停。停用撤下入口并等待当前工作收束,保留材料;退出只停止运行,不撤回启用意图。未配置数据库可离线收集;离线开关不覆盖共享参与意图,重连先读取已有选择,不重建已移除记录。明确在线启用可以重新建立本 Peer 的参与。 + +Capture 的精确 manifest 随签名应用交付,格式为 `swift_bundled`。当前不实现动态插件下载/热加载,其他版本需更新应用。扩展版本与应用版本分开,不声称已有 Registry 原生 Swift 下载通道。 + +应用图标、菜单栏 template 图标和明暗面板标识来自用户提供的 2026-10-21 logo pack。应用 ICNS 在原标志外使用 Mac 专用圆角底板和透明边距,避免通用白底画布满版导致 Dock 尺寸过大。资源选择、原始出处、小尺寸/高 DPI 几何与再生步骤见 `extensions/capture/Sources/CaptureMac/Resources/Brand/README.md`,构建不依赖 Downloads 中的原始目录。 + +发布构建显式设置稳定 `BUNDLE_ID` 与 `SIGNING_IDENTITY`;可用 `NOTARY_PROFILE` 指定已配置的 notarytool Keychain profile。脚本不会寻找或创建发布凭据。发布后保持 bundle ID 与签名团队不变,以保留系统权限、Keychain 和待办身份。 + +```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..a53bb11 --- /dev/null +++ b/Sources/InKCreApp/AppMain.swift @@ -0,0 +1,253 @@ +import AppKit +import CaptureMac +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 + private var module: any NativeExtension { runtime.module } + override convenience init() { + let identity = Bundle.main.bundleIdentifier ?? "dev.inkcre.capture" + let directory = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask)[0] + .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) + super.init() + 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? + 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() + do { + try runtime.startIfEnabled() + } catch { show(error) } + presence = Task { + while !Task.isCancelled { + do { + if let target = try await context.connections.load() { + _ = try await PeerDatabase.shared.register( + target, password: context.credentials.read(for: target.id)) + } + connectionIssue = nil + } catch { connectionIssue = PeerDatabase.failure(error).localizedDescription } + updateStatus() + do { try await Task.sleep(for: .seconds(30)) } catch { break } + } + } + if !runtime.running { showExtensions(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(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 { connection = try await context.connections.load() } catch { + connectionIssue = error.localizedDescription + } + } + } + + func changeEnabled(_ enable: Bool) { + guard !runtime.applying else { return } + Task { + objectWillChange.send() + do { try await runtime.setEnabled(enable) } catch { show(error) } + manager?.refreshCaptureSettings(host: self) + updateStatus() + } + } + + private func updateStatus() { objectWillChange.send() } + + func editConnection(_ target: ConnectionTarget?, onFinished: (@MainActor () -> Void)? = nil) { + guard capture.canOpenSettings else { return } + showSettings(.connection) + guard let manager, manager.attachedSheet == nil else { return } + let sheet = NSWindow( + contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: false) + let close: @MainActor @Sendable () -> Void = { [weak manager, weak sheet] in + guard let manager, let sheet else { return } + manager.endSheet(sheet) + sheet.orderOut(nil) + } + let content = NSHostingView( + rootView: PeerConnectionEditor( + settings: context.connections, credentials: context.credentials, + target: target, saveTitle: onFinished == nil ? "保存" : "保存并返回采集", onCancel: close, + onSaved: { [weak self] target in + self?.connection = target + self?.connectionIssue = nil + self?.capture.connectionDidChange(target) + close() + })) + sheet.contentView = content + sheet.setContentSize(content.fittingSize) + manager.beginSheet(sheet) { [weak manager] _ in + 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 + { + if runtime.running { module.showMain() } else { showExtensions(nil) } + 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 presence?.value + await PeerDatabase.shared.shutdown() + sender.reply(toApplicationShouldTerminate: true) + } catch { + show(error) + sender.reply(toApplicationShouldTerminate: false) + } + } + return .terminateLater + } +} diff --git a/Sources/InKCreApp/PeerSettingsWindow.swift b/Sources/InKCreApp/PeerSettingsWindow.swift new file mode 100644 index 0000000..dcf4112 --- /dev/null +++ b/Sources/InKCreApp/PeerSettingsWindow.swift @@ -0,0 +1,124 @@ +import AppKit +import PeerCore +import SwiftUI + +@MainActor +final class PeerSettingsWindow: NSWindow, NSWindowDelegate { + private weak var host: PeerHost? + enum Pane: Int { case connection, extensions, capture } + private let tabs = NSTabViewController() + + init(host: PeerHost) { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 640, height: 580), + styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) + self.host = host + delegate = self + title = "InKCre 设置" + isReleasedWhenClosed = false + minSize = NSSize(width: 600, height: 540) + tabs.tabStyle = .toolbar + let connection = NSTabViewItem( + viewController: NSHostingController(rootView: ConnectionSettingsView(host: host))) + connection.label = "连接" + connection.image = NSImage(systemSymbolName: "network", accessibilityDescription: "连接") + let extensions = NSTabViewItem( + viewController: NSHostingController(rootView: ExtensionSettingsView(host: host))) + extensions.label = "扩展" + extensions.image = NSImage( + systemSymbolName: "puzzlepiece.extension", accessibilityDescription: "扩展") + let capture = NSTabViewItem(viewController: captureController(host: host)) + capture.label = "采集" + capture.image = NSImage(systemSymbolName: "viewfinder", accessibilityDescription: "采集") + tabs.tabViewItems = [connection, extensions, capture] + contentViewController = tabs + center() + } + + func windowWillClose(_ notification: Notification) { host?.capture.finishEditingSettings() } + func windowDidResignKey(_ notification: Notification) { host?.capture.finishEditingSettings() } + + func select(_ pane: Pane) { tabs.selectedTabViewItemIndex = pane.rawValue } + + func refreshCaptureSettings(host: PeerHost) { + replaceCaptureSettings(with: captureController(host: host)) + } + + func replaceCaptureSettings(with controller: NSViewController) { + let selected = tabs.selectedTabViewItemIndex + let previous = tabs.tabViewItems[Pane.capture.rawValue] + let replacement = NSTabViewItem(viewController: controller) + replacement.label = previous.label + replacement.image = previous.image + // NSTabViewController must update containment when replacing a hosted pane. + tabs.removeTabViewItem(previous) + tabs.insertTabViewItem(replacement, at: Pane.capture.rawValue) + tabs.selectedTabViewItemIndex = selected + } + + private func captureController(host: PeerHost) -> NSViewController { + host.capture.makeSettingsViewController() + ?? NSHostingController( + rootView: + ContentUnavailableView( + "采集扩展已停用", systemImage: "viewfinder", + description: Text("在“扩展”中启用后,可设置采集快捷键和系统权限。"))) + } +} + +private struct ConnectionSettingsView: View { + @ObservedObject var host: PeerHost + var body: some View { + Form { + Section("共享信息库") { + if let target = host.connection { + LabeledContent("信息库", value: target.name) + LabeledContent( + "PostgreSQL", value: "\(target.host):\(target.port)/\(target.database)") + LabeledContent("运行角色", value: target.username) + if let web = target.webURL { + LabeledContent("Web 地址", value: web.absoluteString) + } + } else { + Text("连接信息库后可以收入。未连接时仍可采集、保留草稿和导出材料。") + .foregroundStyle(.secondary) + } + Button(host.connection == nil ? "连接信息库…" : "编辑连接…") { + host.editConnection(host.connection) + } + if let issue = host.connectionIssue { + Text(issue).foregroundStyle(.red).textSelection(.enabled) + } + } + Text("连接由此 Mac 的各扩展共享;密码保存在系统钥匙串中。") + .font(.caption).foregroundStyle(.secondary) + }.formStyle(.grouped) + } +} + +private struct ExtensionSettingsView: View { + @ObservedObject var host: PeerHost + var body: some View { + Form { + Section("随手收集") { + Toggle( + "在此 Mac 启用", + isOn: Binding(get: { host.runtime.enabled }, set: { host.changeEnabled($0) }) + ) + .disabled(host.runtime.applying) + Text( + host.runtime.applying + ? "正在应用…" + : host.runtime.syncIssue + ?? (host.runtime.running + ? "本机运行中" + : host.runtime.enabled ? "尚未启动;请停用后重新启用。" : "已停用;本机材料与共享内容保留") + ) + .foregroundStyle(.secondary) + LabeledContent("版本", value: host.capture.descriptor.version) + Text("随应用交付,提供选区、粘贴、截图与想法采集。") + .font(.caption).foregroundStyle(.secondary) + } + }.formStyle(.grouped) + } +} diff --git a/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..afa93b5 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,11 @@ +# 开源许可与来源 + +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 logo pack 用于本项目品牌;代码 MIT 许可不授予 InKCre 商标或品牌资源的使用权。品牌目录保留出处记录。 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..70eebdf --- /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 = .peer + model.editorOwner = .quick + var presented: CaptureModel.EditorOwner? + model.presentEditor = { presented = model.editorOwner } + let payload = CapturePayload(entry: .selection, materials: [Material(text: "快捷采集的新材料")]) + await model.receive(payload) + #expect(model.pane == .peer) + #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..7c4214c --- /dev/null +++ b/Tests/CaptureMacTests/CaptureLayoutTests.swift @@ -0,0 +1,260 @@ +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 mainWindowSidebarAndPrimarySurfacesRenderOffscreen() async throws { + _ = NSApplication.shared + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let model = try CaptureModel( + directory: directory, identity: "dev.inkcre.surfaces.\(UUID().uuidString)") + defer { model.cancelWork() } + let window = PeerMainWindow(model: model) + window.setFrameAutosaveName("") + window.setFrame(NSRect(x: 0, y: 0, width: 760, height: 520), display: false) + defer { window.orderOut(nil) } + let item = NSMenuItem( + title: "侧栏", action: #selector(NSSplitViewController.toggleSidebar(_:)), + keyEquivalent: "s") + #expect(window.validateUserInterfaceItem(item)) + #expect(item.title == "隐藏侧栏") + #expect(window.tryToPerform(item.action!, with: item)) + #expect(model.columnVisibility == .detailOnly) + #expect(window.validateUserInterfaceItem(item)) + #expect(item.title == "显示侧栏") + try await snapshot(window, named: "main-sidebar-hidden") + #expect(window.tryToPerform(item.action!, with: item)) + #expect(model.columnVisibility == .all) + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + for pane in [CaptureModel.Pane.capture, .queue, .peer] { + model.pane = pane + try await snapshot(window, named: "main-\(pane)-\(appearance.rawValue)") + } + model.records = [CaptureRecord(payload: CapturePayload(annotation: "只有补充想法的草稿也必须有预览。"))] + model.pane = .queue + try await snapshot(window, named: "records-annotation-\(appearance.rawValue)") + model.records = [] + } + let auxiliary = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 560), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + defer { auxiliary.orderOut(nil) } + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + auxiliary.appearance = NSAppearance(named: appearance) + auxiliary.contentViewController = NSHostingController( + rootView: CaptureSettingsView(model: model)) + auxiliary.setContentSize(NSSize(width: 600, height: 560)) + try await snapshot(auxiliary, named: "capture-settings-\(appearance.rawValue)") + model.editorOwner = .quick + model.thought = "用于界面验收的合成想法,不读取剪贴板或屏幕。" + auxiliary.contentViewController = NSHostingController( + rootView: QuickCaptureView(model: model)) + auxiliary.setContentSize(NSSize(width: 500, height: 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..f997990 --- /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..60f2df7 --- /dev/null +++ b/Tests/CaptureTests/CaptureStoreTests.swift @@ -0,0 +1,211 @@ +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)") + try await CaptureQueue(store: store, credentials: credentials).process(force: true) + 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.markDispatch(draft.id).id == draft.id) + } + + @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(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/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/ - - - -
9:41
Essentiali

605-1-3064

Package pick up

Moonlight

Piano scores

John Smith

Business contact

Project Alpha

Design files

Meeting Notes

Weekly team sync

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

Alex Johnson

@alexjohnson

Memory Usage

2.4 GB / 5.0 GB

48% of storage used

Connected Devices

Glasses

Last connected: Today, 3:45 PM

Connected

App Settings

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

Complete project proposal

Schedule team meeting

Review quarterly reports

Prepare presentation slides

Email client updates

- \ No newline at end of file diff --git a/paraflow/project.json b/paraflow/project.json deleted file mode 100644 index e65bac2..0000000 --- a/paraflow/project.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Minimal Knowledge App", - "created_at": "2025-07-26T07:13:42.791846+00:00", - "paraflow_version": "1.0", - "screens": [ - { - "id": "7:0", - "title": "Home Page", - "filename": "7:0.html" - }, - { - "id": "8:0", - "title": "User Profile", - "filename": "8:0.html" - }, - { - "id": "9:0", - "title": "Information Detail", - "filename": "9:0.html" - } - ] -} \ No newline at end of file diff --git a/paraflow/styles.css b/paraflow/styles.css deleted file mode 100644 index ba6495e..0000000 --- a/paraflow/styles.css +++ /dev/null @@ -1,4 +0,0 @@ -.__paraflow-fa-fas {font-family: "Font Awesome 6 Free"; display: inline-block; line-height: 1; font-weight: 900; font-style: normal} -.list-item-container {background-color: rgba(255, 255, 255, 1); margin-bottom: 6px; padding-top: 18px; padding-right: 18px; padding-bottom: 18px; padding-left: 18px; border-top-left-radius: 8px; border-top-right-radius: 8px; border-bottom-right-radius: 8px; border-bottom-left-radius: 8px} -.filter-button-inactive {background-color: rgba(244, 246, 248, 1); color: rgba(7, 11, 17, 1)} -.filter-button-active {background-color: rgba(33, 37, 40, 1); color: rgba(255, 255, 255, 1)} \ No newline at end of file diff --git a/profilehead.png b/profilehead.png deleted file mode 100644 index 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..5817595 --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,80 @@ +#!/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/" +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 +CFBundleVersion13 +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..c125bca --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +for script in scripts/*.sh; do + bash -n "$script" +done +git ls-files --cached --others --exclude-standard -z -- \ + Package.swift 'Sources/**/*.swift' 'Tests/**/*.swift' \ + 'Packages/**/*.swift' 'extensions/**/*.swift' \ + ':!extensions/capture/Sources/CaptureMac/Vendor/**' \ + | xargs -0 swift format lint --strict +swift test --disable-automatic-resolution diff --git a/scripts/collect-licenses.py b/scripts/collect-licenses.py new file mode 100644 index 0000000..956797c --- /dev/null +++ b/scripts/collect-licenses.py @@ -0,0 +1,22 @@ +"""Bundle exact dependency licenses and notices; never fetch moving upstream at package time.""" +import json +import pathlib +import sys + +root = pathlib.Path(__file__).resolve().parents[1] +output = pathlib.Path(sys.argv[1]) +parts = [(root / "THIRD_PARTY_NOTICES.md").read_text(), (root / "LICENSE").read_text()] +for path in sorted((root / "extensions/capture/Legal").rglob("*")): + if path.is_file(): + parts += [str(path.relative_to(root)), path.read_text()] +for pin in json.loads((root / "Package.resolved").read_text())["pins"]: + checkout = root / ".build/checkouts" / pin["identity"] + files = sorted(p for p in checkout.rglob("*") if p.is_file() and + p.name.upper().startswith(("LICENSE", "LICENCE", "NOTICE", "COPYING"))) + if not files: + raise SystemExit(f"Missing license for {pin['identity']}") + parts.append(f"{pin['identity']} {pin['state'].get('version', '')}\n{pin['location']}\nrevision {pin['state']['revision']}") + for path in files: + parts += [str(path.relative_to(checkout)), path.read_text(errors="strict")] +output.parent.mkdir(parents=True, exist_ok=True) +output.write_text("\n\n" + ("\n\n" + "=" * 72 + "\n\n").join(parts))