From ffed0658411c93e6c540762f3508a36dec84e01b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Sat, 15 Aug 2026 15:02:35 +0800 Subject: [PATCH 01/10] =?UTF-8?q?ZY-TAKEOVER-SNAPSHOT=2020260815:=20?= =?UTF-8?q?=E9=93=B8=E6=B8=8A=E6=8E=A5=E7=AE=A1=E5=89=8D=E7=8E=B0=E5=9C=BA?= =?UTF-8?q?=E5=86=BB=E7=BB=93=EF=BC=88Codex=20=E6=9C=AA=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E6=94=B9=E5=8A=A8=E5=85=A8=E9=83=A8=E5=85=A5=E5=86=8C=EF=BC=89?= =?UTF-8?q?=C2=B7=20=E5=86=B0=E6=9C=94=E9=9D=A2=E8=B0=95=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?+=E5=8F=AF=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hololake-native-desktop/README.md | 22 +- ...wledge-workspace-migration-provenance.json | 64 + .../local-runtime-acceptance-20260815.json | 77 ++ .../contracts/code-channel.json | 50 + .../contracts/knowledge-workspace.json | 60 + .../contracts/personal-channel-kernel.json | 52 + .../contracts/stage-one-platform.json | 60 +- .../docs/ARCHITECTURE.md | 24 +- .../hololake-native-desktop/foundation.json | 32 +- .../hololake-native-desktop/package-lock.json | 30 + .../hololake-native-desktop/package.json | 2 + .../scripts/knowledge-code-channel.test.mjs | 71 ++ .../scripts/personal-channel-kernel.test.mjs | 57 + .../src-tauri/Cargo.lock | 75 ++ .../src-tauri/Cargo.toml | 2 + .../src-tauri/src/code_channel.rs | 929 ++++++++++++++ .../src-tauri/src/knowledge_base.rs | 1065 +++++++++++++++++ .../src-tauri/src/lib.rs | 17 + .../src-tauri/src/personal_channel.rs | 853 +++++++++++++ .../src-tauri/tauri.conf.json | 3 + .../hololake-native-desktop/src/main.tsx | 892 ++++++++------ .../hololake-native-desktop/src/styles.css | 455 ++++--- 22 files changed, 4309 insertions(+), 583 deletions(-) create mode 100644 product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json create mode 100644 product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json create mode 100644 product-source/hololake-native-desktop/contracts/code-channel.json create mode 100644 product-source/hololake-native-desktop/contracts/knowledge-workspace.json create mode 100644 product-source/hololake-native-desktop/contracts/personal-channel-kernel.json create mode 100644 product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs create mode 100644 product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/code_channel.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs create mode 100644 product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs diff --git a/product-source/hololake-native-desktop/README.md b/product-source/hololake-native-desktop/README.md index dcdf9580e..eba71911a 100644 --- a/product-source/hololake-native-desktop/README.md +++ b/product-source/hololake-native-desktop/README.md @@ -1,14 +1,18 @@ # HoloLake Native Desktop -状态:`唯一原生主线候选 · 已有底层能力 · 第一阶段可见主体尚未实现` +状态:`唯一原生主线候选 · 个人空间、知识库与代码频道源码已实现 · 安装验收未完成` 这是 HoloLake 唯一未来桌面产品主线的干净源码根,采用 Tauri v2、Rust 和 React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码线的本地候选验收壳, 不是第三条产品线,也还不是第一阶段正式产品。 -当前已经建立可编译原生壳、选定的 GHS-014 个人频道首页、本机 broker/session、动态能力 -路由、GH-PNCC 只读投影,以及失败关闭的签名更新和回滚管道。这些能力保留,但都属于湖下 -基础设施;当前首页只是视觉与连接状态验收面,不代表第一阶段产品主体已经实现。 +当前可见产品已经收束为“我的光湖”:个人身份只在首次使用时建立一次,之后直接进入概览、 +知识库、代码频道、回执与系统详情。知识库由新的 Rust 本机根持有,提供页面目录、阅读、搜索 +和文件夹导入;旧 HoloLake Era 知识数据仅以独立只读来源兼容。代码频道支持粘贴正式 HTTPS +频道地址克隆,也可登记现有本地 Git 文件夹。两者都不因此取得推送、发布或部署权限。 + +个人频道 SQLite 内核仍保留任务、事件和回执能力,但手工填写“任务标题/原因”不再作为默认 +产品入口。当前源码与单元测试已经通过;桌面安装、跨重启真实读回和完整第一阶段仍需独立验收。 ## 供体边界 @@ -40,7 +44,9 @@ React/TypeScript。桌面上的 `world.guanghu.hololake` 安装包是本源码 机器合同:`contracts/stage-one-platform.json`。 -该文件目前只登记模块和边界,不表示第一阶段运行时或可见产品主体已经实现。 +个人频道、知识库和代码频道的机器合同分别是 `contracts/personal-channel-kernel.json`、 +`contracts/knowledge-workspace.json` 与 `contracts/code-channel.json`。本机身份不是平台账户 +认证;本机知识与源码访问也不产生仓库、服务器、部署或发布权限。 ## 本机开发桥 @@ -61,10 +67,10 @@ MCP 只保留为外部工具适配入口;它断开或编程 AI 重启时,活 唯一主线、候选壳、供体和数据保护边界见 `routing/hololake-stage-one-desktop-convergence.json`。当前开发顺序固定为: -1. 实现个人频道身份—任务—事件—回执最小内核; -2. 迁入知识树、页面、搜索和本地持久化纵向切片; +1. 个人频道身份—事件—回执内核:源码与自动化测试已实现; +2. 原生知识库与代码频道:源码与自动化测试已实现,安装验收未执行; 3. 实现人类审批中心与 Git 证据回读; 4. 投影小湖灯跨会话连续性; -5. 最后完成签名安装、更新、回滚和可逆数据迁移。 +5. 最后完成公开签名、更新、回滚和可逆数据迁移。 连接、动态路由、发布广播和更新服务器继续作为受门控基础设施维护,不得抢占上述产品顺序。 diff --git a/product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json b/product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json new file mode 100644 index 000000000..fbfbc9073 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/knowledge-workspace-migration-provenance.json @@ -0,0 +1,64 @@ +{ + "schema": "hololake.knowledge-workspace-migration-provenance/v1", + "record_id": "HLP-KNOWLEDGE-MIGRATION-001", + "state": "CLEAN_ROOM_SOURCE_MIGRATION_IMPLEMENTED_LOCAL_RUNTIME_ACCEPTED", + "target": "product-source/hololake-native-desktop", + "donors": [ + { + "source": "product-source/hololake-desktop", + "role": "READ_ONLY_FOLDER_IMPORT_BEHAVIOR_DONOR", + "runtime_copied": false + }, + { + "source": "product-source/guanghu-knowledge-base", + "role": "READ_ONLY_PAGE_TREE_SEARCH_READING_UX_DONOR", + "runtime_copied": false + }, + { + "source": "HoloLake Era.app 0.8.0", + "role": "READ_ONLY_INSTALLED_BEHAVIOR_AND_COMPATIBILITY_DONOR", + "runtime_copied": false + }, + { + "source": "Singapore Outline 0.80.2", + "role": "READ_ONLY_INTERACTION_AND_INFORMATION_ARCHITECTURE_DONOR", + "runtime_copied": false, + "live_content_documents_observed": 4, + "content_migration_source": false + } + ], + "migration_units": [ + "PAGE_TREE_CONTRACT", + "DOCUMENT_READING_CONTRACT", + "BOUNDED_SEARCH_CONTRACT", + "NATIVE_FOLDER_IMPORT_CONTRACT", + "LOCAL_GIT_COMMIT_RECEIPT", + "NESTED_OUTLINE_NAVIGATION", + "INDEPENDENT_SCROLL_REGIONS", + "GFM_AND_WIKI_LINK_RENDERING", + "EXACT_CONTENT_DEDUPLICATION", + "OPTIMISTIC_NATIVE_EDITING" + ], + "excluded": [ + "ELECTRON_RUNTIME", + "OUTLINE_SERVER_RUNTIME", + "TOLARIA_SURFACE", + "LEGACY_WRITE_PATH", + "IN_PLACE_DATA_MIGRATION" + ], + "legacy_data_boundary": { + "mode": "READ_ONLY_COMPATIBILITY_SOURCE", + "modified": false, + "migrated": false, + "deletion_allowed": false, + "observed_markdown_documents": 279, + "observed_exact_duplicate_groups": 121, + "observed_exact_duplicate_extras": 121 + }, + "native_data_audit": { + "observed_markdown_documents_before_reconciliation": 130, + "cross_source_identical_documents": 121, + "union_unique_documents": 167, + "display_policy": "PREFER_NATIVE_THEN_COALESCE_EXACT_SHA256" + } +} diff --git a/product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json b/product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json new file mode 100644 index 000000000..2c3d93069 --- /dev/null +++ b/product-source/hololake-native-desktop/audit/local-runtime-acceptance-20260815.json @@ -0,0 +1,77 @@ +{ + "schema": "hololake.local-runtime-acceptance/v1", + "receipt_id": "GH-HOLOLAKE-LOCAL-RUNTIME-20260815-001", + "state": "PASS_100_LOCAL_RUNTIME_ONLY", + "observed_at": "2026-08-15T09:18:00+08:00", + "installed_application": { + "path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app", + "bundle_identifier": "world.guanghu.hololake", + "executable_sha256": "310c1b71a96b67d79435b62a553fcdba2377ab3c4ae04a19a1ffa0113ce5bcea", + "cdhash": "7a856cc7495c74ea7aa7c3b6127b73aabf6fe9fb", + "signature": "AD_HOC_HARDENED_RUNTIME_LOCAL_ONLY", + "developer_id_team": null, + "apple_notarized": false + }, + "personal_space": { + "display_name_readback": "冰朔", + "identity_cardinality": 1, + "restart_readback": true, + "manual_task_form_present": false + }, + "knowledge": { + "legacy_source": "HOLOLAKE_ERA_READ_ONLY", + "legacy_document_count": 279, + "native_document_count": 130, + "raw_document_count": 409, + "unique_document_count": 167, + "exact_duplicate_documents_folded": 242, + "native_git_head": "520d5c053dd22b7d73b375973ba1f31edfee82e6", + "folder_import": "PASS", + "repeated_folder_import": "ALREADY_PRESENT_NO_SECOND_TREE", + "nested_tree_open": "PASS", + "document_independent_scroll": "PASS", + "gfm_table_and_wiki_link_projection": "PASS", + "native_edit_with_optimistic_hash_and_git_receipt": "PASS", + "search_query": "连续性验收", + "search_result": "导入/hololake-runtime-acceptance-knowledge/notes/continuity.md", + "restart_readback": true, + "legacy_source_modified": false + }, + "code_channel": { + "input_url": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart", + "normalized_remote": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git", + "git_head": "a4f67a0634dd3664452bb2ea2747d13df1479f99", + "branch": "main", + "repository_clean": true, + "repository_tree_open": "PASS", + "json_machine_to_knowledge_projection": "PASS", + "credential_prompt_observed": false, + "restart_readback": true, + "push_or_deploy_attempted": false + }, + "desktop_convergence": { + "active_desktop_entry_count": 1, + "active_entry": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app", + "recoverable_archive": "/Users/bingshuolingdianyuanhe/Documents/HoloLake Archive/2026-08-15-0912-pre-real-knowledge", + "deleted": false + }, + "automated_gates": { + "node_tests": "50_PASS", + "rust_tests": "59_PASS", + "typescript_vite_build": "PASS", + "npm_dependency_audit": "0_VULNERABILITIES", + "cargo_clippy_deny_warnings": "PASS", + "codesign_strict_verification": "PASS" + }, + "exclusions": [ + "FULL_STAGE_ONE_PRODUCT_COMPLETION", + "PUBLIC_DEVELOPER_ID_SIGNATURE", + "APPLE_NOTARIZATION", + "PUBLIC_RELEASE_ACTIVATION", + "SOURCE_PUBLICATION", + "SERVER_DEPLOYMENT", + "PERSONA_BINDING_RECOVERY_COMPLETION", + "CURRENT_CODEX_NATIVE_CONNECTION_RUNTIME_ACCEPTANCE", + "GH_PNCC_NONZERO_RUNTIME_PROJECTION" + ] +} diff --git a/product-source/hololake-native-desktop/contracts/code-channel.json b/product-source/hololake-native-desktop/contracts/code-channel.json new file mode 100644 index 000000000..33cd103ba --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/code-channel.json @@ -0,0 +1,50 @@ +{ + "schema": "hololake.user-code-channel-contract/v1", + "record_id": "HLP-USER-CODE-CHANNEL-001", + "state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING", + "entry_methods": ["OFFICIAL_HTTPS_CHANNEL_CLONE", "LOCAL_GIT_FOLDER_REGISTRATION"], + "clone": { + "allowed_schemes": ["https"], + "allowed_hosts": ["guanghulab.com", "guanghubingshuo.com"], + "embedded_credentials_allowed": false, + "query_or_fragment_credentials_allowed": false, + "terminal_prompt_allowed": false, + "credential_helper_allowed": false, + "app_owned_destination": true, + "failed_destination_cleanup_bounded": true + }, + "registry": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "location": "TAURI_APP_DATA_CODE_CHANNEL_V1", + "stored_fields_include_credentials": false, + "atomic_write": true, + "restart_readback": true + }, + "workbench": { + "repository_tree_browse": true, + "registered_repository_openable": true, + "path_escape_denied": true, + "symlink_traversal_denied": true, + "maximum_text_file_bytes": 2097152, + "knowledge_projection": true, + "source_projection": true, + "human_readable_formats": ["markdown", "json", "hdlp", "yaml", "toml", "ini", "source_code"] + }, + "authority": { + "source_read_access": true, + "automatic_push": false, + "publication_authority": false, + "deployment_authority": false, + "server_authority": false + }, + "acceptance": { + "rust_url_and_registry_tests": true, + "react_projection_integrated": true, + "repository_tree_runtime_tested": true, + "human_projection_runtime_tested": true, + "installed_runtime_acceptance": true, + "public_clone_runtime_acceptance": true, + "restart_readback": true, + "server_deployment": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/knowledge-workspace.json b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json new file mode 100644 index 000000000..19cd0551a --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/knowledge-workspace.json @@ -0,0 +1,60 @@ +{ + "schema": "hololake.knowledge-workspace-contract/v1", + "record_id": "HLP-KNOWLEDGE-WORKSPACE-001", + "state": "LOCAL_INSTALLED_RUNTIME_ACCEPTED_PUBLIC_RELEASE_PENDING", + "native_storage": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "location": "TAURI_APP_DATA_KNOWLEDGE_V1", + "engine": "LOCAL_GIT_WITH_DOCUMENT_TREE", + "webview_direct_filesystem_access": false, + "automatic_server_upload": false + }, + "capabilities": { + "page_tree": true, + "outline_style_nested_navigation": true, + "independent_tree_and_document_scrolling": true, + "safe_document_read": true, + "bounded_full_text_search": true, + "native_folder_picker_import": true, + "import_commit_receipt": true, + "exact_content_deduplication": true, + "optimistic_native_editing": true, + "native_edit_commit_receipt": true, + "gfm_tables_tasks_and_code": true, + "wiki_link_navigation": true, + "markdown_reading_canvas": true, + "raw_html_rendering": false + }, + "folder_import": { + "maximum_files": 1000, + "maximum_file_bytes": 10485760, + "document_extensions": ["md", "markdown", "txt", "csv", "json", "yaml", "yml"], + "asset_extensions": ["png", "jpg", "jpeg", "gif", "webp", "svg"], + "symlinks_followed": false, + "git_and_dependency_directories_imported": false, + "repeated_import": "MERGE_BY_STABLE_SOURCE_NAME", + "exact_duplicate_result": "ALREADY_PRESENT", + "conflicting_file_overwrite": false, + "count_semantics": "RAW_UNIQUE_AND_DUPLICATE_COUNTS_EXPOSED_SEPARATELY" + }, + "legacy_compatibility": { + "source": "HOLOLAKE_ERA_0_8_KNOWLEDGE_DATA", + "mode": "READ_ONLY_SEPARATE_ROOT", + "in_place_migration": false, + "source_modification_allowed": false, + "tolaria_surface_used": false + }, + "acceptance": { + "rust_unit_tests": true, + "react_projection_integrated": true, + "installed_runtime_acceptance": true, + "folder_import_search_and_restart_readback": true, + "public_signed_runtime_acceptance": false, + "legacy_data_migrated": false, + "legacy_data_available_read_only": true, + "deduplication_runtime_tested": true, + "idempotent_import_runtime_tested": true, + "native_edit_runtime_tested": true, + "server_deployment": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/personal-channel-kernel.json b/product-source/hololake-native-desktop/contracts/personal-channel-kernel.json new file mode 100644 index 000000000..4e8c4260a --- /dev/null +++ b/product-source/hololake-native-desktop/contracts/personal-channel-kernel.json @@ -0,0 +1,52 @@ +{ + "schema": "hololake.personal-channel-kernel-contract/v1", + "record_id": "HLP-PERSONAL-CHANNEL-KERNEL-001", + "state": "SOURCE_IMPLEMENTED_AND_TESTED_NOT_INSTALLED_OR_DEPLOYED", + "scope": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL", + "storage": { + "owner": "HOLOLAKE_NATIVE_RUST_CORE", + "engine": "EMBEDDED_SQLITE", + "location": "TAURI_APP_DATA_PERSONAL_CHANNEL_V1", + "directory_mode": "0700", + "database_mode": "0600", + "synchronous_commit": "FULL", + "webview_direct_database_access": false, + "server_upload_automatic": false + }, + "identity": { + "cardinality": "ONE_LOCAL_HUMAN_SUBJECT_PER_DATABASE", + "stable_native_ids": true, + "display_name_human_confirmed": true, + "platform_account_authentication_claimed": false, + "server_or_repository_authority_granted": false + }, + "task": { + "one_active_task_at_a_time": true, + "purpose_required": true, + "implemented_transitions": ["CREATE_ACTIVE", "ACTIVE_TO_COMPLETED"], + "compare_expected_state_before_transition": true + }, + "event_and_receipt": { + "identity_and_task_mutations_are_single_sqlite_transactions": true, + "every_mutation_has_event": true, + "every_event_has_receipt": true, + "event_chain_sha256": true, + "receipt_chain_sha256": true, + "integrity_verified_before_mutation_and_readback": true, + "tampering_fails_closed": true + }, + "acceptance": { + "initialization_atomicity_test": true, + "single_active_task_test": true, + "task_completion_receipt_test": true, + "process_restart_readback_test": true, + "tamper_detection_test": true, + "react_human_projection_integrated": true, + "react_identity_onboarding_integrated": true, + "react_default_task_form_exposed": false, + "signed_installed_runtime_acceptance": false, + "local_ad_hoc_signed_installed_runtime_acceptance": true, + "data_migration": false, + "server_deployment": false + } +} diff --git a/product-source/hololake-native-desktop/contracts/stage-one-platform.json b/product-source/hololake-native-desktop/contracts/stage-one-platform.json index 57e4fa62f..b67391ece 100644 --- a/product-source/hololake-native-desktop/contracts/stage-one-platform.json +++ b/product-source/hololake-native-desktop/contracts/stage-one-platform.json @@ -1,7 +1,7 @@ { "schema": "hololake.stage-one-platform-contract/v1", "record_id": "HLP-NATIVE-STAGE-ONE-CONTRACT-001", - "state": "LANGUAGE_ARCHITECTURE_CLOSED_REALITY_ENGINEERING_CONTRACT_RUNTIME_NOT_IMPLEMENTED", + "state": "LANGUAGE_ARCHITECTURE_CLOSED_REAL_KNOWLEDGE_AND_CODE_WORKBENCH_LOCAL_RUNTIME_ACCEPTED_STAGE_ONE_INCOMPLETE", "product": "GH-AIOS", "formal_name": "光湖语言系统 · 通用人工智能操作平台", "tcs_work_boundary": { @@ -15,7 +15,8 @@ "stage_gate": { "language_architecture": "CLOSED_AND_ARCHIVED", "current_stage": "REALITY_ENGINEERING_EXECUTION", - "next_minimum_stage": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL" + "completed_source_slice": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL", + "next_minimum_stage": "HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK" }, "persona_cognition_origin": { "human_authored_guanghu_persona_prompt_exists": false, @@ -39,12 +40,63 @@ "KNOWLEDGE_PROJECTION", "HUMAN_APPROVAL_CENTER", "NATIVE_TRUST_BOUNDARY", - "LOCAL_DEVELOPMENT_BRIDGE" + "LOCAL_DEVELOPMENT_BRIDGE", + "USER_CODE_CHANNEL" ,"PERSONAL_NODE_WORK_LAKE_AND_MOBILE_BRIDGE" ], - "human_surface": ["PERSONAL_CHANNEL_HOME", "TASK_AND_DEVELOPMENT_LINES", "LAKE_LAMP_SHARED_BOARD", "MEMORY_AND_KNOWLEDGE", "HUMAN_APPROVAL_CENTER", "EVENTS_GIT_EVIDENCE_AND_RECEIPTS"], + "human_surface": ["PERSONAL_CHANNEL_HOME", "MY_HOLOLAKE_OVERVIEW", "KNOWLEDGE_WORKSPACE", "USER_CODE_CHANNELS", "LOCAL_RECEIPTS", "SYSTEM_DETAILS", "HUMAN_APPROVAL_CENTER"], "universal_language": {"ai_is_language_interface": true, "vendor_adapter_matrix_required": false, "current_ai_self_adapts_to_observed_host": true, "host_self_adaptation_changes_how_not_authority": true}, "stage_one_forbidden": ["INTERNAL_AI_CHAT", "MODEL_API_CONFIGURATION", "MODEL_SELECTION", "INTERNAL_MODEL_INFERENCE", "VENDOR_ADAPTER_MATRIX", "AI_WORKBENCH", "MODEL_AND_CONNECTIONS"], + "personal_channel_kernel": { + "contract": "contracts/personal-channel-kernel.json", + "native_source_implemented": true, + "react_human_projection_integrated": true, + "local_sqlite_restart_readback_tested": true, + "identity_is_local_human_confirmation_not_platform_authentication": true, + "identity_established_once_then_default_ui_enters_my_hololake": true, + "manual_task_title_and_purpose_form_exposed_by_default": false, + "every_identity_or_task_mutation_has_event_and_receipt": true, + "event_and_receipt_hash_chains_verified_on_read": true, + "installed_runtime_acceptance": true + }, + "knowledge_workspace": { + "contract": "contracts/knowledge-workspace.json", + "native_git_owned_root_implemented": true, + "page_tree_read_search_and_idempotent_folder_import_implemented": true, + "outline_style_nested_navigation_and_independent_scrolling_implemented": true, + "gfm_wiki_links_and_optimistic_native_editing_implemented": true, + "exact_content_deduplication_implemented": true, + "legacy_hololake_era_source_is_read_only": true, + "tolaria_surface_used": false, + "installed_runtime_acceptance": true + }, + "user_code_channel": { + "contract": "contracts/code-channel.json", + "official_https_clone_implemented": true, + "local_git_folder_registration_implemented": true, + "repository_tree_and_file_open_implemented": true, + "machine_format_knowledge_projection_implemented": true, + "credentials_in_url_allowed": false, + "credential_prompting_allowed": false, + "push_or_deploy_authority_granted": false, + "installed_runtime_acceptance": true + }, + "local_installed_runtime_acceptance": { + "receipt": "audit/local-runtime-acceptance-20260815.json", + "identity_restart_readback": true, + "legacy_knowledge_read_only_projection": true, + "folder_import_search_and_restart_readback": true, + "official_https_code_channel_clone_and_restart_readback": true, + "knowledge_unique_count": 167, + "knowledge_duplicate_count_folded": 242, + "code_repository_tree_and_json_projection": true, + "direct_local_broker_ready": true, + "current_authenticated_external_ai_connections": 0, + "gh_pncc_repository_mounts": 0, + "gh_pncc_projection_receipts": 0, + "public_developer_id_and_notarization": false, + "server_deployment": false + }, "reality_mutation_requires": ["VERIFIED_HUMAN_SUBJECT", "EXACT_ACTION", "EXACT_TARGET", "IMMUTABLE_PAYLOAD_DIGEST", "EXPIRY", "REPLAY_PROTECTION", "EXECUTION_RECEIPT", "READBACK_RECEIPT"], "context_risk_signals": ["MODEL_CONTEXT_SPEC", "ESTIMATED_TOKENS", "MESSAGE_VOLUME", "TURN_COUNT", "TASK_STAGE", "HOST_WARNING"], "exact_host_compaction_prediction_claimed": false, diff --git a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md index 0b79ef98f..912507f74 100644 --- a/product-source/hololake-native-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-native-desktop/docs/ARCHITECTURE.md @@ -1,10 +1,24 @@ # HoloLake Native Desktop Architecture -The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation. +The stage-one shell is a Tauri v2 application with a Rust-owned local core and a React human projection. The default product surface is **My HoloLake**: overview, knowledge, user code channels, local receipts and system details. The five domains and their server fleet remain submerged system infrastructure rather than primary stage-one navigation. ## Stage-one human projection -The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it exposes one personal-channel entry, not five domain gates. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. The surface reads a native status projection; only an authenticated direct connector can illuminate the live-connected state. A stored resumable session remains visibly distinct from a live connection. +The home surface follows the verified GHS-014 five-lakes visual grammar while applying the current stage-one correction: it is a restrained operating-system workspace, not a slogan page or a five-domain gate. The five named lake themes are token groups only and cannot change layout, copy, routing or authority. Internal transport and release details live under system details rather than dominating the first screen. + +The first visible body uses a Rust-owned SQLite kernel under the Tauri app-data directory. A human-confirmed local display name creates one stable local subject and channel exactly once. The internal task/event/receipt kernel remains available for structured agents, but manual task title and purpose fields are not part of the default human surface. Event and receipt chains remain independently SHA-256-linked and fully revalidated before every read or mutation. A local identity is not platform authentication and grants no repository, node, server or deployment authority. + +## Native knowledge workspace + +The native core owns a separate `knowledge-v1` Git root. It projects a bounded document tree, safe text reads, local search and native folder import into a reading canvas without rendering raw HTML. Folder import ignores symlinks, Git metadata, dependency directories and unsupported files, applies file-count and byte limits, then creates a local Git commit receipt. + +The installed HoloLake Era 0.8 knowledge root is detected only as a separate read-only source. The new application does not write, migrate in place or merge it with the native root. Electron, Outline runtime code and Tolaria UI are not copied into the Tauri product. + +## User code channels + +A human may paste a registered Guanghu HTTPS code-channel address or select an existing local Git folder. HTTPS clone accepts no credentials, queries or fragments and disables terminal prompts, askpass and credential helpers. Clones are confined to an application-owned root; local registrations do not move the source folder. The registry stores a credential-free projection of path, branch and commit. + +This product channel is distinct from the under-lake PNCC persona-evidence projection below. It grants local source access only and never grants push, publication, deployment or server authority. ## External programming AI entry @@ -44,10 +58,10 @@ The Tauri source in this directory is the only future HoloLake desktop mainline. The current candidate already has useful under-lake organs: the signed native shell, local broker and resumable session, authenticated read-only PNCC projection, human-confirmed repository mount, deterministic routing, and fail-closed update/rollback machinery. They remain in the architecture, but their existence does not satisfy the visible stage-one body. -The implementation order is now fixed: +The implementation order is fixed. The first two source slices are implemented and tested but have not received installed-runtime acceptance: -1. personal-channel identity, task, event and receipt kernel; -2. knowledge tree, page, search and local persistence; +1. personal-channel identity, event and receipt kernel — source and tests implemented; +2. native knowledge workspace and user code channel — source and tests implemented; 3. human approval center and Git evidence readback; 4. lake-lamp cross-session continuity projection; 5. signed installation, opt-in update, rollback and reversible data migration. diff --git a/product-source/hololake-native-desktop/foundation.json b/product-source/hololake-native-desktop/foundation.json index 52bfa1c93..3086e769b 100644 --- a/product-source/hololake-native-desktop/foundation.json +++ b/product-source/hololake-native-desktop/foundation.json @@ -1,7 +1,7 @@ { "schema": "hololake.native-desktop-foundation/v1", "record_id": "HLP-NATIVE-DESKTOP-FOUNDATION-001", - "state": "TAURI_CANDIDATE_FOUNDATION_REORIENTED_TO_STAGE_ONE_VISIBLE_BODY", + "state": "LOCAL_INSTALLED_ACCEPTANCE_PASSED_FOR_PERSONAL_KNOWLEDGE_AND_CODE_SLICES", "canonical_shell": "TAURI_V2_RUST_REACT", "candidate_role": "INSTALLED_LOCAL_ACCEPTANCE_CANDIDATE_NOT_SEPARATE_PRODUCT_LINE", "parallel_product_line_created": false, @@ -13,9 +13,31 @@ "selected_visual_direction": "GHS-014_FIVE_LAKES_PERSONAL_CHANNEL_STAGE_ONE", "selected_visual_direction_receipt": "audit/ghs-014-stage-one-ui-receipt.json", "home_status_is_native_projection": true, - "home_surface_role": "SELECTED_VISUAL_ACCEPTANCE_SURFACE_NOT_STAGE_ONE_PRODUCT_COMPLETION", - "stage_one_visible_body_state": "NOT_IMPLEMENTED", - "stage_one_next_slice": "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL", + "home_surface_role": "MY_HOLOLAKE_PRODUCT_WORKSPACE_NOT_STAGE_ONE_PRODUCT_COMPLETION", + "stage_one_visible_body_state": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL_SOURCE_IMPLEMENTED_PRODUCT_INCOMPLETE", + "stage_one_completed_source_slice": "PERSONAL_IDENTITY_KNOWLEDGE_WORKSPACE_AND_CODE_CHANNEL", + "stage_one_next_slice": "HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK", + "personal_channel_kernel_contract": "contracts/personal-channel-kernel.json", + "personal_channel_identity_task_event_receipt_kernel_implemented": true, + "personal_channel_private_sqlite_single_owner_implemented": true, + "personal_channel_restart_readback_and_tamper_tests_passed": true, + "personal_channel_human_projection_integrated": true, + "personal_channel_default_task_form_exposed": false, + "personal_channel_installed_runtime_acceptance": true, + "knowledge_workspace_contract": "contracts/knowledge-workspace.json", + "knowledge_workspace_native_source_implemented": true, + "knowledge_workspace_tree_read_search_and_folder_import_implemented": true, + "knowledge_workspace_legacy_hololake_era_read_only_compatibility": true, + "knowledge_workspace_installed_runtime_acceptance": true, + "code_channel_contract": "contracts/code-channel.json", + "code_channel_https_clone_and_local_git_registration_implemented": true, + "code_channel_credential_prompting_disabled": true, + "code_channel_push_or_deploy_authority_granted": false, + "code_channel_installed_runtime_acceptance": true, + "local_runtime_acceptance_receipt": "audit/local-runtime-acceptance-20260815.json", + "installed_desktop_path": "/Users/bingshuolingdianyuanhe/Desktop/HoloLake 第一阶段.app", + "old_desktop_apps_recoverably_archived": true, + "public_developer_id_and_notarized_acceptance": false, "stage_one_implementation_order": [ "PERSONAL_CHANNEL_IDENTITY_TASK_EVENT_RECEIPT_KERNEL", "KNOWLEDGE_TREE_PAGE_SEARCH_AND_LOCAL_PERSISTENCE", @@ -81,7 +103,7 @@ "pncc_execution_limb_implemented": false, "pncc_persona_lease_acquisition_implemented": false, "pncc_reality_execution_implemented": false, - "stage_one_product_contract_state": "TCS_UNIVERSAL_LANGUAGE_PERSONAL_CHANNEL_CONTRACT_ONLY_RUNTIME_NOT_IMPLEMENTED", + "stage_one_product_contract_state": "PERSONAL_KNOWLEDGE_AND_CODE_SOURCE_SLICES_IMPLEMENTED_FULL_STAGE_ONE_PRODUCT_INCOMPLETE", "stage_one_internal_ai_interaction": false, "stage_one_model_api_configuration": false, "vendor_adapter_matrix_required": false, diff --git a/product-source/hololake-native-desktop/package-lock.json b/product-source/hololake-native-desktop/package-lock.json index 0c34413df..010b8d423 100644 --- a/product-source/hololake-native-desktop/package-lock.json +++ b/product-source/hololake-native-desktop/package-lock.json @@ -11,6 +11,8 @@ "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", + "dompurify": "^3.4.13", + "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1514,6 +1516,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", @@ -1768,6 +1777,15 @@ } } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.405", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz", @@ -1950,6 +1968,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.1.tgz", + "integrity": "sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/product-source/hololake-native-desktop/package.json b/product-source/hololake-native-desktop/package.json index 86ea575d4..b5a4b2c4a 100644 --- a/product-source/hololake-native-desktop/package.json +++ b/product-source/hololake-native-desktop/package.json @@ -14,6 +14,8 @@ "@tauri-apps/api": "2.10.1", "@tauri-apps/plugin-process": "2.3.1", "@tauri-apps/plugin-updater": "2.10.0", + "dompurify": "^3.4.13", + "marked": "^16.4.1", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs new file mode 100644 index 000000000..720c5a410 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/knowledge-code-channel.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8') +const knowledgeContract = JSON.parse(read('contracts/knowledge-workspace.json')) +const codeContract = JSON.parse(read('contracts/code-channel.json')) +const provenance = JSON.parse(read('audit/knowledge-workspace-migration-provenance.json')) +const knowledgeRust = read('src-tauri/src/knowledge_base.rs') +const codeRust = read('src-tauri/src/code_channel.rs') +const lib = read('src-tauri/src/lib.rs') +const ui = read('src/main.tsx') + +test('knowledge workspace is HoloLake-owned and keeps the legacy source read-only', () => { + assert.equal(knowledgeContract.native_storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE') + assert.equal(knowledgeContract.legacy_compatibility.mode, 'READ_ONLY_SEPARATE_ROOT') + assert.equal(knowledgeContract.legacy_compatibility.in_place_migration, false) + assert.equal(knowledgeContract.legacy_compatibility.tolaria_surface_used, false) + assert.equal(provenance.legacy_data_boundary.modified, false) + assert.match(knowledgeRust, /join\("knowledge-v1"\)/) + assert.match(knowledgeRust, /source_docs_root/) +}) + +test('folder import is bounded, idempotent, conflict-safe and creates a local Git receipt', () => { + assert.equal(knowledgeContract.folder_import.maximum_files, 1000) + assert.equal(knowledgeContract.folder_import.symlinks_followed, false) + assert.equal(knowledgeContract.folder_import.repeated_import, 'MERGE_BY_STABLE_SOURCE_NAME') + assert.equal(knowledgeContract.folder_import.exact_duplicate_result, 'ALREADY_PRESENT') + assert.equal(knowledgeContract.folder_import.conflicting_file_overwrite, false) + assert.equal(knowledgeContract.capabilities.import_commit_receipt, true) + assert.equal(knowledgeContract.capabilities.exact_content_deduplication, true) + assert.equal(knowledgeContract.capabilities.optimistic_native_editing, true) + assert.match(knowledgeRust, /MAX_IMPORT_FILES: usize = 1_000/) + assert.match(knowledgeRust, /kind\.is_symlink\(\)/) + assert.match(knowledgeRust, /ImportDisposition::Existing/) + assert.match(knowledgeRust, /HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT/) + assert.match(knowledgeRust, /rev-parse", "HEAD/) +}) + +test('official code-channel clone cannot use credentials or credential prompts', () => { + assert.equal(codeContract.clone.embedded_credentials_allowed, false) + assert.equal(codeContract.clone.terminal_prompt_allowed, false) + assert.equal(codeContract.clone.credential_helper_allowed, false) + assert.equal(codeContract.authority.automatic_push, false) + assert.match(codeRust, /GIT_TERMINAL_PROMPT/) + assert.match(codeRust, /credential\.helper=/) + assert.match(codeRust, /parsed\.username\(\)\.is_empty\(\)/) +}) + +test('the product surface exposes a real knowledge workbench and browsable code channel', () => { + for (const command of [ + 'get_knowledge_snapshot', + 'read_knowledge_document', + 'search_knowledge', + 'save_knowledge_document', + 'select_and_import_knowledge_folder', + 'get_code_channel_snapshot', + 'clone_code_channel', + 'select_local_code_channel', + 'browse_code_channel', + 'read_code_channel_file', + ]) { + assert.match(lib, new RegExp(command)) + assert.match(ui, new RegExp(`['"]${command}['"]`)) + } + assert.match(ui, /知识工作台/) + assert.match(ui, /HoloLake Era/) + assert.match(ui, /知识视图/) + assert.match(ui, /源文件/) + assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/) +}) diff --git a/product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs b/product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs new file mode 100644 index 000000000..7343ff840 --- /dev/null +++ b/product-source/hololake-native-desktop/scripts/personal-channel-kernel.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import test from 'node:test' + +const read = (relative) => fs.readFileSync(new URL(`../${relative}`, import.meta.url), 'utf8') +const contract = JSON.parse(read('contracts/personal-channel-kernel.json')) +const foundation = JSON.parse(read('foundation.json')) +const stageOne = JSON.parse(read('contracts/stage-one-platform.json')) +const rust = read('src-tauri/src/personal_channel.rs') +const lib = read('src-tauri/src/lib.rs') +const ui = read('src/main.tsx') + +test('personal channel kernel has one private SQLite owner and no server authority', () => { + assert.equal(contract.storage.owner, 'HOLOLAKE_NATIVE_RUST_CORE') + assert.equal(contract.storage.engine, 'EMBEDDED_SQLITE') + assert.equal(contract.storage.webview_direct_database_access, false) + assert.equal(contract.identity.platform_account_authentication_claimed, false) + assert.equal(contract.identity.server_or_repository_authority_granted, false) + assert.equal(contract.acceptance.server_deployment, false) +}) + +test('identity and task mutations create one-to-one chained events and receipts', () => { + assert.equal(contract.event_and_receipt.identity_and_task_mutations_are_single_sqlite_transactions, true) + assert.equal(contract.event_and_receipt.every_mutation_has_event, true) + assert.equal(contract.event_and_receipt.every_event_has_receipt, true) + assert.equal(contract.event_and_receipt.tampering_fails_closed, true) + assert.match(rust, /CREATE TABLE IF NOT EXISTS events/) + assert.match(rust, /CREATE TABLE IF NOT EXISTS receipts/) + assert.match(rust, /verify_integrity\(&connection\)/) +}) + +test('the human projection establishes identity once and keeps task mutation below the default surface', () => { + for (const command of [ + 'get_personal_channel_snapshot', + 'initialize_personal_channel', + 'create_personal_channel_task', + 'transition_personal_channel_task', + ]) { + assert.match(lib, new RegExp(`personal_channel::${command}`)) + } + for (const command of ['get_personal_channel_snapshot', 'initialize_personal_channel']) { + assert.match(ui, new RegExp(`['"]${command}['"]`)) + } + assert.doesNotMatch(ui, /['"]create_personal_channel_task['"]/) + assert.doesNotMatch(ui, /['"]transition_personal_channel_task['"]/) + assert.doesNotMatch(ui, /下一件事|为什么做|开始这件事/) + assert.equal(stageOne.personal_channel_kernel.native_source_implemented, true) + assert.equal(stageOne.personal_channel_kernel.installed_runtime_acceptance, true) + assert.equal(foundation.personal_channel_identity_task_event_receipt_kernel_implemented, true) + assert.equal(foundation.personal_channel_installed_runtime_acceptance, true) +}) + +test('full stage-one completion stays false after the first source slice', () => { + assert.equal(stageOne.implementation_complete, false) + assert.equal(foundation.stage_one_next_slice, 'HUMAN_APPROVAL_CENTER_AND_GIT_EVIDENCE_READBACK') + assert.match(foundation.stage_one_visible_body_state, /PRODUCT_INCOMPLETE/) +}) diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.lock b/product-source/hololake-native-desktop/src-tauri/Cargo.lock index 9f44d285f..e6819e8ba 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.lock +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -811,6 +823,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1319,12 +1343,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1351,6 +1393,7 @@ dependencies = [ "dirs", "fs2", "ring", + "rusqlite", "serde", "serde_json", "tauri", @@ -1360,6 +1403,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tempfile", + "url", "uuid", ] @@ -1901,6 +1945,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3027,6 +3082,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4570,6 +4639,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/product-source/hololake-native-desktop/src-tauri/Cargo.toml b/product-source/hololake-native-desktop/src-tauri/Cargo.toml index 990524931..9d2135115 100644 --- a/product-source/hololake-native-desktop/src-tauri/Cargo.toml +++ b/product-source/hololake-native-desktop/src-tauri/Cargo.toml @@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] } dirs = "6" fs2 = "0.4" ring = "0.17" +rusqlite = { version = "0.31", features = ["bundled"] } base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -27,6 +28,7 @@ tauri-runtime-wry = "=2.10.0" tauri-plugin-updater = "2.10.0" tauri-plugin-dialog = "=2.7.2" uuid = { version = "1", features = ["v4"] } +url = "2" [dev-dependencies] tempfile = "3" diff --git a/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs new file mode 100644 index 000000000..0ef18083c --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/code_channel.rs @@ -0,0 +1,929 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use serde::{Deserialize, Serialize}; +use std::cmp::Reverse; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::DialogExt; +use url::Url; +use uuid::Uuid; + +const SNAPSHOT_SCHEMA: &str = "hololake.code-channel/v1"; +const REGISTRY_SCHEMA: &str = "hololake.code-channel-registry/v1"; +const ALLOWED_HOSTS: &[&str] = &["guanghulab.com", "guanghubingshuo.com"]; +const MAX_TREE_ENTRIES: usize = 1_000; +const MAX_CODE_FILE_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CloneCodeChannelInput { + pub url: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CodeChannelEntry { + pub channel_id: String, + pub name: String, + pub source_kind: String, + pub local_path: String, + pub remote_url: Option, + pub git_head: String, + pub branch: String, + pub repository_clean: bool, + pub registered_at_unix_ms: u128, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeChannelSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub storage_root: String, + pub channels: Vec, + pub authority: &'static str, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BrowseCodeChannelInput { + pub channel_id: String, + pub path: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeTreeEntry { + pub path: String, + pub name: String, + pub kind: &'static str, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeTreeSnapshot { + pub schema: &'static str, + pub channel_id: String, + pub path: String, + pub entries: Vec, + pub truncated: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadCodeChannelFileInput { + pub channel_id: String, + pub path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodeFileProjection { + pub schema: &'static str, + pub channel_id: String, + pub path: String, + pub format: String, + pub source: String, + pub human_markdown: String, + pub size_bytes: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct CodeChannelRegistry { + schema: String, + channels: Vec, +} + +#[derive(Clone, Debug)] +struct ValidatedCloneUrl { + normalized: String, + owner: String, + repository: String, +} + +#[tauri::command] +pub async fn get_code_channel_snapshot(app: AppHandle) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&root)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn clone_code_channel( + app: AppHandle, + input: CloneCodeChannelInput, +) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || clone_at(&root, input)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn select_local_code_channel( + app: AppHandle, +) -> Result, String> { + let picker = app.clone(); + let selected = tauri::async_runtime::spawn_blocking(move || { + picker + .dialog() + .file() + .set_title("选择本地 Git 代码频道") + .blocking_pick_folder() + }) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_JOIN_FAILED: {error}"))?; + let Some(selected) = selected else { + return Ok(None); + }; + let selected = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PICKER_PATH_INVALID: {error}"))?; + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || register_local_at(&root, &selected).map(Some)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn browse_code_channel( + app: AppHandle, + input: BrowseCodeChannelInput, +) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || browse_at(&root, input)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn read_code_channel_file( + app: AppHandle, + input: ReadCodeChannelFileInput, +) -> Result { + let root = code_channel_root(&app)?; + tauri::async_runtime::spawn_blocking(move || read_code_file_at(&root, input)) + .await + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_JOIN_FAILED: {error}"))? +} + +fn code_channel_root(app: &AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? + .join("code-channel-v1"); + ensure_root(&root)?; + Ok(root) +} + +fn ensure_root(root: &Path) -> Result<(), String> { + fs::create_dir_all(root.join("repositories")) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?; + fs::set_permissions(root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PERMISSION_FAILED: {error}"))?; + Ok(()) +} + +fn snapshot_at(root: &Path) -> Result { + ensure_root(root)?; + let mut registry = read_registry(root)?; + for channel in &mut registry.channels { + if let Ok(inspection) = inspect_repository(Path::new(&channel.local_path)) { + channel.git_head = inspection.git_head; + channel.branch = inspection.branch; + channel.repository_clean = inspection.repository_clean; + } + } + registry + .channels + .sort_by_key(|entry| Reverse(entry.registered_at_unix_ms)); + Ok(CodeChannelSnapshot { + schema: SNAPSHOT_SCHEMA, + state: "READY", + storage_root: root.to_string_lossy().into_owned(), + channels: registry.channels, + authority: "LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY", + }) +} + +fn browse_at(root: &Path, input: BrowseCodeChannelInput) -> Result { + let (channel, repository) = registered_repository(root, &input.channel_id)?; + let relative = input.path.unwrap_or_default(); + let directory = safe_repository_path(&repository, &relative)?; + if !directory.is_dir() { + return Err("HOLOLAKE_CODE_CHANNEL_DIRECTORY_INVALID".into()); + } + let mut entries = fs::read_dir(&directory) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?; + entries.sort_by_key(|entry| { + let is_file = entry.file_type().map(|kind| kind.is_file()).unwrap_or(true); + (is_file, entry.file_name()) + }); + let mut projected = Vec::new(); + for entry in entries { + if projected.len() >= MAX_TREE_ENTRIES { + break; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if name == ".git" || name == ".DS_Store" || name.starts_with("._") { + continue; + } + let kind = entry + .file_type() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))?; + if kind.is_symlink() || (!kind.is_dir() && !kind.is_file()) { + continue; + } + let entry_path = entry.path(); + let relative_path = relative_posix(&repository, &entry_path)?; + let size_bytes = if kind.is_file() { + entry + .metadata() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_TREE_UNAVAILABLE: {error}"))? + .len() + } else { + 0 + }; + projected.push(CodeTreeEntry { + path: relative_path, + name, + kind: if kind.is_dir() { "directory" } else { "file" }, + size_bytes, + }); + } + Ok(CodeTreeSnapshot { + schema: "hololake.code-channel-tree/v1", + channel_id: channel.channel_id, + path: relative, + truncated: projected.len() >= MAX_TREE_ENTRIES, + entries: projected, + }) +} + +fn read_code_file_at( + root: &Path, + input: ReadCodeChannelFileInput, +) -> Result { + let (channel, repository) = registered_repository(root, &input.channel_id)?; + let file = safe_repository_path(&repository, &input.path)?; + let metadata = fs::metadata(&file) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_UNAVAILABLE: {error}"))?; + if !metadata.is_file() || metadata.len() > MAX_CODE_FILE_BYTES || !is_supported_text_file(&file) + { + return Err("HOLOLAKE_CODE_CHANNEL_FILE_UNSUPPORTED".into()); + } + let source = fs::read_to_string(&file) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_FILE_INVALID_UTF8: {error}"))?; + let format = file_format(&file); + let human_markdown = human_projection(&input.path, &format, &source); + Ok(CodeFileProjection { + schema: "hololake.code-channel-file-projection/v1", + channel_id: channel.channel_id, + path: input.path, + format, + source, + human_markdown, + size_bytes: metadata.len(), + }) +} + +fn registered_repository( + root: &Path, + channel_id: &str, +) -> Result<(CodeChannelEntry, PathBuf), String> { + let registry = read_registry(root)?; + let channel = registry + .channels + .into_iter() + .find(|entry| entry.channel_id == channel_id) + .ok_or("HOLOLAKE_CODE_CHANNEL_NOT_REGISTERED")?; + let repository = PathBuf::from(&channel.local_path) + .canonicalize() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE: {error}"))?; + if !repository.is_dir() { + return Err("HOLOLAKE_CODE_CHANNEL_REPOSITORY_UNAVAILABLE".into()); + } + Ok((channel, repository)) +} + +fn safe_repository_path(root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + if relative.is_empty() { + return Ok(root.to_path_buf()); + } + return Err("HOLOLAKE_CODE_CHANNEL_PATH_INVALID".into()); + } + if relative.is_empty() { + return Ok(root.to_path_buf()); + } + let candidate = root.join(relative_path); + let metadata = fs::symlink_metadata(&candidate) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("HOLOLAKE_CODE_CHANNEL_SYMLINK_DENIED".into()); + } + let canonical = candidate + .canonicalize() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_PATH_UNAVAILABLE: {error}"))?; + if !canonical.starts_with(root) { + return Err("HOLOLAKE_CODE_CHANNEL_PATH_ESCAPE".into()); + } + Ok(canonical) +} + +fn relative_posix(root: &Path, path: &Path) -> Result { + Ok(path + .strip_prefix(root) + .map_err(|_| "HOLOLAKE_CODE_CHANNEL_PATH_INVALID".to_string())? + .to_string_lossy() + .replace('\\', "/")) +} + +fn is_supported_text_file(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if ["README", "LICENSE", "Dockerfile", "Makefile", "Procfile"].contains(&name) { + return true; + } + matches!( + path.extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str(), + "md" | "markdown" + | "txt" + | "json" + | "jsonl" + | "yaml" + | "yml" + | "toml" + | "hdlp" + | "rs" + | "ts" + | "tsx" + | "js" + | "jsx" + | "mjs" + | "cjs" + | "py" + | "sh" + | "zsh" + | "bash" + | "css" + | "scss" + | "html" + | "xml" + | "sql" + | "graphql" + | "go" + | "java" + | "kt" + | "swift" + | "c" + | "h" + | "cpp" + | "hpp" + | "proto" + | "ini" + | "conf" + | "env" + ) +} + +fn file_format(path: &Path) -> String { + path.extension() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("text") + .to_ascii_lowercase() +} + +fn human_projection(path: &str, format: &str, source: &str) -> String { + if matches!(format, "md" | "markdown") { + return source.to_string(); + } + let title = Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("代码文件"); + if format == "json" { + if let Ok(value) = serde_json::from_str::(source) { + return format!( + "# {title}\n\n> 来自代码频道的 JSON 结构,已转换为可读层级。\n\n{}", + json_as_markdown(&value, 2) + ); + } + } + if matches!(format, "hdlp" | "yaml" | "yml" | "toml" | "ini" | "conf") { + let rows = source + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + trimmed + .split_once(':') + .or_else(|| trimmed.split_once('=')) + .map(|(key, value)| format!("| {} | {} |", key.trim(), value.trim())) + }) + .take(120) + .collect::>(); + if !rows.is_empty() { + return format!( + "# {title}\n\n> 来自代码频道的结构化文件。原始机器字段已转成阅读表格。\n\n| 字段 | 内容 |\n| --- | --- |\n{}", + rows.join("\n") + ); + } + } + format!( + "# {title}\n\n> 来自代码频道的 `{format}` 文件。下方保留原始内容,便于核验。\n\n```{format}\n{source}\n```" + ) +} + +fn json_as_markdown(value: &serde_json::Value, level: usize) -> String { + match value { + serde_json::Value::Object(map) => map + .iter() + .map(|(key, value)| match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => format!( + "{} {key}\n\n{}", + "#".repeat(level.min(5)), + json_as_markdown(value, level + 1) + ), + _ => format!("- **{key}**:{}", scalar_json(value)), + }) + .collect::>() + .join("\n\n"), + serde_json::Value::Array(values) => values + .iter() + .enumerate() + .map(|(index, value)| match value { + serde_json::Value::Object(_) | serde_json::Value::Array(_) => { + format!("{}. {}", index + 1, json_as_markdown(value, level + 1)) + } + _ => format!("- {}", scalar_json(value)), + }) + .collect::>() + .join("\n"), + _ => scalar_json(value), + } +} + +fn scalar_json(value: &serde_json::Value) -> String { + value + .as_str() + .map(ToOwned::to_owned) + .unwrap_or_else(|| value.to_string()) +} + +fn clone_at(root: &Path, input: CloneCodeChannelInput) -> Result { + ensure_root(root)?; + let validated = validate_clone_url(&input.url)?; + let destination = root.join("repositories").join(format!( + "{}--{}", + safe_segment(&validated.owner), + safe_segment(&validated.repository) + )); + if destination.exists() { + return Err("HOLOLAKE_CODE_CHANNEL_DESTINATION_EXISTS".into()); + } + let output = Command::new("/usr/bin/git") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_ASKPASS", "/usr/bin/false") + .env("SSH_ASKPASS", "/usr/bin/false") + .env("GIT_CONFIG_NOSYSTEM", "1") + .args([ + "-c", + "credential.helper=", + "-c", + "core.askPass=", + "clone", + "--origin", + "origin", + "--no-tags", + ]) + .arg(&validated.normalized) + .arg(&destination) + .output() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {error}"))?; + if !output.status.success() { + if destination.starts_with(root.join("repositories")) { + let _ = fs::remove_dir_all(&destination); + } + return Err(format!( + "HOLOLAKE_CODE_CHANNEL_CLONE_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let entry = entry_for_repository( + &destination, + "CLONED_HTTPS", + Some(validated.normalized), + Some(format!("{}/{}", validated.owner, validated.repository)), + )?; + upsert_entry(root, entry)?; + snapshot_at(root) +} + +fn register_local_at(root: &Path, selected: &Path) -> Result { + ensure_root(root)?; + let canonical = selected + .canonicalize() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_LOCAL_UNAVAILABLE: {error}"))?; + if !canonical.is_dir() { + return Err("HOLOLAKE_CODE_CHANNEL_LOCAL_INVALID".into()); + } + let remote = git_optional(&canonical, &["remote", "get-url", "origin"]) + .and_then(|value| redact_remote_url(value.trim())); + let entry = entry_for_repository(&canonical, "LOCAL_GIT", remote, None)?; + upsert_entry(root, entry)?; + snapshot_at(root) +} + +struct RepositoryInspection { + git_head: String, + branch: String, + repository_clean: bool, +} + +fn inspect_repository(path: &Path) -> Result { + let inside = git(path, &["rev-parse", "--is-inside-work-tree"], "INSPECT")?; + if inside.trim() != "true" { + return Err("HOLOLAKE_CODE_CHANNEL_NOT_GIT".into()); + } + let git_head = git(path, &["rev-parse", "HEAD"], "READ_HEAD")? + .trim() + .to_string(); + let branch = git_optional(path, &["symbolic-ref", "--short", "HEAD"]) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "DETACHED".into()); + let repository_clean = git(path, &["status", "--porcelain"], "READ_STATUS")? + .trim() + .is_empty(); + Ok(RepositoryInspection { + git_head, + branch, + repository_clean, + }) +} + +fn entry_for_repository( + path: &Path, + source_kind: &str, + remote_url: Option, + explicit_name: Option, +) -> Result { + let inspection = inspect_repository(path)?; + let name = explicit_name.unwrap_or_else(|| { + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("代码频道") + .to_string() + }); + Ok(CodeChannelEntry { + channel_id: format!("channel-{}", Uuid::new_v4()), + name, + source_kind: source_kind.into(), + local_path: path.to_string_lossy().into_owned(), + remote_url, + git_head: inspection.git_head, + branch: inspection.branch, + repository_clean: inspection.repository_clean, + registered_at_unix_ms: now_unix_ms()?, + }) +} + +fn validate_clone_url(raw: &str) -> Result { + let raw = raw.trim(); + if raw.len() > 2_048 { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + let mut parsed = Url::parse(raw).map_err(|_| "HOLOLAKE_CODE_CHANNEL_URL_INVALID")?; + if parsed.scheme() != "https" + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.port().is_some() + { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + let host = parsed + .host_str() + .ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")? + .to_lowercase(); + if !ALLOWED_HOSTS.contains(&host.as_str()) { + return Err("HOLOLAKE_CODE_CHANNEL_HOST_NOT_TRUSTED".into()); + } + let segments = parsed + .path_segments() + .ok_or("HOLOLAKE_CODE_CHANNEL_URL_INVALID")? + .filter(|segment| !segment.is_empty()) + .collect::>(); + if segments.len() != 3 || segments[0] != "code" { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + let owner = segments[1].to_string(); + let repository = segments[2] + .strip_suffix(".git") + .unwrap_or(segments[2]) + .to_string(); + if !valid_git_segment(&owner) || !valid_git_segment(&repository) { + return Err("HOLOLAKE_CODE_CHANNEL_URL_INVALID".into()); + } + parsed.set_path(&format!("/code/{owner}/{repository}.git")); + Ok(ValidatedCloneUrl { + normalized: parsed.to_string(), + owner, + repository, + }) +} + +fn valid_git_segment(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value != "." + && value != ".." + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character)) +} + +fn redact_remote_url(raw: &str) -> Option { + let mut parsed = Url::parse(raw).ok()?; + if parsed.scheme() != "https" { + return None; + } + parsed.set_username("").ok()?; + parsed.set_password(None).ok()?; + parsed.set_query(None); + parsed.set_fragment(None); + Some(parsed.to_string()) +} + +fn safe_segment(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || "-_.".contains(character) { + character + } else { + '-' + } + }) + .collect() +} + +fn upsert_entry(root: &Path, entry: CodeChannelEntry) -> Result<(), String> { + let mut registry = read_registry(root)?; + registry + .channels + .retain(|existing| existing.local_path != entry.local_path); + registry.channels.push(entry); + write_registry(root, ®istry) +} + +fn read_registry(root: &Path) -> Result { + let path = root.join("channels.json"); + if !path.exists() { + return Ok(CodeChannelRegistry { + schema: REGISTRY_SCHEMA.into(), + channels: Vec::new(), + }); + } + let bytes = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_UNAVAILABLE: {error}"))?; + let registry: CodeChannelRegistry = serde_json::from_slice(&bytes) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?; + if registry.schema != REGISTRY_SCHEMA { + return Err("HOLOLAKE_CODE_CHANNEL_REGISTRY_SCHEMA_INVALID".into()); + } + Ok(registry) +} + +fn write_registry(root: &Path, registry: &CodeChannelRegistry) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(registry) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_INVALID: {error}"))?; + let temporary = root.join(format!("channels.json.tmp-{}", Uuid::new_v4())); + let final_path = root.join("channels.json"); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?; + fs::rename(&temporary, &final_path) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_REGISTRY_WRITE_FAILED: {error}"))?; + Ok(()) +} + +fn git(root: &Path, args: &[&str], operation: &str) -> Result { + let output = Command::new("/usr/bin/git") + .current_dir(root) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {error}"))?; + if !output.status.success() { + return Err(format!( + "HOLOLAKE_CODE_CHANNEL_GIT_{operation}_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("HOLOLAKE_CODE_CHANNEL_GIT_{operation}_INVALID_UTF8: {error}")) +} + +fn git_optional(root: &Path, args: &[&str]) -> Option { + let output = Command::new("/usr/bin/git") + .current_dir(root) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn now_unix_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .map_err(|error| format!("HOLOLAKE_CLOCK_INVALID: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn clone_url_is_normalized_to_official_https_git_endpoint() { + let parsed = + validate_clone_url("https://guanghulab.com/code/bingshuo/guanghu-ice-heart").unwrap(); + assert_eq!( + parsed.normalized, + "https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git" + ); + assert_eq!(parsed.owner, "bingshuo"); + assert_eq!(parsed.repository, "guanghu-ice-heart"); + } + + #[test] + fn clone_url_rejects_credentials_queries_and_unknown_hosts() { + assert!( + validate_clone_url("https://user:secret@guanghulab.com/code/bingshuo/repo").is_err() + ); + assert!( + validate_clone_url("https://guanghulab.com/code/bingshuo/repo?token=secret").is_err() + ); + assert!(validate_clone_url("https://example.com/code/bingshuo/repo").is_err()); + } + + #[test] + fn remote_redaction_removes_credentials_and_fragments() { + assert_eq!( + redact_remote_url("https://user:secret@example.com/a/b.git?x=1#readme"), + Some("https://example.com/a/b.git".into()) + ); + assert_eq!(redact_remote_url("git@example.com:a/b.git"), None); + } + + #[test] + fn local_repository_registration_is_persistent_and_deduplicated() { + let root = tempdir().unwrap(); + let repository = tempdir().unwrap(); + ensure_root(root.path()).unwrap(); + git( + repository.path(), + &["init", "--initial-branch=main"], + "TEST_INIT", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.name", "Test"], + "TEST_CONFIG", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.email", "test@example.invalid"], + "TEST_CONFIG", + ) + .unwrap(); + fs::write(repository.path().join("README.md"), "# test\n").unwrap(); + git(repository.path(), &["add", "README.md"], "TEST_ADD").unwrap(); + git( + repository.path(), + &["commit", "-m", "initial"], + "TEST_COMMIT", + ) + .unwrap(); + + register_local_at(root.path(), repository.path()).unwrap(); + register_local_at(root.path(), repository.path()).unwrap(); + let snapshot = snapshot_at(root.path()).unwrap(); + assert_eq!(snapshot.channels.len(), 1); + assert_eq!(snapshot.channels[0].branch, "main"); + assert!(snapshot.channels[0].repository_clean); + } + + #[test] + fn registered_repository_can_be_browsed_and_projected_as_knowledge() { + let root = tempdir().unwrap(); + let repository = tempdir().unwrap(); + ensure_root(root.path()).unwrap(); + git( + repository.path(), + &["init", "--initial-branch=main"], + "TEST_INIT", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.name", "Test"], + "TEST_CONFIG", + ) + .unwrap(); + git( + repository.path(), + &["config", "user.email", "test@example.invalid"], + "TEST_CONFIG", + ) + .unwrap(); + fs::create_dir(repository.path().join("config")).unwrap(); + fs::write( + repository.path().join("config/system.json"), + r#"{"name":"光湖","state":"ready","routes":["native","mcp"]}"#, + ) + .unwrap(); + git(repository.path(), &["add", "."], "TEST_ADD").unwrap(); + git( + repository.path(), + &["commit", "-m", "initial"], + "TEST_COMMIT", + ) + .unwrap(); + + let snapshot = register_local_at(root.path(), repository.path()).unwrap(); + let channel_id = snapshot.channels[0].channel_id.clone(); + let tree = browse_at( + root.path(), + BrowseCodeChannelInput { + channel_id: channel_id.clone(), + path: None, + }, + ) + .unwrap(); + assert!(tree + .entries + .iter() + .any(|entry| entry.name == "config" && entry.kind == "directory")); + + let projection = read_code_file_at( + root.path(), + ReadCodeChannelFileInput { + channel_id, + path: "config/system.json".into(), + }, + ) + .unwrap(); + assert_eq!(projection.format, "json"); + assert!(projection + .human_markdown + .contains("来自代码频道的 JSON 结构")); + assert!(projection.human_markdown.contains("**state**:ready")); + } + + #[test] + fn code_tree_denies_parent_escape() { + let repository = tempdir().unwrap(); + assert!(safe_repository_path(repository.path(), "../secret").is_err()); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs new file mode 100644 index 000000000..61be6341b --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/knowledge_base.rs @@ -0,0 +1,1065 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Clean-room native migration of the HoloLake Era knowledge-workspace contract. + +use ring::digest::{digest, SHA256}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::ffi::OsStr; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::UNIX_EPOCH; +use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::DialogExt; +use uuid::Uuid; + +const SNAPSHOT_SCHEMA: &str = "hololake.native-knowledge-base/v1"; +const MAX_TREE_DOCUMENTS: usize = 5_000; +const MAX_IMPORT_FILES: usize = 1_000; +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_READ_BYTES: u64 = 2 * 1024 * 1024; +const MAX_SEARCH_RESULTS: usize = 100; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDocumentSummary { + pub source: &'static str, + pub path: String, + pub title: String, + pub updated_at_unix_ms: u128, + pub size_bytes: u64, + pub content_sha256: String, + pub duplicate_count: usize, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub native_root: String, + pub legacy_available: bool, + pub legacy_root: Option, + pub documents: Vec, + pub raw_document_count: usize, + pub unique_document_count: usize, + pub duplicate_document_count: usize, + pub truncated: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ReadKnowledgeDocumentInput { + pub source: String, + pub path: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDocument { + pub source: String, + pub path: String, + pub title: String, + pub body: String, + pub updated_at_unix_ms: u128, + pub content_sha256: String, + pub writable: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SaveKnowledgeDocumentInput { + pub path: String, + pub body: String, + pub expected_content_sha256: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSaveResult { + pub schema: &'static str, + pub state: &'static str, + pub git_commit: String, + pub document: KnowledgeDocument, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SearchKnowledgeInput { + pub query: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSearchResult { + pub source: &'static str, + pub path: String, + pub title: String, + pub snippet: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeImportResult { + pub schema: &'static str, + pub state: &'static str, + pub source_name: String, + pub destination: String, + pub imported_documents: usize, + pub imported_assets: usize, + pub existing_documents: usize, + pub conflicts: usize, + pub skipped: usize, + pub failed: usize, + pub first_document: Option, + pub git_commit: String, + pub snapshot: KnowledgeSnapshot, +} + +#[derive(Default)] +struct ImportCounts { + documents: usize, + assets: usize, + existing: usize, + conflicts: usize, + skipped: usize, + failed: usize, + first_document: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ImportDisposition { + Added, + Existing, + Conflict, +} + +#[tauri::command] +pub async fn get_knowledge_snapshot(app: AppHandle) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&roots.0, roots.1.as_deref())) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn read_knowledge_document( + app: AppHandle, + input: ReadKnowledgeDocumentInput, +) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || { + read_document_at(&roots.0, roots.1.as_deref(), input) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn search_knowledge( + app: AppHandle, + input: SearchKnowledgeInput, +) -> Result, String> { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || search_at(&roots.0, roots.1.as_deref(), input)) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn save_knowledge_document( + app: AppHandle, + input: SaveKnowledgeDocumentInput, +) -> Result { + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || save_document_at(&roots.0, input)) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn select_and_import_knowledge_folder( + app: AppHandle, +) -> Result, String> { + let picker = app.clone(); + let selected = tauri::async_runtime::spawn_blocking(move || { + picker + .dialog() + .file() + .set_title("导入到 HoloLake 知识库") + .blocking_pick_folder() + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_JOIN_FAILED: {error}"))?; + let Some(selected) = selected else { + return Ok(None); + }; + let source = selected + .into_path() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PICKER_PATH_INVALID: {error}"))?; + let roots = knowledge_roots(&app)?; + tauri::async_runtime::spawn_blocking(move || { + import_folder_at(&roots.0, roots.1.as_deref(), &source).map(Some) + }) + .await + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_JOIN_FAILED: {error}"))? +} + +fn knowledge_roots(app: &AppHandle) -> Result<(PathBuf, Option), String> { + let native = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))? + .join("knowledge-v1"); + ensure_native_knowledge_root(&native)?; + let legacy = dirs::home_dir() + .map(|home| { + home.join("Library") + .join("Application Support") + .join("hololake-desktop") + .join("data") + .join("knowledge-base") + }) + .filter(|path| path.join("docs").is_dir()); + Ok((native, legacy)) +} + +fn ensure_native_knowledge_root(root: &Path) -> Result<(), String> { + fs::create_dir_all(root.join("docs").join("导入")) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_STORAGE_UNAVAILABLE: {error}"))?; + fs::set_permissions(root, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_PERMISSION_FAILED: {error}"))?; + if !root.join(".git").exists() { + git(root, &["init", "--initial-branch=main"], "INIT")?; + git(root, &["config", "user.name", "HoloLake"], "CONFIG")?; + git( + root, + &["config", "user.email", "local@hololake.invalid"], + "CONFIG", + )?; + } + Ok(()) +} + +fn snapshot_at(native: &Path, legacy: Option<&Path>) -> Result { + let mut raw_documents = Vec::new(); + collect_documents( + &native.join("docs"), + &native.join("docs"), + "native", + &mut raw_documents, + )?; + if let Some(root) = legacy { + collect_documents( + &root.join("docs"), + &root.join("docs"), + "legacy", + &mut raw_documents, + )?; + } + let raw_document_count = raw_documents.len(); + let mut by_hash = HashMap::::new(); + let mut documents = Vec::::new(); + for document in raw_documents { + if let Some(index) = by_hash.get(&document.content_sha256).copied() { + documents[index].duplicate_count += 1; + } else { + by_hash.insert(document.content_sha256.clone(), documents.len()); + documents.push(document); + } + } + documents.sort_by(|left, right| { + left.source + .cmp(right.source) + .then_with(|| left.path.cmp(&right.path)) + }); + let truncated = raw_document_count >= MAX_TREE_DOCUMENTS; + documents.truncate(MAX_TREE_DOCUMENTS); + let unique_document_count = documents.len(); + Ok(KnowledgeSnapshot { + schema: SNAPSHOT_SCHEMA, + state: "READY", + native_root: native.to_string_lossy().into_owned(), + legacy_available: legacy.is_some(), + legacy_root: legacy.map(|path| path.to_string_lossy().into_owned()), + documents, + raw_document_count, + unique_document_count, + duplicate_document_count: raw_document_count.saturating_sub(unique_document_count), + truncated, + }) +} + +fn collect_documents( + root: &Path, + current: &Path, + source: &'static str, + output: &mut Vec, +) -> Result<(), String> { + if output.len() >= MAX_TREE_DOCUMENTS { + return Ok(()); + } + let mut entries = fs::read_dir(current) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if output.len() >= MAX_TREE_DOCUMENTS { + break; + } + let kind = entry + .file_type() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; + if kind.is_symlink() { + continue; + } + let path = entry.path(); + let name = entry.file_name(); + if kind.is_dir() { + if name != OsStr::new(".git") && name != OsStr::new(".hololake") { + collect_documents(root, &path, source, output)?; + } + continue; + } + if !kind.is_file() || !is_readable_document(&path) { + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_TREE_UNAVAILABLE: {error}"))?; + if metadata.len() > MAX_READ_BYTES { + continue; + } + let relative = relative_posix(root, &path)?; + let bytes = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + output.push(KnowledgeDocumentSummary { + source, + path: relative, + title: document_title(&path)?, + updated_at_unix_ms: modified_unix_ms(&metadata), + size_bytes: metadata.len(), + content_sha256: sha256_hex(&bytes), + duplicate_count: 0, + }); + } + Ok(()) +} + +fn read_document_at( + native: &Path, + legacy: Option<&Path>, + input: ReadKnowledgeDocumentInput, +) -> Result { + let root = source_docs_root(native, legacy, &input.source)?; + let path = safe_document_path(&root, &input.path)?; + let metadata = fs::metadata(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if !metadata.is_file() || metadata.len() > MAX_READ_BYTES || !is_readable_document(&path) { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into()); + } + let body = fs::read_to_string(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?; + let content_sha256 = sha256_hex(body.as_bytes()); + let writable = input.source == "native"; + Ok(KnowledgeDocument { + source: input.source, + path: input.path, + title: title_from_text(&body, &path), + body, + updated_at_unix_ms: modified_unix_ms(&metadata), + content_sha256, + writable, + }) +} + +fn save_document_at( + native: &Path, + input: SaveKnowledgeDocumentInput, +) -> Result { + if input.body.len() as u64 > MAX_READ_BYTES + || input.expected_content_sha256.len() != 64 + || !input + .expected_content_sha256 + .chars() + .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase()) + { + return Err("HOLOLAKE_KNOWLEDGE_SAVE_INPUT_INVALID".into()); + } + let root = native.join("docs"); + let path = safe_document_path(&root, &input.path)?; + if !is_readable_document(&path) { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNSUPPORTED".into()); + } + let previous = fs::read(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if sha256_hex(&previous) != input.expected_content_sha256 { + return Err("HOLOLAKE_KNOWLEDGE_SAVE_CONFLICT".into()); + } + if previous != input.body.as_bytes() { + let temporary = path.with_extension(format!("save-{}.tmp", Uuid::new_v4())); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; + file.write_all(input.body.as_bytes()) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; + fs::rename(&temporary, &path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_SAVE_FAILED: {error}"))?; + git( + native, + &["add", "--", &format!("docs/{}", input.path)], + "ADD", + )?; + git( + native, + &["commit", "-m", &format!("knowledge: update {}", input.path)], + "COMMIT", + )?; + } + let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")? + .trim() + .to_string(); + let document = read_document_at( + native, + None, + ReadKnowledgeDocumentInput { + source: "native".into(), + path: input.path, + }, + )?; + Ok(KnowledgeSaveResult { + schema: "hololake.knowledge-document-save/v1", + state: if previous == input.body.as_bytes() { + "UNCHANGED" + } else { + "SAVED" + }, + git_commit, + document, + }) +} + +fn search_at( + native: &Path, + legacy: Option<&Path>, + input: SearchKnowledgeInput, +) -> Result, String> { + let query = input.query.trim().to_lowercase(); + if query.is_empty() || query.chars().count() > 200 { + return Err("HOLOLAKE_KNOWLEDGE_SEARCH_QUERY_INVALID".into()); + } + let snapshot = snapshot_at(native, legacy)?; + let mut results = Vec::new(); + for document in snapshot.documents { + if results.len() >= MAX_SEARCH_RESULTS { + break; + } + let root = source_docs_root(native, legacy, document.source)?; + let path = safe_document_path(&root, &document.path)?; + let body = fs::read_to_string(&path).unwrap_or_default(); + let haystack = format!("{}\n{}", document.title, body).to_lowercase(); + let Some(index) = haystack.find(&query) else { + continue; + }; + let start = haystack[..index] + .char_indices() + .rev() + .nth(40) + .map(|(position, _)| position) + .unwrap_or(0); + let end = haystack[index..] + .char_indices() + .nth(120) + .map(|(position, _)| index + position) + .unwrap_or(haystack.len()); + results.push(KnowledgeSearchResult { + source: document.source, + path: document.path, + title: document.title, + snippet: haystack[start..end].replace('\n', " "), + }); + } + Ok(results) +} + +fn import_folder_at( + native: &Path, + legacy: Option<&Path>, + source: &Path, +) -> Result { + let source = source + .canonicalize() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_UNAVAILABLE: {error}"))?; + if !source.is_dir() || source.starts_with(native) { + return Err("HOLOLAKE_KNOWLEDGE_IMPORT_SOURCE_INVALID".into()); + } + let source_name = safe_segment( + source + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("未命名文件夹"), + ); + let import_root = native.join("docs").join("导入"); + let destination_name = source_name.clone(); + let destination = import_root.join(&destination_name); + let destination_exists = destination.exists(); + let staging = if destination_exists { + destination.clone() + } else { + let path = + native + .join(".import-staging") + .join(format!("{}-{}", destination_name, Uuid::new_v4())); + fs::create_dir_all(&path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + path + }; + let mut counts = ImportCounts::default(); + let import_result = walk_import(&source, &source, &staging, &destination_name, &mut counts); + if let Err(error) = import_result { + if !destination_exists { + let _ = fs::remove_dir_all(&staging); + } + return Err(error); + } + if counts.documents == 0 && counts.assets == 0 && counts.existing == 0 && counts.conflicts == 0 + { + if !destination_exists { + let _ = fs::remove_dir_all(&staging); + } + return Err("HOLOLAKE_KNOWLEDGE_IMPORT_NO_SUPPORTED_FILES".into()); + } + if !destination_exists { + fs::rename(&staging, &destination) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_COMMIT_FAILED: {error}"))?; + } + let relative_destination = format!("docs/导入/{destination_name}"); + if counts.documents + counts.assets > 0 { + if let Err(error) = + git(native, &["add", "--", &relative_destination], "ADD").and_then(|_| { + git( + native, + &["commit", "-m", &format!("import: {source_name}")], + "COMMIT", + ) + }) + { + if !destination_exists { + let _ = fs::remove_dir_all(&destination); + } + let _ = git(native, &["reset", "--mixed"], "ROLLBACK_INDEX"); + return Err(error); + } + } + let git_commit = git(native, &["rev-parse", "HEAD"], "READBACK")? + .trim() + .to_string(); + Ok(KnowledgeImportResult { + schema: "hololake.knowledge-folder-import/v1", + state: if counts.conflicts > 0 { + "CONFLICTS_DETECTED" + } else if counts.documents + counts.assets == 0 { + "ALREADY_PRESENT" + } else { + "IMPORTED" + }, + source_name, + destination: format!("导入/{destination_name}"), + imported_documents: counts.documents, + imported_assets: counts.assets, + existing_documents: counts.existing, + conflicts: counts.conflicts, + skipped: counts.skipped, + failed: counts.failed, + first_document: counts.first_document, + git_commit, + snapshot: snapshot_at(native, legacy)?, + }) +} + +fn walk_import( + root: &Path, + current: &Path, + staging: &Path, + destination_name: &str, + counts: &mut ImportCounts, +) -> Result<(), String> { + if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES { + return Ok(()); + } + let mut entries = fs::read_dir(current) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))? + .collect::, _>>() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + if counts.documents + counts.assets + counts.skipped + counts.failed >= MAX_IMPORT_FILES { + break; + } + let name = entry.file_name(); + if name == OsStr::new(".DS_Store") || name.to_string_lossy().starts_with("._") { + continue; + } + let kind = entry + .file_type() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; + if kind.is_symlink() { + counts.skipped += 1; + continue; + } + let path = entry.path(); + if kind.is_dir() { + let lower = name.to_string_lossy().to_lowercase(); + if [".git", "node_modules", ".idea", ".vscode", "__macosx"].contains(&lower.as_str()) { + counts.skipped += 1; + continue; + } + walk_import(root, &path, staging, destination_name, counts)?; + continue; + } + if !kind.is_file() { + counts.skipped += 1; + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_READ_FAILED: {error}"))?; + if metadata.len() > MAX_FILE_BYTES { + counts.skipped += 1; + continue; + } + let relative = path + .strip_prefix(root) + .map_err(|_| "HOLOLAKE_KNOWLEDGE_IMPORT_PATH_INVALID".to_string())?; + let safe_relative = relative + .components() + .filter_map(|component| match component { + Component::Normal(value) => Some(safe_segment(&value.to_string_lossy())), + _ => None, + }) + .collect::(); + let extension = path + .extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase(); + let imported = if ["md", "markdown", "txt", "csv", "json", "yaml", "yml"] + .contains(&extension.as_str()) + { + import_document(&path, &safe_relative, staging).map(|(target, disposition)| { + match disposition { + ImportDisposition::Added => counts.documents += 1, + ImportDisposition::Existing => counts.existing += 1, + ImportDisposition::Conflict => counts.conflicts += 1, + } + if disposition != ImportDisposition::Conflict { + counts.first_document.get_or_insert_with(|| { + format!( + "导入/{}/{}", + destination_name, + target.to_string_lossy().replace('\\', "/") + ) + }); + } + }) + } else if ["png", "jpg", "jpeg", "gif", "webp", "svg"].contains(&extension.as_str()) { + let target = staging.join(&safe_relative); + import_asset(&path, &target).map(|disposition| match disposition { + ImportDisposition::Added => counts.assets += 1, + ImportDisposition::Existing => counts.existing += 1, + ImportDisposition::Conflict => counts.conflicts += 1, + }) + } else { + counts.skipped += 1; + continue; + }; + if imported.is_err() { + counts.failed += 1; + } + } + Ok(()) +} + +fn import_document( + source: &Path, + relative: &Path, + staging: &Path, +) -> Result<(PathBuf, ImportDisposition), String> { + let extension = source + .extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase(); + let raw = fs::read_to_string(source) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_INVALID: {error}"))?; + let mut target_relative = relative.to_path_buf(); + if extension != "md" { + target_relative.set_extension("md"); + } + let title = source + .file_stem() + .and_then(OsStr::to_str) + .unwrap_or("未命名页面"); + let content = match extension.as_str() { + "md" | "markdown" => raw, + "txt" => format!("# {title}\n\n{raw}\n"), + "csv" => format!("# {title}\n\n```csv\n{raw}\n```\n"), + "json" => format!("# {title}\n\n```json\n{raw}\n```\n"), + "yaml" | "yml" => format!("# {title}\n\n```yaml\n{raw}\n```\n"), + _ => return Err("HOLOLAKE_KNOWLEDGE_IMPORT_DOCUMENT_UNSUPPORTED".into()), + }; + let target = staging.join(&target_relative); + fs::create_dir_all(target.parent().unwrap_or(staging)) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + if target.exists() { + let existing = fs::read(&target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + return Ok(( + target_relative, + if existing == content.as_bytes() { + ImportDisposition::Existing + } else { + ImportDisposition::Conflict + }, + )); + } + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + file.write_all(content.as_bytes()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + file.sync_all() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + Ok((target_relative, ImportDisposition::Added)) +} + +fn import_asset(source: &Path, target: &Path) -> Result { + let bytes = fs::read(source) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_ASSET_INVALID: {error}"))?; + if target.exists() { + let existing = fs::read(target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + return Ok(if existing == bytes { + ImportDisposition::Existing + } else { + ImportDisposition::Conflict + }); + } + fs::create_dir_all(target.parent().unwrap_or_else(|| Path::new("."))) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(target) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_IMPORT_STORAGE_FAILED: {error}"))?; + Ok(ImportDisposition::Added) +} + +fn sha256_hex(bytes: &[u8]) -> String { + digest(&SHA256, bytes) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn source_docs_root(native: &Path, legacy: Option<&Path>, source: &str) -> Result { + match source { + "native" => Ok(native.join("docs")), + "legacy" => legacy + .map(|root| root.join("docs")) + .ok_or_else(|| "HOLOLAKE_LEGACY_KNOWLEDGE_UNAVAILABLE".into()), + _ => Err("HOLOLAKE_KNOWLEDGE_SOURCE_INVALID".into()), + } +} + +fn safe_document_path(root: &Path, relative: &str) -> Result { + let relative_path = Path::new(relative); + if relative_path.is_absolute() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".into()); + } + let canonical_root = root + .canonicalize() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_ROOT_UNAVAILABLE: {error}"))?; + let candidate = canonical_root.join(relative_path); + let canonical = candidate + .canonicalize() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if !canonical.starts_with(&canonical_root) { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_ESCAPE".into()); + } + let metadata = fs::symlink_metadata(&candidate) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_UNAVAILABLE: {error}"))?; + if metadata.file_type().is_symlink() { + return Err("HOLOLAKE_KNOWLEDGE_DOCUMENT_SYMLINK_DENIED".into()); + } + Ok(canonical) +} + +fn is_readable_document(path: &Path) -> bool { + matches!( + path.extension() + .and_then(OsStr::to_str) + .unwrap_or("") + .to_lowercase() + .as_str(), + "md" | "markdown" | "txt" + ) +} + +fn document_title(path: &Path) -> Result { + let body = fs::read_to_string(path) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_DOCUMENT_INVALID_UTF8: {error}"))?; + Ok(title_from_text(&body, path)) +} + +fn title_from_text(body: &str, path: &Path) -> String { + body.lines() + .find_map(|line| line.trim().strip_prefix("# ").map(str::trim)) + .filter(|title| !title.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| { + path.file_stem() + .and_then(OsStr::to_str) + .unwrap_or("未命名页面") + .to_string() + }) +} + +fn safe_segment(value: &str) -> String { + let cleaned = value + .chars() + .map(|character| { + if character.is_control() || "\\/:*?\"<>|".contains(character) { + '-' + } else { + character + } + }) + .collect::() + .trim() + .to_string(); + if cleaned.is_empty() { + "未命名文件夹".into() + } else { + cleaned + } +} + +fn relative_posix(root: &Path, path: &Path) -> Result { + Ok(path + .strip_prefix(root) + .map_err(|_| "HOLOLAKE_KNOWLEDGE_DOCUMENT_PATH_INVALID".to_string())? + .to_string_lossy() + .replace('\\', "/")) +} + +fn modified_unix_ms(metadata: &fs::Metadata) -> u128 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + +fn git(root: &Path, args: &[&str], operation: &str) -> Result { + let output = Command::new("/usr/bin/git") + .current_dir(root) + .env("GIT_TERMINAL_PROMPT", "0") + .args(args) + .output() + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {error}"))?; + if !output.status.success() { + return Err(format!( + "HOLOLAKE_KNOWLEDGE_GIT_{operation}_FAILED: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + String::from_utf8(output.stdout) + .map_err(|error| format!("HOLOLAKE_KNOWLEDGE_GIT_{operation}_INVALID_UTF8: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn snapshot_reads_native_and_legacy_without_merging_roots() { + let native = tempdir().unwrap(); + let legacy = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::create_dir_all(legacy.path().join("docs")).unwrap(); + fs::write(native.path().join("docs/native.md"), "# 原生页面\n").unwrap(); + fs::write(legacy.path().join("docs/legacy.md"), "# 旧版页面\n").unwrap(); + let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap(); + assert_eq!(snapshot.documents.len(), 2); + assert!(snapshot + .documents + .iter() + .any(|item| item.source == "native")); + assert!(snapshot + .documents + .iter() + .any(|item| item.source == "legacy")); + } + + #[test] + fn document_path_escape_is_rejected() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + let error = read_document_at( + native.path(), + None, + ReadKnowledgeDocumentInput { + source: "native".into(), + path: "../secret.md".into(), + }, + ) + .unwrap_err(); + assert!(error.contains("PATH_INVALID")); + } + + #[test] + fn folder_import_is_git_backed_and_converts_text() { + let native = tempdir().unwrap(); + let source = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(source.path().join("readme.txt"), "hello").unwrap(); + fs::write(source.path().join("skip.bin"), [1, 2, 3]).unwrap(); + let result = import_folder_at(native.path(), None, source.path()).unwrap(); + assert_eq!(result.state, "IMPORTED"); + assert_eq!(result.imported_documents, 1); + assert_eq!(result.skipped, 1); + assert_eq!(result.git_commit.len(), 40); + let imported = native + .path() + .join("docs") + .join(&result.destination) + .join("readme.md"); + assert!(fs::read_to_string(imported).unwrap().contains("# readme")); + } + + #[test] + fn repeated_folder_import_is_idempotent_and_does_not_create_another_tree() { + let native = tempdir().unwrap(); + let source_parent = tempdir().unwrap(); + let source = source_parent.path().join("稳定知识源"); + fs::create_dir_all(&source).unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(source.join("page.md"), "# 唯一页面\n\n连续内容\n").unwrap(); + + let first = import_folder_at(native.path(), None, &source).unwrap(); + let second = import_folder_at(native.path(), None, &source).unwrap(); + + assert_eq!(first.state, "IMPORTED"); + assert_eq!(second.state, "ALREADY_PRESENT"); + assert_eq!(second.imported_documents, 0); + assert_eq!(second.existing_documents, 1); + assert_eq!(first.destination, second.destination); + assert_eq!(second.snapshot.raw_document_count, 1); + assert_eq!(second.snapshot.unique_document_count, 1); + assert_eq!( + fs::read_dir(native.path().join("docs/导入")) + .unwrap() + .count(), + 1 + ); + } + + #[test] + fn snapshot_coalesces_exact_content_and_prefers_native_source() { + let native = tempdir().unwrap(); + let legacy = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::create_dir_all(legacy.path().join("docs")).unwrap(); + fs::write(native.path().join("docs/native.md"), "# 同一内容\n").unwrap(); + fs::write(legacy.path().join("docs/legacy.md"), "# 同一内容\n").unwrap(); + + let snapshot = snapshot_at(native.path(), Some(legacy.path())).unwrap(); + + assert_eq!(snapshot.raw_document_count, 2); + assert_eq!(snapshot.unique_document_count, 1); + assert_eq!(snapshot.duplicate_document_count, 1); + assert_eq!(snapshot.documents[0].source, "native"); + assert_eq!(snapshot.documents[0].duplicate_count, 1); + } + + #[test] + fn native_edit_requires_the_exact_current_hash_and_commits_the_update() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(native.path().join("docs/page.md"), "# 旧内容\n").unwrap(); + git(native.path(), &["add", "docs/page.md"], "TEST_ADD").unwrap(); + git(native.path(), &["commit", "-m", "initial"], "TEST_COMMIT").unwrap(); + let current = read_document_at( + native.path(), + None, + ReadKnowledgeDocumentInput { + source: "native".into(), + path: "page.md".into(), + }, + ) + .unwrap(); + + let saved = save_document_at( + native.path(), + SaveKnowledgeDocumentInput { + path: "page.md".into(), + body: "# 新内容\n".into(), + expected_content_sha256: current.content_sha256, + }, + ) + .unwrap(); + + assert_eq!(saved.state, "SAVED"); + assert_eq!(saved.git_commit.len(), 40); + assert_eq!(saved.document.body, "# 新内容\n"); + assert!(save_document_at( + native.path(), + SaveKnowledgeDocumentInput { + path: "page.md".into(), + body: "# 错误覆盖\n".into(), + expected_content_sha256: sha256_hex(b"# old stale value\n"), + }, + ) + .unwrap_err() + .contains("SAVE_CONFLICT")); + } + + #[test] + fn search_returns_bounded_safe_projection() { + let native = tempdir().unwrap(); + ensure_native_knowledge_root(native.path()).unwrap(); + fs::write(native.path().join("docs/page.md"), "# 星湖\n\n连续存在").unwrap(); + let results = search_at( + native.path(), + None, + SearchKnowledgeInput { + query: "连续".into(), + }, + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title, "星湖"); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/src/lib.rs b/product-source/hololake-native-desktop/src-tauri/src/lib.rs index ab6503fed..a8f6ffb92 100644 --- a/product-source/hololake-native-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-native-desktop/src-tauri/src/lib.rs @@ -1,8 +1,11 @@ +mod code_channel; mod direct_local_broker; mod direct_local_session; mod dynamic_capability_routing; mod home_status; +mod knowledge_base; mod local_development_bridge; +mod personal_channel; mod pncc_receipt_projection; mod pncc_remote_git; mod pncc_repository_binding; @@ -33,6 +36,20 @@ pub fn run() { local_development_bridge::acquire_development_write_lane, local_development_bridge::inspect_development_write_lane, local_development_bridge::release_development_write_lane, + personal_channel::get_personal_channel_snapshot, + personal_channel::initialize_personal_channel, + personal_channel::create_personal_channel_task, + personal_channel::transition_personal_channel_task, + knowledge_base::get_knowledge_snapshot, + knowledge_base::read_knowledge_document, + knowledge_base::search_knowledge, + knowledge_base::save_knowledge_document, + knowledge_base::select_and_import_knowledge_folder, + code_channel::get_code_channel_snapshot, + code_channel::clone_code_channel, + code_channel::select_local_code_channel, + code_channel::browse_code_channel, + code_channel::read_code_channel_file, pncc_repository_binding::inspect_mounted_pncc_repository, pncc_repository_binding::select_pncc_repository_candidate, pncc_repository_binding::confirm_pncc_repository_mount, diff --git a/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs b/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs new file mode 100644 index 000000000..3a1dbc379 --- /dev/null +++ b/product-source/hololake-native-desktop/src-tauri/src/personal_channel.rs @@ -0,0 +1,853 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use ring::digest::{digest, SHA256}; +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Manager}; +use uuid::Uuid; + +const KERNEL_SCHEMA: &str = "hololake.personal-channel-kernel/v1"; +const DATABASE_SCHEMA_VERSION: i64 = 1; +const ZERO_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializePersonalChannelInput { + pub display_name: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreatePersonalChannelTaskInput { + pub title: String, + pub purpose: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TransitionPersonalChannelTaskInput { + pub task_id: String, + pub expected_status: String, + pub next_status: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelIdentity { + pub human_subject_id: String, + pub display_name: String, + pub channel_id: String, + pub created_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelTask { + pub task_id: String, + pub title: String, + pub purpose: String, + pub status: String, + pub created_at_unix_ms: i64, + pub updated_at_unix_ms: i64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelEventProjection { + pub sequence: i64, + pub event_id: String, + pub kind: String, + pub task_id: Option, + pub summary: String, + pub occurred_at_unix_ms: i64, + pub event_hash: String, + pub receipt_id: String, + pub receipt_hash: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelIntegrity { + pub state: &'static str, + pub schema_version: i64, + pub event_count: i64, + pub receipt_count: i64, + pub last_event_hash: String, + pub last_receipt_hash: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PersonalChannelSnapshot { + pub schema: &'static str, + pub state: &'static str, + pub identity: Option, + pub current_task: Option, + pub recent_events: Vec, + pub integrity: PersonalChannelIntegrity, + pub storage: &'static str, + pub authority: &'static str, +} + +#[derive(Serialize)] +struct EventHashPayload<'a> { + schema: &'static str, + sequence: i64, + event_id: &'a str, + human_subject_id: &'a str, + kind: &'a str, + task_id: Option<&'a str>, + summary: &'a str, + occurred_at_unix_ms: i64, +} + +#[tauri::command] +pub async fn get_personal_channel_snapshot( + app: AppHandle, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || snapshot_at(&database)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn initialize_personal_channel( + app: AppHandle, + input: InitializePersonalChannelInput, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || initialize_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn create_personal_channel_task( + app: AppHandle, + input: CreatePersonalChannelTaskInput, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || create_task_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +#[tauri::command] +pub async fn transition_personal_channel_task( + app: AppHandle, + input: TransitionPersonalChannelTaskInput, +) -> Result { + let database = personal_channel_database(&app)?; + tauri::async_runtime::spawn_blocking(move || transition_task_at(&database, input)) + .await + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_JOIN_FAILED: {error}"))? +} + +fn personal_channel_database(app: &AppHandle) -> Result { + let app_data = app + .path() + .app_data_dir() + .map_err(|error| format!("HOLOLAKE_APP_DATA_UNAVAILABLE: {error}"))?; + let root = app_data.join("personal-channel-v1"); + create_private_directory(&root)?; + Ok(root.join("personal-channel.sqlite3")) +} + +fn create_private_directory(path: &Path) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_UNAVAILABLE: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| { + format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}") + })?; + } + Ok(()) +} + +fn open_database(path: &Path) -> Result { + if let Some(parent) = path.parent() { + create_private_directory(parent)?; + } + let connection = Connection::open(path) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?; + connection + .busy_timeout(Duration::from_secs(5)) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNAVAILABLE: {error}"))?; + connection + .execute_batch( + "PRAGMA foreign_keys = ON; + PRAGMA journal_mode = DELETE; + PRAGMA synchronous = FULL; + PRAGMA trusted_schema = OFF; + CREATE TABLE IF NOT EXISTS kernel_meta ( + key TEXT PRIMARY KEY NOT NULL, + value TEXT NOT NULL + ); + INSERT OR IGNORE INTO kernel_meta(key, value) VALUES ('schema_version', '1'); + CREATE TABLE IF NOT EXISTS identities ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + human_subject_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + channel_id TEXT NOT NULL UNIQUE, + created_at_unix_ms INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY NOT NULL, + human_subject_id TEXT NOT NULL, + title TEXT NOT NULL, + purpose TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('ACTIVE', 'COMPLETED')), + created_at_unix_ms INTEGER NOT NULL, + updated_at_unix_ms INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS one_active_personal_task + ON tasks(status) WHERE status = 'ACTIVE'; + CREATE TABLE IF NOT EXISTS events ( + sequence INTEGER PRIMARY KEY NOT NULL, + event_id TEXT NOT NULL UNIQUE, + human_subject_id TEXT NOT NULL, + kind TEXT NOT NULL, + task_id TEXT, + summary TEXT NOT NULL, + occurred_at_unix_ms INTEGER NOT NULL, + previous_event_hash TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + event_hash TEXT NOT NULL UNIQUE + ); + CREATE TABLE IF NOT EXISTS receipts ( + sequence INTEGER PRIMARY KEY NOT NULL, + receipt_id TEXT NOT NULL UNIQUE, + event_sequence INTEGER NOT NULL UNIQUE REFERENCES events(sequence), + event_hash TEXT NOT NULL, + payload_sha256 TEXT NOT NULL, + previous_receipt_hash TEXT NOT NULL, + receipt_hash TEXT NOT NULL UNIQUE, + issued_at_unix_ms INTEGER NOT NULL + );", + ) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| { + format!("HOLOLAKE_PERSONAL_CHANNEL_STORAGE_PERMISSION_FAILED: {error}") + })?; + } + let version: String = connection + .query_row( + "SELECT value FROM kernel_meta WHERE key = 'schema_version'", + [], + |row| row.get(0), + ) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID: {error}"))?; + if version != DATABASE_SCHEMA_VERSION.to_string() { + return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into()); + } + Ok(connection) +} + +fn initialize_at( + database: &Path, + input: InitializePersonalChannelInput, +) -> Result { + let display_name = validated_text(&input.display_name, 80, "DISPLAY_NAME")?; + let mut connection = open_database(database)?; + verify_integrity(&connection)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let already_initialized: bool = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM identities WHERE singleton = 1)", + [], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if already_initialized { + return Err("HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED".into()); + } + let human_subject_id = format!("human-local-{}", Uuid::new_v4()); + let channel_id = format!("channel-local-{}", Uuid::new_v4()); + let created_at = now_unix_ms()?; + transaction + .execute( + "INSERT INTO identities(singleton, human_subject_id, display_name, channel_id, created_at_unix_ms) + VALUES(1, ?1, ?2, ?3, ?4)", + params![human_subject_id, display_name, channel_id, created_at], + ) + .map_err(database_write_error)?; + append_event( + &transaction, + &human_subject_id, + "CHANNEL_INITIALIZED", + None, + &format!("{display_name} 建立了个人频道"), + created_at, + )?; + transaction.commit().map_err(database_write_error)?; + snapshot_at(database) +} + +fn create_task_at( + database: &Path, + input: CreatePersonalChannelTaskInput, +) -> Result { + let title = validated_text(&input.title, 160, "TASK_TITLE")?; + let purpose = validated_text(&input.purpose, 1_000, "TASK_PURPOSE")?; + let mut connection = open_database(database)?; + verify_integrity(&connection)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let human_subject_id = require_identity_id(&transaction)?; + let active_exists: bool = transaction + .query_row( + "SELECT EXISTS(SELECT 1 FROM tasks WHERE status = 'ACTIVE')", + [], + |row| row.get(0), + ) + .map_err(database_read_error)?; + if active_exists { + return Err("HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS".into()); + } + let task_id = format!("task-local-{}", Uuid::new_v4()); + let observed_at = now_unix_ms()?; + transaction + .execute( + "INSERT INTO tasks(task_id, human_subject_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms) + VALUES(?1, ?2, ?3, ?4, 'ACTIVE', ?5, ?5)", + params![task_id, human_subject_id, title, purpose, observed_at], + ) + .map_err(database_write_error)?; + append_event( + &transaction, + &human_subject_id, + "TASK_STARTED", + Some(&task_id), + &format!("开始:{title}"), + observed_at, + )?; + transaction.commit().map_err(database_write_error)?; + snapshot_at(database) +} + +fn transition_task_at( + database: &Path, + input: TransitionPersonalChannelTaskInput, +) -> Result { + validate_identifier(&input.task_id, "TASK")?; + if input.expected_status != "ACTIVE" || input.next_status != "COMPLETED" { + return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_TRANSITION_INVALID".into()); + } + let mut connection = open_database(database)?; + verify_integrity(&connection)?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(database_write_error)?; + let human_subject_id = require_identity_id(&transaction)?; + let task = transaction + .query_row( + "SELECT title, status FROM tasks WHERE task_id = ?1", + params![input.task_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional() + .map_err(database_read_error)? + .ok_or("HOLOLAKE_PERSONAL_CHANNEL_TASK_NOT_FOUND")?; + if task.1 != input.expected_status { + return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into()); + } + let observed_at = now_unix_ms()?; + let changed = transaction + .execute( + "UPDATE tasks SET status = 'COMPLETED', updated_at_unix_ms = ?1 + WHERE task_id = ?2 AND status = 'ACTIVE'", + params![observed_at, input.task_id], + ) + .map_err(database_write_error)?; + if changed != 1 { + return Err("HOLOLAKE_PERSONAL_CHANNEL_TASK_STATE_CONFLICT".into()); + } + append_event( + &transaction, + &human_subject_id, + "TASK_COMPLETED", + Some(&input.task_id), + &format!("完成:{}", task.0), + observed_at, + )?; + transaction.commit().map_err(database_write_error)?; + snapshot_at(database) +} + +fn require_identity_id(transaction: &Transaction<'_>) -> Result { + transaction + .query_row( + "SELECT human_subject_id FROM identities WHERE singleton = 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(database_read_error)? + .ok_or_else(|| "HOLOLAKE_PERSONAL_CHANNEL_NOT_INITIALIZED".into()) +} + +fn append_event( + transaction: &Transaction<'_>, + human_subject_id: &str, + kind: &str, + task_id: Option<&str>, + summary: &str, + occurred_at_unix_ms: i64, +) -> Result<(), String> { + let (last_sequence, previous_event_hash, previous_receipt_hash) = transaction + .query_row( + "SELECT e.sequence, e.event_hash, r.receipt_hash + FROM events e JOIN receipts r ON r.event_sequence = e.sequence + ORDER BY e.sequence DESC LIMIT 1", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional() + .map_err(database_read_error)? + .unwrap_or((0, ZERO_HASH.into(), ZERO_HASH.into())); + let sequence = last_sequence + 1; + let event_id = format!("event-local-{}", Uuid::new_v4()); + let payload = EventHashPayload { + schema: KERNEL_SCHEMA, + sequence, + event_id: &event_id, + human_subject_id, + kind, + task_id, + summary, + occurred_at_unix_ms, + }; + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?; + let payload_sha256 = sha256_hex(&payload_bytes); + let event_hash = + sha256_hex(format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes()); + let receipt_seed = sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes()); + let receipt_id = format!("HLR-{}", &receipt_seed[..24]); + let receipt_hash = sha256_hex( + format!( + "receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}" + ) + .as_bytes(), + ); + transaction + .execute( + "INSERT INTO events(sequence, event_id, human_subject_id, kind, task_id, summary, + occurred_at_unix_ms, previous_event_hash, payload_sha256, event_hash) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + sequence, + event_id, + human_subject_id, + kind, + task_id, + summary, + occurred_at_unix_ms, + previous_event_hash, + payload_sha256, + event_hash + ], + ) + .map_err(database_write_error)?; + transaction + .execute( + "INSERT INTO receipts(sequence, receipt_id, event_sequence, event_hash, payload_sha256, + previous_receipt_hash, receipt_hash, issued_at_unix_ms) + VALUES(?1, ?2, ?1, ?3, ?4, ?5, ?6, ?7)", + params![ + sequence, + receipt_id, + event_hash, + payload_sha256, + previous_receipt_hash, + receipt_hash, + occurred_at_unix_ms + ], + ) + .map_err(database_write_error)?; + Ok(()) +} + +fn snapshot_at(database: &Path) -> Result { + let connection = open_database(database)?; + let integrity = verify_integrity(&connection)?; + let identity = connection + .query_row( + "SELECT human_subject_id, display_name, channel_id, created_at_unix_ms + FROM identities WHERE singleton = 1", + [], + |row| { + Ok(PersonalChannelIdentity { + human_subject_id: row.get(0)?, + display_name: row.get(1)?, + channel_id: row.get(2)?, + created_at_unix_ms: row.get(3)?, + }) + }, + ) + .optional() + .map_err(database_read_error)?; + let current_task = connection + .query_row( + "SELECT task_id, title, purpose, status, created_at_unix_ms, updated_at_unix_ms + FROM tasks WHERE status = 'ACTIVE' LIMIT 1", + [], + |row| { + Ok(PersonalChannelTask { + task_id: row.get(0)?, + title: row.get(1)?, + purpose: row.get(2)?, + status: row.get(3)?, + created_at_unix_ms: row.get(4)?, + updated_at_unix_ms: row.get(5)?, + }) + }, + ) + .optional() + .map_err(database_read_error)?; + let mut statement = connection + .prepare( + "SELECT e.sequence, e.event_id, e.kind, e.task_id, e.summary, e.occurred_at_unix_ms, + e.event_hash, r.receipt_id, r.receipt_hash + FROM events e JOIN receipts r ON r.event_sequence = e.sequence + ORDER BY e.sequence DESC LIMIT 12", + ) + .map_err(database_read_error)?; + let recent_events = statement + .query_map([], |row| { + Ok(PersonalChannelEventProjection { + sequence: row.get(0)?, + event_id: row.get(1)?, + kind: row.get(2)?, + task_id: row.get(3)?, + summary: row.get(4)?, + occurred_at_unix_ms: row.get(5)?, + event_hash: row.get(6)?, + receipt_id: row.get(7)?, + receipt_hash: row.get(8)?, + }) + }) + .map_err(database_read_error)? + .collect::, _>>() + .map_err(database_read_error)?; + Ok(PersonalChannelSnapshot { + schema: KERNEL_SCHEMA, + state: if identity.is_some() { + "READY" + } else { + "UNINITIALIZED" + }, + identity, + current_task, + recent_events, + integrity, + storage: "LOCAL_PRIVATE_SQLITE_SINGLE_HOLOLAKE_OWNER", + authority: "LOCAL_HUMAN_CONFIRMED_IDENTITY_NOT_SERVER_AUTHORITY", + }) +} + +fn verify_integrity(connection: &Connection) -> Result { + let schema_version: i64 = connection + .query_row( + "SELECT value FROM kernel_meta WHERE key = 'schema_version'", + [], + |row| row.get::<_, String>(0), + ) + .map_err(database_read_error)? + .parse() + .map_err(|_| "HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_INVALID".to_string())?; + if schema_version != DATABASE_SCHEMA_VERSION { + return Err("HOLOLAKE_PERSONAL_CHANNEL_SCHEMA_UNSUPPORTED".into()); + } + let receipt_count: i64 = connection + .query_row("SELECT COUNT(*) FROM receipts", [], |row| row.get(0)) + .map_err(database_read_error)?; + let mut statement = connection + .prepare( + "SELECT e.sequence, e.event_id, e.human_subject_id, e.kind, e.task_id, e.summary, + e.occurred_at_unix_ms, e.previous_event_hash, e.payload_sha256, e.event_hash, + r.receipt_id, r.event_hash, r.payload_sha256, r.previous_receipt_hash, r.receipt_hash + FROM events e LEFT JOIN receipts r ON r.event_sequence = e.sequence + ORDER BY e.sequence ASC", + ) + .map_err(database_read_error)?; + let mut rows = statement.query([]).map_err(database_read_error)?; + let mut expected_sequence = 1_i64; + let mut previous_event_hash = ZERO_HASH.to_string(); + let mut previous_receipt_hash = ZERO_HASH.to_string(); + let mut event_count = 0_i64; + while let Some(row) = rows.next().map_err(database_read_error)? { + let sequence: i64 = row.get(0).map_err(database_read_error)?; + let event_id: String = row.get(1).map_err(database_read_error)?; + let human_subject_id: String = row.get(2).map_err(database_read_error)?; + let kind: String = row.get(3).map_err(database_read_error)?; + let task_id: Option = row.get(4).map_err(database_read_error)?; + let summary: String = row.get(5).map_err(database_read_error)?; + let occurred_at: i64 = row.get(6).map_err(database_read_error)?; + let stored_previous_event: String = row.get(7).map_err(database_read_error)?; + let stored_payload: String = row.get(8).map_err(database_read_error)?; + let stored_event_hash: String = row.get(9).map_err(database_read_error)?; + let receipt_id: Option = row.get(10).map_err(database_read_error)?; + let receipt_event_hash: Option = row.get(11).map_err(database_read_error)?; + let receipt_payload: Option = row.get(12).map_err(database_read_error)?; + let stored_previous_receipt: Option = row.get(13).map_err(database_read_error)?; + let stored_receipt_hash: Option = row.get(14).map_err(database_read_error)?; + if sequence != expected_sequence || stored_previous_event != previous_event_hash { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + let payload = EventHashPayload { + schema: KERNEL_SCHEMA, + sequence, + event_id: &event_id, + human_subject_id: &human_subject_id, + kind: &kind, + task_id: task_id.as_deref(), + summary: &summary, + occurred_at_unix_ms: occurred_at, + }; + let payload_sha256 = sha256_hex( + &serde_json::to_vec(&payload) + .map_err(|error| format!("HOLOLAKE_PERSONAL_CHANNEL_EVENT_INVALID: {error}"))?, + ); + let event_hash = sha256_hex( + format!("event-chain/v1\n{previous_event_hash}\n{payload_sha256}").as_bytes(), + ); + let receipt_id = receipt_id.ok_or("HOLOLAKE_PERSONAL_CHANNEL_RECEIPT_MISSING")?; + let expected_receipt_id = format!( + "HLR-{}", + &sha256_hex(format!("receipt-id/v1\n{event_hash}").as_bytes())[..24] + ); + let receipt_hash = sha256_hex( + format!( + "receipt-chain/v1\n{previous_receipt_hash}\n{receipt_id}\n{event_hash}\n{payload_sha256}" + ) + .as_bytes(), + ); + if stored_payload != payload_sha256 + || stored_event_hash != event_hash + || receipt_id != expected_receipt_id + || receipt_event_hash.as_deref() != Some(event_hash.as_str()) + || receipt_payload.as_deref() != Some(payload_sha256.as_str()) + || stored_previous_receipt.as_deref() != Some(previous_receipt_hash.as_str()) + || stored_receipt_hash.as_deref() != Some(receipt_hash.as_str()) + { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + previous_event_hash = event_hash; + previous_receipt_hash = receipt_hash; + expected_sequence += 1; + event_count += 1; + } + if receipt_count != event_count { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + let identity_count: i64 = connection + .query_row("SELECT COUNT(*) FROM identities", [], |row| row.get(0)) + .map_err(database_read_error)?; + if identity_count > 1 || (identity_count == 1 && event_count == 0) { + return Err("HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED".into()); + } + Ok(PersonalChannelIntegrity { + state: "PASS_100", + schema_version, + event_count, + receipt_count, + last_event_hash: previous_event_hash, + last_receipt_hash: previous_receipt_hash, + }) +} + +fn validated_text(value: &str, maximum_chars: usize, kind: &str) -> Result { + let trimmed = value.trim(); + let count = trimmed.chars().count(); + if count == 0 + || count > maximum_chars + || trimmed.chars().any(|character| character.is_control()) + { + return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_INVALID")); + } + Ok(trimmed.to_string()) +} + +fn validate_identifier(value: &str, kind: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 128 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err(format!("HOLOLAKE_PERSONAL_CHANNEL_{kind}_ID_INVALID")); + } + Ok(()) +} + +fn now_unix_ms() -> Result { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("HOLOLAKE_SYSTEM_CLOCK_INVALID: {error}"))? + .as_millis(); + i64::try_from(millis).map_err(|_| "HOLOLAKE_SYSTEM_CLOCK_INVALID".into()) +} + +fn sha256_hex(value: &[u8]) -> String { + digest(&SHA256, value) + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn database_read_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_UNREADABLE: {error}") +} + +fn database_write_error(error: rusqlite::Error) -> String { + format!("HOLOLAKE_PERSONAL_CHANNEL_DATABASE_WRITE_FAILED: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn database(temp: &TempDir) -> PathBuf { + temp.path().join("personal-channel.sqlite3") + } + + fn initialize(database: &Path) -> PersonalChannelSnapshot { + initialize_at( + database, + InitializePersonalChannelInput { + display_name: "冰朔".into(), + }, + ) + .unwrap() + } + + #[test] + fn initialization_creates_identity_event_and_receipt_atomically() { + let temp = TempDir::new().unwrap(); + let snapshot = initialize(&database(&temp)); + assert_eq!(snapshot.state, "READY"); + assert_eq!(snapshot.identity.unwrap().display_name, "冰朔"); + assert_eq!(snapshot.integrity.event_count, 1); + assert_eq!(snapshot.integrity.receipt_count, 1); + assert_eq!(snapshot.recent_events[0].kind, "CHANNEL_INITIALIZED"); + assert!(snapshot.recent_events[0].receipt_id.starts_with("HLR-")); + } + + #[test] + fn restart_reads_the_same_identity_task_event_and_receipt_chain() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + let created = create_task_at( + &database, + CreatePersonalChannelTaskInput { + title: "完成第一阶段闭环".into(), + purpose: "让身份、任务、事件与回执在重启后仍然可见".into(), + }, + ) + .unwrap(); + let before_hash = created.integrity.last_receipt_hash.clone(); + drop(created); + let after_restart = snapshot_at(&database).unwrap(); + assert_eq!( + after_restart.current_task.unwrap().title, + "完成第一阶段闭环" + ); + assert_eq!(after_restart.integrity.event_count, 2); + assert_eq!(after_restart.integrity.last_receipt_hash, before_hash); + } + + #[test] + fn one_active_task_is_enforced_and_completion_is_receipted() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + let created = create_task_at( + &database, + CreatePersonalChannelTaskInput { + title: "当前任务".into(), + purpose: "验证单一当前焦点".into(), + }, + ) + .unwrap(); + assert_eq!( + create_task_at( + &database, + CreatePersonalChannelTaskInput { + title: "冲突任务".into(), + purpose: "不应被创建".into(), + }, + ) + .unwrap_err(), + "HOLOLAKE_PERSONAL_CHANNEL_ACTIVE_TASK_EXISTS" + ); + let completed = transition_task_at( + &database, + TransitionPersonalChannelTaskInput { + task_id: created.current_task.unwrap().task_id, + expected_status: "ACTIVE".into(), + next_status: "COMPLETED".into(), + }, + ) + .unwrap(); + assert!(completed.current_task.is_none()); + assert_eq!(completed.recent_events[0].kind, "TASK_COMPLETED"); + assert_eq!(completed.integrity.event_count, 3); + } + + #[test] + fn changed_event_bytes_fail_closed_on_readback() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + let connection = open_database(&database).unwrap(); + connection + .execute( + "UPDATE events SET summary = '被篡改' WHERE sequence = 1", + [], + ) + .unwrap(); + assert_eq!( + snapshot_at(&database).unwrap_err(), + "HOLOLAKE_PERSONAL_CHANNEL_INTEGRITY_FAILED" + ); + } + + #[test] + fn local_identity_is_singleton_and_not_recreated() { + let temp = TempDir::new().unwrap(); + let database = database(&temp); + initialize(&database); + assert_eq!( + initialize_at( + &database, + InitializePersonalChannelInput { + display_name: "另一个人".into(), + }, + ) + .unwrap_err(), + "HOLOLAKE_PERSONAL_CHANNEL_ALREADY_INITIALIZED" + ); + } +} diff --git a/product-source/hololake-native-desktop/src-tauri/tauri.conf.json b/product-source/hololake-native-desktop/src-tauri/tauri.conf.json index b3a123ac2..1e7d5c30e 100644 --- a/product-source/hololake-native-desktop/src-tauri/tauri.conf.json +++ b/product-source/hololake-native-desktop/src-tauri/tauri.conf.json @@ -27,6 +27,9 @@ "bundle": { "active": true, "targets": "all", + "icon": [ + "icons/icon.png" + ], "createUpdaterArtifacts": false, "category": "Productivity" } diff --git a/product-source/hololake-native-desktop/src/main.tsx b/product-source/hololake-native-desktop/src/main.tsx index 3ceb2bd23..52addf3b0 100644 --- a/product-source/hololake-native-desktop/src/main.tsx +++ b/product-source/hololake-native-desktop/src/main.tsx @@ -1,14 +1,16 @@ import { StrictMode, useCallback, useEffect, useMemo, useState } from 'react' import { createRoot } from 'react-dom/client' import { invoke } from '@tauri-apps/api/core' +import DOMPurify from 'dompurify' +import { marked } from 'marked' import './design-tokens.css' import './styles.css' type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear' -type Panel = 'channel' | 'receipts' | 'updates' | 'settings' | null +type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system' +type KnowledgeSource = 'native' | 'legacy' interface HomeStatus { - schema: string directLocalBrokerState: string directConnectionCount: number resumableSessionCount: number @@ -16,114 +18,73 @@ interface HomeStatus { pnccReceiptCount: number updateState: string releaseRecoveryState: string - automaticUpstreamUpdates: boolean mcpRole: string } - -interface DiscoveryTicketReceipt { - schema: string +interface PersonalChannelIdentity { humanSubjectId: string; displayName: string; channelId: string; createdAtUnixMs: number } +interface PersonalChannelEvent { sequence: number; eventId: string; kind: string; summary: string; occurredAtUnixMs: number; receiptId: string; receiptHash: string } +interface PersonalChannelSnapshot { state: string; identity?: PersonalChannelIdentity; recentEvents: PersonalChannelEvent[]; integrity: { state: string; eventCount: number; receiptCount: number } } +interface KnowledgeDocumentSummary { + source: KnowledgeSource + path: string + title: string + updatedAtUnixMs: number + sizeBytes: number + contentSha256: string + duplicateCount: number +} +interface KnowledgeSnapshot { state: string - accountKey: string - laneId: string - clientInstanceId: string - discoveryTicket: string - issuedAtUnixMs: number - receiptId: string + nativeRoot: string + legacyAvailable: boolean + legacyRoot?: string + documents: KnowledgeDocumentSummary[] + rawDocumentCount: number + uniqueDocumentCount: number + duplicateDocumentCount: number + truncated: boolean } - -interface ReceiptEvent { - sequence: number - kind: string - observedAtUnixMs: number - eventHash: string +interface KnowledgeDocument { + source: KnowledgeSource + path: string + title: string + body: string + updatedAtUnixMs: number + contentSha256: string + writable: boolean } - -interface ReceiptProjection { - events: ReceiptEvent[] - returnedEventCount: number - lastSequence: number -} - -interface RepositoryInspection { - personaId: string - humanResponsibilitySubject: string - repositoryPath: string - gitHead: string - repositoryClean: boolean - currentCheckpoint: { relativePath: string; sha256: string } - modelInferenceStarted: boolean - realityExecutionAllowed: boolean -} - -interface RepositoryCandidate { - schema: string +interface KnowledgeSearchResult { source: KnowledgeSource; path: string; title: string; snippet: string } +interface KnowledgeImportResult { state: string - candidateId: string - confirmationToken: string - expiresAtUnixMs: number - inspection: RepositoryInspection -} - -interface ReleaseCandidate { - candidateId: string - confirmationToken: string - releaseId: string - currentVersion: string - version: string - notes: string - features: string[] - fixes: string[] - minimumVersion: string - dataMigrationRequired: boolean - compatibilityNotes?: string - restartMessage?: string - rollbackSupported: boolean - previousVersion?: string - expiresAtUnixMs: number -} - -interface ReleaseCheckReceipt { - state: string - candidate?: ReleaseCandidate + sourceName: string + importedDocuments: number + importedAssets: number + existingDocuments: number + conflicts: number + skipped: number + firstDocument?: string + gitCommit: string + snapshot: KnowledgeSnapshot } +interface KnowledgeSaveResult { state: string; gitCommit: string; document: KnowledgeDocument } +interface CodeChannelEntry { channelId: string; name: string; sourceKind: string; localPath: string; remoteUrl?: string; gitHead: string; branch: string; repositoryClean: boolean; registeredAtUnixMs: number } +interface CodeChannelSnapshot { state: string; channels: CodeChannelEntry[]; authority: string } +interface CodeTreeEntry { path: string; name: string; kind: 'directory' | 'file'; sizeBytes: number } +interface CodeTreeSnapshot { channelId: string; path: string; entries: CodeTreeEntry[]; truncated: boolean } +interface CodeFileProjection { channelId: string; path: string; format: string; source: string; humanMarkdown: string; sizeBytes: number } +interface ReceiptEvent { sequence: number; kind: string; observedAtUnixMs: number; eventHash: string } +interface ReceiptProjection { events: ReceiptEvent[] } +interface DiscoveryTicketReceipt { laneId: string; clientInstanceId: string; discoveryTicket: string } +interface ReleaseCandidate { candidateId: string; confirmationToken: string; currentVersion: string; version: string; notes: string; features: string[]; fixes: string[]; dataMigrationRequired: boolean } +interface ReleaseCheckReceipt { candidate?: ReleaseCandidate } +const previewStatus: HomeStatus = { directLocalBrokerState: 'UNVERIFIED', directConnectionCount: 0, resumableSessionCount: 0, codeRepositoryMountCount: 0, pnccReceiptCount: 0, updateState: 'UNPROVISIONED_FAIL_CLOSED', releaseRecoveryState: 'NONE', mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY' } +const previewPersonal: PersonalChannelSnapshot = { state: 'UNAVAILABLE', recentEvents: [], integrity: { state: 'UNKNOWN', eventCount: 0, receiptCount: 0 } } +const previewKnowledge: KnowledgeSnapshot = { state: 'UNAVAILABLE', nativeRoot: '', legacyAvailable: false, documents: [], rawDocumentCount: 0, uniqueDocumentCount: 0, duplicateDocumentCount: 0, truncated: false } +const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], authority: 'LOCAL_SOURCE_ACCESS_ONLY_NO_PUSH_OR_DEPLOY_AUTHORITY' } const themes: Array<{ id: ThemeId; name: string }> = [ - { id: 'night', name: '夜湖星光' }, - { id: 'dawn', name: '晨湖曦光' }, - { id: 'nebula', name: '星云紫夜' }, - { id: 'candle', name: '烛畔暖湖' }, - { id: 'clear', name: '清浅澄湖' }, + { id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' }, ] - -const recoveryPreviewStates = [ - 'NONE', - 'AWAITING_HUMAN_HEALTH_CONFIRMATION', - 'ROLLED_BACK_AWAITING_HUMAN_ACKNOWLEDGEMENT', - 'INSTALL_FAILED_BACKUP_RETAINED', - 'INSTALL_INTERRUPTED_BACKUP_RETAINED', - 'INSTALL_REPLACED_AWAITING_MANUAL_RESTART', - 'ROLLBACK_REPLACED_AWAITING_MANUAL_RESTART', - 'RECOVERY_STATE_UNREADABLE_ACTION_REQUIRED', -] -const requestedRecoveryPreview = import.meta.env.DEV - ? new URLSearchParams(window.location.search).get('releaseRecoveryState') - : null -const recoveryPreviewState = requestedRecoveryPreview && recoveryPreviewStates.includes(requestedRecoveryPreview) - ? requestedRecoveryPreview - : 'NONE' - -const previewStatus: HomeStatus = { - schema: 'hololake.home-status/preview', - directLocalBrokerState: 'PREVIEW', - directConnectionCount: 0, - resumableSessionCount: 0, - codeRepositoryMountCount: 0, - pnccReceiptCount: 0, - updateState: 'UNPROVISIONED_FAIL_CLOSED', - releaseRecoveryState: recoveryPreviewState, - automaticUpstreamUpdates: false, - mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', -} +const viewLabels: Record = { overview: '总览', knowledge: '知识工作台', code: '代码频道', receipts: '回执', system: '系统详情' } function getOrCreateLocalId(key: string, prefix: string) { const existing = window.localStorage.getItem(key) @@ -133,193 +94,332 @@ function getOrCreateLocalId(key: string, prefix: string) { return generated } -function Icon({ name }: { name: 'settings' | 'arrow' | 'receipt' | 'close' | 'copy' }) { - const paths = { - settings: <>, - arrow: <>, - receipt: <>, - close: <>, +function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' | 'system') { + const value = String(error) + if (value.includes('HOST_NOT_TRUSTED')) return '该地址不属于已登记的光湖代码频道。' + if (value.includes('URL_INVALID')) return '频道地址无效,请粘贴完整的 HTTPS 克隆地址。' + if (value.includes('DESTINATION_EXISTS')) return '该代码频道已经存在,无需再次克隆。' + if (value.includes('CLONE_FAILED')) return '代码频道克隆失败,请核对地址及读取权限。' + if (value.includes('NO_SUPPORTED_FILES')) return '文件夹中没有可导入的知识文件。' + if (value.includes('SAVE_CONFLICT')) return '页面已在别处更新,请重新打开后再编辑。' + if (value.includes('FILE_UNSUPPORTED')) return '该文件不是当前可阅读的文本格式。' + if (context === 'identity') return '个人空间未能建立。' + if (context === 'knowledge') return '知识操作未完成,原文件未被覆盖。' + if (context === 'code') return '代码频道操作未完成。' + return '系统操作未完成。' +} + +function Icon({ name }: { name: ViewId | 'search' | 'import' | 'folder' | 'copy' | 'arrow' | 'chevron' | 'file' | 'edit' | 'save' | 'back' }) { + const paths: Record = { + overview: <>, + knowledge: <>, + code: <>, + receipts: <>, + system: <>, + search: <>, + import: <>, + folder: , + file: <>, copy: <>, + arrow: <>, + chevron: , + edit: <>, + save: <>, + back: , } return } +function escapeHtml(value: string) { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"') +} + +function splitFrontmatter(body: string) { + const normalized = body.replace(/\r\n/g, '\n') + if (!normalized.startsWith('---\n')) return { content: normalized, metadata: {} as Record } + const closing = normalized.indexOf('\n---\n', 4) + if (closing < 0) return { content: normalized, metadata: {} as Record } + const metadata: Record = {} + for (const line of normalized.slice(4, closing).split('\n')) { + const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line) + if (match) metadata[match[1]] = match[2].replace(/^['"]|['"]$/g, '') + } + return { content: normalized.slice(closing + 5), metadata } +} + +function markdownHtml(body: string) { + const withWikiLinks = body.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_whole, target: string, label?: string) => + ``) + const html = marked.parse(withWikiLinks, { async: false, gfm: true, breaks: false }) as string + return DOMPurify.sanitize(html, { ADD_ATTR: ['data-wiki'], ADD_TAGS: ['button'] }) +} + +function documentOutline(body: string) { + return splitFrontmatter(body).content.split('\n').flatMap((line, index) => { + const match = /^(#{1,4})\s+(.+)$/.exec(line.trim()) + return match ? [{ id: `outline-${index}`, level: match[1].length, title: match[2].replace(/[*_`]/g, '') }] : [] + }).slice(0, 24) +} + +function MarkdownDocument({ body, onWiki }: { body: string; onWiki?: (target: string) => void }) { + const parsed = useMemo(() => splitFrontmatter(body), [body]) + const html = useMemo(() => markdownHtml(parsed.content), [parsed.content]) + return
{ + const element = (event.target as HTMLElement).closest('[data-wiki]') + if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki) + }} dangerouslySetInnerHTML={{ __html: html }} /> +} + +interface TreeNode { name: string; key: string; folders: Map; documents: KnowledgeDocumentSummary[] } +function knowledgeTree(documents: KnowledgeDocumentSummary[]) { + const root: TreeNode = { name: '知识', key: '', folders: new Map(), documents: [] } + for (const document of documents) { + const segments = document.path.split('/').filter(Boolean) + const file = segments.pop() + let cursor = root + for (const segment of segments) { + const key = cursor.key ? `${cursor.key}/${segment}` : segment + if (!cursor.folders.has(segment)) cursor.folders.set(segment, { name: segment, key, folders: new Map(), documents: [] }) + cursor = cursor.folders.get(segment)! + } + cursor.documents.push({ ...document, title: document.title || file || '未命名页面' }) + } + return root +} +function countTree(node: TreeNode): number { + return node.documents.length + [...node.folders.values()].reduce((sum, child) => sum + countTree(child), 0) +} +function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen }: { node: TreeNode; depth: number; expanded: Set; active?: string; onToggle: (key: string) => void; onOpen: (item: KnowledgeDocumentSummary) => void }) { + const folders = [...node.folders.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) + const documents = [...node.documents].sort((a, b) => a.title.localeCompare(b.title, 'zh-CN')) + return <> + {folders.map((folder) => { + const open = depth === 0 || expanded.has(folder.key) + return
+ + {open && } +
+ })} + {documents.map((document) => )} + +} + function HoloLakeApp() { const [theme, setTheme] = useState(() => (window.localStorage.getItem('hololake-theme') as ThemeId) || 'night') + const [view, setView] = useState('knowledge') const [status, setStatus] = useState(previewStatus) - const [panel, setPanel] = useState(null) - const [ticket, setTicket] = useState(null) + const [personal, setPersonal] = useState(previewPersonal) + const [knowledge, setKnowledge] = useState(previewKnowledge) + const [codeChannels, setCodeChannels] = useState(previewCode) + const [activeDocument, setActiveDocument] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState(null) + const [knowledgeBusy, setKnowledgeBusy] = useState(false) + const [knowledgeMessage, setKnowledgeMessage] = useState('') + const [expanded, setExpanded] = useState>(() => new Set(['导入'])) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const [lastKnowledgeReceipt, setLastKnowledgeReceipt] = useState('') + const [cloneUrl, setCloneUrl] = useState('') + const [codeBusy, setCodeBusy] = useState(false) + const [codeMessage, setCodeMessage] = useState('') + const [activeChannel, setActiveChannel] = useState(null) + const [codeTree, setCodeTree] = useState(null) + const [activeCodeFile, setActiveCodeFile] = useState(null) + const [codeMode, setCodeMode] = useState<'human' | 'source'>('human') + const [displayName, setDisplayName] = useState('') + const [identityBusy, setIdentityBusy] = useState(false) + const [identityMessage, setIdentityMessage] = useState('') const [receipts, setReceipts] = useState([]) - const [repositoryCandidate, setRepositoryCandidate] = useState(null) - const [repositoryBusy, setRepositoryBusy] = useState(false) - const [repositoryMessage, setRepositoryMessage] = useState('') + const [ticket, setTicket] = useState(null) + const [systemMessage, setSystemMessage] = useState('') const [releaseCandidate, setReleaseCandidate] = useState(null) - const [releaseBusy, setReleaseBusy] = useState(false) - const [releaseMessage, setReleaseMessage] = useState('') - const [message, setMessage] = useState('') + const [systemBusy, setSystemBusy] = useState(false) - const refreshStatus = useCallback(async () => { - try { - setStatus(await invoke('get_hololake_home_status')) - } catch { - setStatus(previewStatus) - } + const refreshCore = useCallback(async () => { + const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([ + invoke('get_hololake_home_status'), + invoke('get_personal_channel_snapshot'), + invoke('get_knowledge_snapshot'), + invoke('get_code_channel_snapshot'), + ]) + if (homeResult.status === 'fulfilled') setStatus(homeResult.value) + if (personalResult.status === 'fulfilled') setPersonal(personalResult.value) + if (knowledgeResult.status === 'fulfilled') setKnowledge(knowledgeResult.value) + if (codeResult.status === 'fulfilled') setCodeChannels(codeResult.value) + }, []) + const loadReceipts = useCallback(async () => { + try { + const projection = await invoke('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } }) + setReceipts(projection.events) + } catch { setReceipts([]) } }, []) - useEffect(() => { - void refreshStatus() - const timer = window.setInterval(() => void refreshStatus(), 3000) + void refreshCore() + const timer = window.setInterval(() => void refreshCore(), 3500) return () => window.clearInterval(timer) - }, [refreshStatus]) - + }, [refreshCore]) useEffect(() => { document.documentElement.dataset.theme = theme window.localStorage.setItem('hololake-theme', theme) }, [theme]) + useEffect(() => { if (view === 'receipts') void loadReceipts() }, [loadReceipts, view]) - const connectionState = useMemo(() => { - if (status.directConnectionCount > 0) return { label: '已连接', detail: `${status.directConnectionCount} 个直连通道在线`, tone: 'ready' } - if (status.resumableSessionCount > 0) return { label: '可续接', detail: `${status.resumableSessionCount} 个会话等待续接`, tone: 'waiting' } - return { label: '等待连接', detail: '本机直连核心已就绪', tone: 'quiet' } - }, [status]) + const visibleDocuments = useMemo(() => { + if (!searchResults) return knowledge.documents + return searchResults.map((result) => knowledge.documents.find((item) => item.source === result.source && item.path === result.path)).filter(Boolean) as KnowledgeDocumentSummary[] + }, [knowledge.documents, searchResults]) + const tree = useMemo(() => knowledgeTree(visibleDocuments), [visibleDocuments]) + const activeSummary = activeDocument + ? knowledge.documents.find((item) => item.source === activeDocument.source && item.path === activeDocument.path) + : undefined + const parsedDocument = useMemo(() => activeDocument ? splitFrontmatter(activeDocument.body) : null, [activeDocument]) + const outline = useMemo(() => activeDocument ? documentOutline(activeDocument.body) : [], [activeDocument]) + const connectionOnline = status.directConnectionCount > 0 + const connectionLabel = connectionOnline + ? '本地原生连接 · 客户端在线' + : status.resumableSessionCount > 0 + ? '原生会话可续接 · 当前离线' + : '原生接口已驻留 · 尚无客户端' - const releaseRecoveryIdle = useMemo(() => [ - 'NONE', - 'HEALTH_CONFIRMED_BACKUP_REMOVED', - 'ROLLBACK_CONFIRMED_BACKUP_REMOVED', - 'INSTALL_FAILURE_ACKNOWLEDGED_BACKUP_REMOVED', - ].includes(status.releaseRecoveryState), [status.releaseRecoveryState]) - - const releaseRecoveryNeedsAttention = !releaseRecoveryIdle - - const openReceipts = async () => { - setPanel('receipts') - await loadReceipts() - } - - const loadReceipts = async () => { + const openDocument = async (source: KnowledgeSource, path: string) => { + setKnowledgeBusy(true) + setKnowledgeMessage('') + setEditing(false) try { - const projection = await invoke('query_pncc_receipt_projection', { input: { afterSequence: 0, limit: 25 } }) - setReceipts(projection.events) - } catch { - setReceipts([]) - } + const document = await invoke('read_knowledge_document', { input: { source, path } }) + setActiveDocument(document) + setDraft(document.body) + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } } - - const chooseRepository = async () => { - setRepositoryBusy(true) - setRepositoryMessage('') - try { - const candidate = await invoke('select_pncc_repository_candidate') - setRepositoryCandidate(candidate) - if (!candidate) setRepositoryMessage('已取消选择,没有写入任何绑定。') - } catch (error) { - setRepositoryCandidate(null) - setRepositoryMessage(`这个文件夹不能作为代码通道:${String(error)}`) - } finally { - setRepositoryBusy(false) - } + const openWiki = (target: string) => { + const normalized = target.replace(/\.md$/i, '').toLowerCase() + const match = knowledge.documents.find((item) => + item.title.toLowerCase() === normalized || item.path.replace(/\.md$/i, '').toLowerCase().endsWith(normalized)) + if (match) void openDocument(match.source, match.path) + else setKnowledgeMessage(`未找到链接页面:${target}`) } - - const confirmRepository = async () => { - if (!repositoryCandidate) return - setRepositoryBusy(true) - setRepositoryMessage('') + const runSearch = async (event: React.FormEvent) => { + event.preventDefault() + if (!searchQuery.trim()) { setSearchResults(null); return } + setKnowledgeBusy(true) try { - await invoke('confirm_pncc_repository_mount', { - input: { - candidateId: repositoryCandidate.candidateId, - confirmationToken: repositoryCandidate.confirmationToken, - }, + setSearchResults(await invoke('search_knowledge', { input: { query: searchQuery } })) + setActiveDocument(null) + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } + } + const importKnowledge = async () => { + setKnowledgeBusy(true) + setKnowledgeMessage('') + try { + const result = await invoke('select_and_import_knowledge_folder') + if (!result) return + setKnowledge(result.snapshot) + setSearchResults(null) + setSearchQuery('') + setLastKnowledgeReceipt(result.gitCommit) + setKnowledgeMessage(`导入完成:新增 ${result.importedDocuments},已存在 ${result.existingDocuments},冲突 ${result.conflicts}。`) + if (result.firstDocument) await openDocument('native', result.firstDocument) + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } + } + const saveDocument = async () => { + if (!activeDocument?.writable) return + setKnowledgeBusy(true) + try { + const result = await invoke('save_knowledge_document', { + input: { path: activeDocument.path, body: draft, expectedContentSha256: activeDocument.contentSha256 }, }) - setRepositoryCandidate(null) - setRepositoryMessage('代码仓库已按当前固定提交绑定为只读通道。') - await Promise.all([refreshStatus(), loadReceipts()]) - } catch (error) { - setRepositoryMessage(`绑定没有写入:${String(error)}`) - } finally { - setRepositoryBusy(false) - } + setActiveDocument(result.document) + setDraft(result.document.body) + setEditing(false) + setLastKnowledgeReceipt(result.gitCommit) + setKnowledgeMessage(result.state === 'UNCHANGED' ? '内容没有变化。' : '页面已保存,并写入本机 Git 回执。') + await refreshCore() + } catch (error) { setKnowledgeMessage(humanError(error, 'knowledge')) } + finally { setKnowledgeBusy(false) } } - const checkUpdate = async () => { - setReleaseBusy(true) - setReleaseMessage('') + const cloneCodeChannel = async (event: React.FormEvent) => { + event.preventDefault() + if (!cloneUrl.trim()) return + setCodeBusy(true) + setCodeMessage('') try { - const receipt = await invoke('check_hololake_update') - setReleaseCandidate(receipt.candidate || null) - if (!receipt.candidate) setReleaseMessage('当前已经是最新版本。') - } catch (error) { - const reason = String(error) - setReleaseCandidate(null) - setReleaseMessage(reason.includes('UNPROVISIONED_NO_NETWORK_REQUEST') - ? '京东主控的正式发布地址和更新公钥还没有登记。HoloLake 保持离线,不会向任何上游检查更新。' - : `更新检查没有完成:${reason}`) - } finally { - setReleaseBusy(false) - } + const snapshot = await invoke('clone_code_channel', { input: { url: cloneUrl } }) + setCodeChannels(snapshot) + setCloneUrl('') + setCodeMessage('代码频道已克隆并登记。') + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } } - - const confirmUpdateInstall = async () => { - if (!releaseCandidate) return - setReleaseBusy(true) - setReleaseMessage('正在重新核对广播并验证安装包;完成后由你手动重启 HoloLake。') + const selectLocalCodeChannel = async () => { + setCodeBusy(true) + setCodeMessage('') try { - await invoke('confirm_hololake_update_install', { - input: { - candidateId: releaseCandidate.candidateId, - confirmationToken: releaseCandidate.confirmationToken, - }, - }) - setReleaseCandidate(null) - setReleaseMessage('签名安装包已验证并安装。请在准备好后手动重启 HoloLake。') - await refreshStatus() - } catch (error) { - setReleaseMessage(`更新没有安装:${String(error)}`) - } finally { - setReleaseBusy(false) - } - } - - const resolveReleaseRecovery = async (action: 'keep' | 'rollback') => { - setReleaseBusy(true) - setReleaseMessage(action === 'rollback' ? '正在复核上一版签名并恢复;完成后仍由你手动重启。' : '正在确认当前版本健康并清理上一版副本。') - try { - if (action === 'rollback') { - await invoke('rollback_hololake_update') - setReleaseMessage('上一版已恢复到应用位置。请手动重启 HoloLake,启动后再确认恢复正常。') - } else { - await invoke('confirm_hololake_release_health') - setReleaseMessage('当前运行版本已由你确认,临时回退副本已经清理。') + const snapshot = await invoke('select_local_code_channel') + if (snapshot) { + setCodeChannels(snapshot) + setCodeMessage('本地代码频道已登记,原文件未移动。') } - await refreshStatus() - } catch (error) { - setReleaseMessage(`没有改变当前应用:${String(error)}`) - } finally { - setReleaseBusy(false) - } + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } } - - const issueInvitation = async () => { - setMessage('') + const browseChannel = async (channel: CodeChannelEntry, path = '') => { + setCodeBusy(true) + setActiveChannel(channel) + setActiveCodeFile(null) try { - const receipt = await invoke('issue_direct_local_discovery_ticket', { + setCodeTree(await invoke('browse_code_channel', { input: { channelId: channel.channelId, path } })) + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } + } + const openCodeEntry = async (entry: CodeTreeEntry) => { + if (!activeChannel) return + if (entry.kind === 'directory') { await browseChannel(activeChannel, entry.path); return } + setCodeBusy(true) + setCodeMode('human') + try { + setActiveCodeFile(await invoke('read_code_channel_file', { + input: { channelId: activeChannel.channelId, path: entry.path }, + })) + } catch (error) { setCodeMessage(humanError(error, 'code')) } + finally { setCodeBusy(false) } + } + const codeParent = () => codeTree?.path.split('/').filter(Boolean).slice(0, -1).join('/') || '' + + const initializeIdentity = async (event: React.FormEvent) => { + event.preventDefault() + setIdentityBusy(true) + try { + setPersonal(await invoke('initialize_personal_channel', { input: { displayName } })) + setDisplayName('') + } catch (error) { setIdentityMessage(humanError(error, 'identity')) } + finally { setIdentityBusy(false) } + } + const issueInvitation = async () => { + setSystemBusy(true) + setSystemMessage('') + try { + setTicket(await invoke('issue_direct_local_discovery_ticket', { input: { accountId: getOrCreateLocalId('hololake-local-account', 'human-local'), laneId: 'personal-channel', clientInstanceId: getOrCreateLocalId('hololake-external-ai-client', 'external-ai'), }, - }) - setTicket(receipt) - setMessage('连接邀请已生成。它只在这台电脑上有效。') - } catch (error) { - setMessage(`暂时无法生成连接邀请:${String(error)}`) - } + })) + setSystemMessage('一次性发现凭据已生成。连接成功后,状态条会显示真实在线数。') + } catch (error) { setSystemMessage(humanError(error, 'system')) } + finally { setSystemBusy(false) } } - const invitationText = ticket ? JSON.stringify({ schema: 'hololake.direct-local-invitation/v1', - transport: 'INSTALLED_HOLOLAKE_EXECUTABLE_WITH_CONNECTOR_FLAG', connectorArgument: '--connector', openSession: { operation: 'OPEN_SESSION', @@ -330,160 +430,170 @@ function HoloLakeApp() { discoveryTicket: ticket.discoveryTicket, }, }, - mcpRole: 'DISCOVERY_RECOVERY_COMPATIBILITY_ONLY', }, null, 2) : '' - const copyInvitation = async () => { try { await navigator.clipboard.writeText(invitationText) - setMessage('已复制。把它交给桌面上的编程 AI 即可。') - } catch { - setMessage('复制没有成功,请选中下方邀请内容后复制。') - } + setSystemMessage('连接凭据已复制。') + } catch { setSystemMessage('复制失败,请手动选择凭据。') } + } + const checkUpdate = async () => { + setSystemBusy(true) + try { + const result = await invoke('check_hololake_update') + setReleaseCandidate(result.candidate || null) + setSystemMessage(result.candidate ? '发现已签名更新。' : '当前没有可安装更新。') + } catch (error) { setSystemMessage(humanError(error, 'system')) } + finally { setSystemBusy(false) } + } + const installUpdate = async () => { + if (!releaseCandidate) return + setSystemBusy(true) + try { + await invoke('confirm_hololake_update_install', { + input: { candidateId: releaseCandidate.candidateId, confirmationToken: releaseCandidate.confirmationToken }, + }) + setSystemMessage('更新安装已启动。') + } catch (error) { setSystemMessage(humanError(error, 'system')) } + finally { setSystemBusy(false) } } - return ( -
-