From 03fa490030b3992bb1bf889e6d06ea6461c0a4ad Mon Sep 17 00:00:00 2001 From: htommr Date: Fri, 16 Oct 2026 17:14:57 +0800 Subject: [PATCH 1/3] chore: prepare commercial repository snapshot --- CLAUDE.md | 6 +- README.md | 16 +- README.zh-CN.md | 16 +- app/Cargo.lock | 29 +- app/Cargo.toml | 3 +- app/flowix-cli/Cargo.toml | 2 +- app/flowix-cli/src/paths.rs | 4 +- app/flowix-cli/src/store.rs | 103 +- app/flowix-core/src/collection.rs | 38 +- .../src/memo_file/database_layout.rs | 103 + .../src/memo_file/file_management.rs | 38 +- .../src/memo_file/index_store/schema.rs | 291 +-- .../src/memo_file/internal_migration.rs | 35 + .../src/memo_file/local_migrations.rs | 55 + .../src/memo_file/media_resource.rs | 34 +- app/flowix-core/src/memo_file/mod.rs | 9 + app/flowix-core/src/memo_file/note_index.rs | 147 +- app/flowix-core/src/memo_file/notebook.rs | 170 +- .../src/memo_file/notebook_registry.rs | 189 +- .../src/memo_file/storage_repair.rs | 327 ++++ app/flowix-core/src/memo_file/tests.rs | 50 +- app/flowix-core/src/memo_file/workspace.rs | 450 +++++ app/flowix-core/src/secret/mod.rs | 46 +- app/flowix-desktop/Cargo.toml | 1 - app/flowix-desktop/build.rs | 4 +- app/flowix-desktop/entitlements.plist | 12 +- .../src/agent_external/claude/mod.rs | 2 +- .../deepseek_harness/manager.rs | 39 - .../agent_external/deepseek_harness/mod.rs | 2 +- .../deepseek_harness/run_coordinator.rs | 2 +- .../src/agent_external/lifecycle.rs | 2 +- .../src/agent_external/pi/config.rs | 101 +- .../src/agent_external/pi/runtime.rs | 89 +- .../src/agent_external/runtime_registry.rs | 16 +- .../src/agent_external/shared/lifecycle.rs | 2 +- .../src/agent_external_config.rs | 113 +- app/flowix-desktop/src/agent_history.rs | 8 +- .../src/agent_history/adapters.rs | 8 +- app/flowix-desktop/src/agent_lifecycle.rs | 8 +- .../src/agent_session/migrations.rs | 58 +- app/flowix-desktop/src/agent_session/store.rs | 77 +- .../src/agent_session/store/conversations.rs | 59 +- .../src/agent_session/store/external.rs | 14 +- .../src/agent_session/store/provider.rs | 4 +- app/flowix-desktop/src/agent_session/tests.rs | 32 +- app/flowix-desktop/src/agent_wire.rs | 6 +- app/flowix-desktop/src/app/bootstrap.rs | 750 +++++--- app/flowix-desktop/src/app/document_access.rs | 52 +- app/flowix-desktop/src/app/paths.rs | 2 +- app/flowix-desktop/src/app/startup.rs | 110 +- app/flowix-desktop/src/app/state.rs | 10 +- app/flowix-desktop/src/app/watchdog.rs | 2 +- app/flowix-desktop/src/commands/agent/chat.rs | 211 +- .../src/commands/agent/external_config.rs | 206 +- .../src/commands/agent/flowix_instructions.rs | 2 +- .../src/commands/agent/model_catalog.rs | 2 +- .../src/commands/agent/runtime.rs | 6 +- .../src/commands/agent_access.rs | 633 ------ .../src/commands/agent_workspace.rs | 87 + app/flowix-desktop/src/commands/boot.rs | 49 +- app/flowix-desktop/src/commands/cloud.rs | 151 +- .../cloud/local_adapter/remote_apply.rs | 12 +- app/flowix-desktop/src/commands/dialog.rs | 131 +- .../commands/dialog/attachment_fingerprint.rs | 2 +- .../src/commands/dialog/upload_sessions.rs | 3 - .../src/commands/external_document.rs | 31 +- app/flowix-desktop/src/commands/file.rs | 238 +-- .../src/commands/file/folder_import.rs | 198 ++ .../src/commands/file_browser_watch.rs | 11 +- app/flowix-desktop/src/commands/helpers.rs | 199 +- app/flowix-desktop/src/commands/kv.rs | 15 +- app/flowix-desktop/src/commands/media.rs | 4 +- .../src/commands/memo/creates.rs | 16 +- app/flowix-desktop/src/commands/memo/reads.rs | 4 - .../src/commands/memo/versions.rs | 3 - app/flowix-desktop/src/commands/mod.rs | 2 +- app/flowix-desktop/src/commands/notebook.rs | 116 +- app/flowix-desktop/src/commands/product.rs | 40 +- app/flowix-desktop/src/commands/settings.rs | 24 +- app/flowix-desktop/src/commands/thread.rs | 36 +- app/flowix-desktop/src/commands/window.rs | 25 +- app/flowix-desktop/src/config/access.rs | 420 ---- app/flowix-desktop/src/config/mod.rs | 35 +- .../src/config/property_presets.rs | 48 +- .../src/config/security_bookmark.rs | 271 ++- app/flowix-desktop/src/config/user.rs | 617 ++++-- app/flowix-desktop/src/device_registration.rs | 96 +- app/flowix-desktop/src/lib.rs | 1 + app/flowix-desktop/src/maintenance/mod.rs | 2 +- app/flowix-desktop/src/plugin.rs | 68 +- app/flowix-desktop/src/runtime_state.rs | 177 +- .../src/storage_initialization.rs | 636 ++++++ app/flowix-desktop/src/system_data.rs | 16 +- app/flowix-desktop/src/template_store.rs | 146 +- app/flowix-desktop/src/watcher/manager.rs | 3 +- app/flowix-desktop/tauri.conf.dev.json | 2 +- app/flowix-desktop/tauri.conf.json | 2 +- .../tauri.windows.dev.conf.json | 2 +- app/flowix-sync/Cargo.toml | 1 + app/flowix-sync/build.rs | 18 + app/flowix-sync/src/client.rs | 83 +- app/flowix-sync/src/environment.rs | 35 + app/flowix-sync/src/lib.rs | 7 +- app/flowix-sync/src/manager.rs | 3 +- app/flowix-sync/src/manager/auth.rs | 1 - app/flowix-sync/src/manager/catalog.rs | 68 + .../src/manager/lifecycle/tests.rs | 2 +- app/flowix-sync/src/manager/state.rs | 75 +- app/flowix-sync/src/models.rs | 36 +- app/flowix-sync/src/store/schema.rs | 45 +- app/flowix-sync/src/store/v2_tests.rs | 3 - .../app/agent-window-effects.test.ts | 55 +- app/flowix-web/app/agent-window-effects.tsx | 27 +- app/flowix-web/app/app.tsx | 187 +- app/flowix-web/app/main-window-effects.tsx | 8 +- .../app/main-window-startup.test.ts | 72 + app/flowix-web/app/main-window-startup.ts | 118 +- .../use-main-window-business-controller.ts | 14 +- .../use-main-window-system-controller.ts | 7 + .../app/use-agent-access-window-sync.ts | 14 - .../app/use-agent-workspace-window-sync.ts | 25 + app/flowix-web/app/user-config-sync.ts | 7 +- app/flowix-web/assets/skill-library.svg | 7 + app/flowix-web/features/agent/agent-setup.ts | 2 - .../components/agent-background-terminals.tsx | 16 +- .../agent-conversation-detail-submit.ts | 2 + .../components/agent-conversation-detail.tsx | 117 +- .../agent-conversation-identity.tsx | 166 ++ .../components/agent-conversation-list.tsx | 54 +- .../agent-conversation-more-menu.tsx | 136 ++ .../agent-conversation-status-bar.tsx | 6 +- .../agent-conversation-titlebar.tsx | 273 +-- .../features/agent/components/agent-icon.tsx | 2 +- .../agent-message-font-switcher.tsx | 84 + .../components/agent-runtime-status-list.tsx | 56 +- .../agent/components/agent-tasks-section.tsx | 27 +- .../conversation-list-pagination.ts | 2 - .../use-agent-conversation-surface.ts | 51 + .../components/use-conversation-list-data.ts | 58 +- .../agent/config/agent-model-options.ts | 16 + .../agent/events/agent-event-mapper.ts | 4 +- .../features/agent/events/message-identity.ts | 4 +- .../features/agent/public/app-api.ts | 5 + .../agent/public/dsh-onboarding-api.ts | 3 +- .../features/agent/public/shell-api.ts | 18 +- .../features/agent/public/workspace-api.ts | 47 +- .../agent/runtime/agent-runtime-spec.ts | 71 +- .../runtime/agent-runtime-status.test.ts | 34 +- .../agent/runtime/agent-runtime-status.ts | 29 +- .../agent/runtime/primary-workspace.ts | 2 +- .../agent/runtime/workspace-snapshot.test.ts | 21 +- .../agent/runtime/workspace-snapshot.ts | 10 +- .../agent/services/agent-catalog-service.ts | 174 ++ .../services/codex-slash-command-service.ts | 8 +- .../services/composer-submission-service.ts | 11 +- .../services/dsh-command-service.test.ts | 5 +- .../agent/services/dsh-command-service.ts | 20 +- .../external-agent-runtime-adapters.ts | 22 +- .../agent/store/agent-access-store.test.ts | 215 --- .../agent/store/agent-access-store.ts | 292 --- .../features/agent/store/agent-data-store.ts | 908 +++++++++ .../agent/store/agent-history-adapters.ts | 8 +- .../agent/store/agent-message-font-store.ts | 81 + .../agent/store/agent-runtime-store.ts | 128 +- .../agent/store/agent-session-actions.test.ts | 57 +- .../store/agent-session-runtime-bridges.ts | 3 - .../agent/store/agent-session-store.ts | 57 +- .../agent/store/agent-session-test-facade.ts | 11 + .../agent/store/agent-user-preferences.ts | 38 + .../agent/store/agent-workspace-slice.test.ts | 233 +++ .../agent/store/agent-workspace-slice.ts | 125 ++ .../agent/store/chat-stream.pi-mode.test.ts | 52 + .../features/agent/store/chat-stream.ts | 34 +- .../features/agent/store/context-block.ts | 2 +- .../agent/store/dsh-model-config-store.ts | 2 + .../agent/store/follow-up-queue-slice.ts | 8 +- .../store/global-agent-settings-sync.test.ts | 31 - .../agent/store/global-agent-settings-sync.ts | 27 - app/flowix-web/features/agent/store/index.ts | 11 +- .../store/initial-runtime-config.test.ts | 170 +- .../agent/store/initial-runtime-config.ts | 25 +- .../agent/store/session-meta-slice.ts | 38 +- .../agent/store/session-persistence.ts | 25 + .../session-reducer/reduce-projection.ts | 2 +- .../features/agent/store/session-state.ts | 1 + .../agent/store/thread-lifecycle-slice.ts | 66 +- .../agent/store/thread-runtime-state.ts | 3 +- .../agent/store/thread-titles.test.ts | 10 - .../features/agent/store/thread-titles.ts | 8 +- .../features/agent/store/user-message.ts | 15 +- .../thread-card/agent-thread-card-dom.ts | 8 +- .../agent-thread-card-extension.tsx | 40 +- .../thread-card/agent-thread-card-icons.ts | 24 +- .../thread-card/agent-thread-card-markdown.ts | 174 +- .../agent-thread-card-submit-controller.ts | 2 + .../thread-card/agent-thread-card-submit.ts | 4 +- .../thread-card/agent-thread-card-view.tsx | 137 +- .../thread-card/agent-thread-card.test.ts | 144 +- .../agent/thread-card/badge-hover-card.tsx | 4 +- .../chrome/badge-chrome-controller.tsx | 17 +- .../composer/composer-add-menu-controller.ts | 19 +- .../composer/composer-attachment-compat.ts | 14 + .../composer-attachment-controller.test.ts | 109 ++ .../composer-attachment-controller.ts | 290 +++ .../composer/composer-attachment-document.ts | 124 ++ .../composer/composer-attachment-node-view.ts | 54 + .../composer/composer-attachment-preview.ts | 143 ++ .../composer-attachment-token.test.ts | 124 ++ .../composer/composer-attachment-token.ts | 135 ++ .../composer/composer-attachment-types.ts | 9 + .../thread-card/composer/composer-commands.ts | 14 +- .../composer/composer-controller.test.ts | 36 +- .../composer/composer-controller.ts | 47 +- .../composer/composer-dom-factory.test.ts | 5 +- .../composer/composer-dom-factory.ts | 30 +- .../composer-folder-controller.test.ts | 2 +- .../composer/composer-folder-data.ts | 31 +- .../composer/composer-folder-token.ts | 53 +- .../composer-image-controller.test.ts | 182 -- .../composer/composer-image-controller.ts | 252 --- .../composer/composer-inline-atom-caret.ts | 39 + .../composer/composer-insertion-anchor.ts | 36 + .../composer/composer-skill-token.ts | 49 +- .../composer-slash-command-controller.test.ts | 40 +- .../composer-slash-command-controller.ts | 40 +- .../composer/composer-slash-token.ts | 199 +- .../composer-submission-controller.test.ts | 9 +- .../composer-submission-controller.ts | 15 +- .../composer/composer-submission-state.ts | 2 +- .../agent/thread-card/composer/index.ts | 14 +- .../composer/send-button-renderer.tsx | 12 +- .../agent/thread-card/link-navigation.test.ts | 48 +- .../agent/thread-card/link-navigation.ts | 38 +- .../thread-card/messages/image-preview.ts | 2 +- .../messages/message-item-renderer.test.ts | 17 +- .../messages/message-item-renderer.ts | 87 +- .../messages/message-list-renderer.ts | 6 +- .../note/note-picker-controller.test.ts | 6 +- .../note/note-picker-controller.ts | 12 +- .../runtime/runtime-info-requester.ts | 2 +- .../thread-card-subscriptions-controller.ts | 57 +- .../agent-workspace-repositories.test.ts | 106 +- ...external-agent-settings-controller.test.ts | 5 +- .../external-agent-settings-controller.ts | 666 ++----- .../settings/external-agent-settings.ts | 20 +- .../settings/featured-notes-controller.ts | 2 +- .../notebook-agent-settings-dialog.tsx | 93 +- .../view/agent-thread-card-dom-factory.ts | 8 +- .../dataset-filter-location.test.ts | 32 + .../collection/dataset-filter-location.ts | 24 + .../features/collection/folder-collection.ts | 15 +- app/flowix-web/features/collection/model.ts | 15 +- .../features/collection/recovery.test.ts | 49 + .../features/collection/recovery.ts | 280 +++ .../features/collection/rule-filter.ts | 38 +- .../content/components/content-icon.tsx | 186 ++ .../content/components/note-content-icon.tsx | 15 + .../features/content/model/content-kind.ts | 119 ++ .../features/content/public/content-api.ts | 17 + .../document/components/csv-preview.test.tsx | 133 ++ .../document/components/csv-preview.tsx | 172 +- .../components/document-container.tsx | 87 +- .../components/document-titlebar-mac.tsx | 17 +- .../components/document-titlebar-shared.tsx | 255 +-- .../components/document-titlebar-win.tsx | 17 +- .../document/components/docx-preview.tsx | 94 +- .../components/editor-font-switcher.tsx | 70 +- .../note-properties/date-value-input.tsx | 2 +- .../components/property-preset-menu.tsx | 7 +- .../document/components/session/types.ts | 12 + .../document/properties/color-value-input.tsx | 2 +- .../document/properties/icon-value-input.tsx | 9 +- .../properties/multi-select-value-input.tsx | 2 +- .../properties/select-value-input.tsx | 2 +- .../document/public/heading-navigation-api.ts | 5 + .../store/document-editor-view-store.ts | 2 +- .../document/store/document-history-store.ts | 12 +- .../document/use-cases/document-navigation.ts | 66 +- .../use-cases/heading-anchor-publish.ts | 15 +- .../open-document-local-link.test.ts | 32 +- .../use-cases/open-document-local-link.ts | 9 +- .../features/editor/code-file.test.ts | 11 +- app/flowix-web/features/editor/code-file.ts | 128 +- .../drag-context-menu/block-info.test.ts | 4 +- .../drag-context-menu/block-info.ts | 2 +- .../components/drag-context-menu/style.ts | 4 +- .../focus-editor-on-pointer.test.ts | 48 + .../components/focus-editor-on-pointer.ts | 21 + .../editor/components/slash-menu-dropdown.tsx | 26 +- .../extensions/attachment-link/index.ts | 7 +- .../attachment-link/upload/chunked.ts | 2 +- .../attachment-link/upload/commands.ts | 21 +- .../attachment-link/upload/file-source.ts | 1 + .../attachment-link/upload/plugin.ts | 6 +- .../extensions/frontmatter-node-view.ts | 6 +- .../editor/extensions/note-link/index.ts | 7 +- .../extensions/note-link/memo-resolver.ts | 4 +- .../note-reference-preview-controller.ts | 257 +++ .../note-link/note-reference-preview.test.ts | 138 ++ .../note-link/obsidian-link.test.ts | 4 +- .../editor/extensions/note-link/view-note.ts | 761 ++++---- .../note-mention/note-mention-dropdown.tsx | 2 +- .../editor/extensions/paste-rules/index.ts | 13 +- .../extensions/shared/inline-atom-nodeview.ts | 173 ++ .../features/editor/extensions/slash-menu.tsx | 8 +- .../editor/extensions/stable-caret.test.ts | 190 +- .../editor/extensions/stable-caret.ts | 119 +- .../tag-mention/tag-mention-dropdown.tsx | 2 +- .../features/editor/markdown-editor.tsx | 42 +- .../use-external-file-content-detection.ts | 27 + .../features/editor/use-external-file-mime.ts | 27 - .../media-library-filter-panel.tsx | 20 +- .../media-library-photo-wall.tsx | 8 +- .../media-library-reference-node.tsx | 178 +- .../media-library/media-library-view.test.tsx | 88 + .../media-library/media-library-view.tsx | 101 +- .../features/media-library/model.ts | 109 +- .../use-media-library-actions.ts | 9 +- .../memo/components/file-type-icon.tsx | 206 +- .../memo/components/folder-file-tree.tsx | 7 +- .../memo/components/memo-list-data-loader.tsx | 23 +- .../memo-list-navigation-drawer.tsx | 2 +- .../components/memo-list-services-host.tsx | 23 +- .../features/memo/components/memo-list.tsx | 52 +- .../notebook-access-files-list.test.ts | 26 +- .../components/notebook-access-files-list.tsx | 106 +- .../notebook-create-context-menu.tsx | 14 +- .../memo/components/notebook-dialogs.tsx | 8 +- .../components/notebook-file-tree.test.tsx | 6 +- .../memo/components/notebook-file-tree.tsx | 302 ++- .../memo/components/notebook-folder-import.ts | 27 + .../components/notebook-folder-view.test.ts | 46 - .../memo/components/notebook-folder-view.tsx | 127 +- .../memo/components/notebook-icon-menu.tsx | 174 +- .../components/notebook-move-failure-toast.ts | 111 ++ .../notebook-repositories-section.tsx | 62 +- .../components/notebook-tree-file-icon.tsx | 41 +- .../memo/components/notebook-tree-row.tsx | 23 +- .../features/memo/components/tag-tree.tsx | 2 +- .../memo/components/use-folder-tree.test.tsx | 34 + .../memo/components/use-folder-tree.ts | 77 +- .../memo/hooks/create-notebook-flow-state.ts | 5 - .../hooks/use-create-notebook-flow.test.ts | 9 +- .../memo/hooks/use-create-notebook-flow.ts | 109 +- .../features/memo/public/drag-resource-api.ts | 34 + .../features/memo/public/file-icon-api.ts | 2 + .../memo/public/note-navigation-api.ts | 8 + .../features/memo/public/shell-api.ts | 24 +- .../features/memo/public/startup-api.ts | 13 + .../note-link-rename.integration.test.ts | 2 +- .../features/memo/services/note-repository.ts | 4 +- .../memo/use-cases/open-by-target.test.ts | 77 +- .../features/memo/use-cases/open-by-target.ts | 100 +- .../memo/use-cases/open-notebook-note.test.ts | 21 +- .../memo/use-cases/open-notebook-note.ts | 14 +- .../multidimensional-table/model.test.ts | 20 + .../features/multidimensional-table/model.ts | 112 +- .../note-create-picker.tsx | 141 +- .../multidimensional-table/record-filter.ts | 10 +- .../multidimensional-table/table-commands.ts | 4 +- .../table-dataset-menu.tsx | 76 +- .../table-document-view.tsx | 919 ++++++++- .../table-gallery-drop.test.tsx | 63 + .../table-note-drop-overlay.tsx | 46 + .../table-note-drop.test.ts | 78 + .../multidimensional-table/table-note-drop.ts | 54 + .../table-record-row.test.tsx | 12 +- .../table-record-row.tsx | 25 +- .../table-reference-node.tsx | 7 +- .../table-secondary-views.test.tsx | 37 + .../table-secondary-views.tsx | 490 ++++- .../table-tag-value-input.tsx | 2 +- .../table-text-value-input.tsx | 2 +- .../use-table-cell-writer.test.tsx | 5 + .../use-table-cell-writer.ts | 20 +- .../use-table-note-drop-target.ts | 94 + .../use-table-session.test.tsx | 13 + .../use-table-session.ts | 29 +- .../notifications/actionable-notice-host.tsx | 2 +- .../features/onboarding/onboarding-screen.tsx | 2 +- .../plugin/plugin-agent-workbench.tsx | 25 +- .../features/plugin/plugin-document-view.tsx | 47 +- .../features/plugin/plugin-field-control.tsx | 15 +- .../features/plugin/plugin-workbench.tsx | 23 +- .../components/dsh-install-prompt.tsx | 19 +- .../components/settings-tab-group.tsx | 103 + .../hooks/use-apply-font-settings.ts | 16 +- .../hooks/use-cloud-account-state.test.tsx | 36 +- .../hooks/use-cloud-account-state.ts | 19 +- .../preferences/preferences-tab-config.tsx | 129 +- .../features/preferences/preferences-view.tsx | 227 +-- .../preferences/public/runtime-api.ts | 4 + .../features/preferences/sections/agent.tsx | 67 +- .../features/preferences/sections/agents.tsx | 42 +- .../features/preferences/sections/cli.tsx | 2 +- .../sections/cloud-sync.orders.test.tsx | 128 ++ .../preferences/sections/cloud-sync.tsx | 512 +++-- .../features/preferences/sections/dsh.tsx | 121 +- .../sections/environment-config.tsx | 195 ++ .../sections/external-path-row.tsx | 4 +- .../preferences/sections/file-management.tsx | 7 +- .../features/preferences/sections/format.tsx | 16 +- .../features/preferences/sections/general.tsx | 49 +- .../features/preferences/sections/index.ts | 8 +- .../preferences/sections/note-settings.tsx | 86 +- .../preferences/sections/pi-mcp-settings.tsx | 31 +- .../features/preferences/sections/pi.tsx | 165 +- .../features/preferences/sections/plugins.tsx | 49 +- .../preferences/sections/primitives.tsx | 14 +- .../preferences/sections/shortcuts.tsx | 6 +- .../features/preferences/sections/types.ts | 28 +- .../tab-pages/environment-config.tsx | 8 + .../features/preferences/tab-pages/format.tsx | 31 + .../preferences/tab-pages/general.tsx | 21 + .../features/preferences/tab-pages/mcp.tsx | 13 + .../components/browser-column-header.test.tsx | 23 +- .../components/browser-column-header.tsx | 93 +- .../shell/components/browser-column.tsx | 40 +- .../external-file-drop-overlay.tsx | 15 +- .../components/global-search-command.tsx | 10 +- .../features/shell/components/list-column.tsx | 4 +- .../shell/components/main-prompt-host.tsx | 7 +- .../shell/components/main-status-bar-host.tsx | 1 + .../components/notebook-delete-dialog.tsx | 14 +- .../components/prompts/app-update-prompt.tsx | 23 +- .../prompts/navigation-failure-prompt.tsx | 2 +- .../status-bar/product-update-pill.tsx | 96 +- .../components/status-bar/status-bar.tsx | 6 +- .../components/work-column-titlebar-shell.tsx | 33 +- app/flowix-web/features/shell/main-layout.tsx | 223 ++- .../surface/browser-column-registry.test.ts | 36 +- .../surface/browser-column-registry.tsx | 552 ++---- .../surface/document-list-view-model.ts | 10 + .../surface/document-list-view.test.tsx | 30 +- .../features/surface/document-list-view.tsx | 144 +- .../surface/file-browser-view.test.tsx | 28 +- .../features/surface/file-browser-view.tsx | 88 +- .../features/surface/html-resource-view.tsx | 54 +- app/flowix-web/features/surface/index.ts | 13 +- .../markdown-document-surface.test.tsx | 64 + .../surface/markdown-document-surface.tsx | 108 ++ .../surface/media-resource-view.test.tsx | 84 + .../features/surface/media-resource-view.tsx | 180 +- .../features/surface/public/shell-api.ts | 17 + .../surface/registry-transitions.test.tsx | 14 +- .../features/surface/registry.test.ts | 33 +- app/flowix-web/features/surface/registry.tsx | 417 +--- app/flowix-web/features/surface/resolver.ts | 55 +- .../features/surface/resource-file-icon.tsx | 16 + app/flowix-web/features/surface/types.ts | 54 +- .../surface/use-document-list-data.ts | 48 +- .../surface/work-column-surface-adapter.ts | 41 + .../surface/work-file-browser-view.test.tsx | 45 + .../surface/work-file-browser-view.tsx | 32 +- ...orkspace-file-browser-surface-renderer.tsx | 147 ++ .../workspace-surface-lifecycle-bridge.ts | 19 + .../workspace-surface-renderer-contract.ts | 56 + .../workspace-surface-renderer-registry.tsx | 169 ++ .../surface/workspace-surface-renderers.tsx | 264 +++ .../surface/workspace-surface-runtime.ts | 169 ++ .../workspace-surface-state-view.test.tsx | 79 + .../surface/workspace-surface-state-view.tsx | 155 ++ .../workspace-web-surface-renderer.test.tsx | 77 + .../workspace-web-surface-renderer.tsx | 290 +++ .../workspace/model/legacy-folder-list.ts | 21 + .../workspace/model/surface-contract.ts | 98 + .../workspace/model/surface-identity.ts | 63 + .../workspace/model/surface-opening.ts | 47 + .../workspace/model/surface-target.ts | 94 + .../workspace/model/web-navigation.ts | 24 + .../features/workspace/public/app-api.ts | 3 +- .../workspace/public/browser-column-api.ts | 41 +- .../features/workspace/public/host-api.ts | 3 + .../workspace/public/main-window-api.ts | 4 +- .../workspace/public/note-navigation-api.ts | 7 + .../workspace/public/open-surface-api.ts | 7 + .../workspace/public/search-navigation-api.ts | 1 - .../features/workspace/public/shell-api.ts | 29 +- .../features/workspace/public/startup-api.ts | 3 +- .../features/workspace/public/surface-api.ts | 21 + .../workspace/public/surface-lifecycle-api.ts | 8 + .../workspace/store/browser-column-store.ts | 206 +- .../workspace/store/file-browser-target.ts | 20 +- .../store/surface-lifecycle-store.test.ts | 41 + .../store/surface-lifecycle-store.ts | 55 + .../workspace/store/work-column-target.ts | 104 +- .../store/workspace-content-identity.ts | 50 +- .../workspace/store/workspace-focus-store.ts | 7 +- .../store/workspace-restore-store.ts | 7 +- .../features/workspace/surface-registry.ts | 107 ++ .../agent-conversation-navigation.test.ts | 23 +- .../agent-conversation-navigation.ts | 18 +- .../browser-column-collections.test.ts | 27 +- .../browser-column-coordinator.test.ts | 57 + .../use-cases/browser-column-coordinator.ts | 27 +- .../browser-column-navigation.test.ts | 49 +- .../use-cases/browser-column-navigation.ts | 278 +-- .../use-cases/collection-navigation.test.ts | 62 +- .../use-cases/surface-opening.test.ts | 80 + .../workspace/use-cases/surface-opening.ts | 343 ++++ .../use-cases/workspace-content-activation.ts | 77 +- .../use-cases/workspace-host-focus.test.tsx | 55 + .../use-cases/workspace-host-focus.ts | 11 + .../use-cases/workspace-navigation.test.ts | 43 + .../use-cases/workspace-navigation.ts | 441 ++++- app/flowix-web/index.html | 20 - .../lib/agent-access-defaults.test.ts | 46 - app/flowix-web/lib/agent-access-defaults.ts | 131 -- app/flowix-web/lib/agent-types.ts | 91 +- app/flowix-web/lib/constants.ts | 4 +- app/flowix-web/lib/i18n/messages.en-US.ts | 244 ++- app/flowix-web/lib/i18n/messages.zh-CN.ts | 244 ++- app/flowix-web/lib/i18n/provider.tsx | 2 +- app/flowix-web/lib/path.ts | 8 + app/flowix-web/lib/save-barrier.ts | 33 + .../lib/store/workspace-startup-store.ts | 53 + app/flowix-web/lib/types/agent-access.ts | 95 - .../lib/types/agent-workspace.test-support.ts | 40 + app/flowix-web/lib/types/agent-workspace.ts | 52 + app/flowix-web/lib/typography-font-mode.ts | 26 + app/flowix-web/platform/tauri/client/agent.ts | 60 +- app/flowix-web/platform/tauri/client/cloud.ts | 37 +- .../platform/tauri/client/desktop.ts | 9 + .../platform/tauri/client/general.ts | 44 +- app/flowix-web/platform/tauri/client/memos.ts | 8 +- .../platform/tauri/client/updater.ts | 2 + app/flowix-web/platform/tauri/errors.ts | 9 + app/flowix-web/platform/tauri/event-bus.ts | 4 +- .../shared/drag-drop/drag-drop-coordinator.ts | 190 ++ app/flowix-web/shared/error-boundary.tsx | 1 + .../shared/ui/centered-loading-spinner.tsx | 2 +- .../shared/ui/context-menu.focus.test.tsx | 150 ++ app/flowix-web/shared/ui/context-menu.tsx | 152 +- app/flowix-web/shared/ui/dropdown-menu.tsx | 7 +- app/flowix-web/shared/ui/flowix-checkbox.tsx | 61 + .../shared/ui/font-size-switcher.tsx | 39 + app/flowix-web/shared/ui/list-surface.tsx | 2 +- app/flowix-web/shared/ui/popover.tsx | 43 +- app/flowix-web/shared/ui/select.tsx | 22 +- .../shared/ui/typography-font-switcher.tsx | 58 + .../styles/agent-conversation/composer.css | 1701 +++++++++++++++++ .../styles/agent-conversation/titlebar.css | 138 ++ .../background-terminals.css | 183 ++ .../styles/agent-thread-card/chrome.css | 87 +- .../styles/agent-thread-card/composer.css | 1207 ------------ .../styles/agent-thread-card/controls.css | 6 +- .../styles/agent-thread-card/markdown.css | 549 +++--- .../styles/agent-thread-card/messages.css | 416 ++-- .../styles/agent-thread-card/note-picker.css | 231 +-- .../styles/agent-thread-card/popovers.css | 454 ----- app/flowix-web/styles/checkbox.css | 71 + app/flowix-web/styles/code-editor.css | 6 +- .../styles/editor-agent-thread-card.css | 3 +- .../styles/editor-attachment-link.css | 115 +- app/flowix-web/styles/editor-bubble-menus.css | 7 - app/flowix-web/styles/editor-code-block.css | 10 +- app/flowix-web/styles/editor-frontmatter.css | 39 +- app/flowix-web/styles/editor-mermaid.css | 4 +- .../styles/editor-note-reference.css | 98 +- app/flowix-web/styles/editor.css | 498 +++-- app/flowix-web/styles/fonts.css | 2 +- app/flowix-web/styles/index.css | 91 +- .../styles/multidimensional-table.css | 69 +- app/flowix-web/styles/pdf-print.css | 29 +- app/flowix-web/styles/plugin-markmap.css | 2 +- app/flowix-web/types/agent.ts | 79 +- docs/AGENT-DATA.md | 25 + docs/COLLECTION-INDEX.md | 17 +- docs/STARTUP.md | 61 + docs/WORKSPACE-DRAG-DROP.md | 35 + docs/WORKSPACE-PREPARATION-DESIGN.md | 44 + docs/WORKSPACE-SURFACE-ARCHITECTURE.md | 120 ++ dsh-appserver/test/runtime-llm.smoke.mjs | 2 +- dsh-appserver/test/runtime/host.mjs | 6 +- package-lock.json | 4 +- package.json | 2 +- scripts/build-tauri-production.mjs | 5 + scripts/check-layer-boundaries.mjs | 35 + scripts/register-macos-dev-app.mjs | 6 +- scripts/run-macos-dev-app.mjs | 54 + scripts/tauri-dev.mjs | 64 +- 581 files changed, 29495 insertions(+), 14507 deletions(-) create mode 100644 app/flowix-core/src/memo_file/database_layout.rs create mode 100644 app/flowix-core/src/memo_file/local_migrations.rs create mode 100644 app/flowix-core/src/memo_file/storage_repair.rs create mode 100644 app/flowix-core/src/memo_file/workspace.rs delete mode 100644 app/flowix-desktop/src/commands/agent_access.rs create mode 100644 app/flowix-desktop/src/commands/agent_workspace.rs create mode 100644 app/flowix-desktop/src/commands/file/folder_import.rs delete mode 100644 app/flowix-desktop/src/config/access.rs create mode 100644 app/flowix-desktop/src/storage_initialization.rs create mode 100644 app/flowix-sync/build.rs create mode 100644 app/flowix-sync/src/environment.rs delete mode 100644 app/flowix-web/app/use-agent-access-window-sync.ts create mode 100644 app/flowix-web/app/use-agent-workspace-window-sync.ts create mode 100644 app/flowix-web/assets/skill-library.svg create mode 100644 app/flowix-web/features/agent/components/agent-conversation-identity.tsx create mode 100644 app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx create mode 100644 app/flowix-web/features/agent/components/agent-message-font-switcher.tsx create mode 100644 app/flowix-web/features/agent/components/use-agent-conversation-surface.ts create mode 100644 app/flowix-web/features/agent/config/agent-model-options.ts create mode 100644 app/flowix-web/features/agent/services/agent-catalog-service.ts delete mode 100644 app/flowix-web/features/agent/store/agent-access-store.test.ts delete mode 100644 app/flowix-web/features/agent/store/agent-access-store.ts create mode 100644 app/flowix-web/features/agent/store/agent-data-store.ts create mode 100644 app/flowix-web/features/agent/store/agent-message-font-store.ts create mode 100644 app/flowix-web/features/agent/store/agent-user-preferences.ts create mode 100644 app/flowix-web/features/agent/store/agent-workspace-slice.test.ts create mode 100644 app/flowix-web/features/agent/store/agent-workspace-slice.ts create mode 100644 app/flowix-web/features/agent/store/chat-stream.pi-mode.test.ts delete mode 100644 app/flowix-web/features/agent/store/global-agent-settings-sync.test.ts delete mode 100644 app/flowix-web/features/agent/store/global-agent-settings-sync.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-compat.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.test.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-controller.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-document.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-node-view.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-preview.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.test.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-token.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-attachment-types.ts delete mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-image-controller.test.ts delete mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-image-controller.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-inline-atom-caret.ts create mode 100644 app/flowix-web/features/agent/thread-card/composer/composer-insertion-anchor.ts create mode 100644 app/flowix-web/features/collection/dataset-filter-location.test.ts create mode 100644 app/flowix-web/features/collection/dataset-filter-location.ts create mode 100644 app/flowix-web/features/collection/recovery.test.ts create mode 100644 app/flowix-web/features/collection/recovery.ts create mode 100644 app/flowix-web/features/content/components/content-icon.tsx create mode 100644 app/flowix-web/features/content/components/note-content-icon.tsx create mode 100644 app/flowix-web/features/content/model/content-kind.ts create mode 100644 app/flowix-web/features/content/public/content-api.ts create mode 100644 app/flowix-web/features/document/components/csv-preview.test.tsx create mode 100644 app/flowix-web/features/document/public/heading-navigation-api.ts create mode 100644 app/flowix-web/features/editor/components/focus-editor-on-pointer.test.ts create mode 100644 app/flowix-web/features/editor/components/focus-editor-on-pointer.ts create mode 100644 app/flowix-web/features/editor/extensions/note-link/note-reference-preview-controller.ts create mode 100644 app/flowix-web/features/editor/extensions/note-link/note-reference-preview.test.ts create mode 100644 app/flowix-web/features/editor/extensions/shared/inline-atom-nodeview.ts create mode 100644 app/flowix-web/features/editor/use-external-file-content-detection.ts delete mode 100644 app/flowix-web/features/editor/use-external-file-mime.ts create mode 100644 app/flowix-web/features/media-library/media-library-view.test.tsx create mode 100644 app/flowix-web/features/memo/components/notebook-folder-import.ts delete mode 100644 app/flowix-web/features/memo/components/notebook-folder-view.test.ts create mode 100644 app/flowix-web/features/memo/components/notebook-move-failure-toast.ts create mode 100644 app/flowix-web/features/memo/public/drag-resource-api.ts create mode 100644 app/flowix-web/features/memo/public/file-icon-api.ts create mode 100644 app/flowix-web/features/memo/public/note-navigation-api.ts create mode 100644 app/flowix-web/features/memo/public/startup-api.ts create mode 100644 app/flowix-web/features/multidimensional-table/table-gallery-drop.test.tsx create mode 100644 app/flowix-web/features/multidimensional-table/table-note-drop-overlay.tsx create mode 100644 app/flowix-web/features/multidimensional-table/table-note-drop.test.ts create mode 100644 app/flowix-web/features/multidimensional-table/table-note-drop.ts create mode 100644 app/flowix-web/features/multidimensional-table/use-table-note-drop-target.ts create mode 100644 app/flowix-web/features/preferences/components/settings-tab-group.tsx create mode 100644 app/flowix-web/features/preferences/sections/cloud-sync.orders.test.tsx create mode 100644 app/flowix-web/features/preferences/sections/environment-config.tsx create mode 100644 app/flowix-web/features/preferences/tab-pages/environment-config.tsx create mode 100644 app/flowix-web/features/preferences/tab-pages/format.tsx create mode 100644 app/flowix-web/features/preferences/tab-pages/general.tsx create mode 100644 app/flowix-web/features/preferences/tab-pages/mcp.tsx create mode 100644 app/flowix-web/features/surface/document-list-view-model.ts create mode 100644 app/flowix-web/features/surface/markdown-document-surface.test.tsx create mode 100644 app/flowix-web/features/surface/markdown-document-surface.tsx create mode 100644 app/flowix-web/features/surface/media-resource-view.test.tsx create mode 100644 app/flowix-web/features/surface/work-column-surface-adapter.ts create mode 100644 app/flowix-web/features/surface/work-file-browser-view.test.tsx create mode 100644 app/flowix-web/features/surface/workspace-file-browser-surface-renderer.tsx create mode 100644 app/flowix-web/features/surface/workspace-surface-lifecycle-bridge.ts create mode 100644 app/flowix-web/features/surface/workspace-surface-renderer-contract.ts create mode 100644 app/flowix-web/features/surface/workspace-surface-renderer-registry.tsx create mode 100644 app/flowix-web/features/surface/workspace-surface-renderers.tsx create mode 100644 app/flowix-web/features/surface/workspace-surface-runtime.ts create mode 100644 app/flowix-web/features/surface/workspace-surface-state-view.test.tsx create mode 100644 app/flowix-web/features/surface/workspace-surface-state-view.tsx create mode 100644 app/flowix-web/features/surface/workspace-web-surface-renderer.test.tsx create mode 100644 app/flowix-web/features/surface/workspace-web-surface-renderer.tsx create mode 100644 app/flowix-web/features/workspace/model/legacy-folder-list.ts create mode 100644 app/flowix-web/features/workspace/model/surface-contract.ts create mode 100644 app/flowix-web/features/workspace/model/surface-identity.ts create mode 100644 app/flowix-web/features/workspace/model/surface-opening.ts create mode 100644 app/flowix-web/features/workspace/model/surface-target.ts create mode 100644 app/flowix-web/features/workspace/model/web-navigation.ts create mode 100644 app/flowix-web/features/workspace/public/host-api.ts create mode 100644 app/flowix-web/features/workspace/public/note-navigation-api.ts create mode 100644 app/flowix-web/features/workspace/public/open-surface-api.ts create mode 100644 app/flowix-web/features/workspace/public/surface-api.ts create mode 100644 app/flowix-web/features/workspace/public/surface-lifecycle-api.ts create mode 100644 app/flowix-web/features/workspace/store/surface-lifecycle-store.test.ts create mode 100644 app/flowix-web/features/workspace/store/surface-lifecycle-store.ts create mode 100644 app/flowix-web/features/workspace/surface-registry.ts create mode 100644 app/flowix-web/features/workspace/use-cases/browser-column-coordinator.test.ts create mode 100644 app/flowix-web/features/workspace/use-cases/surface-opening.test.ts create mode 100644 app/flowix-web/features/workspace/use-cases/surface-opening.ts create mode 100644 app/flowix-web/features/workspace/use-cases/workspace-host-focus.test.tsx create mode 100644 app/flowix-web/features/workspace/use-cases/workspace-host-focus.ts delete mode 100644 app/flowix-web/lib/agent-access-defaults.test.ts delete mode 100644 app/flowix-web/lib/agent-access-defaults.ts create mode 100644 app/flowix-web/lib/save-barrier.ts create mode 100644 app/flowix-web/lib/store/workspace-startup-store.ts delete mode 100644 app/flowix-web/lib/types/agent-access.ts create mode 100644 app/flowix-web/lib/types/agent-workspace.test-support.ts create mode 100644 app/flowix-web/lib/types/agent-workspace.ts create mode 100644 app/flowix-web/lib/typography-font-mode.ts create mode 100644 app/flowix-web/shared/drag-drop/drag-drop-coordinator.ts create mode 100644 app/flowix-web/shared/ui/context-menu.focus.test.tsx create mode 100644 app/flowix-web/shared/ui/flowix-checkbox.tsx create mode 100644 app/flowix-web/shared/ui/font-size-switcher.tsx create mode 100644 app/flowix-web/shared/ui/typography-font-switcher.tsx create mode 100644 app/flowix-web/styles/agent-conversation/composer.css create mode 100644 app/flowix-web/styles/agent-conversation/titlebar.css create mode 100644 app/flowix-web/styles/agent-thread-card/background-terminals.css delete mode 100644 app/flowix-web/styles/agent-thread-card/composer.css delete mode 100644 app/flowix-web/styles/agent-thread-card/popovers.css create mode 100644 app/flowix-web/styles/checkbox.css create mode 100644 docs/AGENT-DATA.md create mode 100644 docs/STARTUP.md create mode 100644 docs/WORKSPACE-DRAG-DROP.md create mode 100644 docs/WORKSPACE-PREPARATION-DESIGN.md create mode 100644 docs/WORKSPACE-SURFACE-ARCHITECTURE.md create mode 100755 scripts/run-macos-dev-app.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 8ccafabc6..c817d2458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Flowix 是一款桌面笔记应用(**Tauri 2 + Rust 后端,React 19 + TS + T ```bash export PATH="$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" -npm run tauri:dev # 推荐:独立 dev bundle ID (com.flowix.app.dev / "Flowix Dev"),可与生产 app 并存 +npm run tauri:dev # 推荐:独立 dev bundle ID (com.flowix-dev.app / "Flowix Dev"),可与生产 app 并存 npm run tauri:dev:win # Windows 开发启动:使用 app/flowix-desktop/tauri.windows.dev.conf.json npm run tauri dev # ⚠️ 走默认 tauri.conf.json,与生产同 bundle ID (com.flowix.app),已被生产占住时会立刻 exit 0 npm run dev # 仅前端 (localhost:1420) @@ -35,12 +35,12 @@ cargo test --workspace --lib # 跑全部 通过差异化 Tauri 配置,让 dev 版与已安装的生产版同时运行: -- **dev**:`npm run tauri:dev` → `app/flowix-desktop/tauri.conf.dev.json` → bundle ID `com.flowix.app.dev` / `Flowix Dev` +- **dev**:`npm run tauri:dev` → `app/flowix-desktop/tauri.conf.dev.json` → bundle ID `com.flowix-dev.app` / `Flowix Dev` - **生产**:`npm run tauri:build:prod` → `tauri.conf.json` + 平台覆盖层 + 签名覆盖层 → 平台专用 `tauri.*.production.local.json` → bundle ID `com.flowix.app` / `Flowix` `tauri:dev` 通过 `--config` 指向独立配置,**不要**改 `tauri.conf.json` 的 `identifier` / `productName` / `mainBinaryName` / `bundle.macOS.bundleName` —— 这四个字段是生产身份的锚点。`tauri.conf.production.json` 作为覆盖层被 `tauri build --config` 深合并在 `tauri.conf.json` 之上,因此 dev 配置改动不会污染生产链路。 -dev 与生产现使用不同 bundle ID(`com.flowix.app.dev` vs `com.flowix.app`),可同时运行且互不冲突(Tauri `app_data_dir` / `tauri-plugin-single-instance` lock 都按 identifier 派生)。代价:dev 首次运行需要重新授予一次 user-selected folder 授权(TCC 按 identifier 记忆授权),prod 已授予的不会带过来。视觉上仍通过 bundle name / 窗口标题区分(`Flowix Dev` vs `Flowix`)。URL scheme `flowix://` 仍共用,让浏览器深链能落到任一已装实例。 +dev 与生产现使用不同 bundle ID(`com.flowix-dev.app` vs `com.flowix.app`),可同时运行且互不冲突(Tauri `app_data_dir` / `tauri-plugin-single-instance` lock 都按 identifier 派生)。代价:dev 首次运行需要重新授予一次 user-selected folder 授权(TCC 按 identifier 记忆授权),prod 已授予的不会带过来。视觉上仍通过 bundle name / 窗口标题区分(`Flowix Dev` vs `Flowix`)。Dev 使用独立 URL scheme `flowix-dev://`,以便浏览器回调明确交给 Dev 实例。 ### macOS 本地生产包 ad-hoc 签名 diff --git a/README.md b/README.md index 3b623c6c8..2e38d5dbe 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,21 @@ git clone https://github.com/text2future/flowix.git cd flowix npm install -npm run tauri dev -npm run dev -npm run tauri build +npm run tauri:dev +npm run tauri:build:prod ``` +Run `npm run dev` separately only when you want the Vite frontend without the Tauri desktop backend. + +The desktop dev build (`npm run tauri:dev`) connects to `https://cloud.flowix.cc` by default. In Preferences → Development → Environment configuration, choose Staging or Prod; Flowix restarts to apply the change. The saved preference is authoritative and takes precedence over the build default. `--cloud-env=staging|prod` sets that default and is used until a preference is saved. The environments keep separate sign-in credentials and sync databases. + +```bash +npm run tauri:dev -- --cloud-env=staging +npm run tauri:dev -- --cloud-env=prod +``` + +`npm run tauri:dev:pi` and `npm run tauri:dev:win` accept the same option. Production packages built with `npm run tauri:build:prod` always connect to `https://cloud.flowix.cc`, and do not show the environment selector. Running `npm run dev` alone starts only the Vite frontend; it does not start desktop Cloud sync. + The development environment requires Node.js 20+, Rust 1.75+ and Tauri v2; the desktop app supports macOS 14+ and Windows 10+. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 9ab913255..b931c389c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,11 +121,21 @@ git clone https://github.com/text2future/flowix.git cd flowix npm install -npm run tauri dev -npm run dev -npm run tauri build +npm run tauri:dev +npm run tauri:build:prod ``` +单独运行 `npm run dev` 只启动 Vite 前端,不会启动 Tauri 桌面后端。 + +桌面开发版 `npm run tauri:dev` 默认连接 `https://cloud.flowix.cc`。在「偏好设置 → 开发 → 环境配置」中选择 Staging 或 Prod,Flowix 会重启以应用切换。首次启动默认使用 Prod;已保存的界面选择优先于构建默认值;`--cloud-env=staging|prod` 设置默认值,仅在尚未保存界面选择时生效: + +```bash +npm run tauri:dev -- --cloud-env=staging +npm run tauri:dev -- --cloud-env=prod +``` + +`npm run tauri:dev:pi` 和 `npm run tauri:dev:win` 也接受该参数。正式包 `npm run tauri:build:prod` 始终连接 `https://cloud.flowix.cc`,不会显示环境下拉。单独运行 `npm run dev` 只启动 Vite 前端,不会启动桌面端 Cloud 同步。 + 开发环境要求 Node.js 20+、Rust 1.75+ 与 Tauri v2;桌面应用支持 macOS 14+ 与 Windows 10+。 ## 许可协议 diff --git a/app/Cargo.lock b/app/Cargo.lock index 738c819b0..52645ee79 100644 --- a/app/Cargo.lock +++ b/app/Cargo.lock @@ -1222,7 +1222,7 @@ dependencies = [ [[package]] name = "flowix-cli" -version = "1.5.6" +version = "1.5.7" dependencies = [ "chrono", "clap", @@ -1242,7 +1242,7 @@ dependencies = [ [[package]] name = "flowix-core" -version = "1.5.6" +version = "1.5.7" dependencies = [ "chrono", "diffy", @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "flowix-desktop" -version = "1.5.6" +version = "1.5.7" dependencies = [ "async-trait", "base64 0.22.1", @@ -1287,7 +1287,6 @@ dependencies = [ "image", "libc", "machine-uid", - "mime_guess", "minisign-verify", "nanoid", "notify", @@ -1330,7 +1329,7 @@ dependencies = [ [[package]] name = "flowix-installer-helper" -version = "1.5.6" +version = "1.5.7" dependencies = [ "dunce", "windows", @@ -1338,7 +1337,7 @@ dependencies = [ [[package]] name = "flowix-plugin-runtime" -version = "1.5.6" +version = "1.5.7" dependencies = [ "chrono", "flowix-core", @@ -1353,7 +1352,7 @@ dependencies = [ [[package]] name = "flowix-sync" -version = "1.5.6" +version = "1.5.7" dependencies = [ "chrono", "flowix-core", @@ -2650,16 +2649,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "minisign-verify" version = "0.2.5" @@ -5447,12 +5436,6 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicase" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/app/Cargo.toml b/app/Cargo.toml index c2da9dd36..dbb9a3b19 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -11,7 +11,7 @@ members = [ # 锁定共用依赖到同一版本 ── 避免 backend / cli 走不一致版本。 [workspace.package] -version = "1.5.6" +version = "1.5.7" edition = "2021" # 锁定共用依赖到同一版本 ── 避免 backend / cli 走不一致版本。 @@ -25,7 +25,6 @@ serde_yaml = "0.9" toml = "0.8" tauri-plugin-process = "2" tauri-plugin-updater = "2" -tauri-plugin-opener = "2" chrono = { version = "0.4", features = ["serde"] } dirs = "5.0" walkdir = "2.4" diff --git a/app/flowix-cli/Cargo.toml b/app/flowix-cli/Cargo.toml index 7b6d20a21..bb5c88b46 100644 --- a/app/flowix-cli/Cargo.toml +++ b/app/flowix-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "flowix-cli" -version = "1.5.6" +version = "1.5.7" description = "Flowix 命令行工具 ── 共享同一份 memo_file 存储, 跟桌面端 watcher 双向同步" authors = ["Flowix Contributors"] edition.workspace = true diff --git a/app/flowix-cli/src/paths.rs b/app/flowix-cli/src/paths.rs index 3b9fb1a1d..e2d0536f6 100644 --- a/app/flowix-cli/src/paths.rs +++ b/app/flowix-cli/src/paths.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use crate::errors::CliError; -/// 用户配置目录名 (~/./ 下放 index.db / boot/preference.json / +/// 用户配置目录名 (~/./ 下放 db/local.db / boot/preference.json / /// agent-config.toml / boot/system.json 等)。 pub const USER_CONFIG_DIR_NAME: &str = ".flowix"; @@ -26,7 +26,7 @@ pub struct Resolved { /// 或 `$XDG_DATA_HOME/flowix` (Linux) #[allow(dead_code)] pub app_data: PathBuf, - /// `~/.flowix/`. 笔记本注册表就存在这里 (`/index.db`). + /// `~/.flowix/`. 笔记本注册表位于 `/db/local.db`. #[allow(dead_code)] pub config_dir: PathBuf, } diff --git a/app/flowix-cli/src/store.rs b/app/flowix-cli/src/store.rs index 4e8794d3b..8fed676cf 100644 --- a/app/flowix-cli/src/store.rs +++ b/app/flowix-cli/src/store.rs @@ -1,6 +1,9 @@ //! Notebook configuration shared by the path-addressed CLI and MCP commands. use crate::{errors::CliError, fmt, paths}; -use flowix_core::{memo_file::{MemoFile, NotebookConfig}, MemoService, NoteService}; +use flowix_core::{ + memo_file::{MemoFile, NotebookConfig}, + MemoService, NoteService, +}; use std::collections::HashMap; pub fn open() -> Result { @@ -15,7 +18,9 @@ pub(crate) fn notebooks_list_data() -> Result<(Vec, Option Result, CliError> { +pub(crate) fn notebook_note_counts( + configs: &[NotebookConfig], +) -> Result, CliError> { let mf = open()?; let mut counts = HashMap::new(); for config in configs { @@ -24,20 +29,35 @@ pub(crate) fn notebook_note_counts(configs: &[NotebookConfig]) -> Result Result, CliError> { +pub(crate) fn notebook_tag_counts( + configs: &[NotebookConfig], +) -> Result, CliError> { let mf = open()?; - Ok(configs.iter().map(|config| { - (config.id.clone(), mf.read_notebook_tag_paths(Some(&config.id)).unwrap_or_default().len()) - }).collect()) + Ok(configs + .iter() + .map(|config| { + ( + config.id.clone(), + mf.read_notebook_tag_paths(Some(&config.id)) + .unwrap_or_default() + .len(), + ) + }) + .collect()) } pub fn find_notebook<'a>(configs: &'a [NotebookConfig], key: &str) -> Option<&'a NotebookConfig> { - configs.iter().find(|config| config.id == key) + configs + .iter() + .find(|config| config.id == key) .or_else(|| configs.iter().find(|config| config.name == key)) } @@ -46,9 +66,14 @@ pub(crate) fn resolve_notebook_key(notebook: Option<&str>) -> Result) -> Result Result<(), CliError> { let (configs, selected) = notebooks_list_data()?; - fmt::print_notebooks_json(&configs, ¬ebook_note_counts(&configs)?, - ¬ebook_tag_counts(&configs)?, selected.as_deref()); + fmt::print_notebooks_json( + &configs, + ¬ebook_note_counts(&configs)?, + ¬ebook_tag_counts(&configs)?, + selected.as_deref(), + ); Ok(()) } pub fn cmd_notebooks() -> Result<(), CliError> { let (configs, selected) = notebooks_list_data()?; - fmt::print_notebooks(&configs, ¬ebook_note_counts(&configs)?, - ¬ebook_tag_counts(&configs)?, selected.as_deref()); + fmt::print_notebooks( + &configs, + ¬ebook_note_counts(&configs)?, + ¬ebook_tag_counts(&configs)?, + selected.as_deref(), + ); Ok(()) } pub fn cmd_tags(notebook: Option<&str>, json: bool) -> Result<(), CliError> { let payload = notebook_tags(notebook)?; - if json { println!("{}", serde_json::to_string_pretty(&payload).map_err(|error| CliError::Other(error.to_string()))?); } - else if let Some(tags) = payload["tags"].as_array() { - for tag in tags { if let Some(name) = tag.as_str() { println!("{name}"); } } + if json { + println!( + "{}", + serde_json::to_string_pretty(&payload) + .map_err(|error| CliError::Other(error.to_string()))? + ); + } else if let Some(tags) = payload["tags"].as_array() { + for tag in tags { + if let Some(name) = tag.as_str() { + println!("{name}"); + } + } } Ok(()) } @@ -90,9 +134,24 @@ pub fn cmd_completion(shell: &str) -> Result<(), CliError> { let mut command = crate::cli::cli_command(); let mut output = std::io::stdout(); match shell { - "bash" => clap_complete::generate(clap_complete::shells::Bash, &mut command, "flowix", &mut output), - "zsh" => clap_complete::generate(clap_complete::shells::Zsh, &mut command, "flowix", &mut output), - "fish" => clap_complete::generate(clap_complete::shells::Fish, &mut command, "flowix", &mut output), + "bash" => clap_complete::generate( + clap_complete::shells::Bash, + &mut command, + "flowix", + &mut output, + ), + "zsh" => clap_complete::generate( + clap_complete::shells::Zsh, + &mut command, + "flowix", + &mut output, + ), + "fish" => clap_complete::generate( + clap_complete::shells::Fish, + &mut command, + "flowix", + &mut output, + ), other => return Err(CliError::Usage(format!("unknown shell: {other}"))), } Ok(()) diff --git a/app/flowix-core/src/collection.rs b/app/flowix-core/src/collection.rs index 1401693e1..1b89b70db 100644 --- a/app/flowix-core/src/collection.rs +++ b/app/flowix-core/src/collection.rs @@ -304,7 +304,15 @@ impl CollectionDocument { changed = true; } } - if let Some(filter) = dataset.get_mut("filter") { changed |= rules(filter, previous, next); } + if let Some(filter) = dataset.get_mut("filter") { + if let Some(path) = filter.get("path").and_then(Value::as_str) { + if let Some(path) = rebase(path, previous, next) { + filter["path"] = Value::String(path); + changed = true; + } + } + changed |= rules(filter, previous, next); + } if let Some(views) = self.payload.get_mut("views").and_then(|views| views.get_mut("items")).and_then(Value::as_array_mut) { for view in views { if let Some(filter) = view.get_mut("filter") { changed |= rules(filter, previous, next); } @@ -506,7 +514,7 @@ fn validate_collection_v2(document: &CollectionDocument) -> Result<(), String> { } if let Some(filter) = dataset.get("filter") { if !filter.is_null() { - validate_rule_filter(filter, document.collection.kind)?; + validate_dataset_filter(filter, document.collection.kind)?; } } let views = payload.get("views").ok_or("MISSING_COLLECTION_VIEWS")?; @@ -582,7 +590,31 @@ fn validate_collection_v2(document: &CollectionDocument) -> Result<(), String> { Ok(()) } fn validate_rule_filter(filter: &Value, collection_type: CollectionType) -> Result<(), String> { - validate_rule_filter_depth(filter, collection_type, 0) + validate_rule_filter_depth(filter, collection_type, 0) +} +fn validate_dataset_filter(filter: &Value, collection_type: CollectionType) -> Result<(), String> { + if filter.get("conditions").is_none() && filter.get("match").is_none() { + return validate_rule_filter(filter, collection_type); + } + only_keys(filter, &["path", "match", "conditions"])?; + match filter.get("path") { + Some(Value::Null) => {} + Some(Value::String(path)) if path.is_empty() => {} + Some(Value::String(path)) => validate_relative_resource_path(Some(path), "filter.path")?, + _ => return Err("INVALID_COLLECTION_FILTER_PATH".into()), + } + if !matches!(filter["match"].as_str(), Some("all" | "any")) { + return Err("INVALID_COLLECTION_FILTER_MATCH".into()); + } + if collection_type == CollectionType::MediaLibrary && filter["match"] != "all" { + return Err("INVALID_COLLECTION_FILTER_MATCH".into()); + } + for rule in array(filter, "conditions")? { + if rule.get("all").is_some() || rule.get("any").is_some() || rule["field"] == "path" { + return Err("INVALID_COLLECTION_FILTER_CONDITION".into()); + } + } + validate_rule_filter(&serde_json::json!({"all":array(filter, "conditions")?,"any":[]}), collection_type) } fn validate_rule_filter_depth(filter: &Value, collection_type: CollectionType, depth: usize) -> Result<(), String> { only_keys(filter, &["all", "any"])?; diff --git a/app/flowix-core/src/memo_file/database_layout.rs b/app/flowix-core/src/memo_file/database_layout.rs new file mode 100644 index 000000000..d0c217787 --- /dev/null +++ b/app/flowix-core/src/memo_file/database_layout.rs @@ -0,0 +1,103 @@ +//! User-level SQLite database layout helpers. +//! +//! Databases used to live directly under `~/.flowix/`. They now live under +//! `~/.flowix/db/`. Publish a complete SQLite snapshot under a layout lock; +//! interruption leaves the original database and its WAL together for retry. + +use std::fs::{self, OpenOptions}; +use std::io; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; + +pub const DATABASE_DIRECTORY_NAME: &str = "db"; + +/// Create `db/` and snapshot existing named databases from the config root. +/// The source is retained with its sidecars. An existing canonical database +/// wins, leaving the complete legacy copy untouched. +pub fn prepare_database_directory( + config_dir: &Path, + database_names: &[&str], +) -> io::Result { + super::ensure_storage_directory(config_dir)?; + let lock_path = config_dir.join(".database-layout.lock"); + if fs::symlink_metadata(&lock_path).is_ok_and(|metadata| !metadata.file_type().is_file()) { + super::backup_storage_file(&lock_path)?; + } + let lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(lock_path)?; + FileExt::lock_exclusive(&lock)?; + + let result = (|| { + let database_dir = config_dir.join(DATABASE_DIRECTORY_NAME); + super::ensure_storage_directory(&database_dir)?; + for name in database_names { + validate_database_name(name)?; + move_database_with_sidecars(config_dir, &database_dir, name)?; + } + Ok(database_dir) + })(); + + let unlock_result = FileExt::unlock(&lock); + match (result, unlock_result) { + (Ok(path), Ok(())) => Ok(path), + (Err(error), _) => Err(error), + (Ok(_), Err(error)) => Err(error), + } +} + +fn validate_database_name(name: &str) -> io::Result<()> { + let path = Path::new(name); + if name.is_empty() + || path.components().count() != 1 + || !path.file_name().is_some_and(|n| n == name) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid database filename: {name}"), + )); + } + Ok(()) +} + +fn move_database_with_sidecars(root: &Path, database_dir: &Path, name: &str) -> io::Result<()> { + let source = root.join(name); + let destination = database_dir.join(name); + + // The canonical database wins as a complete group. Never attach sidecars + // from an older copy, even when the two main files happen to be identical. + if fs::symlink_metadata(&destination).is_ok() { + return Ok(()); + } + // Orphan WAL files cannot belong to a new main database. + super::backup_database(&destination)?; + if !source.is_file() { + return Ok(()); + } + // VACUUM INTO includes committed WAL contents in a standalone database. + // Only publish after SQLite has closed the snapshot. Never split the + // source main file from its WAL, even if the process exits mid-migration. + let temporary = database_dir.join(format!(".{name}.{}.tmp", uuid::Uuid::new_v4())); + let snapshot = (|| -> io::Result<()> { + let conn = rusqlite::Connection::open_with_flags( + &source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ).map_err(io::Error::other)?; + conn.execute("VACUUM INTO ?1", [temporary.to_string_lossy().as_ref()]) + .map_err(io::Error::other)?; + drop(conn); + fs::File::open(&temporary)?.sync_all()?; + fs::rename(&temporary, &destination) + })(); + if let Err(error) = snapshot { + let _ = fs::remove_file(&temporary); + if super::is_rebuildable_storage_error(&error) { + super::backup_database(&source)?; + return Ok(()); // The normal initializer creates an empty canonical DB. + } + return Err(error); + } + Ok(()) +} diff --git a/app/flowix-core/src/memo_file/file_management.rs b/app/flowix-core/src/memo_file/file_management.rs index ce6b26071..dc77028cb 100644 --- a/app/flowix-core/src/memo_file/file_management.rs +++ b/app/flowix-core/src/memo_file/file_management.rs @@ -17,35 +17,9 @@ pub struct FileManagementPolicy { /// the Markdown note index. #[serde(default)] pub excluded_index_paths: Vec, - #[serde(default)] - pub legacy_skip_dirs: Vec, - #[serde(default)] - pub legacy_skip_files: Vec, - #[serde(default)] - pub legacy_watcher_migrated: bool, -} - -fn wildcard_match(pattern: &str, name: &str) -> bool { - let (pattern, name) = (pattern.as_bytes(), name.as_bytes()); - let (mut p, mut n, mut star, mut retry) = (0, 0, None, 0); - while n < name.len() { - if p < pattern.len() && pattern[p] == name[n] { p += 1; n += 1; } - else if p < pattern.len() && pattern[p] == b'*' { star = Some(p); p += 1; retry = n; } - else if let Some(position) = star { p = position + 1; retry += 1; n = retry; } - else { return false; } - } - while p < pattern.len() && pattern[p] == b'*' { p += 1; } - p == pattern.len() } impl FileManagementPolicy { - pub fn matches_legacy_entry(&self, absolute: &Path) -> bool { - let Some(name) = absolute.file_name().map(|name| name.to_string_lossy()) else { return false; }; - self.legacy_skip_dirs.iter().any(|skip| skip == name.as_ref()) - || std::fs::symlink_metadata(absolute).is_ok_and(|metadata| metadata.is_file()) - && self.legacy_skip_files.iter().any(|pattern| wildcard_match(pattern, &name)) - } - pub fn is_locked_name(name: &str) -> bool { matches!(name, ".flowix" | ".plugin-output") || name.eq_ignore_ascii_case(".index.yaml") } @@ -89,7 +63,6 @@ impl FileManagementPolicy { let ignored_name = notebook_agents_file || name.starts_with('.') || matches!(name.as_ref(), "attachments" | "attachments-cache" | "node_modules" | "bower_components" | "__pycache__") - || self.legacy_skip_dirs.iter().any(|skip| skip == name.as_ref()) || legacy_media_sidecar || (is_last && (matches!(name.as_ref(), "Thumbs.db" | "ehthumbs.db" | "desktop.ini") || name.ends_with(".tmp") || name.ends_with(".swp") || name.ends_with(".bak") @@ -105,14 +78,6 @@ impl FileManagementPolicy { /// as names. The relative path is always scoped to `root`. pub fn is_ignored_at(&self, root: &Path, relative: &Path) -> bool { if self.is_ignored(relative) { return true; } - let key = relative.to_string_lossy().replace('\\', "/"); - let file = root.join(relative); - if !self.included_paths.iter().any(|included| included == &key) - && std::fs::symlink_metadata(&file).is_ok_and(|metadata| metadata.is_file()) - { - let name = relative.file_name().unwrap_or_default().to_string_lossy(); - if self.legacy_skip_files.iter().any(|pattern| wildcard_match(pattern, &name)) { return true; } - } let mut current = root.to_path_buf(); let mut key = String::new(); for component in relative.components() { @@ -144,8 +109,7 @@ impl FileManagementPolicy { && relative.file_name().is_some_and(|name| name.to_string_lossy() == "AGENTS.md"); let default_policy = FileManagementPolicy::default(); if !is_root_agents - && (default_policy.is_ignored_at(root, relative) - || self.matches_legacy_entry(&root.join(relative))) + && default_policy.is_ignored_at(root, relative) { return true; } diff --git a/app/flowix-core/src/memo_file/index_store/schema.rs b/app/flowix-core/src/memo_file/index_store/schema.rs index e33fc28ec..2de1978d7 100644 --- a/app/flowix-core/src/memo_file/index_store/schema.rs +++ b/app/flowix-core/src/memo_file/index_store/schema.rs @@ -3,19 +3,6 @@ use std::sync::{LazyLock, Mutex}; static MEMO_RELATIVE_PATH_MIGRATION_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -/// Legacy global indexes predate several optional tables and columns. -/// Importing from an absent source is a no-op, not an error; anything else -/// still fails. -fn tolerate_missing_legacy_table(error: std::io::Error) -> std::io::Result { - if error.to_string().contains("no such table") - || error.to_string().contains("no such column") - { - Ok(0) - } else { - Err(error) - } -} - impl MemoFile { pub fn storage_title_from_filename(filename: &str) -> String { let stem = filename.strip_suffix(".md").unwrap_or(filename).to_string(); @@ -52,7 +39,7 @@ impl MemoFile { .unwrap_or_else(|| self.get_default_notebook_path()) } - fn ensure_memo_tables(&self, conn: &Connection) -> std::io::Result<()> { + fn ensure_memo_tables(&self, conn: &Connection, run_legacy_migrations: bool) -> std::io::Result<()> { conn.execute_batch( r#" PRAGMA foreign_keys = ON; @@ -197,37 +184,51 @@ impl MemoFile { "#, ) .map_err(sqlite_to_io)?; - self.migrate_memo_relative_paths(conn)?; - self.migrate_memo_todo_ids(conn)?; - self.migrate_memo_lifecycles(conn)?; - conn.execute_batch( - r#" - INSERT OR IGNORE INTO notebook_tags - (notebook_id, path, created_at, updated_at) - SELECT DISTINCT - m.notebook_id, - mt.tag, - CAST(strftime('%s', 'now') AS INTEGER) * 1000, - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - FROM memo_tags mt - JOIN memos m ON m.id = mt.memo_id - WHERE NOT EXISTS ( - SELECT 1 - FROM schema_migrations - WHERE migration_key = 'notebook_tags_v1' - ); + if run_legacy_migrations { + self.migrate_memo_relative_paths(conn)?; + self.migrate_memo_todo_ids(conn)?; + self.migrate_memo_lifecycles(conn)?; + conn.execute_batch( + r#" + INSERT OR IGNORE INTO notebook_tags + (notebook_id, path, created_at, updated_at) + SELECT DISTINCT + m.notebook_id, + mt.tag, + CAST(strftime('%s', 'now') AS INTEGER) * 1000, + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + FROM memo_tags mt + JOIN memos m ON m.id = mt.memo_id + WHERE NOT EXISTS ( + SELECT 1 + FROM schema_migrations + WHERE migration_key = 'notebook_tags_v1' + ); - INSERT OR IGNORE INTO schema_migrations (migration_key, completed_at) - VALUES ( - 'notebook_tags_v1', - CAST(strftime('%s', 'now') AS INTEGER) * 1000 - ); - "#, - ) - .map_err(sqlite_to_io)?; + INSERT OR IGNORE INTO schema_migrations (migration_key, completed_at) + VALUES ( + 'notebook_tags_v1', + CAST(strftime('%s', 'now') AS INTEGER) * 1000 + ); + "#, + ) + .map_err(sqlite_to_io)?; + } Ok(()) } + /// Upgrade an existing memo index during version initialization. Ordinary + /// database access creates the current schema but never rewrites legacy rows. + pub fn initialize_memo_index_schema(&self, notebook_id: &str) -> std::io::Result<()> { + let path = self.notebook_db_path(notebook_id)?; + super::super::with_database_rebuild(&path, || { + let conn = self.open_notebook_db(notebook_id)?; + self.ensure_memo_tables(&conn, true)?; + self.ensure_local_notebook_row(&conn, notebook_id)?; + Ok(()) + }) + } + /// Upgrade the v3 index where filename was the notebook-relative /// location. Keep filename as the basename and make relative_path the /// durable location identity so same-named notes can coexist in folders. @@ -590,209 +591,6 @@ impl MemoFile { transaction.and(restore) } - fn import_legacy_global_memo_index( - &self, - conn: &Connection, - notebook_id: &str, - ) -> std::io::Result<()> { - let already_imported: Option = conn - .query_row( - "SELECT value FROM notebook_index_meta WHERE key = 'legacy_global_index_import_v1'", - [], - |row| row.get(0), - ) - .optional() - .map_err(sqlite_to_io)?; - if already_imported.is_some() { - return Ok(()); - } - - let legacy_path = self.registry_db_path(); - if !legacy_path.is_file() { - conn.execute( - "INSERT OR REPLACE INTO notebook_index_meta (key, value) VALUES ('legacy_global_index_import_v1', ?1)", - ["missing"], - ) - .map_err(sqlite_to_io)?; - return Ok(()); - } - - let legacy = self.open_registry_db()?; - let has_memos: bool = legacy - .query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memos')", - [], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - if !has_memos { - conn.execute( - "INSERT OR REPLACE INTO notebook_index_meta (key, value) VALUES ('legacy_global_index_import_v1', ?1)", - ["empty"], - ) - .map_err(sqlite_to_io)?; - return Ok(()); - } - - let legacy_path_string = legacy_path.to_string_lossy().to_string(); - conn.execute("ATTACH DATABASE ?1 AS legacy_index", [&legacy_path_string]) - .map_err(sqlite_to_io)?; - // Pre-relative-path global indexes kept the notebook-relative location - // in `filename` and had no `relative_path` column; pre-todo-id todo - // tables had no stable `todo_id`. Detect both and adapt the import. - let legacy_has = |table: &str, column: &str| -> std::io::Result { - let count: i64 = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info(?1, 'legacy_index') WHERE name = ?2", - [table, column], - |row| row.get(0), - ) - .map_err(sqlite_to_io)?; - Ok(count > 0) - }; - let legacy_relative_path_expr = if legacy_has("memos", "relative_path")? { - "relative_path" - } else { - "filename" - }; - let legacy_todo_id_expr = if legacy_has("memo_todos", "todo_id")? { - "mt.todo_id" - } else { - "'todo-legacy-' || mt.rowid" - }; - let result = (|| -> std::io::Result<()> { - let tx = conn.unchecked_transaction().map_err(sqlite_to_io)?; - - // Global indexes written before the lifecycle refactor predate the - // memo_lifecycles table. Skipping the import is safe: the primary - // copy stays valid and migrate_memo_lifecycles backfills lifecycle - // rows from the imported memos. - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at, deleted_at) - SELECT memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at, deleted_at - FROM legacy_index.memo_lifecycles WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at, deleted_at) - SELECT memo_id, notebook_id, '', 1, 1, updated_at, updated_at, NULL - FROM legacy_index.memo_content_revisions - WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - // Older global indexes did not have revisions yet. The - // primary copy remains valid when that optional table is absent. - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - &format!( - "INSERT OR IGNORE INTO memos - (id, notebook_id, filename, relative_path, preview, thumbnail, thumbnail_checked, agents_checked, created_at, updated_at, favorited, icon, properties) - SELECT id, notebook_id, filename, {legacy_relative_path_expr}, COALESCE(preview, ''), thumbnail, thumbnail_checked, agents_checked, created_at, updated_at, favorited, icon, COALESCE(properties, '{{}}') - FROM legacy_index.memos WHERE notebook_id = ?1" - ), - [notebook_id], - ) - .map_err(sqlite_to_io)?; - // The ensure_memo_tables lifecycle backfill ran before this import, - // so imported memos have no lifecycle rows yet. Backfill now: later - // revision imports reference these rows under FK RESTRICT. - tx.execute( - "INSERT OR IGNORE INTO memo_lifecycles - (memo_id, notebook_id, relative_path, generation, is_deleted, created_at, updated_at) - SELECT id, notebook_id, relative_path, 1, 0, created_at, updated_at - FROM memos WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io)?; - tx.execute( - "INSERT OR IGNORE INTO memo_content_revisions - (memo_id, notebook_id, content_hash, local_revision, change_id, updated_at) - SELECT memo_id, notebook_id, content_hash, local_revision, change_id, updated_at - FROM legacy_index.memo_content_revisions WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_tags (memo_id, tag) - SELECT mt.memo_id, mt.tag FROM legacy_index.memo_tags mt - JOIN legacy_index.memos m ON m.id = mt.memo_id - WHERE m.notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_colors (memo_id, color, position) - SELECT mc.memo_id, mc.color, mc.position FROM legacy_index.memo_colors mc - JOIN legacy_index.memos m ON m.id = mc.memo_id - WHERE m.notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - &format!( - "INSERT OR IGNORE INTO memo_todos - (memo_id, todo_id, content, status, priority, time_range, owner, assignee, created_at, updated_at, position) - SELECT mt.memo_id, {legacy_todo_id_expr}, mt.content, mt.status, mt.priority, mt.time_range, mt.owner, mt.assignee, mt.created_at, mt.updated_at, mt.position - FROM legacy_index.memo_todos mt - JOIN legacy_index.memos m ON m.id = mt.memo_id - WHERE m.notebook_id = ?1" - ), - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_agents (memo_id, thread_id, title, agent_type, position) - SELECT ma.memo_id, ma.thread_id, ma.title, ma.agent_type, ma.position - FROM legacy_index.memo_agents ma - JOIN legacy_index.memos m ON m.id = ma.memo_id - WHERE m.notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO notebook_tags (notebook_id, path, created_at, updated_at) - SELECT notebook_id, path, created_at, updated_at - FROM legacy_index.notebook_tags WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO memo_index_state (notebook_id, version, last_updated, migrated_at) - SELECT notebook_id, version, last_updated, migrated_at - FROM legacy_index.memo_index_state WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR IGNORE INTO notebook_data_migrations (notebook_id, migration_key, version, completed_at) - SELECT notebook_id, migration_key, version, completed_at - FROM legacy_index.notebook_data_migrations WHERE notebook_id = ?1", - [notebook_id], - ) - .map_err(sqlite_to_io) - .or_else(tolerate_missing_legacy_table)?; - tx.execute( - "INSERT OR REPLACE INTO notebook_index_meta (key, value) VALUES ('legacy_global_index_import_v1', ?1)", - ["complete"], - ) - .map_err(sqlite_to_io)?; - tx.commit().map_err(sqlite_to_io) - })(); - let detach = conn.execute_batch("DETACH DATABASE legacy_index;"); - result.and(detach.map_err(sqlite_to_io)) - } - fn ensure_local_notebook_row( &self, conn: &Connection, @@ -831,9 +629,8 @@ impl MemoFile { notebook_id: &str, ) -> std::io::Result { let conn = self.open_notebook_db(notebook_id)?; - self.ensure_memo_tables(&conn)?; + self.ensure_memo_tables(&conn, false)?; self.ensure_local_notebook_row(&conn, notebook_id)?; - self.import_legacy_global_memo_index(&conn, notebook_id)?; Ok(conn) } @@ -1439,7 +1236,7 @@ impl MemoFile { version: u32, ) -> std::io::Result<()> { let conn = self.open_memo_index_db_for_notebook_id(notebook_id)?; - self.ensure_memo_tables(&conn)?; + self.ensure_memo_tables(&conn, false)?; conn.execute( "INSERT INTO notebook_data_migrations (notebook_id, migration_key, version, completed_at) diff --git a/app/flowix-core/src/memo_file/internal_migration.rs b/app/flowix-core/src/memo_file/internal_migration.rs index 55b7afd9e..98576efb8 100644 --- a/app/flowix-core/src/memo_file/internal_migration.rs +++ b/app/flowix-core/src/memo_file/internal_migration.rs @@ -29,6 +29,29 @@ impl NotebookInternalMigrationReport { } impl MemoFile { + /// Whether a registered notebook still has the old plugin artifact paths. + /// New notebooks use `.flowix/plugin` from creation and do not need this + /// migration. + pub fn has_legacy_notebook_internal_data(&self, notebook_id: &str) -> io::Result { + let base = self + .get_notebook_config_by_id(notebook_id) + .map(|config| PathBuf::from(config.path)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("notebook not found: {notebook_id}"), + ) + })?; + if !base.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("notebook directory missing: {}", base.display()), + )); + } + Ok(has_legacy_content(&base.join(".plugin-output"))? + || has_legacy_content(&base.join(".flowix/artifacts"))?) + } + /// Move legacy notebook-local data to `.flowix/`. /// /// This method is safe to call on every notebook open/switch. The marker @@ -108,6 +131,18 @@ impl MemoFile { } } +fn has_legacy_content(path: &Path) -> io::Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Ok(true); + } + Ok(fs::read_dir(path)?.next().is_some()) +} + fn migrate_legacy_artifacts( source: &Path, plugin_root: &Path, diff --git a/app/flowix-core/src/memo_file/local_migrations.rs b/app/flowix-core/src/memo_file/local_migrations.rs new file mode 100644 index 000000000..402e58a68 --- /dev/null +++ b/app/flowix-core/src/memo_file/local_migrations.rs @@ -0,0 +1,55 @@ +//! Shared device-local migration journal. Both historical journal layouts are accepted. +use rusqlite::Connection; +use std::io; + +pub fn initialize_local_migration_table(conn: &mut Connection) -> io::Result<()> { + let columns = { + let mut statement = conn + .prepare("PRAGMA table_info(local_migrations)") + .map_err(io::Error::other)?; + let rows = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(io::Error::other)?; + rows.collect::, _>>() + .map_err(io::Error::other)? + }; + if columns.is_empty() { + conn.execute_batch("CREATE TABLE local_migrations (migration_key TEXT PRIMARY KEY, completed_at INTEGER NOT NULL DEFAULT 0);").map_err(io::Error::other)?; + } else if columns.iter().any(|name| name == "key") + && !columns.iter().any(|name| name == "migration_key") + { + let tx = conn.transaction().map_err(io::Error::other)?; + tx.execute_batch("ALTER TABLE local_migrations RENAME TO local_migrations_old; + CREATE TABLE local_migrations (migration_key TEXT PRIMARY KEY, completed_at INTEGER NOT NULL DEFAULT 0); + INSERT INTO local_migrations (migration_key, completed_at) SELECT key, 0 FROM local_migrations_old; + DROP TABLE local_migrations_old;").map_err(io::Error::other)?; + tx.commit().map_err(io::Error::other)?; + } else if !columns.iter().any(|name| name == "migration_key") + || !columns.iter().any(|name| name == "completed_at") + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported local_migrations schema", + )); + } + Ok(()) +} + +/// Notebook jobs shared by execution and startup pending detection. +#[derive(Clone, Copy)] +pub enum NotebookInitializationJob { + ResetLegacyStorage, + InitializeSchema, +} + +impl NotebookInitializationJob { + pub const ALL: [Self; 2] = [Self::ResetLegacyStorage, Self::InitializeSchema]; + + pub fn key(self, notebook_id: &str) -> String { + let prefix = match self { + Self::ResetLegacyStorage => "storage_reset_v1", + Self::InitializeSchema => "storage_schema_v1", + }; + format!("{prefix}:notebook:{notebook_id}") + } +} diff --git a/app/flowix-core/src/memo_file/media_resource.rs b/app/flowix-core/src/memo_file/media_resource.rs index ad3f95d6a..db32c8664 100644 --- a/app/flowix-core/src/memo_file/media_resource.rs +++ b/app/flowix-core/src/memo_file/media_resource.rs @@ -3,7 +3,7 @@ //! Media files remain on disk, while their user properties and resource //! identity are stored in the notebook-local database at //! `/.flowix/notebook.db`. It is deliberately separate from the -//! global notebook registry database (`~/.flowix/index.db`). +//! global notebook registry database (`~/.flowix/db/local.db`). use std::collections::{HashMap, HashSet}; use std::fs; @@ -158,6 +158,35 @@ pub fn media_kind_for_path(path: &Path) -> Option { impl MemoFile { pub(crate) fn open_notebook_db(&self, notebook_id: &str) -> std::io::Result { + self.open_notebook_db_inner(notebook_id, false) + } + + /// Upgrade the notebook media catalog during version initialization. + pub fn initialize_media_resource_schema(&self, notebook_id: &str) -> std::io::Result<()> { + self.open_notebook_db_inner(notebook_id, true)?; + Ok(()) + } + + fn open_notebook_db_inner( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> std::io::Result { + let path = self.notebook_db_path(notebook_id)?; + if run_legacy_migrations { + super::with_database_rebuild(&path, || { + self.open_notebook_db_unchecked(notebook_id, true) + }) + } else { + self.open_notebook_db_unchecked(notebook_id, false) + } + } + + fn open_notebook_db_unchecked( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> std::io::Result { let path = self.notebook_db_path(notebook_id)?; let mut conn = Connection::open(path).map_err(sqlite_to_io)?; conn.busy_timeout(std::time::Duration::from_secs(10)) @@ -196,6 +225,9 @@ impl MemoFile { "#, ) .map_err(sqlite_to_io)?; + if !run_legacy_migrations { + return Ok(conn); + } let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .map_err(sqlite_to_io)?; diff --git a/app/flowix-core/src/memo_file/mod.rs b/app/flowix-core/src/memo_file/mod.rs index a1a53c75d..f8500e76f 100644 --- a/app/flowix-core/src/memo_file/mod.rs +++ b/app/flowix-core/src/memo_file/mod.rs @@ -30,12 +30,15 @@ pub const MEMO_ID_ALPHABET: [char; 36] = [ pub const MEMO_ID_LENGTH: usize = 8; mod content; +mod database_layout; +mod storage_repair; mod derivation; mod file_management; mod file_io; pub(crate) mod frontmatter; mod index_store; mod internal_migration; +mod local_migrations; mod media_resource; mod migration; mod notebook; @@ -74,7 +77,10 @@ pub use frontmatter::{ LEGACY_FRONTMATTER_KEY, SYSTEM_FRONTMATTER_KEYS, }; pub use index_store::{MemoContentCommit, MemoContentRevision, NoteContentCommit, NoteContentRevision}; +pub use local_migrations::{initialize_local_migration_table, NotebookInitializationJob}; pub use internal_migration::{NotebookInternalMigrationReport, NOTEBOOK_INTERNAL_MIGRATION_KEY}; +pub use storage_repair::{ensure_storage_directory, read_or_create_json, backup_database, backup_storage_file, is_rebuildable_storage_error, with_database_rebuild}; +pub use database_layout::{prepare_database_directory, DATABASE_DIRECTORY_NAME}; pub use media_resource::{media_kind_for_path, MediaResource, MediaResourceKind, MediaResourcePage}; pub use migration::{DataMigrationReport, NotebookMigrationReport, LATEST_DATA_MIGRATION_VERSION}; pub use ops::{ @@ -340,3 +346,6 @@ impl MemoFile { #[cfg(test)] mod tests; + +mod workspace; +pub use workspace::{NotebookAddDir, NotebookWorkspaceConfig, NotebookWorkspaceSnapshot}; diff --git a/app/flowix-core/src/memo_file/note_index.rs b/app/flowix-core/src/memo_file/note_index.rs index c603fc66a..02f0906e5 100644 --- a/app/flowix-core/src/memo_file/note_index.rs +++ b/app/flowix-core/src/memo_file/note_index.rs @@ -2297,13 +2297,44 @@ impl MemoFile { } pub(crate) fn open_note_index_connection(&self, notebook_id: &str) -> io::Result { + self.open_note_index_connection_inner(notebook_id, false) + } + + /// Upgrade an existing note index only from the version-initialization worker. + pub fn initialize_note_index_schema(&self, notebook_id: &str) -> io::Result<()> { + self.open_note_index_connection_inner(notebook_id, true)?; + Ok(()) + } + + fn open_note_index_connection_inner( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> io::Result { + let path = self.notebook_db_path(notebook_id)?; + if run_legacy_migrations { + super::with_database_rebuild(&path, || { + self.open_note_index_connection_unchecked(notebook_id, true) + }) + } else { + self.open_note_index_connection_unchecked(notebook_id, false) + } + } + + fn open_note_index_connection_unchecked( + &self, + notebook_id: &str, + run_legacy_migrations: bool, + ) -> io::Result { let path = self.notebook_db_path(notebook_id)?; let mut conn = Connection::open(path).map_err(io::Error::other)?; conn.busy_timeout(std::time::Duration::from_secs(10)) .map_err(io::Error::other)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") .map_err(io::Error::other)?; - discard_legacy_v2_note_projection(&mut conn)?; + if run_legacy_migrations { + discard_legacy_v2_note_projection(&mut conn)?; + } conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; \ CREATE TABLE IF NOT EXISTS note_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); \ CREATE TABLE IF NOT EXISTS notes (relative_path TEXT PRIMARY KEY, \ @@ -2315,8 +2346,6 @@ impl MemoFile { CREATE TABLE IF NOT EXISTS note_tags (relative_path TEXT NOT NULL REFERENCES notes(relative_path) ON DELETE CASCADE, \ tag TEXT NOT NULL, PRIMARY KEY(relative_path, tag)); \ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag); \ - CREATE INDEX IF NOT EXISTS idx_notes_created_page ON notes(favorited DESC, created_at DESC, relative_path DESC); \ - CREATE INDEX IF NOT EXISTS idx_notes_updated_page ON notes(favorited DESC, updated_at DESC, relative_path DESC); \ CREATE TABLE IF NOT EXISTS note_todos (relative_path TEXT NOT NULL REFERENCES notes(relative_path) ON DELETE CASCADE, \ position INTEGER NOT NULL, todo_id TEXT NOT NULL, content TEXT NOT NULL, status TEXT NOT NULL, \ priority TEXT NOT NULL DEFAULT '', time_range TEXT NOT NULL DEFAULT '', owner TEXT NOT NULL DEFAULT '', \ @@ -2336,55 +2365,59 @@ impl MemoFile { );", ) .map_err(io::Error::other)?; - for (column, definition) in [ - ("priority", "TEXT NOT NULL DEFAULT ''"), - ("time_range", "TEXT NOT NULL DEFAULT ''"), - ("owner", "TEXT NOT NULL DEFAULT ''"), - ("assignee", "TEXT NOT NULL DEFAULT ''"), - ("created_at", "INTEGER NOT NULL DEFAULT 0"), - ("updated_at", "INTEGER NOT NULL DEFAULT 0"), - ] { - let mut statement = conn - .prepare("PRAGMA table_info(note_todos)") - .map_err(io::Error::other)?; - let names = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - if !names.iter().any(|name| name == column) { - conn.execute_batch(&format!( - "ALTER TABLE note_todos ADD COLUMN {column} {definition}" - )) - .map_err(io::Error::other)?; + if run_legacy_migrations { + for (column, definition) in [ + ("priority", "TEXT NOT NULL DEFAULT ''"), + ("time_range", "TEXT NOT NULL DEFAULT ''"), + ("owner", "TEXT NOT NULL DEFAULT ''"), + ("assignee", "TEXT NOT NULL DEFAULT ''"), + ("created_at", "INTEGER NOT NULL DEFAULT 0"), + ("updated_at", "INTEGER NOT NULL DEFAULT 0"), + ] { + let mut statement = conn + .prepare("PRAGMA table_info(note_todos)") + .map_err(io::Error::other)?; + let names = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(io::Error::other)? + .collect::, _>>() + .map_err(io::Error::other)?; + if !names.iter().any(|name| name == column) { + conn.execute_batch(&format!( + "ALTER TABLE note_todos ADD COLUMN {column} {definition}" + )) + .map_err(io::Error::other)?; + } } - } - for (column, definition) in [ - ("thumbnail", "TEXT"), - ("created_at", "INTEGER NOT NULL DEFAULT 0"), - ("updated_at", "INTEGER NOT NULL DEFAULT 0"), - ("favorited", "INTEGER NOT NULL DEFAULT 0"), - ("icon", "TEXT"), - ("colors_json", "TEXT NOT NULL DEFAULT '[]'"), - ("filename", "TEXT NOT NULL DEFAULT ''"), - ("filename_lower", "TEXT NOT NULL DEFAULT ''"), - ] { - let mut statement = conn - .prepare("PRAGMA table_info(notes)") - .map_err(io::Error::other)?; - let names = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(io::Error::other)? - .collect::, _>>() - .map_err(io::Error::other)?; - if !names.iter().any(|name| name == column) { - conn.execute_batch(&format!( - "ALTER TABLE notes ADD COLUMN {column} {definition}" - )) - .map_err(io::Error::other)?; + for (column, definition) in [ + ("thumbnail", "TEXT"), + ("created_at", "INTEGER NOT NULL DEFAULT 0"), + ("updated_at", "INTEGER NOT NULL DEFAULT 0"), + ("favorited", "INTEGER NOT NULL DEFAULT 0"), + ("icon", "TEXT"), + ("colors_json", "TEXT NOT NULL DEFAULT '[]'"), + ("filename", "TEXT NOT NULL DEFAULT ''"), + ("filename_lower", "TEXT NOT NULL DEFAULT ''"), + ] { + let mut statement = conn + .prepare("PRAGMA table_info(notes)") + .map_err(io::Error::other)?; + let names = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(io::Error::other)? + .collect::, _>>() + .map_err(io::Error::other)?; + if !names.iter().any(|name| name == column) { + conn.execute_batch(&format!( + "ALTER TABLE notes ADD COLUMN {column} {definition}" + )) + .map_err(io::Error::other)?; + } } } - conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_filename_page ON notes(favorited DESC, filename_lower ASC, filename ASC, relative_path ASC);") + conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_notes_created_page ON notes(favorited DESC, created_at DESC, relative_path DESC); \ + CREATE INDEX IF NOT EXISTS idx_notes_updated_page ON notes(favorited DESC, updated_at DESC, relative_path DESC); \ + CREATE INDEX IF NOT EXISTS idx_notes_filename_page ON notes(favorited DESC, filename_lower ASC, filename ASC, relative_path ASC);") .map_err(io::Error::other)?; conn.execute( "INSERT INTO note_index_meta (key, value) VALUES ('schema_version', ?1) \ @@ -2400,10 +2433,18 @@ impl MemoFile { ) .map_err(io::Error::other)?; if stored_version != SCHEMA_VERSION.to_string() { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("unsupported Note index schema version: {stored_version}"), - )); + let legacy_version = stored_version.parse::().unwrap_or(i64::MAX); + if !run_legacy_migrations || legacy_version > SCHEMA_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported Note index schema version: {stored_version}"), + )); + } + conn.execute( + "UPDATE note_index_meta SET value = ?1 WHERE key = 'schema_version'", + params![SCHEMA_VERSION.to_string()], + ) + .map_err(io::Error::other)?; } let stored_notebook: Option = conn .query_row( diff --git a/app/flowix-core/src/memo_file/notebook.rs b/app/flowix-core/src/memo_file/notebook.rs index 1515a65d8..303adbfde 100644 --- a/app/flowix-core/src/memo_file/notebook.rs +++ b/app/flowix-core/src/memo_file/notebook.rs @@ -1,19 +1,17 @@ //! Notebook registry storage. //! -//! The authoritative notebook registry lives in `index.db` under the user -//! config directory (`~/.flowix/index.db` in production). Notebook-owned note +//! The authoritative notebook registry lives in `db/local.db` under the user +//! config directory (`~/.flowix/db/local.db` in production). Notebook-owned note //! and media data live separately in `/.flowix/notebook.db`. +use rusqlite::{params, Connection, OptionalExtension, Row}; use std::collections::HashSet; use std::fs; use std::path::PathBuf; -use rusqlite::{params, Connection, OptionalExtension, Row}; use super::file_io::atomic_write_bytes; -use super::types::{ - NotebookConfig, NotebookManifest, NotebookSetupJob, NotebookSetupJobStatus, -}; use super::types::NotebookSetupReport; +use super::types::{NotebookConfig, NotebookManifest, NotebookSetupJob, NotebookSetupJobStatus}; use super::MemoFile; pub(super) fn sqlite_to_io(error: rusqlite::Error) -> std::io::Error { @@ -51,7 +49,8 @@ fn map_notebook_setup_job(row: &Row<'_>) -> rusqlite::Result { completed_files: completed_files.max(0) as usize, total_files: total_files.max(0) as usize, message: row.get(6)?, - report: report_json.and_then(|json| serde_json::from_str::(&json).ok()), + report: report_json + .and_then(|json| serde_json::from_str::(&json).ok()), updated_at: row.get(7)?, overwrite_existing: row.get::<_, i64>(9)? != 0, }) @@ -164,11 +163,7 @@ impl MemoFile { scene_id: &str, ) -> std::io::Result<()> { Self::ensure_notebook_manifest(config)?; - Self::set_notebook_scene_id( - std::path::Path::new(&config.path), - &config.id, - scene_id, - )?; + Self::set_notebook_scene_id(std::path::Path::new(&config.path), &config.id, scene_id)?; Ok(()) } @@ -189,8 +184,7 @@ impl MemoFile { Ok(flowix_dir.join("notebook.db")) } - /// Device-local notebook registry database. The on-disk name remains - /// `index.db` for compatibility; it stores registry state, not the active + /// Device-local notebook registry database. It stores registry state, not the active /// notebook's note catalog. pub fn registry_db_path(&self) -> PathBuf { self.registry.db_path() @@ -218,6 +212,71 @@ impl MemoFile { Ok(()) } + /// Explicit upgrade entry point; regular registry reads never import old databases. + pub fn initialize_registry_storage(&self) -> std::io::Result<()> { + super::database_layout::prepare_database_directory( + &self.registry.config_dir, + &["local.db", "index.db"], + )?; + let mut connection = super::with_database_rebuild(&self.registry_db_path(), || { + let connection = self.registry.open_for_initialization()?; + self.workspace_connection()?; + self.read_notebook_configs()?; + self.read_selected_notebook_id()?; + Ok(connection) + })?; + if let Err(error) = self.registry.import_legacy_registry(&mut connection) { + if !super::is_rebuildable_storage_error(&error) + && error.kind() != std::io::ErrorKind::AlreadyExists + { + return Err(error); + } + // A bad legacy input must not prevent use of a healthy canonical registry. + drop(connection); + super::backup_database(&self.registry.config_dir.join("db/index.db"))?; + connection = self.registry.open_for_initialization()?; + self.registry.import_legacy_registry(&mut connection)?; + } + Ok(()) + } + + /// Read a completion marker from the device-local migration journal. + pub fn local_migration_completed(&self, key: &str) -> std::io::Result { + let connection = self.registry.open()?; + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM local_migrations WHERE migration_key = ?1)", + [key], + |row| row.get(0), + ) + .map_err(sqlite_to_io) + } + + /// Record a completed device-local migration step. + pub fn mark_local_migration_completed(&self, key: &str) -> std::io::Result<()> { + let connection = self.registry.open()?; + connection + .execute( + "INSERT OR IGNORE INTO local_migrations (migration_key, completed_at) VALUES (?1, CAST(strftime('%s','now') AS INTEGER) * 1000)", + [key], + ) + .map_err(sqlite_to_io)?; + Ok(()) + } + + /// Whether a registered notebook still needs its legacy storage decision. + /// Offline notebooks remain pending until their paths can be inspected. + pub fn has_pending_notebook_storage_initialization(&self) -> std::io::Result { + for notebook in self.read_notebook_configs()? { + for job in super::NotebookInitializationJob::ALL { + if !self.local_migration_completed(&job.key(¬ebook.id))? { + return Ok(true); + } + } + } + Ok(false) + } + pub(super) fn open_registry_db(&self) -> std::io::Result { self.registry.open() } @@ -345,13 +404,89 @@ impl MemoFile { .collect() } - /// Synchronize notebook rows in `index.db` without deleting unchanged + /// Synchronize notebook rows in `local.db` without deleting unchanged /// notebook ids. Deleting and reinserting every row would trigger /// `ON DELETE CASCADE` on memo rows for notebooks that still exist. pub fn write_notebook_configs(&self, notebooks: &[NotebookConfig]) -> std::io::Result<()> { self.write_notebook_configs_with_setup_job(notebooks, None) } + /// Drop legacy notebook display, setup, and external-directory settings + /// while keeping the registry identities and their filesystem paths. + /// The folder name becomes the display name, icons are cleared, and order + /// is rebuilt from the stable path order. + pub fn reset_legacy_notebook_settings_for_initialization(&self) -> std::io::Result<()> { + let mut conn = self.open_registry_db()?; + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + tx.execute_batch( + "DROP TABLE IF EXISTS notebook_add_dirs; + DROP TABLE IF EXISTS notebook_workspace_state; + CREATE TABLE notebook_workspace_state ( + notebook_id TEXT PRIMARY KEY REFERENCES notebooks(id) ON DELETE CASCADE, + revision INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE notebook_add_dirs ( + id TEXT NOT NULL, + notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, + path TEXT NOT NULL, + path_key TEXT NOT NULL, + label TEXT NOT NULL, + enabled INTEGER NOT NULL, + sort INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(notebook_id,id), + UNIQUE(notebook_id,path_key) + );", + ) + .map_err(sqlite_to_io)?; + let mut notebooks = { + let mut statement = tx + .prepare("SELECT id, path FROM notebooks") + .map_err(sqlite_to_io)?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + notebooks.sort_by_key(|(_, path)| path.to_lowercase()); + for (index, (id, path)) in notebooks.iter().enumerate() { + let display_name = std::path::Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("笔记本"); + tx.execute( + "UPDATE notebooks SET name=?1, icon=NULL, sort=?2 WHERE id=?3", + rusqlite::params![display_name, (index as i64 + 1) * 10, id], + ) + .map_err(sqlite_to_io)?; + } + + tx.execute("DELETE FROM notebook_setup_jobs", []) + .map_err(sqlite_to_io)?; + tx.execute( + "INSERT INTO notebook_workspace_state (notebook_id, revision) + SELECT id, 1 FROM notebooks", + [], + ) + .map_err(sqlite_to_io)?; + tx.execute("DROP TABLE IF EXISTS data_migration_state", []) + .map_err(sqlite_to_io)?; + tx.commit().map_err(sqlite_to_io)?; + + *self + .registry + .configs_cache + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + Ok(()) + } + /// Atomically persist notebook registration and its optional first-open /// setup job. This closes the crash window between creating a notebook and /// remembering which template it should receive. @@ -478,7 +613,10 @@ impl MemoFile { Ok(()) } - pub fn get_notebook_setup_job(&self, notebook_id: &str) -> std::io::Result> { + pub fn get_notebook_setup_job( + &self, + notebook_id: &str, + ) -> std::io::Result> { let conn = self.open_registry_db()?; conn.query_row( r#"SELECT notebook_id, template_id, status, stage, completed_files, diff --git a/app/flowix-core/src/memo_file/notebook_registry.rs b/app/flowix-core/src/memo_file/notebook_registry.rs index 82de89310..e2e173da7 100644 --- a/app/flowix-core/src/memo_file/notebook_registry.rs +++ b/app/flowix-core/src/memo_file/notebook_registry.rs @@ -23,16 +23,34 @@ impl NotebookRegistry { } pub(super) fn db_path(&self) -> PathBuf { - self.config_dir.join("index.db") + self.config_dir.join("db").join("local.db") } pub(super) fn open(&self) -> std::io::Result { + self.open_with_legacy_migrations(false) + } + + pub(super) fn open_for_initialization(&self) -> std::io::Result { + self.open_with_legacy_migrations(true) + } + + fn open_with_legacy_migrations( + &self, + run_legacy_migrations: bool, + ) -> std::io::Result { if let Some(parent) = self.db_path().parent() { fs::create_dir_all(parent)?; } let mut conn = Connection::open(self.db_path()).map_err(sqlite_to_io)?; conn.busy_timeout(Duration::from_secs(10)) .map_err(sqlite_to_io)?; + let notebooks_table_exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='notebooks')", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io)?; let has_sort_column = conn .prepare("PRAGMA table_info(notebooks)") .map_err(sqlite_to_io)? @@ -40,7 +58,13 @@ impl NotebookRegistry { .map_err(sqlite_to_io)? .filter_map(Result::ok) .any(|name| name == "sort"); - if !has_sort_column { + if notebooks_table_exists && !has_sort_column && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook registry requires version initialization", + )); + } + if notebooks_table_exists && !has_sort_column { conn.execute_batch( r#" CREATE TABLE IF NOT EXISTS notebooks ( @@ -94,6 +118,10 @@ impl NotebookRegistry { overwrite_existing INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS local_migrations ( + migration_key TEXT PRIMARY KEY, + completed_at INTEGER NOT NULL DEFAULT 0 + ); "#, ) .map_err(sqlite_to_io)?; @@ -104,6 +132,12 @@ impl NotebookRegistry { .map_err(sqlite_to_io)? .filter_map(Result::ok) .any(|name| name == "report_json"); + if !has_setup_report && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook setup registry requires version initialization", + )); + } if !has_setup_report { conn.execute_batch("ALTER TABLE notebook_setup_jobs ADD COLUMN report_json TEXT;") .map_err(sqlite_to_io)?; @@ -115,13 +149,19 @@ impl NotebookRegistry { .map_err(sqlite_to_io)? .filter_map(Result::ok) .any(|name| name == "overwrite_existing"); + if !has_setup_overwrite && !run_legacy_migrations { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "notebook setup registry requires version initialization", + )); + } if !has_setup_overwrite { conn.execute_batch( "ALTER TABLE notebook_setup_jobs ADD COLUMN overwrite_existing INTEGER NOT NULL DEFAULT 0;", ) .map_err(sqlite_to_io)?; } - if !has_sort_column { + if notebooks_table_exists && !has_sort_column { let tx = conn.transaction().map_err(sqlite_to_io)?; let mut stmt = tx .prepare( @@ -146,4 +186,147 @@ impl NotebookRegistry { } Ok(conn) } + + pub(super) fn import_legacy_registry(&self, conn: &mut Connection) -> std::io::Result<()> { + super::initialize_local_migration_table(conn)?; + let imported: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM local_migrations WHERE migration_key = 'registry_v1')", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io)?; + if imported { + return Ok(()); + } + let legacy_path = self.config_dir.join("db").join("index.db"); + if !legacy_path.is_file() { + conn.execute( + "INSERT OR IGNORE INTO local_migrations (migration_key, completed_at) VALUES ('registry_v1', CAST(strftime('%s','now') AS INTEGER) * 1000)", + [], + ) + .map_err(sqlite_to_io)?; + return Ok(()); + } + let tx = conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + let imported: bool = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM local_migrations WHERE migration_key = 'registry_v1')", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io)?; + if imported { + return Ok(()); + } + tx.execute( + "ATTACH DATABASE ?1 AS old_registry", + [legacy_path.to_string_lossy().as_ref()], + ) + .map_err(sqlite_to_io)?; + let has_table = |name: &str| -> std::io::Result { + tx.query_row("SELECT EXISTS(SELECT 1 FROM old_registry.sqlite_master WHERE type = 'table' AND name = ?1)", [name], |row| row.get(0)).map_err(sqlite_to_io) + }; + if has_table("notebooks")? { + let has_sort: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('notebooks', 'old_registry') WHERE name = 'sort')", [], |row| row.get(0)).map_err(sqlite_to_io)?; + let sort = if has_sort { + "COALESCE(sort, 0)" + } else { + "ROW_NUMBER() OVER (ORDER BY created_at, name COLLATE NOCASE) * 10" + }; + let conflicts: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM old_registry.notebooks old JOIN main.notebooks current ON old.id=current.id OR old.path=current.path WHERE old.id<>current.id OR old.path<>current.path)", [], |row| row.get(0)).map_err(sqlite_to_io)?; + if conflicts { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "legacy notebook registry conflicts with local.db; both databases were preserved")); + } + tx.execute_batch(&format!("INSERT OR IGNORE INTO notebooks (id,name,icon,path,is_default,sort,created_at,updated_at) SELECT id,name,icon,path,is_default,{sort},created_at,updated_at FROM old_registry.notebooks;")).map_err(sqlite_to_io)?; + let old_count: i64 = tx + .query_row("SELECT COUNT(*) FROM old_registry.notebooks", [], |row| { + row.get(0) + }) + .map_err(sqlite_to_io)?; + let imported_count: i64 = tx + .query_row( + "SELECT COUNT(*) FROM old_registry.notebooks old JOIN main.notebooks current ON old.id=current.id AND old.path=current.path", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io)?; + if old_count != imported_count { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy notebook registry verification failed; source data was preserved", + )); + } + } else { + let has_tables: bool = tx + .query_row( + "SELECT EXISTS(SELECT 1 FROM old_registry.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%')", + [], + |row| row.get(0), + ) + .map_err(sqlite_to_io)?; + if has_tables { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "legacy index database has an unsupported registry schema; source data was preserved", + )); + } + } + tx.execute( + "INSERT INTO local_migrations (migration_key, completed_at) VALUES ('registry_v1', CAST(strftime('%s','now') AS INTEGER) * 1000)", + [], + ) + .map_err(sqlite_to_io)?; + tx.commit().map_err(sqlite_to_io) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn imports_only_registry_rows_without_rewriting_old_index() { + let temp = tempfile::tempdir().unwrap(); + let legacy = Connection::open(temp.path().join("index.db")).unwrap(); + legacy.execute_batch("CREATE TABLE notebooks (id TEXT PRIMARY KEY,name TEXT,icon TEXT,path TEXT,is_default INTEGER,sort INTEGER,created_at INTEGER,updated_at INTEGER); + INSERT INTO notebooks VALUES ('old','Old',NULL,'/old',1,30,1,2); + CREATE TABLE app_state (key TEXT PRIMARY KEY,value TEXT,updated_at INTEGER); + INSERT INTO app_state VALUES ('selected_notebook_id','old',2); + CREATE TABLE notebook_setup_jobs (notebook_id TEXT PRIMARY KEY,template_id TEXT,status TEXT,stage TEXT,completed_files INTEGER,total_files INTEGER,message TEXT,updated_at INTEGER); + INSERT INTO notebook_setup_jobs VALUES ('old','template','pending','queued',0,1,NULL,2);").unwrap(); + drop(legacy); + let registry = NotebookRegistry::new(temp.path().to_path_buf()); + let conn = registry.open().unwrap(); + let selected: i64 = conn + .query_row( + "SELECT COUNT(*) FROM app_state WHERE key='selected_notebook_id'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(selected, 0); + let setup_jobs: i64 = conn + .query_row("SELECT COUNT(*) FROM notebook_setup_jobs", [], |r| r.get(0)) + .unwrap(); + assert_eq!(setup_jobs, 0); + conn.execute("DELETE FROM notebooks WHERE id='old'", []) + .unwrap(); + drop(conn); + let conn = registry.open().unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM notebooks", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + let legacy = Connection::open(temp.path().join("db/index.db")).unwrap(); + assert_eq!( + legacy + .query_row("SELECT COUNT(*) FROM notebooks", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert!(temp.path().join("db/local.db").is_file()); + } } diff --git a/app/flowix-core/src/memo_file/storage_repair.rs b/app/flowix-core/src/memo_file/storage_repair.rs new file mode 100644 index 000000000..37edb7234 --- /dev/null +++ b/app/flowix-core/src/memo_file/storage_repair.rs @@ -0,0 +1,327 @@ +//! Small startup helpers for recreatable application storage. +use std::{ + error::Error, + fs, io, + path::{Path, PathBuf}, +}; + +/// Preserve a database and all its SQLite companions in one backup directory. +/// No replacement is attempted unless every existing component was preserved. +pub fn backup_database(path: &Path) -> io::Result> { + backup_files(path, &["", "-wal", "-shm", "-journal"]) +} + +pub fn backup_storage_file(path: &Path) -> io::Result> { + backup_files(path, &[""]) +} + +fn backup_files(path: &Path, suffixes: &[&str]) -> io::Result> { + let mut sources = Vec::new(); + for suffix in suffixes { + let mut name = path.as_os_str().to_os_string(); + name.push(suffix); + let source = PathBuf::from(name); + match fs::symlink_metadata(&source) { + Ok(_) => sources.push(source), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + if sources.is_empty() { + return Ok(None); + } + let parent = path + .parent() + .ok_or_else(|| io::Error::other("storage path has no parent"))?; + let backup = parent + .join("rebuild-backups") + .join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&backup)?; + let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); + for source in sources { + let target = backup.join(source.file_name().unwrap()); + if let Err(error) = fs::rename(&source, &target) { + for (source, target) in moved.iter().rev() { + if let Err(rollback) = fs::rename(target, source) { + return Err(io::Error::other(format!( + "backup failed: {error}; rollback failed: {rollback}; preserved files: {}", + backup.display() + ))); + } + } + return Err(error); + } + moved.push((source, target)); + } + tracing::warn!(path = %path.display(), backup = %backup.display(), "preserved abnormal application storage before rebuilding"); + Ok(Some(backup)) +} + +/// Busy, permission, disk-full and I/O errors must never move a live database. +pub fn is_rebuildable_storage_error(error: &(dyn Error + 'static)) -> bool { + let mut current = Some(error); + while let Some(error) = current { + if let Some(sqlite) = error.downcast_ref::() { + return match sqlite { + rusqlite::Error::SqlInputError { msg, .. } => [ + "no such table", + "no such column", + "has no column", + "malformed", + ] + .iter() + .any(|part| msg.contains(part)), + rusqlite::Error::SqliteFailure(code, Some(message)) + if code.code == rusqlite::ErrorCode::Unknown => + { + [ + "no such table", + "no such column", + "has no column", + "malformed", + ] + .iter() + .any(|part| message.contains(part)) + } + rusqlite::Error::SqliteFailure(code, _) => matches!( + code.code, + rusqlite::ErrorCode::DatabaseCorrupt + | rusqlite::ErrorCode::NotADatabase + | rusqlite::ErrorCode::SchemaChanged + ), + rusqlite::Error::InvalidColumnName(_) + | rusqlite::Error::InvalidColumnType(..) + | rusqlite::Error::FromSqlConversionFailure(..) => true, + _ => false, + }; + } + if let Some(io) = error.downcast_ref::() { + if io.kind() == io::ErrorKind::InvalidData { + return true; + } + if let Some(inner) = io.get_ref() { + current = Some(inner); + continue; + } + } + current = error.source(); + } + false +} + +/// The operation must release its connections before returning an error. +pub fn with_database_rebuild( + path: &Path, + mut initialize: impl FnMut() -> io::Result, +) -> io::Result { + // Serialize initialization and repair across processes. Ordinary database + // access must not call this helper or rename storage held by other readers. + if let Some(parent) = path.parent() { ensure_storage_directory(parent)?; } + let mut lock_path = path.as_os_str().to_os_string(); + lock_path.push(".rebuild.lock"); + let lock = fs::OpenOptions::new().create(true).truncate(false) + .read(true).write(true).open(PathBuf::from(lock_path))?; + fs2::FileExt::lock_exclusive(&lock)?; + match fs::symlink_metadata(path) { + Ok(metadata) if !metadata.file_type().is_file() => { + backup_database(path)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + backup_database(path)?; + } + _ => {} + } + match initialize() { + Ok(value) => Ok(value), + Err(error) if is_rebuildable_storage_error(&error) => { + tracing::warn!(%error, path = %path.display(), "recreating abnormal application database"); + backup_database(path)?; + initialize() + } + Err(error) => Err(error), + } +} + +pub fn ensure_storage_directory(path: &Path) -> io::Result<()> { + if fs::symlink_metadata(path).is_ok() && !path.is_dir() { + backup_storage_file(path)?; + } + fs::create_dir_all(path) +} + +/// JSON owned by the application: preserve malformed input, then persist defaults. +pub fn read_or_create_json(path: &Path, default: impl FnOnce() -> T) -> io::Result +where + T: serde::de::DeserializeOwned + serde::Serialize, +{ + if let Some(parent) = path.parent() { ensure_storage_directory(parent)?; } + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(value) => return Ok(value), + Err(_) => { + backup_storage_file(path)?; + } + }, + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) if error.kind() == io::ErrorKind::IsADirectory => { + backup_storage_file(path)?; + } + Err(error) => return Err(error), + } + let value = default(); + let bytes = serde_json::to_vec_pretty(&value).map_err(io::Error::other)?; + super::atomic_write_bytes(path, &bytes)?; + Ok(value) +} + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + use crate::memo_file::{prepare_database_directory, MemoFile, NotebookConfig}; + + #[test] + fn preserves_complete_sqlite_group() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("local.db"); + for suffix in ["", "-wal", "-shm", "-journal"] { + fs::write( + temp.path().join(format!("local.db{suffix}")), + suffix.as_bytes(), + ) + .unwrap(); + } + let backup = backup_database(&path).unwrap().unwrap(); + for suffix in ["", "-wal", "-shm", "-journal"] { + assert_eq!( + fs::read(backup.join(format!("local.db{suffix}"))).unwrap(), + suffix.as_bytes() + ); + assert!(!temp.path().join(format!("local.db{suffix}")).exists()); + } + } + + #[test] + fn canonical_database_does_not_receive_legacy_wal() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("db")).unwrap(); + fs::write(temp.path().join("local.db"), b"legacy").unwrap(); + fs::write(temp.path().join("local.db-wal"), b"legacy wal").unwrap(); + fs::write(temp.path().join("db/local.db"), b"canonical").unwrap(); + prepare_database_directory(temp.path(), &["local.db"]).unwrap(); + assert_eq!( + fs::read(temp.path().join("db/local.db")).unwrap(), + b"canonical" + ); + assert!(!temp.path().join("db/local.db-wal").exists()); + assert_eq!( + fs::read(temp.path().join("local.db-wal")).unwrap(), + b"legacy wal" + ); + } + + #[test] + fn invalid_registry_rebuilds_and_legacy_input_cannot_block_startup() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("db")).unwrap(); + fs::write(temp.path().join("db/local.db"), b"broken registry").unwrap(); + fs::write(temp.path().join("db/index.db"), b"broken legacy").unwrap(); + let memo = MemoFile::new(temp.path().to_path_buf()); + memo.initialize_registry_storage().unwrap(); + assert!(memo.read_notebook_configs().unwrap().is_empty()); + assert!(memo.read_notebook_workspace().unwrap().notebooks.is_empty()); + let backups = fs::read_dir(temp.path().join("db/rebuild-backups")) + .unwrap() + .count(); + assert_eq!(backups, 2); + memo.initialize_registry_storage().unwrap(); + assert_eq!( + fs::read_dir(temp.path().join("db/rebuild-backups")) + .unwrap() + .count(), + 2 + ); + } + + #[test] + fn malformed_registry_schema_is_recreated() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir(temp.path().join("db")).unwrap(); + let path = temp.path().join("db/local.db"); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("CREATE TABLE notebooks(id TEXT);") + .unwrap(); + drop(conn); + let memo = MemoFile::new(temp.path().to_path_buf()); + memo.initialize_registry_storage().unwrap(); + assert!(memo.read_notebook_configs().unwrap().is_empty()); + assert!(path.parent().unwrap().join("rebuild-backups").exists()); + } + + #[test] + fn malformed_json_is_preserved_and_defaults_are_persisted() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("preference.json"); + fs::write(&path, b"{broken").unwrap(); + let value: serde_json::Value = + read_or_create_json(&path, || serde_json::json!({"language":"zh-CN"})).unwrap(); + assert_eq!(value["language"], "zh-CN"); + assert_eq!( + serde_json::from_slice::(&fs::read(&path).unwrap()).unwrap(), + value + ); + assert!(temp.path().join("rebuild-backups").exists()); + } + + #[test] + fn busy_database_is_not_moved_or_rebuilt() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("local.db"); + fs::write(&path, b"keep").unwrap(); + let result: io::Result<()> = with_database_rebuild(&path, || { + Err(io::Error::other(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_BUSY), + None, + ))) + }); + assert!(result.is_err()); + assert_eq!(fs::read(&path).unwrap(), b"keep"); + assert!(!temp.path().join("rebuild-backups").exists()); + } + + #[test] + fn notebook_database_rebuild_keeps_markdown_and_attachments() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notebook"); + fs::create_dir_all(root.join(".flowix")).unwrap(); + fs::create_dir_all(root.join("attachments")).unwrap(); + fs::write(root.join("kept.md"), b"# Keep this note\n").unwrap(); + fs::write(root.join("attachments/kept.bin"), b"attachment").unwrap(); + fs::write( + root.join(".flowix/notebook.db"), + b"invalid notebook database", + ) + .unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.write_notebook_configs(&[NotebookConfig { + id: "nb_kept".into(), + name: "Kept".into(), + icon: None, + path: root.to_string_lossy().into_owned(), + is_default: false, + sort: 0, + created_at: 0, + updated_at: 0, + }]) + .unwrap(); + memo.reconcile_note_index_blocking("nb_kept").unwrap(); + assert_eq!( + fs::read(root.join("kept.md")).unwrap(), + b"# Keep this note\n" + ); + assert_eq!( + fs::read(root.join("attachments/kept.bin")).unwrap(), + b"attachment" + ); + assert!(root.join(".flowix/rebuild-backups").exists()); + } +} diff --git a/app/flowix-core/src/memo_file/tests.rs b/app/flowix-core/src/memo_file/tests.rs index b223f9010..12f5a07de 100644 --- a/app/flowix-core/src/memo_file/tests.rs +++ b/app/flowix-core/src/memo_file/tests.rs @@ -247,16 +247,14 @@ fn pending_data_migrations_run_once_and_persist_version() { ); } -/// Global indexes written before the lifecycle refactor have `memos` but no -/// `memo_lifecycles`. The legacy import must skip the missing table instead of -/// failing startup data migrations (regression for -/// "no such table: legacy_index.memo_lifecycles"). +/// Global index rows are deliberately not imported into the rebuilt notebook +/// database. Markdown files remain the note source of truth. #[test] -fn legacy_global_index_without_memo_lifecycles_imports_cleanly() { +fn legacy_global_index_memo_rows_are_discarded() { let (mf, base) = fresh_memo_file(); // Seed the registry db with a pre-lifecycle global index: `memos` exists, // `memo_lifecycles` does not. - let legacy_path = mf.registry_db_path(); + let legacy_path = mf.registry_db_path().with_file_name("index.db"); let legacy = rusqlite::Connection::open(&legacy_path).unwrap(); legacy .execute_batch( @@ -286,8 +284,7 @@ fn legacy_global_index_without_memo_lifecycles_imports_cleanly() { let report = mf.run_pending_data_migrations().unwrap(); assert_eq!(report.to_version, super::LATEST_DATA_MIGRATION_VERSION); - // The legacy memo row is imported into the notebook-local index, and the - // lifecycle table is backfilled from the imported memos. + // The legacy memo row is discarded instead of copied into the fresh index. let notebook_db = mf.notebook_db_path("nb_test").unwrap(); let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); let memos: i64 = conn @@ -297,7 +294,7 @@ fn legacy_global_index_without_memo_lifecycles_imports_cleanly() { |row| row.get(0), ) .unwrap(); - assert_eq!(memos, 1); + assert_eq!(memos, 0); let lifecycles: i64 = conn .query_row( "SELECT COUNT(*) FROM memo_lifecycles WHERE memo_id = 'memo-legacy-1'", @@ -305,19 +302,16 @@ fn legacy_global_index_without_memo_lifecycles_imports_cleanly() { |row| row.get(0), ) .unwrap(); - assert_eq!(lifecycles, 1); + assert_eq!(lifecycles, 0); assert!(base.join(".flowix/notebook.json").is_file()); } -/// Real-world pre-refactor global indexes (observed in the field) lack -/// `memos.relative_path`, lack `memo_todos.todo_id`, and lack -/// `memo_lifecycles` entirely. The import must adapt: filename becomes the -/// relative path, todo ids are synthesized from rowids, and lifecycle rows -/// are backfilled. +/// Real-world pre-refactor memo rows are discarded even when the old global +/// schema differs from the current notebook index schema. #[test] -fn legacy_global_index_with_pre_relative_path_schema_imports_cleanly() { +fn legacy_global_index_pre_relative_path_memo_rows_are_discarded() { let (mf, _tmp) = fresh_memo_file(); - let legacy_path = mf.registry_db_path(); + let legacy_path = mf.registry_db_path().with_file_name("index.db"); let legacy = rusqlite::Connection::open(&legacy_path).unwrap(); legacy .execute_batch( @@ -372,23 +366,14 @@ fn legacy_global_index_with_pre_relative_path_schema_imports_cleanly() { let notebook_db = mf.notebook_db_path("nb_test").unwrap(); let conn = rusqlite::Connection::open(¬ebook_db).unwrap(); - let (imported_relative_path, imported_preview): (String, String) = conn + let imported_memos: i64 = conn .query_row( - "SELECT relative_path, preview FROM memos WHERE id = 'memo-legacy-1'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(imported_relative_path, "Old.md"); - assert_eq!(imported_preview, "# Old"); - let todo_id: String = conn - .query_row( - "SELECT todo_id FROM memo_todos WHERE memo_id = 'memo-legacy-1'", + "SELECT COUNT(*) FROM memos WHERE id = 'memo-legacy-1'", [], |row| row.get(0), ) .unwrap(); - assert!(todo_id.starts_with("todo-legacy-")); + assert_eq!(imported_memos, 0); let lifecycles: i64 = conn .query_row( "SELECT COUNT(*) FROM memo_lifecycles WHERE memo_id = 'memo-legacy-1'", @@ -396,8 +381,7 @@ fn legacy_global_index_with_pre_relative_path_schema_imports_cleanly() { |row| row.get(0), ) .unwrap(); - assert_eq!(lifecycles, 1); - // The revision row survives: its lifecycle FK target was backfilled. + assert_eq!(lifecycles, 0); let revisions: i64 = conn .query_row( "SELECT COUNT(*) FROM memo_content_revisions WHERE memo_id = 'memo-legacy-1'", @@ -405,7 +389,7 @@ fn legacy_global_index_with_pre_relative_path_schema_imports_cleanly() { |row| row.get(0), ) .unwrap(); - assert_eq!(revisions, 1); + assert_eq!(revisions, 0); } #[test] @@ -3458,7 +3442,7 @@ fn open_index_db_upgrades_legacy_schema_and_normalizes_sort() { }); mf.write_notebook_configs(&next_configs).unwrap(); - let conn = rusqlite::Connection::open(config_dir.join("index.db")).unwrap(); + let conn = rusqlite::Connection::open(config_dir.join("db/local.db")).unwrap(); let sort_alpha: i64 = conn .query_row( "SELECT sort FROM notebooks WHERE id = 'nb_alpha'", diff --git a/app/flowix-core/src/memo_file/workspace.rs b/app/flowix-core/src/memo_file/workspace.rs new file mode 100644 index 000000000..43857a87c --- /dev/null +++ b/app/flowix-core/src/memo_file/workspace.rs @@ -0,0 +1,450 @@ +//! Device-local notebook repository attachments. JSON files are upgrade inputs only. +use super::notebook::sqlite_to_io; +use super::{MemoFile, NotebookConfig}; +use rusqlite::{params, OptionalExtension, TransactionBehavior}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fs, path::Path}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct NotebookAddDir { + pub id: String, + pub path: String, + pub label: String, + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub missing: bool, +} +fn default_enabled() -> bool { + true +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookWorkspaceConfig { + #[serde(default = "config_version")] + pub version: u32, + #[serde(default)] + pub revision: u64, + #[serde(default)] + pub add_dirs: Vec, +} +fn config_version() -> u32 { + 1 +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotebookWorkspaceSnapshot { + pub notebooks: Vec, + pub notebook_configs: HashMap, +} + +impl MemoFile { + pub(super) fn workspace_connection(&self) -> std::io::Result { + let conn = self.open_registry_db()?; + conn.execute_batch("CREATE TABLE IF NOT EXISTS notebook_workspace_state ( + notebook_id TEXT PRIMARY KEY REFERENCES notebooks(id) ON DELETE CASCADE, + revision INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS notebook_add_dirs ( + id TEXT NOT NULL, notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, + path TEXT NOT NULL, path_key TEXT NOT NULL, label TEXT NOT NULL, enabled INTEGER NOT NULL, + sort INTEGER NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + PRIMARY KEY(notebook_id,id), UNIQUE(notebook_id,path_key));").map_err(sqlite_to_io)?; + Ok(conn) + } + + /// This is the only remaining reader of old global Agent runtime defaults. + pub fn read_legacy_agent_runtime(&self) -> std::io::Result> { + Ok(self + .read_legacy_agent_access()? + .get("defaults") + .and_then(|d| d.get("runtime")) + .cloned()) + } + fn read_legacy_agent_access(&self) -> std::io::Result { + let path = self.registry.config_dir.join("agent-access.json"); + match fs::read(&path) { + Ok(bytes) => match serde_json::from_slice(&bytes) { + Ok(value) => Ok(value), + Err(_) => { + super::backup_storage_file(&path)?; + Ok(serde_json::Value::Null) + } + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(serde_json::Value::Null), + Err(e) => Err(e), + } + } + pub fn read_notebook_workspace(&self) -> std::io::Result { + let notebooks = self.read_notebook_configs()?; + let conn = self.workspace_connection()?; + let mut notebook_configs = HashMap::new(); + for notebook in ¬ebooks { + let revision = conn + .query_row( + "SELECT revision FROM notebook_workspace_state WHERE notebook_id=?1", + [¬ebook.id], + |r| r.get(0), + ) + .optional() + .map_err(sqlite_to_io)? + .unwrap_or(0); + let mut stmt = conn.prepare("SELECT id,path,label,enabled FROM notebook_add_dirs WHERE notebook_id=?1 ORDER BY sort,id").map_err(sqlite_to_io)?; + let add_dirs = stmt + .query_map([¬ebook.id], |r| { + let path: String = r.get(1)?; + let missing = !Path::new(&path).is_dir(); + Ok(NotebookAddDir { + id: r.get(0)?, + path, + label: r.get(2)?, + enabled: r.get(3)?, + missing, + }) + }) + .map_err(sqlite_to_io)? + .collect::>() + .map_err(sqlite_to_io)?; + notebook_configs.insert( + notebook.id.clone(), + NotebookWorkspaceConfig { + version: 1, + revision, + add_dirs, + }, + ); + } + Ok(NotebookWorkspaceSnapshot { + notebooks, + notebook_configs, + }) + } + pub fn update_notebook_add_dir( + &self, + notebook_id: &str, + path: &str, + attach: bool, + ) -> std::io::Result { + let notebook = self + .read_notebook_configs()? + .into_iter() + .find(|n| n.id == notebook_id) + .ok_or_else(|| std::io::Error::other("notebook not found"))?; + let normalized = normalized_directory(path); + let stored_path = normalized.to_string_lossy(); + let key = directory_key(path); + if attach { + validate_add_dir(¬ebook.path, path, true)?; + } + let mut conn = self.workspace_connection()?; + let tx = conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .map_err(sqlite_to_io)?; + let now = chrono::Utc::now().timestamp_millis(); + let changed = if attach { + let label = Path::new(path) + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + tx.execute("INSERT INTO notebook_add_dirs (id,notebook_id,path,path_key,label,enabled,sort,created_at,updated_at) VALUES (?1,?2,?3,?4,?5,1,COALESCE((SELECT MAX(sort)+1 FROM notebook_add_dirs WHERE notebook_id=?2),0),?6,?6) ON CONFLICT(notebook_id,path_key) DO UPDATE SET enabled=1,updated_at=excluded.updated_at WHERE enabled=0", params![uuid::Uuid::new_v4().to_string(),notebook_id,stored_path,key,label,now]).map_err(sqlite_to_io)? + } else { + // Exact stored paths also allow removing a missing symlink after its target disappeared. + tx.execute( + "DELETE FROM notebook_add_dirs WHERE notebook_id=?1 AND (path_key=?2 OR path=?3)", + params![notebook_id, key, path], + ) + .map_err(sqlite_to_io)? + }; + if changed > 0 { + tx.execute("INSERT INTO notebook_workspace_state VALUES (?1,1) ON CONFLICT(notebook_id) DO UPDATE SET revision=revision+1",[notebook_id]).map_err(sqlite_to_io)?; + } + tx.commit().map_err(sqlite_to_io)?; + self.read_notebook_workspace() + } + pub fn attached_directory_paths(&self) -> std::io::Result> { + let conn = self.workspace_connection()?; + let mut stmt = conn + .prepare("SELECT DISTINCT path FROM notebook_add_dirs WHERE enabled=1") + .map_err(sqlite_to_io)?; + let paths = stmt + .query_map([], |r| r.get::<_, String>(0)) + .map_err(sqlite_to_io)? + .collect::, _>>() + .map_err(sqlite_to_io)?; + Ok(paths + .into_iter() + .map(std::path::PathBuf::from) + .filter(|path| path.is_dir()) + .collect()) + } +} +fn normalized_directory(path: &str) -> std::path::PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| { + let mut normalized = std::path::PathBuf::new(); + for component in Path::new(path).components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized + }) +} +fn directory_key(path: &str) -> String { + let key = normalized_directory(path).to_string_lossy().into_owned(); + #[cfg(target_os = "windows")] + let key = key.to_lowercase(); + key +} +fn validate_add_dir(notebook: &str, path: &str, require_exists: bool) -> std::io::Result<()> { + if !Path::new(path).is_absolute() || (require_exists && !Path::new(path).is_dir()) { + return Err(std::io::Error::other("add-dir is unavailable")); + } + let root = normalized_directory(notebook); + let target = normalized_directory(path); + if root.starts_with(&target) || target.starts_with(root.join(".flowix")) { + return Err(std::io::Error::other("invalid notebook add-dir scope")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> (tempfile::TempDir, MemoFile, NotebookConfig) { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notes"); + fs::create_dir(&root).unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + let notebook = NotebookConfig { + id: "one".into(), + name: "One".into(), + icon: None, + path: root.to_string_lossy().into(), + is_default: true, + sort: 0, + created_at: 1, + updated_at: 1, + }; + memo.write_notebook_configs(std::slice::from_ref(¬ebook)) + .unwrap(); + (temp, memo, notebook) + } + #[test] + fn imports_local_mounts_once_and_does_not_grant_orphan_global_entries() { + let (temp, memo, notebook) = fixture(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + let missing = temp.path().join("missing"); + let source = Path::new(¬ebook.path).join(".flowix/agent.json"); + fs::write( + &source, + serde_json::json!({"version":1,"revision":4,"addDirs":[ + {"id":"repo","path":repo,"label":"Repo","enabled":true}, + {"id":"missing","path":missing,"label":"Missing","enabled":true} + ]}) + .to_string(), + ) + .unwrap(); + fs::write( + temp.path().join("config/agent-access.json"), + r#"{"entries":[{"kind":"folder","path":"/orphan","enabled":true}]}"#, + ) + .unwrap(); + let first = memo.read_notebook_workspace().unwrap(); + assert_eq!(first.notebook_configs["one"].add_dirs.len(), 2); + assert!(first.notebook_configs["one"].add_dirs[1].missing); + assert_eq!( + memo.attached_directory_paths().unwrap(), + vec![dunce::canonicalize(&repo).unwrap()] + ); + memo.update_notebook_add_dir("one", repo.to_str().unwrap(), false) + .unwrap(); + fs::write(&source, "invalid JSON").unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 1 + ); + assert!(memo.attached_directory_paths().unwrap().is_empty()); + } + #[test] + fn shares_paths_across_notebooks_and_cascades_deleted_notebook_mounts() { + let (temp, memo, one) = fixture(); + let mut two = one.clone(); + two.id = "two".into(); + two.path = temp.path().join("two").to_string_lossy().into(); + fs::create_dir(&two.path).unwrap(); + memo.write_notebook_configs(&[one.clone(), two.clone()]) + .unwrap(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + for id in ["one", "two"] { + memo.update_notebook_add_dir(id, repo.to_str().unwrap(), true) + .unwrap(); + } + memo.update_notebook_add_dir("one", repo.to_str().unwrap(), true) + .unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 1 + ); + memo.write_notebook_configs(&[two]).unwrap(); + let conn = memo.workspace_connection().unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM notebook_add_dirs", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + memo.attached_directory_paths().unwrap(), + vec![dunce::canonicalize(&repo).unwrap()] + ); + } + #[test] + fn imports_global_legacy_defaults_and_defers_unavailable_notebooks() { + let (temp, memo, notebook) = fixture(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + fs::write( + temp.path().join("config/agent-access.json"), + serde_json::json!({"defaults":{"files":{"one":{"folders":[repo]}}}}).to_string(), + ) + .unwrap(); + fs::rename(¬ebook.path, temp.path().join("parked")).unwrap(); + assert!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .is_empty() + ); + fs::rename(temp.path().join("parked"), ¬ebook.path).unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 1 + ); + assert!(memo + .update_notebook_add_dir("one", temp.path().to_str().unwrap(), true) + .is_err()); + } + #[test] + fn invalid_legacy_json_remains_retryable() { + let (temp, memo, notebook) = fixture(); + let source = Path::new(¬ebook.path).join(".flowix/agent.json"); + fs::write(&source, "broken").unwrap(); + assert!(memo.read_notebook_workspace().is_err()); + fs::write(&source, r#"{"version":1,"addDirs":[]}"#).unwrap(); + assert!(memo.read_notebook_workspace().is_ok()); + assert!(temp.path().join("config/db/local.db").is_file()); + } +} + +#[cfg(test)] +mod concurrency_tests { + use super::*; + #[test] + fn concurrent_process_connections_preserve_independent_mounts() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config"); + let notes = temp.path().join("notes"); + fs::create_dir(¬es).unwrap(); + let memo = MemoFile::new(config.clone()); + memo.write_notebook_configs(&[NotebookConfig { + id: "one".into(), + name: "One".into(), + icon: None, + path: notes.to_string_lossy().into(), + is_default: true, + sort: 0, + created_at: 1, + updated_at: 1, + }]) + .unwrap(); + memo.read_notebook_workspace().unwrap(); + let paths: Vec<_> = (0..4) + .map(|i| { + let p = temp.path().join(format!("repo{i}")); + fs::create_dir(&p).unwrap(); + p + }) + .collect(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(paths.len())); + let threads: Vec<_> = paths + .iter() + .cloned() + .map(|path| { + let config = config.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let memo = MemoFile::new(config); + barrier.wait(); + memo.update_notebook_add_dir("one", path.to_str().unwrap(), true) + .unwrap(); + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["one"] + .add_dirs + .len(), + 4 + ); + assert_eq!(memo.attached_directory_paths().unwrap().len(), 4); + } +} + +#[cfg(test)] +mod legacy_global_defaults_tests { + use super::*; + #[test] + fn stale_global_defaults_apply_only_to_preupgrade_notebooks() { + let temp = tempfile::tempdir().unwrap(); + let notes = temp.path().join("old"); + fs::create_dir(¬es).unwrap(); + let repo = temp.path().join("repo"); + fs::create_dir(&repo).unwrap(); + let config = temp.path().join("config"); + let memo = MemoFile::new(config.clone()); + let old = NotebookConfig { + id: "old".into(), + name: "Old".into(), + icon: None, + path: notes.to_string_lossy().into(), + is_default: true, + sort: 0, + created_at: 0, + updated_at: 0, + }; + memo.write_notebook_configs(std::slice::from_ref(&old)) + .unwrap(); + fs::write(config.join("agent-access.json"),serde_json::json!({"entries":[{"id":"old","kind":"notebook"}],"defaults":{"files":{"_global":{"folders":[repo]}}}}).to_string()).unwrap(); + assert_eq!( + memo.read_notebook_workspace().unwrap().notebook_configs["old"] + .add_dirs + .len(), + 1 + ); + let mut new = old.clone(); + new.id = "new".into(); + new.path = temp.path().join("new").to_string_lossy().into(); + fs::create_dir(&new.path).unwrap(); + memo.write_notebook_configs(&[old, new]).unwrap(); + assert!( + memo.read_notebook_workspace().unwrap().notebook_configs["new"] + .add_dirs + .is_empty() + ); + } +} diff --git a/app/flowix-core/src/secret/mod.rs b/app/flowix-core/src/secret/mod.rs index 028791bdc..e550ce940 100644 --- a/app/flowix-core/src/secret/mod.rs +++ b/app/flowix-core/src/secret/mod.rs @@ -3,9 +3,8 @@ //! //! ## 存储 //! -//! - 数据库文件: `/default.db` (生产环境 `~/.flowix/default.db`), -//! 与 `index.db` 同目录, 受 `~/.flowix` 目录 `0o700` 权限保护; unix 下 -//! db 文件本身再设一次 `0o600`。 +//! - 数据库文件: `/db/default.db` (生产环境 `~/.flowix/db/default.db`), +//! Unix 下 db 文件权限为 `0o600`。 //! - 表 `secret_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, 通用 //! key-value 形态, 未来可承载其它敏感 / 关键配置, 不止 API key。 //! @@ -167,18 +166,14 @@ impl DbBackend { if self.db_path.exists() { set_file_owner_only_perms(&self.db_path)?; } - let conn = Connection::open(&self.db_path) - .map_err(|e| SecretStoreError::Platform(format!("open db: {e}")))?; - conn.execute_batch( - r#" - PRAGMA secure_delete = ON; - CREATE TABLE IF NOT EXISTS secret_kv ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - "#, - ) - .map_err(|e| SecretStoreError::Platform(format!("init table: {e}")))?; + let conn = crate::memo_file::with_database_rebuild(&self.db_path, || { + let conn = Connection::open(&self.db_path).map_err(std::io::Error::other)?; + conn.execute_batch("PRAGMA secure_delete = ON; + CREATE TABLE IF NOT EXISTS secret_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL); + SELECT key, value FROM secret_kv LIMIT 0;") + .map_err(std::io::Error::other)?; + Ok(conn) + }).map_err(|error| SecretStoreError::Platform(format!("initialize secret database: {error}")))?; set_file_owner_only_perms(&self.db_path)?; Ok(conn) } @@ -225,7 +220,7 @@ impl SecretBackend for DbBackend { /// /// 用法: /// ```ignore -/// let store = SecretStore::new("~/.flowix/default.db"); +/// let store = SecretStore::new("~/.flowix/db/default.db"); /// store.save("openai_responses::default", "sk-...")?; /// let key = store.load("openai_responses::default")?.unwrap(); /// ``` @@ -235,7 +230,7 @@ pub struct SecretStore { impl SecretStore { /// 构造真实 SQLite 后端的 SecretStore ── db 路径由调用方 (UserConfigStore) - /// 按 config_dir 派生 (`~/.flowix/default.db`), desktop / CLI 共用。 + /// 按 config_dir 派生 (`~/.flowix/db/default.db`), desktop / CLI 共用。 pub fn new(db_path: impl AsRef) -> Self { Self { backend: Box::new(DbBackend::new(db_path)), @@ -360,3 +355,20 @@ impl SecretBackend for MockBackend { self.backend_kind } } + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + + #[test] + fn invalid_secret_database_is_preserved_before_creating_empty_credentials() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("default.db"); + std::fs::write(&path, b"invalid credential database").unwrap(); + let backend = DbBackend::new(path); + assert!(backend.load("missing-account").unwrap().is_none()); + backend.save("new-account", "new-value").unwrap(); + assert!(backend.load("new-account").unwrap().is_some()); + assert!(temp.path().join("rebuild-backups").exists()); + } +} diff --git a/app/flowix-desktop/Cargo.toml b/app/flowix-desktop/Cargo.toml index 3c9a82eca..b83e46951 100644 --- a/app/flowix-desktop/Cargo.toml +++ b/app/flowix-desktop/Cargo.toml @@ -50,7 +50,6 @@ fs2 = { workspace = true } dunce = { workspace = true } uuid = { workspace = true } url = "2" -mime_guess = "2" hostname = "0.4" machine-uid = "0.5" sha2 = { workspace = true } diff --git a/app/flowix-desktop/build.rs b/app/flowix-desktop/build.rs index 9de34ab40..c451cd877 100644 --- a/app/flowix-desktop/build.rs +++ b/app/flowix-desktop/build.rs @@ -72,8 +72,10 @@ fn main() { let info_plist = if dev_mode { let source = std::fs::read_to_string(manifest_dir.join("Info.plist")) .expect("failed to read Info.plist"); + println!("cargo:rerun-if-changed={}", manifest_dir.join("Info.plist").display()); let dev = source.replace("flowix", "flowix-dev") - .replace("com.flowix.app.deeplink", "com.flowix.app.dev.deeplink"); + .replace("Flowix", "Flowix Dev") + .replace("com.flowix.app.deeplink", "com.flowix-dev.app.deeplink"); let generated = std::path::PathBuf::from( std::env::var_os("OUT_DIR").expect("OUT_DIR is set"), ).join("Flowix-Dev-Info.plist"); diff --git a/app/flowix-desktop/entitlements.plist b/app/flowix-desktop/entitlements.plist index 65948472e..f8afa6d70 100644 --- a/app/flowix-desktop/entitlements.plist +++ b/app/flowix-desktop/entitlements.plist @@ -66,19 +66,9 @@ --> - + com.apple.security.files.user-selected.read-write - com.apple.security.files.bookmarks.app-scope diff --git a/app/flowix-desktop/src/agent_external/claude/mod.rs b/app/flowix-desktop/src/agent_external/claude/mod.rs index 55ddd7ae4..69dc573ab 100644 --- a/app/flowix-desktop/src/agent_external/claude/mod.rs +++ b/app/flowix-desktop/src/agent_external/claude/mod.rs @@ -4,7 +4,7 @@ mod events; mod history; mod stream; -pub const AGENT_TYPE: &str = "claude"; +pub const AGENT_TYPE: &str = "claudecode"; // History API —— 读取 ~/.claude/projects//*.jsonl,还原为 ChatMessage 流。 pub use history::{get_session, get_session_page, is_claude_session_id}; diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs index 3a42798f3..3c28cb7d1 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/manager.rs @@ -1124,45 +1124,6 @@ impl DeepSeekHarnessManager { Ok(()) } - /// Move any key written by an older Flowix build into DSH, then remove - /// the duplicate only after DSH confirms ownership. - pub async fn migrate_legacy_credentials( - &self, - configs: &[crate::config::AiConfigFile], - ) -> Result<(), String> { - for config in configs { - let route = if config.model.provider_id.trim().is_empty() { - config.model.provider.trim() - } else { - config.model.provider_id.trim() - }; - let Some(secret) = self - .user_config - .legacy_dsh_secret(route) - .map_err(|error| error.to_string())? - .filter(|value| !value.trim().is_empty()) - else { - continue; - }; - let reference = Self::credential_reference(&config.model); - if !self.credential_configured(&reference).await? { - let host = self.model_host().await?; - let result = timed_host_request( - &host.client(), - protocol::credential_set_request(host.next_request_id(), &reference, &secret), - ) - .await; - result?; - } - if self.credential_configured(&reference).await? { - self.user_config - .delete_legacy_dsh_secret(route) - .map_err(|error| error.to_string())?; - } - } - Ok(()) - } - pub async fn persist_credential(&self, config: &AiModelConfig) -> Result<(), String> { let reference = Self::credential_reference(config); let route = if config.provider_id.trim().is_empty() { diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs index 28623c10f..aa1125b56 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/mod.rs @@ -16,4 +16,4 @@ mod transport; pub use config::resolve_runtime_config; pub use manager::{DeepSeekHarnessManager, DeepSeekHarnessSessionUsage}; -pub const AGENT_TYPE: &str = "deepseek-harness"; +pub const AGENT_TYPE: &str = "dsh"; diff --git a/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs b/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs index e2ff484d9..7d95ab2fd 100644 --- a/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs +++ b/app/flowix-desktop/src/agent_external/deepseek_harness/run_coordinator.rs @@ -200,7 +200,7 @@ mod tests { runs.register("t", "r1", Some("s1"), Arc::new(AtomicBool::new(false))) .await.unwrap(); runs.mark_stopping("t", "r1").await; - let snapshot = runs.running_threads("deepseek-harness").await; + let snapshot = runs.running_threads("dsh").await; assert_eq!(snapshot["t"].phase.as_deref(), Some("stopping")); assert!(runs.register("t", "r2", None, Arc::new(AtomicBool::new(false))).await.is_err()); assert!(runs.remove_if_matches("t", "r1").await); diff --git a/app/flowix-desktop/src/agent_external/lifecycle.rs b/app/flowix-desktop/src/agent_external/lifecycle.rs index 5297826e4..014a803ae 100644 --- a/app/flowix-desktop/src/agent_external/lifecycle.rs +++ b/app/flowix-desktop/src/agent_external/lifecycle.rs @@ -233,7 +233,7 @@ mod tests { image_paths: Vec::new(), run_id: None, system_reminder_directory: None, - agent_type: Some("claude".to_string()), + agent_type: Some("claudecode".to_string()), runtime_config: None, permission_mode: None, codex_model: None, diff --git a/app/flowix-desktop/src/agent_external/pi/config.rs b/app/flowix-desktop/src/agent_external/pi/config.rs index 747f65192..95540dc99 100644 --- a/app/flowix-desktop/src/agent_external/pi/config.rs +++ b/app/flowix-desktop/src/agent_external/pi/config.rs @@ -32,6 +32,10 @@ pub struct PiProviderConfig { pub display_name: String, pub api: String, pub base_url: String, + /// Flowix-owned provider source, surfaced for settings UI classification. + /// Stored in Pi settings.json so Pi's native models.json stays provider-only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source: Option, #[serde(default)] pub api_key: Option, #[serde(default)] @@ -65,8 +69,10 @@ pub struct PiModelCatalog { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PiFeatures { - pub code_mode: bool, pub tool_search: bool, + /// Read-only input for the one-time frontend preference migration. + #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")] + pub legacy_code_mode: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -79,9 +85,9 @@ pub struct PiMcpServer { pub fn features() -> Result { let dir = pi_config_dir()?; let settings = read_json(&dir.join("settings.json"))?; - let features: PiFeatures = - serde_json::from_value(settings.get("flowixFeatures").cloned().unwrap_or_default()) - .unwrap_or_default(); + let value = settings.get("flowixFeatures").cloned().unwrap_or_default(); + let mut features: PiFeatures = serde_json::from_value(value.clone()).unwrap_or_default(); + features.legacy_code_mode = value.get("codeMode").and_then(Value::as_bool); Ok(features) } @@ -90,11 +96,26 @@ pub fn save_features(features: PiFeatures) -> Result<(), String> { fs::create_dir_all(&dir).map_err(|error| error.to_string())?; let path = dir.join("settings.json"); let mut settings = read_json(&path)?; - settings["flowixFeatures"] = - serde_json::to_value(features).map_err(|error| error.to_string())?; + if !settings["flowixFeatures"].is_object() { + settings["flowixFeatures"] = json!({}); + } + settings["flowixFeatures"]["toolSearch"] = json!(features.tool_search); write_json(&path, &settings) } +pub fn clear_legacy_code_mode() -> Result<(), String> { + let path = pi_config_dir()?.join("settings.json"); + let mut settings = read_json(&path)?; + let removed = settings + .get_mut("flowixFeatures") + .and_then(Value::as_object_mut) + .and_then(|features| features.remove("codeMode")); + if removed.is_some() { + write_json(&path, &settings)?; + } + Ok(()) +} + fn mcp_config_path() -> Result { Ok(pi_config_dir()?.join("mcp.json")) } @@ -343,6 +364,9 @@ pub fn list() -> Result, String> { let settings = read_json(&dir.join("settings.json"))?; let default_provider = settings.get("defaultProvider").and_then(Value::as_str); let default_model = settings.get("defaultModel").and_then(Value::as_str); + let cloud_provider_id = settings + .get("flowixCloudProviderId") + .and_then(Value::as_str); let providers = models .get("providers") .and_then(Value::as_object) @@ -400,6 +424,8 @@ pub fn list() -> Result, String> { .and_then(Value::as_str) .unwrap_or_default() .to_string(), + source: (cloud_provider_id == Some(id.as_str())) + .then(|| "flowix-cloud".to_string()), api_key: None, credential_configured: auth .get(&id) @@ -495,6 +521,7 @@ pub fn save(mut config: PiProviderConfig, app: &AppHandle) -> Result<(), String> let settings_path = dir.join("settings.json"); let mut settings = read_json(&settings_path)?; + let mut settings_changed = false; if let Some(default_model) = config .default_model_id .as_deref() @@ -502,12 +529,19 @@ pub fn save(mut config: PiProviderConfig, app: &AppHandle) -> Result<(), String> { settings["defaultProvider"] = json!(id); settings["defaultModel"] = json!(default_model); - write_json(&settings_path, &settings)?; + settings_changed = true; } else if settings.get("defaultProvider").and_then(Value::as_str) == Some(id.as_str()) { if let Some(settings) = settings.as_object_mut() { settings.remove("defaultProvider"); settings.remove("defaultModel"); } + settings_changed = true; + } + if config.source.as_deref() == Some("flowix-cloud") { + settings["flowixCloudProviderId"] = json!(id); + settings_changed = true; + } + if settings_changed { write_json(&settings_path, &settings)?; } @@ -539,11 +573,21 @@ pub fn delete(provider_id: &str) -> Result<(), String> { write_json(&models_path, &models)?; let settings_path = dir.join("settings.json"); let mut settings = read_json(&settings_path)?; + let mut settings_changed = false; if settings.get("defaultProvider").and_then(Value::as_str) == Some(id) { if let Some(settings) = settings.as_object_mut() { settings.remove("defaultProvider"); settings.remove("defaultModel"); } + settings_changed = true; + } + if settings.get("flowixCloudProviderId").and_then(Value::as_str) == Some(id) { + if let Some(settings) = settings.as_object_mut() { + settings.remove("flowixCloudProviderId"); + } + settings_changed = true; + } + if settings_changed { write_json(&settings_path, &settings)?; } let auth_path = dir.join("auth.json"); @@ -554,6 +598,49 @@ pub fn delete(provider_id: &str) -> Result<(), String> { write_json(&auth_path, &auth) } +pub fn legacy_cloud_provider_id(api_base: &str) -> Result, String> { + let dir = pi_config_dir()?; + let models = read_json(&dir.join("models.json"))?; + let auth = read_json(&dir.join("auth.json"))?; + let provider_id = "flowix-cloud"; + let expected_base_url = format!("{}/v1/ai", api_base.trim_end_matches('/')); + let provider = models + .get("providers") + .and_then(Value::as_object) + .and_then(|providers| providers.get(provider_id)); + let looks_like_cloud_provider = provider.is_some_and(|provider| { + provider.get("name").and_then(Value::as_str) == Some("Flowix Cloud") + && provider.get("api").and_then(Value::as_str) == Some("openai-completions") + && provider.get("baseUrl").and_then(Value::as_str) == Some(&expected_base_url) + }); + let has_cloud_credential = auth + .get(provider_id) + .and_then(|entry| entry.get("key")) + .and_then(Value::as_str) + .is_some_and(|key| key.starts_with("fai_")); + Ok((looks_like_cloud_provider && has_cloud_credential).then(|| provider_id.to_string())) +} + +pub fn delete_cloud_provider(api_base: &str) -> Result<(), String> { + let dir = pi_config_dir()?; + let settings = read_json(&dir.join("settings.json"))?; + let marked_provider_id = settings + .get("flowixCloudProviderId") + .and_then(Value::as_str) + .map(str::to_string); + let legacy_provider_id = legacy_cloud_provider_id(api_base)?; + if let Some(provider_id) = marked_provider_id { + delete(&provider_id)?; + if legacy_provider_id.as_deref() == Some(provider_id.as_str()) { + return Ok(()); + } + } + if let Some(provider_id) = legacy_provider_id { + delete(&provider_id)?; + } + Ok(()) +} + pub async fn test_connection(config: PiProviderConfig) -> Result { let dir = pi_config_dir()?; let auth = read_json(&dir.join("auth.json"))?; diff --git a/app/flowix-desktop/src/agent_external/pi/runtime.rs b/app/flowix-desktop/src/agent_external/pi/runtime.rs index 26e5aaf3a..4d2eaa8fe 100644 --- a/app/flowix-desktop/src/agent_external/pi/runtime.rs +++ b/app/flowix-desktop/src/agent_external/pi/runtime.rs @@ -1,5 +1,5 @@ use std::collections::{HashMap, VecDeque}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::Arc; @@ -715,20 +715,6 @@ impl ExternalLifecycleEmitter for PiRpcManager { impl PiRpcManager { pub fn new(thread_manager: Arc) -> Self { - #[cfg(windows)] - { - static INITIALIZE_SESSIONS: std::sync::Once = std::sync::Once::new(); - INITIALIZE_SESSIONS.call_once(|| { - if let Some(local) = std::env::var_os("LOCALAPPDATA") { - if let Err(error) = - crate::runtime_state::migrate_pi_sessions(std::path::Path::new(&local)) - { - // Legacy conversations remain accessible in place. - tracing::warn!("Pi session migration deferred: {error}"); - } - } - }); - } Self { thread_manager, sessions: Mutex::new(HashMap::new()), @@ -989,13 +975,11 @@ impl PiRpcManager { session.shutdown(thread_id).await; } let mut deleted = false; - for candidate in pi_session_candidates(thread_id)? { - match std::fs::remove_dir_all(candidate) { - Ok(()) => deleted = true, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!("could not delete Pi conversation session: {error}")); - } + match std::fs::remove_dir_all(pi_session_dir(thread_id)?) { + Ok(()) => deleted = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("could not delete Pi conversation session: {error}")); } } Ok(deleted) @@ -1836,7 +1820,7 @@ impl PiRpcManager { tools: pi_tools_for_permission_mode(permission_mode).into(), code_mode: message .code_mode_for_runtime(AGENT_TYPE) - .unwrap_or(pi_features.code_mode), + .unwrap_or(false), tool_search: pi_features.tool_search, provider: provider.clone().filter(|value| !value.trim().is_empty()), model: runtime_model.clone(), @@ -3006,14 +2990,6 @@ fn pi_session_dir(thread_id: &str) -> Result { Ok(root.join(stable_name)) } -fn legacy_pi_session_dir(thread_id: &str) -> Result { - let root = pi_session_root()?; - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - thread_id.hash(&mut hasher); - Ok(root.join(format!("{:016x}", hasher.finish()))) -} - fn pi_session_root() -> Result { #[cfg(windows)] { @@ -3026,57 +3002,8 @@ fn pi_session_root() -> Result { .ok_or_else(|| "user data directory is unavailable".to_string()) } -fn pi_session_candidates(thread_id: &str) -> Result, String> { - #[allow(unused_mut)] - let mut candidates = vec![ - pi_session_dir(thread_id)?, - legacy_pi_session_dir(thread_id)?, - ]; - #[cfg(windows)] - { - let local = std::env::var_os("LOCALAPPDATA").ok_or("LOCALAPPDATA is unavailable")?; - let old_root = PathBuf::from(local).join("Flowix").join("pi-sessions"); - candidates.push( - old_root.join( - candidates[0] - .file_name() - .ok_or("Pi session path has no name")?, - ), - ); - candidates.push( - old_root.join( - candidates[1] - .file_name() - .ok_or("Pi session path has no name")?, - ), - ); - } - Ok(candidates) -} - fn resolve_pi_session_dir(thread_id: &str, create: bool) -> Result { - #[cfg(windows)] - let path = - crate::runtime_state::resolve_pi_session_candidates(&pi_session_candidates(thread_id)?)?; - #[cfg(not(windows))] - let path = { - let stable = pi_session_dir(thread_id)?; - let legacy = legacy_pi_session_dir(thread_id)?; - if !stable.exists() && legacy.exists() { - std::fs::create_dir_all(stable.parent().ok_or("Pi session path has no parent")?) - .map_err(|error| format!("cannot create Pi session directory: {error}"))?; - match std::fs::rename(&legacy, &stable) { - Ok(()) => {} - Err(_) if stable.exists() => {} - Err(error) => { - return Err(format!( - "could not migrate Pi conversation session: {error}" - )) - } - } - } - stable - }; + let path = pi_session_dir(thread_id)?; if create { std::fs::create_dir_all(&path) .map_err(|error| format!("cannot create Pi session directory: {error}"))?; diff --git a/app/flowix-desktop/src/agent_external/runtime_registry.rs b/app/flowix-desktop/src/agent_external/runtime_registry.rs index b3f36fd39..1a5386101 100644 --- a/app/flowix-desktop/src/agent_external/runtime_registry.rs +++ b/app/flowix-desktop/src/agent_external/runtime_registry.rs @@ -45,7 +45,7 @@ impl ExternalRuntimeKind { Self::DeepSeekHarness, ]; - pub const fn key(self) -> &'static str { + pub const fn id(self) -> &'static str { match self { Self::Codex => CODEX_AGENT_TYPE, Self::Claude => CLAUDE_AGENT_TYPE, @@ -59,11 +59,11 @@ impl ExternalRuntimeKind { pub fn parse(value: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "codex" => Ok(Self::Codex), - "claude" => Ok(Self::Claude), + "claudecode" | "claude" => Ok(Self::Claude), "hermes" => Ok(Self::Hermes), "opencode" => Ok(Self::OpenCode), "pi" => Ok(Self::Pi), - "deepseek-harness" | "deepseek_harness" | "dsh" => Ok(Self::DeepSeekHarness), + "dsh" | "deepseek-harness" | "deepseek_harness" => Ok(Self::DeepSeekHarness), other => Err(format!("unsupported agent type: {other}")), } } @@ -73,8 +73,8 @@ impl ExternalRuntimeKind { pub trait ExternalCliRuntime: Send + Sync { fn kind(&self) -> ExternalRuntimeKind; - fn key(&self) -> &'static str { - self.kind().key() + fn id(&self) -> &'static str { + self.kind().id() } async fn chat_stream( @@ -669,7 +669,7 @@ impl ExternalRuntimeRegistry { if registry.insert(kind, runtime).is_some() { return Err(format!( "external runtime is registered more than once for {}", - kind.key() + kind.id() )); } } @@ -1052,7 +1052,7 @@ fn merge_run_snapshot(target: &mut HashMap, snapshot: RuntimeRu if snapshot.info.started_at > existing.started_at { tracing::warn!( thread_id = %snapshot.thread_id, - kept_runtime = snapshot.runtime.key(), + kept_runtime = snapshot.runtime.id(), dropped_runtime = existing.agent_type.as_deref().unwrap_or("unknown"), "multiple runtimes reported the same running thread; keeping the newest run" ); @@ -1061,7 +1061,7 @@ fn merge_run_snapshot(target: &mut HashMap, snapshot: RuntimeRu tracing::warn!( thread_id = %snapshot.thread_id, kept_runtime = existing.agent_type.as_deref().unwrap_or("unknown"), - dropped_runtime = snapshot.runtime.key(), + dropped_runtime = snapshot.runtime.id(), "multiple runtimes reported the same running thread; keeping the newest run" ); } diff --git a/app/flowix-desktop/src/agent_external/shared/lifecycle.rs b/app/flowix-desktop/src/agent_external/shared/lifecycle.rs index 0d8517a55..fefa9124e 100644 --- a/app/flowix-desktop/src/agent_external/shared/lifecycle.rs +++ b/app/flowix-desktop/src/agent_external/shared/lifecycle.rs @@ -249,7 +249,7 @@ pub async fn persist_external_chunk_for_thread_with_metadata( // local journal is retained only for Claude. Codex and DSH messages must // come from their provider history APIs; their live chunks are runtime // state only and must never become a second local transcript. - if agent_type != "claude" { + if agent_type != "claudecode" { return; } let payload_json = match chunk_payload_json(chunk, agent_type, run_id, metadata) { diff --git a/app/flowix-desktop/src/agent_external_config.rs b/app/flowix-desktop/src/agent_external_config.rs index e6ef3b92b..8e5098f3a 100644 --- a/app/flowix-desktop/src/agent_external_config.rs +++ b/app/flowix-desktop/src/agent_external_config.rs @@ -1,5 +1,5 @@ //! External CLI path config ── 持久化在 `~/.flowix/agent-external-config.json`�?//! -//! 作为�?external agent (codex / claude / gemini / hermes / openclaw) 执�?�?�� +//! External CLI paths for supported Agents are stored here. //! �?�?��参照": //! - �?��时探测一�?(`run_startup_detect`), 把命�?�� path 写入文件并灌�?//! `agent_external::cli_resolver::REGISTRY`; 此后运�?�?`resolve_external_cli` //! 命中即用, 不再每条消息跑探测链�?//! - `source = "user"` 的条�?��动探测跳�? 尊重用户在偏好�?�?��手改的路径�?//! - path 失效 (文件�?��) 不自�?fallback ── �?`executable_available` �?false @@ -18,8 +18,26 @@ use crate::agent_external::cli_resolver::{ }; use crate::agent_external::{claude, codex, hermes, opencode}; -/// 5 �?external agent �?binary_name (= AgentTypeKey = registry key)�?/// 顺序影响偏好设置列表呈现, 不影响逻辑�? -pub const EXTERNAL_AGENT_KEYS: &[&str] = &["codex", "claude", "hermes", "opencode"]; +/// Stable IDs for the external CLI Agents configured by this module. +pub const EXTERNAL_AGENT_KEYS: &[&str] = &["codex", "claudecode", "hermes", "opencode"]; + +fn canonical_external_agent_key(agent_key: &str) -> Option<&'static str> { + match agent_key { + "codex" => Some("codex"), + "claude" | "claudecode" => Some("claudecode"), + "hermes" => Some("hermes"), + "opencode" => Some("opencode"), + _ => None, + } +} + +fn external_binary_name(agent_key: &str) -> &str { + if agent_key == "claudecode" { "claude" } else { agent_key } +} + +fn is_supported_external_agent_key(agent_key: &str) -> bool { + EXTERNAL_AGENT_KEYS.contains(&agent_key) +} pub struct AgentExternalConfig { path: PathBuf, @@ -42,6 +60,10 @@ pub struct AgentExternalEntry { /// `auto` = �?��探测写入 (�??下�?�?��覆盖); `user` = 用户手改 (�?��探测跳过)�? #[serde(default = "default_source")] pub source: AgentExternalSource, + /// Whether this path was explicitly configured or discovery has completed. + /// A missing path before that point means unknown, not uninstalled. + #[serde(default)] + pub detected: bool, } fn default_source() -> AgentExternalSource { @@ -66,11 +88,42 @@ impl AgentExternalConfig { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let data = Self::read_from_disk(&path).unwrap_or_default(); - Ok(Self { + let mut data = flowix_core::memo_file::read_or_create_json(&path, AgentExternalConfigFile::default)?; + // Canonicalize the one renamed external Agent ID on first load. If a + // canonical entry already exists, it wins; otherwise carry the user's + // existing path/source/detection state across unchanged. + let migrated_legacy_key = if let Some(legacy) = data.agents.remove("claude") { + match data.agents.remove("claudecode") { + Some(current) => { + let chosen = match (current.source, legacy.source) { + (AgentExternalSource::User, _) => current, + (_, AgentExternalSource::User) => legacy, + _ if current.path.is_some() || current.detected => current, + _ => legacy, + }; + data.agents.insert("claudecode".to_string(), chosen); + } + None => { + data.agents.insert("claudecode".to_string(), legacy); + } + } + true + } else { + false + }; + let original_entry_count = data.agents.len(); + data.agents + .retain(|key, _| is_supported_external_agent_key(key)); + let removed_unsupported_entries = migrated_legacy_key || data.agents.len() != original_entry_count; + let config = Self { path, data: RwLock::new(data), - }) + }; + if removed_unsupported_entries { + let sanitized = config.read_data().clone(); + config.flush(&sanitized)?; + } + Ok(config) } pub fn transient(path: PathBuf) -> Self { @@ -98,22 +151,6 @@ impl AgentExternalConfig { }) } - fn read_from_disk(path: &Path) -> Option { - if !path.exists() { - return None; - } - let content = fs::read_to_string(path).ok()?; - match serde_json::from_str::(&content) { - Ok(data) => Some(data), - Err(e) => { - tracing::warn!( - "agent-external-config.json parse error: {e}, falling back to empty" - ); - None - } - } - } - fn flush(&self, data: &AgentExternalConfigFile) -> std::io::Result<()> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; @@ -138,9 +175,10 @@ impl AgentExternalConfig { /// 读取�?agent 的当前条�?(path + source)�? pub fn get_entry(&self, agent_key: &str) -> AgentExternalEntry { - self.read_data() - .agents - .get(agent_key) + let Some(agent_key) = canonical_external_agent_key(agent_key) else { + return AgentExternalEntry::default(); + }; + self.read_data().agents.get(agent_key) .cloned() .unwrap_or_default() } @@ -156,16 +194,23 @@ impl AgentExternalConfig { agent_key: &str, path: PathBuf, ) -> std::io::Result { + let Some(agent_key) = canonical_external_agent_key(agent_key) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unsupported external Agent: {agent_key}"), + )); + }; let entry = AgentExternalEntry { path: Some(path.clone()), source: AgentExternalSource::User, + detected: true, }; { let mut data = self.write_data(); data.agents.insert(agent_key.to_string(), entry.clone()); self.flush(&data)?; } - update_external_cli_path(agent_key, Some(path)); + update_external_cli_path(external_binary_name(agent_key), Some(path)); Ok(entry) } @@ -201,6 +246,7 @@ impl AgentExternalConfig { let entry = AgentExternalEntry { path, source: AgentExternalSource::Auto, + detected: true, }; data.agents.insert(key.to_string(), entry); changed = true; @@ -222,7 +268,7 @@ impl AgentExternalConfig { let mut map: HashMap = HashMap::new(); for &key in EXTERNAL_AGENT_KEYS { if let Some(p) = data.agents.get(key).and_then(|e| e.path.clone()) { - map.insert(key.to_string(), p); + map.insert(external_binary_name(key).to_string(), p); } } set_external_cli_registry(map); @@ -231,8 +277,14 @@ impl AgentExternalConfig { /// 重新探测单个 agent: 清注册表该项 -> 跑探�?-> �?`source = auto` -> /// 更新注册表。返回探测到�?path (`None` = 没探测到)�? pub fn redetect(&self, agent_key: &str) -> std::io::Result> { + let Some(agent_key) = canonical_external_agent_key(agent_key) else { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unsupported external Agent: {agent_key}"), + )); + }; // Remove the cached path before running the fallback detection chain. - update_external_cli_path(agent_key, None); + update_external_cli_path(external_binary_name(agent_key), None); let detected = detect_external_binary(agent_key); { let mut data = self.write_data(); @@ -241,13 +293,14 @@ impl AgentExternalConfig { AgentExternalEntry { path: detected.clone(), source: AgentExternalSource::Auto, + detected: true, }, ); self.flush(&data)?; } // 探测到才回填注册�? 没探测到保持移除状�?(�?agent �?unavailable)�? if let Some(p) = detected.clone() { - update_external_cli_path(agent_key, Some(p)); + update_external_cli_path(external_binary_name(agent_key), Some(p)); } Ok(detected) } @@ -258,7 +311,7 @@ impl AgentExternalConfig { fn detect_external_binary(agent_key: &str) -> Option { let path = match agent_key { "codex" => codex::resolve_codex_binary(), - "claude" => claude::cli::resolve_claude_binary(), + "claudecode" => claude::cli::resolve_claude_binary(), "hermes" => hermes::cli::resolve_hermes_binary(), "opencode" => opencode::resolve_opencode_binary(), other => { diff --git a/app/flowix-desktop/src/agent_history.rs b/app/flowix-desktop/src/agent_history.rs index bc9c9f2aa..9c71f2cb5 100644 --- a/app/flowix-desktop/src/agent_history.rs +++ b/app/flowix-desktop/src/agent_history.rs @@ -77,7 +77,7 @@ impl AgentHistoryService { if registry.insert(runtime, adapter).is_some() { return Err(format!( "history adapter is registered more than once for {}", - runtime.key() + runtime.id() )); } } @@ -136,13 +136,13 @@ impl AgentHistoryService { let before = page.oldest_sequence.ok_or_else(|| { format!( "{} history reported more pages without a cursor", - runtime.key() + runtime.id() ) })?; if previous_cursor == Some(before) { return Err(format!( "{} history cursor did not advance at {before}", - runtime.key() + runtime.id() )); } previous_cursor = Some(before); @@ -164,7 +164,7 @@ impl AgentHistoryService { fn adapter(&self, runtime: ExternalRuntimeKind) -> Result<&Arc, String> { self.adapters .get(&runtime) - .ok_or_else(|| format!("history adapter is not registered for {}", runtime.key())) + .ok_or_else(|| format!("history adapter is not registered for {}", runtime.id())) } } diff --git a/app/flowix-desktop/src/agent_history/adapters.rs b/app/flowix-desktop/src/agent_history/adapters.rs index 666ff6faa..968452569 100644 --- a/app/flowix-desktop/src/agent_history/adapters.rs +++ b/app/flowix-desktop/src/agent_history/adapters.rs @@ -60,7 +60,7 @@ impl HistoryAdapter for CodexHistoryAdapter { ) -> Result { let session_id = self .threads - .get_external_session(request.thread_id, self.runtime().key()) + .get_external_session(request.thread_id, self.runtime().id()) .await .map_err(|error| error.to_string())? .unwrap_or_else(|| request.thread_id.to_string()); @@ -144,7 +144,7 @@ impl HistoryAdapter for ClaudeHistoryAdapter { } async fn list_threads(&self) -> Result, String> { self.threads - .list_external_threads(self.runtime().key()) + .list_external_threads(self.runtime().id()) .await .map_err(|error| error.to_string()) } @@ -187,7 +187,7 @@ impl HistoryAdapter for HermesHistoryAdapter { } async fn list_threads(&self) -> Result, String> { self.threads - .list_external_threads(self.runtime().key()) + .list_external_threads(self.runtime().id()) .await .map_err(|error| error.to_string()) } @@ -198,7 +198,7 @@ impl HistoryAdapter for HermesHistoryAdapter { if let Some(page) = self .threads .get_external_event_messages_page( - self.runtime().key(), + self.runtime().id(), request.thread_id, request.before_sequence, request.limit, diff --git a/app/flowix-desktop/src/agent_lifecycle.rs b/app/flowix-desktop/src/agent_lifecycle.rs index 3d31bbb8e..d6e08588a 100644 --- a/app/flowix-desktop/src/agent_lifecycle.rs +++ b/app/flowix-desktop/src/agent_lifecycle.rs @@ -87,7 +87,7 @@ impl AgentLifecycleService { if registry.insert(runtime, adapter).is_some() { return Err(format!( "lifecycle adapter is registered more than once for {}", - runtime.key() + runtime.id() )); } } @@ -204,7 +204,7 @@ impl ThreadLifecycleAdapter for CodexLifecycleAdapter { // 3. otherwise the conversation never reached the provider let codex_thread_id = match self .threads - .get_external_session(thread_id, self.runtime().key()) + .get_external_session(thread_id, self.runtime().id()) .await .map_err(|error| error.to_string())? { @@ -273,9 +273,9 @@ mod tests { .apply("claude", "thread-1", LifecycleAction::Delete) .await .unwrap()); - // Unparseable types (local agents) keep local-only semantics too. + // Unsupported types keep local-only semantics too. assert!(!service - .apply("gemini", "thread-1", LifecycleAction::Delete) + .apply("unsupported-agent", "thread-1", LifecycleAction::Delete) .await .unwrap()); // A never-started Codex conversation has no provider thread, so no diff --git a/app/flowix-desktop/src/agent_session/migrations.rs b/app/flowix-desktop/src/agent_session/migrations.rs index f13319575..f8243f2af 100644 --- a/app/flowix-desktop/src/agent_session/migrations.rs +++ b/app/flowix-desktop/src/agent_session/migrations.rs @@ -10,7 +10,7 @@ use rusqlite::{params, Connection}; use super::error::ThreadError; -pub(super) const THREAD_DB_SCHEMA_VERSION: i64 = 13; +pub(super) const THREAD_DB_SCHEMA_VERSION: i64 = 14; pub(super) const WAL_AUTOCHECKPOINT_PAGES: i64 = 4000; pub(super) const WAL_JOURNAL_SIZE_LIMIT_BYTES: i64 = 64 * 1024 * 1024; @@ -34,6 +34,12 @@ impl super::store::ThreadManager { let current_version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; if current_version == THREAD_DB_SCHEMA_VERSION { + // A version marker can survive manual table deletion. Validate only + // the essential schema; this does not scan conversation contents. + conn.execute_batch("SELECT thread_id FROM threads LIMIT 0; + SELECT id, thread_id, content FROM thread_messages LIMIT 0; + SELECT instance_id FROM agent_conversation_instances LIMIT 0; + SELECT id FROM threads_index LIMIT 0;")?; return Ok(()); } if current_version > THREAD_DB_SCHEMA_VERSION { @@ -156,6 +162,7 @@ impl super::store::ThreadManager { Self::ensure_simplified_thread_schema(conn)?; Self::ensure_pi_thread_metadata_columns(conn)?; Self::ensure_path_and_legacy_role_columns(conn)?; + Self::migrate_agent_identity_ids(conn)?; // Role files are no longer part of conversations. Clear values from // historical columns before marking the schema current. conn.execute_batch( @@ -178,6 +185,55 @@ impl super::store::ThreadManager { Ok(()) } + /// Move persisted provider identities to the stable product IDs. This is + /// deliberately data-only: messages, provider session IDs, and runtime + /// configuration snapshots are left untouched. + fn migrate_agent_identity_ids(conn: &mut Connection) -> Result<(), ThreadError> { + let tx = conn.transaction()?; + tx.execute_batch( + " + UPDATE threads SET agent_id = 'claudecode' WHERE agent_id = 'claude'; + UPDATE threads SET agent_id = 'dsh' WHERE agent_id = 'deepseek-harness'; + + UPDATE agent_conversation_instances SET agent_type = 'claudecode' WHERE agent_type = 'claude'; + UPDATE agent_conversation_instances SET agent_type = 'dsh' WHERE agent_type = 'deepseek-harness'; + + UPDATE agent_instances SET agent = 'claudecode' WHERE agent = 'claude'; + UPDATE agent_instances SET agent = 'dsh' WHERE agent = 'deepseek-harness'; + + -- A canonical row wins if both old and new runtime bindings exist. + DELETE FROM thread_external_sessions + WHERE runtime IN ('claude', 'deepseek-harness') + AND EXISTS ( + SELECT 1 FROM thread_external_sessions current + WHERE (current.thread_id = thread_external_sessions.thread_id + OR current.external_session_id = thread_external_sessions.external_session_id) + AND current.runtime = CASE thread_external_sessions.runtime + WHEN 'claude' THEN 'claudecode' ELSE 'dsh' END + ); + UPDATE thread_external_sessions SET runtime = 'claudecode' WHERE runtime = 'claude'; + UPDATE thread_external_sessions SET runtime = 'dsh' WHERE runtime = 'deepseek-harness'; + + -- Event journals can contain idempotency keys. Remove only exact + -- old/new key collisions before changing the runtime discriminator. + DELETE FROM agent_external_events + WHERE runtime IN ('claude', 'deepseek-harness') + AND event_key IS NOT NULL AND trim(event_key) <> '' + AND EXISTS ( + SELECT 1 FROM agent_external_events current + WHERE current.thread_id = agent_external_events.thread_id + AND current.event_key = agent_external_events.event_key + AND current.runtime = CASE agent_external_events.runtime + WHEN 'claude' THEN 'claudecode' ELSE 'dsh' END + ); + UPDATE agent_external_events SET runtime = 'claudecode' WHERE runtime = 'claude'; + UPDATE agent_external_events SET runtime = 'dsh' WHERE runtime = 'deepseek-harness'; + ", + )?; + tx.commit()?; + Ok(()) + } + /// Additive first phase of the thread-store migration. /// /// The new tables deliberately contain only product identity and external diff --git a/app/flowix-desktop/src/agent_session/store.rs b/app/flowix-desktop/src/agent_session/store.rs index aa0e46f78..24f205591 100644 --- a/app/flowix-desktop/src/agent_session/store.rs +++ b/app/flowix-desktop/src/agent_session/store.rs @@ -22,11 +22,12 @@ use super::error::ThreadError; pub struct ThreadManager { conn: Mutex, + db_path: Option, } fn external_default_title(runtime: &str) -> &'static str { match runtime { - "claude" => "Claude Code session", + "claudecode" => "Claude Code session", "hermes" => "Hermes session", "opencode" => "OpenCode session", _ => "Codex session", @@ -42,18 +43,51 @@ impl ThreadManager { } pub fn new(db_path: PathBuf) -> Result { - let mut conn = Connection::open(db_path)?; - Self::run_migrations(&mut conn)?; + let manager = Self::new_uninitialized(db_path)?; + manager.initialize_storage()?; + Ok(manager) + } + + /// Defer disk access and schema initialization to the startup worker. + pub fn new_uninitialized(db_path: PathBuf) -> Result { + // Disk initialization belongs to the startup worker, including repair. + let conn = Connection::open_in_memory()?; Ok(Self { conn: Mutex::new(conn), + db_path: Some(db_path), }) } + pub fn initialize_storage(&self) -> Result<(), ThreadError> { + let mut connection = self.lock_conn(); + let Some(path) = self.db_path.as_ref() else { + return Self::run_migrations(&mut connection); + }; + // Always reopen the disk path on retry. A failed attempt must never + // turn the temporary connection into a successful in-memory store. + let old = std::mem::replace(&mut *connection, Connection::open_in_memory()?); + drop(old); + let initialized = flowix_core::memo_file::with_database_rebuild(path, || { + let mut conn = Connection::open(path).map_err(std::io::Error::other)?; + Self::run_migrations(&mut conn).map_err(|error| { + if matches!(error, ThreadError::UnsupportedSchemaVersion { .. }) { + std::io::Error::new(std::io::ErrorKind::InvalidData, error) + } else { + std::io::Error::other(error) + } + })?; + Ok(conn) + }).map_err(|error| ThreadError::Join(format!("initialize thread database: {error}")))?; + *connection = initialized; + Ok(()) + } + pub fn new_in_memory() -> Result { let mut conn = Connection::open_in_memory()?; Self::run_migrations(&mut conn)?; Ok(Self { conn: Mutex::new(conn), + db_path: None, }) } @@ -163,3 +197,40 @@ impl ThreadManager { Ok(()) } } + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + + #[test] + fn invalid_thread_database_is_backed_up_and_recreated() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("thread.db"); + std::fs::write(&path, b"invalid thread database").unwrap(); + let manager = ThreadManager::new(path).unwrap(); + manager.initialize_storage().unwrap(); + let count: i64 = manager + .lock_conn() + .query_row("SELECT COUNT(*) FROM threads", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + assert!(temp.path().join("rebuild-backups").exists()); + } + + #[test] + fn completed_schema_marker_does_not_hide_missing_tables() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("thread.db"); + let manager = ThreadManager::new(path).unwrap(); + manager + .lock_conn() + .execute_batch("PRAGMA foreign_keys=OFF; DROP TABLE thread_messages;") + .unwrap(); + manager.initialize_storage().unwrap(); + manager + .lock_conn() + .execute_batch("SELECT id FROM thread_messages LIMIT 0;") + .unwrap(); + assert!(temp.path().join("rebuild-backups").exists()); + } +} diff --git a/app/flowix-desktop/src/agent_session/store/conversations.rs b/app/flowix-desktop/src/agent_session/store/conversations.rs index 0987b0639..3537c3973 100644 --- a/app/flowix-desktop/src/agent_session/store/conversations.rs +++ b/app/flowix-desktop/src/agent_session/store/conversations.rs @@ -3,8 +3,8 @@ use super::ThreadManager; use crate::agent_session::error::ThreadError; use crate::agent_session::types::{ - AgentConversationCursor, AgentConversationInstance, - AgentConversationSource, AgentConversationTypeCount, UpsertAgentConversationInstance, + AgentConversationCursor, AgentConversationInstance, AgentConversationSource, + AgentConversationTypeCount, UpsertAgentConversationInstance, }; use rusqlite::{params, OptionalExtension}; use std::path::PathBuf; @@ -41,21 +41,44 @@ impl ThreadManager { let new_relative = new_relative.replace('\\', "/"); let old_absolute = old_absolute.replace('\\', "/"); let new_absolute = new_absolute.replace('\\', "/"); - if old_notebook_id == new_notebook_id && old_relative == new_relative && old_absolute == new_absolute { return Ok(()); } + if old_notebook_id == new_notebook_id + && old_relative == new_relative + && old_absolute == new_absolute + { + return Ok(()); + } let mut conn = self.lock_conn(); let tx = conn.transaction()?; for (table, notebook_col, relative_col, document_col) in [ - ("agent_instances", "notebook_id", "relative_path", "document_path"), - ("agent_conversation_instances", "source_notebook_id", "source_relative_path", "source_document_path"), + ( + "agent_instances", + "notebook_id", + "relative_path", + "document_path", + ), + ( + "agent_conversation_instances", + "source_notebook_id", + "source_relative_path", + "source_document_path", + ), ] { tx.execute( - &format!("UPDATE {table} SET + &format!( + "UPDATE {table} SET {notebook_col} = ?5, {relative_col} = ?1 || substr({relative_col}, length(?2) + 1), {document_col} = ?3 || substr({relative_col}, length(?2) + 1) WHERE {notebook_col} = ?4 AND - ({relative_col} = ?2 OR substr({relative_col}, 1, length(?2) + 1) = ?2 || '/')"), - params![new_relative, old_relative, new_absolute, old_notebook_id, new_notebook_id], + ({relative_col} = ?2 OR substr({relative_col}, 1, length(?2) + 1) = ?2 || '/')" + ), + params![ + new_relative, + old_relative, + new_absolute, + old_notebook_id, + new_notebook_id + ], )?; tx.execute( &format!("UPDATE {table} SET @@ -108,10 +131,10 @@ impl ThreadManager { COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -192,10 +215,10 @@ impl ThreadManager { COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -275,10 +298,10 @@ impl ThreadManager { COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -317,10 +340,10 @@ impl ThreadManager { COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions @@ -528,10 +551,10 @@ impl ThreadManager { COALESCE( CASE i.agent WHEN 'codex' THEN (SELECT external_id FROM threads_codex WHERE thread_id = ti.id) - WHEN 'deepseek-harness' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) + WHEN 'dsh' THEN (SELECT external_id FROM threads_dsh WHERE thread_id = ti.id) WHEN 'opencode' THEN (SELECT external_id FROM threads_opencode WHERE thread_id = ti.id) WHEN 'hermes' THEN (SELECT external_id FROM threads_hermes WHERE thread_id = ti.id) - WHEN 'claude' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) + WHEN 'claudecode' THEN (SELECT external_id FROM threads_claude WHERE thread_id = ti.id) WHEN 'pi' THEN (SELECT external_id FROM threads_pi WHERE thread_id = ti.id) END, (SELECT external_session_id FROM thread_external_sessions diff --git a/app/flowix-desktop/src/agent_session/store/external.rs b/app/flowix-desktop/src/agent_session/store/external.rs index afdef4fc0..deeb44075 100644 --- a/app/flowix-desktop/src/agent_session/store/external.rs +++ b/app/flowix-desktop/src/agent_session/store/external.rs @@ -11,8 +11,8 @@ use crate::agent_types::AgentId; use rusqlite::{params, Connection, OptionalExtension}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; -use std::sync::Arc; use std::path::PathBuf; +use std::sync::Arc; const MAX_EXTERNAL_EVENTS_PER_THREAD: i64 = 10_000; const EXTERNAL_HISTORY_TRUNCATED_JSON: &str = r#"{"kind":"history_truncated","version":1}"#; @@ -488,11 +488,11 @@ impl ThreadManager { ) -> Result, ThreadError> { let thread_id = thread_id.to_string(); self.run_blocking(move |tm| { - if !tm.external_event_history_exists_inner("claude", &thread_id)? { + if !tm.external_event_history_exists_inner("claudecode", &thread_id)? { return Ok(None); } tm.get_external_event_messages_page_inner( - "claude", + "claudecode", &thread_id, before_event_id, turn_limit, @@ -649,7 +649,7 @@ impl ThreadManager { let messages = materialize_external_messages(events); Ok(ThreadMessagesPage { - messages: if runtime == "deepseek-harness" { + messages: if runtime == "dsh" { normalize_legacy_deepseek_thinking(messages) } else { messages @@ -747,7 +747,7 @@ pub(crate) fn materialize_external_messages(events: Vec) -> }; let mut message_id = external_run_scoped_id(&event.runtime, &payload, role, &message_id); - if event.runtime == "deepseek-harness" { + if event.runtime == "dsh" { if let Some(run_key) = external_event_run_key(&event.runtime, &payload) { let segment_key = format!("{run_key}:{message_id}"); let current_id = dsh_current_assistant_ids @@ -804,7 +804,7 @@ pub(crate) fn materialize_external_messages(events: Vec) -> messages.push(message); } Some("tool_call") => { - if event.runtime == "deepseek-harness" { + if event.runtime == "dsh" { if let Some(run_key) = external_event_run_key(&event.runtime, &payload) { dsh_pending_assistant_segment.insert(run_key); } @@ -854,7 +854,7 @@ pub(crate) fn materialize_external_messages(events: Vec) -> } } Some("tool_result") => { - if event.runtime == "deepseek-harness" { + if event.runtime == "dsh" { if let Some(run_key) = external_event_run_key(&event.runtime, &payload) { dsh_pending_assistant_segment.insert(run_key); } diff --git a/app/flowix-desktop/src/agent_session/store/provider.rs b/app/flowix-desktop/src/agent_session/store/provider.rs index 3f9c41152..418d7a011 100644 --- a/app/flowix-desktop/src/agent_session/store/provider.rs +++ b/app/flowix-desktop/src/agent_session/store/provider.rs @@ -25,10 +25,10 @@ impl ProviderThreadStore { pub(super) fn for_runtime(runtime: &str) -> Option { match runtime { "codex" => Some(Self::Codex), - "deepseek-harness" => Some(Self::Dsh), + "dsh" => Some(Self::Dsh), "opencode" => Some(Self::OpenCode), "hermes" => Some(Self::Hermes), - "claude" => Some(Self::Claude), + "claudecode" => Some(Self::Claude), "pi" => Some(Self::Pi), _ => None, } diff --git a/app/flowix-desktop/src/agent_session/tests.rs b/app/flowix-desktop/src/agent_session/tests.rs index 88c2d2a43..e314d22c2 100644 --- a/app/flowix-desktop/src/agent_session/tests.rs +++ b/app/flowix-desktop/src/agent_session/tests.rs @@ -308,20 +308,20 @@ mod tests { let manager = ThreadManager::for_tests(); let first = manager .ensure_thread( - "gemini-local-1", - AgentId("gemini".to_string()), + "codex-local-1", + AgentId("codex".to_string()), "first title".to_string(), ) .await .unwrap(); - assert_eq!(first.thread_id, "gemini-local-1"); - assert_eq!(first.agent_id.0, "gemini"); + assert_eq!(first.thread_id, "codex-local-1"); + assert_eq!(first.agent_id.0, "codex"); assert_eq!(first.title, "first title"); let second = manager .ensure_thread( - "gemini-local-1", - AgentId("gemini".to_string()), + "codex-local-1", + AgentId("codex".to_string()), "second title".to_string(), ) .await @@ -1438,7 +1438,7 @@ mod tests { } #[test] - fn newer_thread_schema_is_rejected() { + fn newer_thread_schema_is_preserved_before_rebuild() { let dir = tempfile::tempdir().expect("tempdir"); let db_path = dir.path().join("thread.db"); let conn = rusqlite::Connection::open(&db_path).expect("open database"); @@ -1450,17 +1450,13 @@ mod tests { .expect("set future schema version"); drop(conn); - let error = match ThreadManager::new(db_path) { - Ok(_) => panic!("future schema must be rejected"), - Err(error) => error, - }; - assert!(matches!( - error, - crate::agent_session::error::ThreadError::UnsupportedSchemaVersion { - found, - supported - } if found == supported + 1 - )); + let manager = ThreadManager::new(db_path).expect("recreate a usable current database"); + manager.initialize_storage().unwrap(); + assert!(dir.path().join("rebuild-backups").exists()); + let backup = std::fs::read_dir(dir.path().join("rebuild-backups")).unwrap().next().unwrap().unwrap().path().join("thread.db"); + let preserved = rusqlite::Connection::open(backup).unwrap(); + let version: i64 = preserved.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap(); + assert_eq!(version, crate::agent_session::migrations::THREAD_DB_SCHEMA_VERSION + 1); } #[tokio::test] diff --git a/app/flowix-desktop/src/agent_wire.rs b/app/flowix-desktop/src/agent_wire.rs index 4fa5c89db..f8f27fef7 100644 --- a/app/flowix-desktop/src/agent_wire.rs +++ b/app/flowix-desktop/src/agent_wire.rs @@ -64,6 +64,8 @@ pub struct RuntimePathConfig { #[derive(Serialize, Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeConfig { + /// Notebook scope used to validate persisted workspace paths before each run. + pub notebook_id: Option, pub codex: Option, pub claude: Option, pub hermes: Option, @@ -165,11 +167,11 @@ impl AgentUserMessage { let config = self.runtime_config.as_ref()?; match runtime { "codex" => config.codex.as_ref(), - "claude" => config.claude.as_ref(), + "claudecode" => config.claude.as_ref(), "hermes" => config.hermes.as_ref(), "opencode" => config.opencode.as_ref(), "pi" => config.pi.as_ref(), - "deepseek-harness" => config.deepseek_harness.as_ref(), + "dsh" => config.deepseek_harness.as_ref(), _ => None, } } diff --git a/app/flowix-desktop/src/app/bootstrap.rs b/app/flowix-desktop/src/app/bootstrap.rs index a7c4f310e..fcfa5e4f2 100644 --- a/app/flowix-desktop/src/app/bootstrap.rs +++ b/app/flowix-desktop/src/app/bootstrap.rs @@ -14,11 +14,8 @@ use crate::app::watchdog::spawn_external_agent_watchdog; use crate::cli_link; use crate::commands; use crate::config::user as user_config; -use crate::config::AgentAccessStore; -use crate::config::SecurityBookmarkStore; use crate::events as dispatcher; use crate::memo_events::{self, MemoChangeSource, MemoDerivedChanged, MemoEvent}; -use crate::plugin; use crate::runtime_log; use crate::system_data::SystemData; use crate::watcher::MemoWatcher; @@ -51,6 +48,15 @@ fn record_slow_notebook_stage(notebook_id: &str, stage: &str, elapsed: Duration) ); } +fn version_initialization_pending( + device_registry: &crate::device_registration::DeviceRegistry, + user_config: &crate::config::UserConfigStore, +) -> bool { + !device_registry.is_version_initialization_complete() + || device_registry.notebook_storage_reset_version() < 1 + || user_config.has_legacy_watcher_exclusions() +} + pub fn run() { install_panic_log_hook(); let startup_coordinator = Arc::new(crate::app::startup::StartupCoordinator::new()); @@ -80,13 +86,16 @@ pub fn run() { let user_config_dir = get_user_config_dir(&home_dir); std::fs::create_dir_all(&user_config_dir).ok(); - if let Err(error) = plugin::ensure_builtin_plugins() { - tracing::warn!("[startup] failed to initialize plugins: {error}"); - } - let thread_db_path = user_config_dir.join("thread.db"); + // Only choose the canonical location here. Path migration uses VACUUM INTO + // and runs in the startup worker, where its progress can reach the WebView. + let database_dir = user_config_dir.join("db"); + let thread_db_path = database_dir.join("thread.db"); let user_config = Arc::new(user_config::UserConfigStore::new(home_dir.clone())); + let security_bookmarks = Arc::new(crate::config::SecurityBookmarkStore::new( + user_config_dir.clone(), + )); - // 笔�?�?��册表真源�?~/.flowix/index.db (SQLite); `MemoFile::open_index_db` + // 笔记本注册表真源为 ~/.flowix/db/local.db (SQLite); MemoFile 按需建表 // 首�?�??时建表�?这里不需要任何�?盘迁�?── �?`notebook.json` �?��已废�? let memo_file = flowix_core::memo_file::MemoFile::new(user_config_dir.clone()); @@ -105,7 +114,7 @@ pub fn run() { }; // External CLI 璺緞閰嶇疆 (~/.flowix/agent-external-config.json) 鈹€鈹€ - // 作为 codex/claude/gemini/hermes/openclaw 执�?�?��的唯一参照�? + // Supported external Agents use agent-external-config as their path source. let agent_external_config_path = user_config_dir.join("agent-external-config.json"); let agent_external_config = match AgentExternalConfig::new(agent_external_config_path.clone()) { Ok(store) => store, @@ -120,7 +129,7 @@ pub fn run() { let memo_file_arc = Arc::new(RwLock::new(memo_file)); let notebook_transition = Arc::new(std::sync::Mutex::new(())); - let thread_manager = match ThreadManager::new(thread_db_path.clone()) { + let thread_manager = match ThreadManager::new_uninitialized(thread_db_path.clone()) { Ok(manager) => manager, Err(err) => { tracing::error!( @@ -138,11 +147,6 @@ pub fn run() { // Agent �??�?���?store ── 必须�?notebook registry �?`memo_file_arc` // 都就�?��后构�?(�?store 会�? notebook registry �?? + 对账)�? - let security_bookmarks_arc = Arc::new(SecurityBookmarkStore::new(user_config_dir.clone())); - let agent_access_arc = Arc::new(AgentAccessStore::new( - user_config_dir.clone(), - &*crate::lock_utils::read_lock(&memo_file_arc, "memo_file"), - )); // 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ // 监听 user-config-changed �?���?whitelist �? 也需�?user_config_arc, @@ -152,9 +156,8 @@ pub fn run() { // 这里把构�?AppState 需要的子结�?clone 出来 (�?�� `move` 捕获), // 同时把另一�?clone 喂给 sub-component 构造函数�? let user_config_for_state = user_config_arc.clone(); + let security_bookmarks_for_state = security_bookmarks.clone(); let memo_file_for_state = memo_file_arc.clone(); - let agent_access_for_state = agent_access_arc.clone(); - let security_bookmarks_for_state = security_bookmarks_arc.clone(); let thread_manager_for_state = thread_manager_arc.clone(); let startup_for_state = startup_coordinator.clone(); let notebook_transition_for_state = notebook_transition.clone(); @@ -228,24 +231,8 @@ pub fn run() { flowix_installer_helper::listen_for_shutdown(move || shutdown_app.exit(0)); } record_startup_stage(&startup_coordinator, "native-setup-start"); - if let Err(error) = crate::template_store::initialize(&user_config_dir_for_device) { - tracing::warn!("[startup] failed to initialize template directories: {error}"); - } - - // Read the notebook registry synchronously so AppState can be - // created. Structural migrations themselves are scheduled after - // AppState and IPC are available; the startup coordinator is the - // gate for the WebView and notebook-dependent commands. - let initial_notebooks = { - let memo_file = crate::lock_utils::read_lock(&memo_file_arc, "memo_file"); - let notebooks = memo_file.read_notebook_configs()?; - for notebook in ¬ebooks { - security_bookmarks_for_state - .start_accessing_for_path(std::path::Path::new(¬ebook.path)); - } - notebooks - }; - + // Registry import and notebook inspection run in the startup + // initialization worker after AppState exists. // 鈹€鈹€ 0) 鍚姩璁惧鐧昏 / last_seen 鍒锋柊 鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€鈹€ // 不阻�? spawn 一�?fire-and-forget tokio 任务, �?��内部 // �?sleep 10s �?POST, 与产品更�?7s 检查错开。远�?�� @@ -255,8 +242,19 @@ pub fn run() { &user_config_dir_for_device, app_version, )); + if version_initialization_pending(&device_registry, &user_config_for_state) { + startup_coordinator.mark_running( + crate::app::startup::StartupStage::Initialization, + "versionInitialization", + ); + } else { + startup_coordinator.mark_running( + crate::app::startup::StartupStage::WorkspacePreparation, + "preparingData", + ); + } device_registry.clone().spawn_startup_registration(); - app.manage(device_registry); + app.manage(device_registry.clone()); // 鈹€鈹€ 1) 鍚姩鎺㈡祴 external CLI 璺緞 鈹€鈹€ // �?source=auto/缺失�?agent 跑探测链 (env>PATH>候�?shell), @@ -264,6 +262,44 @@ pub fn run() { // can spawn processes, so run it after the first workspace paint. agent_external_config.load_into_registry(); + let is_dev_build = option_env!("FLOWIX_DEV_MODE") == Some("1"); + // A saved Preferences selection is the source of truth. The build + // target only supplies the initial/default environment. + let cloud_environment = if is_dev_build { + let saved_environment = user_config_for_state + .get_preference() + .cloud_environment; + flowix_sync::CloudEnvironment::parse(&saved_environment) + .unwrap_or_else(flowix_sync::default_cloud_environment) + } else { + flowix_sync::CloudEnvironment::Prod + }; + runtime_log::record_event( + "info", + "cloud.environment.selected", + format!( + "environment={}, api_base={}", + cloud_environment.name(), + cloud_environment.api_base() + ), + ); + let cloud_sync_db_path = if is_dev_build { + user_config_dir_for_device + .join("db") + .join(format!("sync-dev-{}.db", cloud_environment.name())) + } else { + user_config_dir_for_device.join("db").join("sync.db") + }; + let cloud_refresh_token_store: Arc = + if is_dev_build { + Arc::new(crate::config::CloudDevRefreshTokenStore::new( + user_config_for_state.clone(), + cloud_environment, + )) + } else { + user_config_for_state.clone() + }; + let cloud_state_app = app.handle().clone(); let cloud_state_notifier: Arc = Arc::new(move |state| { @@ -272,20 +308,20 @@ pub fn run() { } }); let cloud_sync_for_state = Arc::new( - flowix_sync::SyncManager::new_with_session_dependencies( - flowix_sync::DEFAULT_CLOUD_API_BASE, - user_config_dir_for_device.join("sync.db"), - user_config_for_state.clone(), + flowix_sync::SyncManager::new_with_session_dependencies_deferred( + cloud_environment.api_base(), + &cloud_sync_db_path, + cloud_refresh_token_store.clone(), cloud_state_notifier.clone(), ) .unwrap_or_else(|error| { tracing::error!( "failed to initialize cloud sync database: {error}; using a temporary database" ); - flowix_sync::SyncManager::new_with_session_dependencies( - flowix_sync::DEFAULT_CLOUD_API_BASE, + flowix_sync::SyncManager::new_with_session_dependencies_deferred( + cloud_environment.api_base(), std::env::temp_dir().join(format!("flowix-sync-{}.db", std::process::id())), - user_config_for_state.clone(), + cloud_refresh_token_store.clone(), cloud_state_notifier.clone(), ) .expect("failed to initialize temporary cloud sync database") @@ -295,9 +331,11 @@ pub fn run() { let app_state = AppState { upload_sessions: Default::default(), document_access: Default::default(), + security_bookmarks: security_bookmarks_for_state.clone(), export_access: Default::default(), user_config: user_config_for_state.clone(), cloud_sync: cloud_sync_for_state.clone(), + cloud_environment, system_data, agent_external_config, memo_file: memo_file_for_state.clone(), @@ -309,8 +347,6 @@ pub fn run() { agent_history: agent_history.clone(), agent_lifecycle: agent_lifecycle.clone(), thread_manager: thread_manager_for_state.clone(), - agent_access: agent_access_for_state.clone(), - security_bookmarks: security_bookmarks_for_state.clone(), plugin_runs: crate::plugin::PluginRunCoordinator::default(), notebook_imports: Default::default(), notebook_template_initializations: Default::default(), @@ -319,10 +355,6 @@ pub fn run() { }; app_state.upload_sessions.start_cleanup(); app.manage(app_state); - let session_manager = cloud_sync_for_state.clone(); - tauri::async_runtime::spawn(async move { - session_manager.run_session_service().await; - }); external_runtimes.listen_for_terminal_events(app.handle()); if let Some(window) = app.get_webview_window("main") { // �?��即�?齐主题背�?��, 消除冷启动白�?(尤其深色主�?)�? @@ -362,18 +394,6 @@ pub fn run() { app.manage(commands::file_browser_watch::FileBrowserWatchState::new( app.handle().clone(), )); - // Restore security-scoped access for user-selected reference - // folders as well. External CLI children inherit the parent's - // active extensions, so this must happen before any agent spawn. - for entry in agent_access_for_state - .get_config() - .entries - .into_iter() - .filter(|entry| entry.enabled && !entry.missing) - { - security_bookmarks_for_state - .start_accessing_for_path(std::path::Path::new(&entry.path)); - } // �?��时把 preference.json::watcher 应用�?MemoWatcher; // 同时监听 user-config-changed,在偏好更新后热加载 watcher 规则。 { @@ -410,14 +430,15 @@ pub fn run() { register_deep_links(app); spawn_startup_reconciliation( app.handle().clone(), - initial_notebooks.clone(), memo_file_for_state.clone(), + cloud_sync_for_state.clone(), memo_watcher.clone(), user_config_for_watcher.clone(), startup_coordinator.clone(), external_runtimes.clone(), user_config_dir_for_device.clone(), thread_manager_for_state.clone(), + device_registry, ); record_startup_stage(&startup_coordinator, "native-setup-complete"); @@ -430,6 +451,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ // 偏好 (JSON, �?user_config) commands::product::get_product_info, + commands::product::set_cloud_environment, commands::product::open_log_dir, commands::product::open_file_with_default_app, commands::product::reveal_in_file_manager, @@ -465,6 +487,7 @@ pub fn run() { commands::settings::delete_pi_mcp_server, commands::settings::get_pi_features, commands::settings::save_pi_features, + commands::settings::clear_pi_legacy_code_mode, commands::settings::get_pi_model_catalog, commands::settings::save_pi_model_config, commands::settings::delete_pi_model_config, @@ -487,7 +510,10 @@ pub fn run() { commands::boot::get_boot_features, commands::boot::set_boot_intro_displayed, commands::boot::set_boot_onboarding_completed, + commands::boot::get_version_initialization_completed, + commands::boot::set_version_initialization_completed, commands::boot::get_startup_status, + commands::boot::retry_startup, commands::boot::get_startup_notebook_id, commands::boot::notify_startup_interactive, commands::boot::record_startup_stage, @@ -506,8 +532,13 @@ pub fn run() { commands::cloud::cloud_link_notebook, commands::cloud::cloud_set_notebook_enabled, commands::cloud::cloud_refresh_membership, + commands::cloud::cloud_connect_pi_model_provider, + commands::cloud::cloud_disconnect_pi_model_provider, commands::cloud::cloud_list_products, + commands::cloud::cloud_list_billing_orders, + commands::cloud::cloud_check_billing_order, commands::cloud::cloud_create_checkout, + commands::cloud::cloud_submit_feedback, commands::cloud::cloud_sync_now, commands::cloud::cloud_note_history, commands::cloud::list_local_path_archives, @@ -517,11 +548,11 @@ pub fn run() { commands::cloud::cloud_resolve_markdown_conflict, commands::cloud::cloud_resolve_attachment_conflict, commands::cloud::cloud_restore_note_revision, - // agent 鍙闂洰褰?(JSON, 璧?agent_access) - commands::agent_access::get_agent_access, - commands::agent_access::set_agent_access, - commands::agent_access::get_notebook_agent_configs, - commands::agent_access::set_notebook_agent_config, + // Device-local notebook workspaces (local.db). + commands::agent_workspace::get_agent_workspace, + commands::agent_workspace::get_legacy_agent_preferences, + commands::agent_workspace::attach_notebook_directory, + commands::agent_workspace::detach_notebook_directory, // System metadata (JSON, ~/.flowix/boot/system.json) commands::kv::get_tag_system_metadata, commands::kv::set_tag_system_layout, @@ -560,7 +591,7 @@ pub fn run() { commands::recovery::clear_recovery_draft_through, commands::recovery::list_recovery_drafts, commands::external_document::read_external_document, - commands::external_document::get_external_document_mime_type, + commands::external_document::detect_external_document_text, commands::external_document::write_external_document, commands::memo::reads::get_launch_open_files, commands::memo::reads::search_path_notes, @@ -631,6 +662,7 @@ pub fn run() { commands::file::move_file, commands::file::move_folder, commands::file::import_file, + commands::file::folder_import::import_folder, commands::file::rename_folder, commands::file::delete_file, commands::file::delete_folder, @@ -646,6 +678,7 @@ pub fn run() { commands::web::parse_web_page, // dialog commands::dialog::select_directory, + commands::dialog::select_import_directory, commands::dialog::select_files, commands::dialog::save_file_dialog, commands::dialog::write_export_file, @@ -657,7 +690,7 @@ pub fn run() { commands::dialog::upload_sessions::cancel_attachment_upload, commands::dialog::copy_attachment_file, commands::dialog::open_attachment_file, - commands::agent_access::add_agent_access_folder_from_picker, + commands::agent_workspace::pick_notebook_directory, // agent commands::agent::external_config::agent_runtime_status, commands::agent::external_config::get_agent_external_config, @@ -769,111 +802,260 @@ pub fn run() { fn spawn_startup_reconciliation( app: tauri::AppHandle, - initial_notebooks: Vec, memo_file: Arc>, + cloud_sync: Arc, memo_watcher: Arc>, user_config_for_watcher: Arc, startup: Arc, external_runtimes: Arc, user_config_dir: PathBuf, thread_manager: Arc, + device_registry: Arc, ) { tauri::async_runtime::spawn_blocking(move || { - let started = Instant::now(); - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - run_startup_reconciliation( - &app, - &initial_notebooks, - &memo_file, - &memo_watcher, - &user_config_for_watcher, - &startup, - ) - })); - - match result { - Ok(Ok(())) => { - startup.mark_ready(); - record_startup_stage(&startup, "native-ready"); - tracing::info!( - elapsed_ms = started.elapsed().as_millis(), - "[startup] workspace ready" - ); - handle_cold_start_open_targets(&app); - if !startup.wait_until_interactive(Duration::from_secs(120)) { - tracing::warn!( - "[startup] first workspace paint was not reported within 120 seconds" - ); + let mut session_service_started = false; + loop { + let started = Instant::now(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_version_initialization( + &device_registry, + &memo_file, + &thread_manager, + &cloud_sync, + &user_config_for_watcher, + &user_config_dir, + &startup, + )?; + if !session_service_started { + let session_manager = cloud_sync.clone(); + tauri::async_runtime::spawn(async move { + session_manager.run_session_service().await; + }); + session_service_started = true; } - record_startup_stage(&startup, "background-start"); - let app_for_cli_detection = app.clone(); - tauri::async_runtime::spawn_blocking(move || { - app_for_cli_detection - .state::() - .agent_external_config - .run_startup_detect(); - }); - match thread_manager.clear_all_loading() { - Ok(0) => tracing::debug!("[Startup] no orphan is_loading=1 rows"), - Ok(n) => tracing::info!("[Startup] cleared {n} orphan is_loading=1 rows"), - Err(error) => tracing::warn!("[Startup] clear_all_loading failed: {error}"), - } - crate::maintenance::spawn_startup_maintenance( - app.package_info().version.to_string(), - user_config_dir, - thread_manager, - ); - start_post_startup_services(app.clone(), external_runtimes); - let current_notebook_id = crate::lock_utils::read_lock(&memo_file, "memo_file") - .current_notebook_id_value(); - { - let mut watcher = memo_watcher - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let initial_notebooks = crate::lock_utils::read_lock(&memo_file, "memo_file") + .read_notebook_configs() + .map_err(|error| format!("reload notebook registry failed: {error}"))?; + run_startup_reconciliation( + &app, + &initial_notebooks, + &memo_file, + &memo_watcher, + &user_config_for_watcher, + &startup, + )?; + Ok::<_, String>(initial_notebooks) + })); + + match result { + Ok(Ok(initial_notebooks)) => { + startup.mark_ready(); + record_startup_stage(&startup, "native-ready"); + tracing::info!( + elapsed_ms = started.elapsed().as_millis(), + "[startup] workspace ready" + ); + handle_cold_start_open_targets(&app); + if !startup.wait_until_interactive(Duration::from_secs(120)) { + tracing::warn!( + "[startup] first workspace paint was not reported within 120 seconds" + ); + } + record_startup_stage(&startup, "background-start"); + let app_for_cli_detection = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + app_for_cli_detection + .state::() + .agent_external_config + .run_startup_detect(); + }); + match thread_manager.clear_all_loading() { + Ok(0) => tracing::debug!("[Startup] no orphan is_loading=1 rows"), + Ok(n) => tracing::info!("[Startup] cleared {n} orphan is_loading=1 rows"), + Err(error) => tracing::warn!("[Startup] clear_all_loading failed: {error}"), + } + crate::maintenance::spawn_startup_maintenance( + app.package_info().version.to_string(), + user_config_dir, + thread_manager, + ); + start_post_startup_services(app.clone(), external_runtimes); + let current_notebook_id = crate::lock_utils::read_lock(&memo_file, "memo_file") + .current_notebook_id_value(); + { + let mut watcher = memo_watcher + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for notebook in &initial_notebooks { + if current_notebook_id.as_deref() != Some(notebook.id.as_str()) + && !watcher.add_notebook_root(notebook) + { + tracing::warn!(notebook = %notebook.id, "[startup] background notebook watch unavailable"); + } + } + } for notebook in &initial_notebooks { - if current_notebook_id.as_deref() != Some(notebook.id.as_str()) - && !watcher.add_notebook_root(notebook) + if current_notebook_id.as_deref() != Some(notebook.id.as_str()) { + let _ = reconcile_startup_notebook(&app, notebook, &memo_file); + } + let media_started = Instant::now(); + if let Err(error) = crate::lock_utils::read_lock(&memo_file, "memo_file") + .reconcile_media_resources(¬ebook.id) { - tracing::warn!(notebook = %notebook.id, "[startup] background notebook watch unavailable"); + tracing::warn!(notebook = %notebook.id, "[startup] media maintenance failed: {error}"); } + tracing::info!(notebook = %notebook.id, elapsed_ms = media_started.elapsed().as_millis(), "[startup] media maintenance checked"); + record_slow_notebook_stage( + ¬ebook.id, + "media-reconcile", + media_started.elapsed(), + ); + std::thread::yield_now(); } + tracing::info!( + elapsed_ms = started.elapsed().as_millis(), + "[startup] background maintenance completed" + ); + record_startup_stage(&startup, "background-complete"); + return; } - for notebook in &initial_notebooks { - if current_notebook_id.as_deref() != Some(notebook.id.as_str()) { - let _ = reconcile_startup_notebook(&app, notebook, &memo_file); - } - let media_started = Instant::now(); - if let Err(error) = crate::lock_utils::read_lock(&memo_file, "memo_file") - .reconcile_media_resources(¬ebook.id) - { - tracing::warn!(notebook = %notebook.id, "[startup] media maintenance failed: {error}"); - } - tracing::info!(notebook = %notebook.id, elapsed_ms = media_started.elapsed().as_millis(), "[startup] media maintenance checked"); - record_slow_notebook_stage( - ¬ebook.id, - "media-reconcile", - media_started.elapsed(), + Ok(Err(error)) => { + tracing::error!("[startup] migration gate failed: {error}"); + crate::runtime_log::record_event("error", "startup.failed", serde_json::json!({ + "step": startup.status().step, "error": error, + "elapsedMs": startup.elapsed_ms(), "attemptElapsedMs": started.elapsed().as_millis() + }).to_string()); + startup.mark_failed(error); + } + Err(_) => { + tracing::error!("[startup] migration gate panicked"); + crate::runtime_log::record_event( + "error", + "startup.failed", + "startup migration panicked", ); - std::thread::yield_now(); + startup.mark_failed("startup migration panicked"); } - tracing::info!( - elapsed_ms = started.elapsed().as_millis(), - "[startup] background maintenance completed" - ); - record_startup_stage(&startup, "background-complete"); - } - Ok(Err(error)) => { - tracing::error!("[startup] migration gate failed: {error}"); - startup.mark_failed(error); - } - Err(_) => { - tracing::error!("[startup] migration gate panicked"); - startup.mark_failed("startup migration panicked"); } + startup.wait_for_retry(); } }); } +fn run_version_initialization( + device_registry: &crate::device_registration::DeviceRegistry, + memo_file: &Arc>, + thread_manager: &ThreadManager, + cloud_sync: &flowix_sync::SyncManager, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + startup: &crate::app::startup::StartupCoordinator, +) -> Result<(), String> { + let version_init_pending = version_initialization_pending(device_registry, user_config); + if version_init_pending { + startup.mark_running( + crate::app::startup::StartupStage::Initialization, + "versionInitialization", + ); + startup.set_progress(0, 0, 4, "prepareDatabasePaths"); + } + if let Err(error) = crate::storage_initialization::prepare_database_paths(user_config_dir) { + if !version_init_pending { + // Keep retry diagnostics on the initialization stage even when + // this recovered path-migration attempt runs outside the version gate. + startup.mark_running( + crate::app::startup::StartupStage::Initialization, + "prepareDatabasePaths", + ); + } + return Err(format!("prepare database paths failed: {error}")); + } + if version_init_pending { + startup.set_progress(8, 1, 4, "prepareThreadDatabase"); + } + thread_manager + .initialize_storage() + .map_err(|error| format!("initialize thread database failed: {error}"))?; + if version_init_pending { + startup.set_progress(16, 2, 4, "prepareSyncDatabase"); + } + cloud_sync + .initialize_storage() + .map_err(|error| format!("initialize sync database failed: {error}"))?; + if version_init_pending { + startup.set_progress(24, 3, 4, "prepareNotebookRegistry"); + } + crate::lock_utils::read_lock(memo_file, "memo_file") + .initialize_registry_storage() + .map_err(|error| format!("initialize notebook registry failed: {error}"))?; + if version_init_pending { + startup.set_progress(30, 4, 4, "prepareNotebookRegistry"); + } + if crate::lock_utils::read_lock(memo_file, "memo_file") + .local_migration_completed(crate::storage_initialization::CONFIG_LOCAL_MIGRATIONS_JOB) + .map_err(|error| format!("read configuration initialization progress failed: {error}"))? + { + user_config.prepare_initialized_runtime(); + } + let previous_app_version = device_registry.last_successful_app_version(); + let version_completed = device_registry.is_version_initialization_complete(); + let reset_completed = device_registry.notebook_storage_reset_version() >= 1; + let initialize_current_version = !version_completed || !reset_completed; + let notebook_storage_pending = crate::lock_utils::read_lock(memo_file, "memo_file") + .has_pending_notebook_storage_initialization() + .map_err(|error| format!("inspect pending notebook initialization failed: {error}"))?; + // Preserve the once-per-version gate and its existing recovery exceptions. + // Adding a migration job does not make initialization run on every launch. + if !version_initialization_pending(device_registry, user_config) && !notebook_storage_pending { + user_config.ensure_runtime_storage()?; + user_config.prepare_initialized_runtime(); + tracing::info!("[startup] version initialization already completed for this app version"); + record_startup_stage(startup, "version-initialization-skipped"); + return Ok(()); + } + + if !version_init_pending { + startup.mark_running( + crate::app::startup::StartupStage::Initialization, + "versionInitialization", + ); + startup.set_progress(30, 4, 4, "prepareNotebookRegistry"); + } + tracing::info!( + previous_app_version = ?previous_app_version, + "[startup] starting version initialization" + ); + record_startup_stage(startup, "version-initialization-start"); + if initialize_current_version { + device_registry.begin_version_initialization()?; + } + + crate::storage_initialization::initialize_legacy_storage_with_progress( + device_registry, + &crate::lock_utils::read_lock(memo_file, "memo_file"), + user_config, + user_config_dir, + &get_app_data_path(), + |completed, total, task| { + let percent = 30 + (55 * completed / total.max(1)) as u8; + startup.set_progress(percent, completed, total, task); + }, + )?; + + startup.set_progress(88, 0, 2, "prepareRuntimeStorage"); + user_config.ensure_runtime_storage()?; + startup.set_progress(95, 1, 2, "activateRuntime"); + user_config.prepare_initialized_runtime(); + + if initialize_current_version { + device_registry.complete_version_initialization()?; + } + startup.set_progress(100, 2, 2, "completeInitialization"); + + record_startup_stage(startup, "version-initialization-complete"); + Ok(()) +} + fn run_startup_reconciliation( app: &tauri::AppHandle, initial_notebooks: &[flowix_core::memo_file::NotebookConfig], @@ -882,6 +1064,17 @@ fn run_startup_reconciliation( user_config_for_watcher: &Arc, startup: &crate::app::startup::StartupCoordinator, ) -> Result<(), String> { + startup.mark_running( + crate::app::startup::StartupStage::WorkspacePreparation, + "preparingData", + ); + + if let Err(error) = + crate::lock_utils::read_lock(memo_file, "memo_file").read_notebook_workspace() + { + tracing::warn!(%error, "notebook attachment migration deferred"); + } + // MemoFile starts without an operation context. Resolve the persisted // selection before deciding which notebook is on the startup critical path. let current_notebook_id = { @@ -910,78 +1103,28 @@ fn run_startup_reconciliation( selected }; tracing::info!(notebook = ?current_notebook_id, "[startup] selected notebook resolved"); - let legacy_watcher = user_config_for_watcher.get_preference().watcher; - for notebook in initial_notebooks { - let root = Path::new(¬ebook.path); - if root.is_dir() { - if let Err(error) = crate::commands::file::migrate_legacy_watcher_rules( - root, - &legacy_watcher, - &crate::lock_utils::read_lock(&memo_file, "memo_file"), - ) { - tracing::warn!(notebook = %notebook.id, %error, "legacy watcher rule migration deferred"); - } - } - } runtime_log::record_event( "info", "startup.selection", serde_json::json!({ "notebookId": current_notebook_id.as_deref() }).to_string(), ); record_startup_stage(startup, "selected-notebook-resolved"); - startup.mark_running("dataMigrations"); - let migration_started = Instant::now(); - { - let memo_file = crate::lock_utils::read_lock(memo_file, "memo_file"); - let report = memo_file - .run_pending_data_migrations() - .map_err(|error| format!("startup data migration failed: {error}"))?; - if report.applied > 0 { - tracing::info!( - from_version = report.from_version, - to_version = report.to_version, - applied = report.applied, - "startup data migrations completed" - ); - } - } - tracing::info!( - elapsed_ms = migration_started.elapsed().as_millis(), - "[startup] data migrations checked" + startup.mark_running( + crate::app::startup::StartupStage::WorkspacePreparation, + "reconcilingCurrentNotebook", ); - record_startup_stage(startup, "data-migrations-checked"); - - startup.mark_running("migratingCurrentNotebook"); - if let Some(notebook_id) = current_notebook_id.as_deref() { - { - let memo_file = crate::lock_utils::read_lock(memo_file, "memo_file"); - let migration_started = Instant::now(); - let report = memo_file - .ensure_notebook_migrations(notebook_id) - .map_err(|error| format!("current notebook migration failed: {error}"))?; - tracing::info!(notebook = %notebook_id, elapsed_ms = migration_started.elapsed().as_millis(), "[startup] current notebook migration completed"); - if report.moved_files > 0 || report.rebuilt_tags > 0 { - tracing::info!( - notebook = %notebook_id, - moved_files = report.moved_files, - rebuilt_tags = report.rebuilt_tags, - "current notebook migrations completed" - ); - } - } - } - - record_startup_stage(startup, "current-notebook-migrations-checked"); - startup.mark_running("reconcilingCurrentNotebook"); if let Some(notebook) = initial_notebooks .iter() .find(|notebook| Some(notebook.id.as_str()) == current_notebook_id.as_deref()) { + app.state::() + .security_bookmarks + .record_accessible_directory(Path::new(¬ebook.path)); let _ = reconcile_startup_notebook(app, notebook, memo_file); } record_startup_stage(startup, "current-notebook-reconciled"); - // Bind the watcher after structural migrations, before background scans. + // Bind the watcher after the startup index pass and before background scans. // Its index updates share the memo index lock with reconciliation. let watcher_started = Instant::now(); let current_watch_configs = initial_notebooks @@ -1014,24 +1157,6 @@ fn reconcile_startup_notebook( ) -> Result<(), String> { let started = Instant::now(); let memo_file = crate::lock_utils::read_lock(memo_file, "memo_file"); - match memo_file.migrate_note_properties_for_notebook(¬ebook.id) { - Ok(report) if report.notes_written > 0 => { - tracing::info!(notebook = %notebook.id, notes = report.notes_written, "[startup] note properties migrated") - } - Err(error) => { - tracing::warn!(notebook = %notebook.id, %error, "[startup] note property migration will retry") - } - _ => {} - } - match memo_file.migrate_note_todo_metadata_for_notebook(¬ebook.id) { - Ok(report) if report.notes_written > 0 => { - tracing::info!(notebook = %notebook.id, notes = report.notes_written, "[startup] task metadata migrated") - } - Err(error) => { - tracing::warn!(notebook = %notebook.id, %error, "[startup] task migration will retry") - } - _ => {} - } let report = memo_file .reconcile_note_index(¬ebook.id) .map_err(|error| { @@ -1063,6 +1188,11 @@ fn start_post_startup_services( fn handle_second_instance(app: &tauri::AppHandle, args: Vec) { // 二�?�?��: 区分 markdown 文件�?���?flowix:// 深链�? // 两个通道�?��同时触发 (用户�?`xdg-open foo.md flowix://memo/abc123` �?��)�? let app = app.clone(); + // Launch Services starts a short-lived second process for a protocol URL. + // Bring the already-running window forward before waiting for app startup + // or parsing the URL, so even a malformed/stale callback cannot leave the + // browser's transient launch as the only visible activation. + focus_main_window(&app); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = app.state::().startup.wait_until_ready() { tracing::warn!("second-instance target deferred because startup failed: {error}"); @@ -1082,16 +1212,49 @@ fn handle_second_instance(app: &tauri::AppHandle, args: Vec) { }); } +#[cfg(desktop)] +fn focus_main_window(app: &tauri::AppHandle) { + #[cfg(target_os = "macos")] + { + // A protocol launch briefly activates its own helper process. Focusing + // the existing NSWindow alone does not reactivate the already-running + // app on macOS, so explicitly activate NSApp on the main thread first. + let app = app.clone(); + let app_for_main_thread = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(mtm) = objc2::MainThreadMarker::new() { + objc2_app_kit::NSApp(mtm).activate(); + } + if let Some(window) = app_for_main_thread.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + }); + } + + #[cfg(not(target_os = "macos"))] + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + #[cfg(desktop)] fn dispatch_google_callback(app: &tauri::AppHandle, raw: &str) -> bool { - let Ok(url) = url::Url::parse(raw) else { return false; }; + let Ok(url) = url::Url::parse(raw) else { + return false; + }; let expected_scheme = if option_env!("FLOWIX_DEV_MODE") == Some("1") { "flowix-dev" } else { "flowix" }; - if url.scheme() != expected_scheme || url.host_str() != Some("auth") || - url.path() != "/google/callback" { + if url.scheme() != expected_scheme + || url.host_str() != Some("auth") + || url.path() != "/google/callback" + { return false; } let mut code = None; @@ -1106,14 +1269,20 @@ fn dispatch_google_callback(app: &tauri::AppHandle, raw: &str) -> bool { } } let callback = match (auth_error, code, oauth_state) { - (Some(error), _, Some(state)) => Some(commands::cloud::GoogleCallback::Failure { error, state }), - (None, Some(code), Some(state)) => Some(commands::cloud::GoogleCallback::Success { code, state }), + (Some(error), _, Some(state)) => { + Some(commands::cloud::GoogleCallback::Failure { error, state }) + } + (None, Some(code), Some(state)) => { + Some(commands::cloud::GoogleCallback::Success { code, state }) + } _ => None, }; if let Some(callback) = callback { let app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = commands::cloud::complete_google_deep_link(app.clone(), callback).await { + if let Err(error) = + commands::cloud::complete_google_deep_link(app.clone(), callback).await + { let _ = app.emit("cloud-google-auth-error", error); } }); @@ -1125,7 +1294,11 @@ fn dispatch_google_callback(app: &tauri::AppHandle, raw: &str) -> bool { fn register_deep_links(app: &mut tauri::App) { use tauri_plugin_deep_link::DeepLinkExt; - let callback_scheme = if option_env!("FLOWIX_DEV_MODE") == Some("1") { "flowix-dev" } else { "flowix" }; + let callback_scheme = if option_env!("FLOWIX_DEV_MODE") == Some("1") { + "flowix-dev" + } else { + "flowix" + }; // Bundled app metadata owns registration on macOS; runtime registration // remains useful on platforms where the deep-link plugin supports it. let _ = app.deep_link().register(callback_scheme); @@ -1140,6 +1313,7 @@ fn register_deep_links(app: &mut tauri::App) { .map(|url| url.to_string()) .collect::>(); let app = app_handle.clone(); + focus_main_window(&app); let startup = startup.clone(); tauri::async_runtime::spawn_blocking(move || { if let Err(error) = startup.wait_until_ready() { @@ -1357,7 +1531,7 @@ fn stop_external_agent_children(app: &tauri::AppHandle, phase: &str) { if total > 0 { let summary = stopped .iter() - .map(|result| format!("{}={}", result.runtime.key(), result.affected)) + .map(|result| format!("{}={}", result.runtime.id(), result.affected)) .collect::>() .join(", "); tracing::info!("stopped external agent children on {phase}: {summary}"); @@ -1371,3 +1545,109 @@ fn stop_external_agent_children(app: &tauri::AppHandle, phase: &str) { } }); } + +#[cfg(test)] +mod missing_dependency_tests { + use super::*; + + #[test] + fn storage_rebuild_starts_with_three_invalid_databases() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = get_user_config_dir(&home); + let user = Arc::new(user_config::UserConfigStore::new(home)); + let device = crate::device_registration::DeviceRegistry::load(&config, "test"); + device.set_version_initialization_completed(true).unwrap(); + device.complete_notebook_storage_reset_version(1).unwrap(); + std::fs::create_dir_all(config.join("db")).unwrap(); + for name in ["local.db", "thread.db", "sync.db"] { + std::fs::write(config.join("db").join(name), b"invalid database").unwrap(); + } + let threads = ThreadManager::new_uninitialized(config.join("db/thread.db")).unwrap(); + let sync = flowix_sync::SyncManager::new_with_session_dependencies_deferred( + flowix_sync::CloudEnvironment::Prod.api_base(), + config.join("db/sync.db"), + user.clone(), + Arc::new(|_| {}), + ) + .unwrap(); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config.clone(), + ))); + run_version_initialization( + &device, + &memo, + &threads, + &sync, + &user, + &config, + &crate::app::startup::StartupCoordinator::new(), + ) + .unwrap(); + assert!(memo + .read() + .unwrap() + .read_notebook_configs() + .unwrap() + .is_empty()); + assert!(!user + .get_property_presets() + .unwrap() + .system_fields + .is_empty()); + assert_eq!( + std::fs::read_dir(config.join("db/rebuild-backups")) + .unwrap() + .count(), + 3 + ); + } + + #[test] + fn missing_runtime_dependencies_start_with_completed_boot_marker() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config_dir = get_user_config_dir(&home); + let user_config = Arc::new(user_config::UserConfigStore::new(home)); + let device = crate::device_registration::DeviceRegistry::load(&config_dir, "test"); + device.set_version_initialization_completed(true).unwrap(); + device.complete_notebook_storage_reset_version(1).unwrap(); + let memo = Arc::new(RwLock::new(flowix_core::memo_file::MemoFile::new( + config_dir.clone(), + ))); + memo.read().unwrap().initialize_registry_storage().unwrap(); + user_config.ensure_runtime_storage().unwrap(); + std::fs::remove_dir_all(config_dir.join("db")).unwrap(); + let threads = ThreadManager::new_in_memory().unwrap(); + let sync = flowix_sync::SyncManager::new_with_session_dependencies_deferred( + flowix_sync::CloudEnvironment::Prod.api_base(), + temp.path().join("sync.db"), + user_config.clone(), + Arc::new(|_| {}), + ) + .unwrap(); + let startup = crate::app::startup::StartupCoordinator::new(); + run_version_initialization( + &device, + &memo, + &threads, + &sync, + &user_config, + &config_dir, + &startup, + ) + .unwrap(); + assert!(device.is_version_initialization_complete()); + assert!(memo + .read() + .unwrap() + .read_notebook_configs() + .unwrap() + .is_empty()); + assert!(!user_config + .get_property_presets() + .unwrap() + .system_fields + .is_empty()); + } +} diff --git a/app/flowix-desktop/src/app/document_access.rs b/app/flowix-desktop/src/app/document_access.rs index 981b96e6e..5808fc3d9 100644 --- a/app/flowix-desktop/src/app/document_access.rs +++ b/app/flowix-desktop/src/app/document_access.rs @@ -5,6 +5,7 @@ use std::sync::RwLock; #[derive(Default)] pub struct DocumentAccess { paths: RwLock>, + directories: RwLock>, generations: RwLock>, } @@ -37,16 +38,43 @@ impl DocumentAccess { true } + pub fn grant_directory_for_generation( + &self, + window: &str, + generation: u64, + path: &Path, + ) -> bool { + let Ok(generations) = self.generations.read() else { + return false; + }; + if *generations.get(window).unwrap_or(&0) != generation { + return false; + } + let Some(path) = existing_directory(path) else { + return false; + }; + let Ok(mut directories) = self.directories.write() else { + return false; + }; + directories.insert((window.to_string(), path)); + true + } + pub fn contains(&self, window: &str, path: &Path) -> bool { if self.generations.read().is_err() { return false; } - let Some(path) = existing_file(path) else { + let Some(path) = existing_path(path) else { return false; }; - self.paths + let file_granted = path.is_file() && self.paths .read() - .is_ok_and(|paths| paths.contains(&(window.to_string(), path))) + .is_ok_and(|paths| paths.contains(&(window.to_string(), path.clone()))); + file_granted || self.directories.read().is_ok_and(|directories| { + directories.iter().any(|(owner, directory)| { + owner == window && path.starts_with(directory) + }) + }) } pub fn revoke(&self, window: &str) { @@ -58,6 +86,9 @@ impl DocumentAccess { if let Ok(mut paths) = self.paths.write() { paths.retain(|(owner, _)| owner != window); } + if let Ok(mut directories) = self.directories.write() { + directories.retain(|(owner, _)| owner != window); + } } } @@ -69,6 +100,21 @@ fn existing_file(path: &Path) -> Option { canonical.is_file().then_some(canonical) } +fn existing_directory(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let canonical = dunce::canonicalize(path).ok()?; + canonical.is_dir().then_some(canonical) +} + +fn existing_path(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + dunce::canonicalize(path).ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/app/flowix-desktop/src/app/paths.rs b/app/flowix-desktop/src/app/paths.rs index 739e08cf5..0ee541fe0 100644 --- a/app/flowix-desktop/src/app/paths.rs +++ b/app/flowix-desktop/src/app/paths.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -/// 用户配置�?���?(~/./ 下放 index.db / boot/preference.json / +/// 用户配置�?���?(~/./ 下放 db/*.db / boot/preference.json / /// agent-config.toml / boot/system.json 绛?銆? pub const USER_CONFIG_DIR_NAME: &str = ".flowix"; diff --git a/app/flowix-desktop/src/app/startup.rs b/app/flowix-desktop/src/app/startup.rs index 63fa18321..77efc86ce 100644 --- a/app/flowix-desktop/src/app/startup.rs +++ b/app/flowix-desktop/src/app/startup.rs @@ -19,12 +19,30 @@ pub enum StartupPhase { Failed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum StartupStage { + Initialization, + WorkspacePreparation, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupProgress { + pub percent: u8, + pub completed: usize, + pub total: usize, + pub task: String, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct StartupStatus { pub phase: StartupPhase, + pub stage: StartupStage, pub step: String, pub error: Option, + pub progress: Option, } struct StartupState { @@ -52,8 +70,10 @@ impl StartupCoordinator { state: Mutex::new(StartupState { status: StartupStatus { phase: StartupPhase::Pending, - step: "initializing".to_string(), + stage: StartupStage::Initialization, + step: "initialization".to_string(), error: None, + progress: None, }, interactive: false, }), @@ -73,28 +93,74 @@ impl StartupCoordinator { self.started_at.elapsed().as_millis() } - pub fn mark_running(&self, step: impl Into) { + pub fn mark_running(&self, stage: StartupStage, step: impl Into) { self.update(StartupStatus { phase: StartupPhase::Running, + stage, step: step.into(), error: None, + progress: None, }); } + pub fn set_progress( + &self, + percent: u8, + completed: usize, + total: usize, + task: impl Into, + ) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + state.status.progress = Some(StartupProgress { + percent: percent.min(100), + completed, + total, + task: task.into(), + }); + self.changed.notify_all(); + } + pub fn mark_ready(&self) { self.update(StartupStatus { phase: StartupPhase::Ready, + stage: StartupStage::WorkspacePreparation, step: "ready".to_string(), error: None, + progress: None, }); } pub fn mark_failed(&self, error: impl Into) { - self.update(StartupStatus { - phase: StartupPhase::Failed, - step: "failed".to_string(), - error: Some(error.into()), - }); + // Preserve the stage and task that failed for retry UI and diagnostics. + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + state.status.phase = StartupPhase::Failed; + state.status.error = Some(error.into()); + state.status.progress = None; + self.changed.notify_all(); + } + + /// Only a failed attempt may be retried; the existing worker owns all attempts. + pub fn request_retry(&self) -> Result<(), String> { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + if state.status.phase != StartupPhase::Failed { + return Err("startup is not failed".into()); + } + state.status = StartupStatus { + phase: StartupPhase::Pending, + stage: StartupStage::Initialization, + step: "initialization".into(), + error: None, + progress: None, + }; + self.changed.notify_all(); + Ok(()) + } + + pub fn wait_for_retry(&self) { + let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner()); + while state.status.phase == StartupPhase::Failed { + state = self.changed.wait(state).unwrap_or_else(|p| p.into_inner()); + } } pub fn wait_until_ready(&self) -> Result<(), String> { @@ -163,18 +229,32 @@ impl StartupCoordinator { #[cfg(test)] mod tests { - use super::{StartupCoordinator, StartupPhase}; + use super::{StartupCoordinator, StartupPhase, StartupStage}; use std::sync::Arc; use std::thread; use std::time::Duration; + #[test] + fn retry_only_reopens_failed_startup() { + let coordinator = StartupCoordinator::new(); + assert!(coordinator.request_retry().is_err()); + coordinator.mark_failed("interrupted"); + coordinator.request_retry().unwrap(); + coordinator.wait_for_retry(); + assert_eq!(coordinator.status().phase, StartupPhase::Pending); + assert!(coordinator.status().error.is_none()); + assert!(coordinator.request_retry().is_err()); + coordinator.mark_ready(); + assert!(coordinator.request_retry().is_err()); + } + #[test] fn waiters_are_released_when_startup_is_ready() { let coordinator = Arc::new(StartupCoordinator::new()); let waiter = coordinator.clone(); let handle = thread::spawn(move || waiter.wait_until_ready()); - coordinator.mark_running("migrating"); + coordinator.mark_running(StartupStage::Initialization, "migrating"); coordinator.mark_ready(); assert_eq!(handle.join().unwrap(), Ok(())); @@ -192,6 +272,18 @@ mod tests { ); } + #[test] + fn failure_retains_the_stage_and_task() { + let coordinator = StartupCoordinator::new(); + coordinator.mark_running(StartupStage::WorkspacePreparation, "load-current-notebook"); + coordinator.mark_failed("disk unavailable"); + let status = coordinator.status(); + assert_eq!(status.stage, StartupStage::WorkspacePreparation); + assert_eq!(status.step, "load-current-notebook"); + coordinator.request_retry().unwrap(); + assert_eq!(coordinator.status().stage, StartupStage::Initialization); + } + #[test] fn maintenance_waits_for_the_first_interactive_workspace() { let coordinator = Arc::new(StartupCoordinator::new()); diff --git a/app/flowix-desktop/src/app/state.rs b/app/flowix-desktop/src/app/state.rs index bc0fc7899..4c9d5f954 100644 --- a/app/flowix-desktop/src/app/state.rs +++ b/app/flowix-desktop/src/app/state.rs @@ -7,7 +7,7 @@ use crate::agent_external::runtime_registry::ExternalRuntimeRegistry; use crate::agent_external_config::AgentExternalConfig; use crate::agent_session::ThreadManager; use crate::app::startup::StartupCoordinator; -use crate::config::{AgentAccessStore, SecurityBookmarkStore, UserConfigStore}; +use crate::config::{SecurityBookmarkStore, UserConfigStore}; use crate::plugin::PluginRunCoordinator; use crate::system_data::SystemData; use flowix_core::memo_file::MemoFile; @@ -39,9 +39,12 @@ pub struct NotebookImportStatus { pub struct AppState { pub upload_sessions: Arc, pub document_access: crate::app::document_access::DocumentAccess, + pub security_bookmarks: Arc, pub export_access: crate::app::export_access::ExportAccess, pub user_config: Arc, pub cloud_sync: Arc, + /// Effective Cloud target selected at startup. Release builds always use prod. + pub cloud_environment: flowix_sync::CloudEnvironment, /// Legacy system metadata reader used to migrate notebook tag state into /// each notebook's `.flowix/system.json`. pub system_data: SystemData, @@ -66,11 +69,6 @@ pub struct AppState { /// Runtimes without a lifecycle adapter keep Flowix-local semantics. pub agent_lifecycle: Arc, pub thread_manager: Arc, - /// Agent 可访问目录 (notebook + 用户额外 folder), 持久化在 - /// `~/.flowix/agent-access.json`。驱动 external agent 的 - /// 工作区 / 资料目录过滤。 - pub agent_access: Arc, - pub security_bookmarks: Arc, pub plugin_runs: PluginRunCoordinator, /// Last known state for background notebook imports. Keeping the state in /// AppState lets a Webview recover when it subscribes after an event was diff --git a/app/flowix-desktop/src/app/watchdog.rs b/app/flowix-desktop/src/app/watchdog.rs index 821b65847..4466a778b 100644 --- a/app/flowix-desktop/src/app/watchdog.rs +++ b/app/flowix-desktop/src/app/watchdog.rs @@ -39,7 +39,7 @@ pub fn spawn_external_agent_watchdog( if total > 0 { let summary = finalized .iter() - .map(|result| format!("{}={}", result.runtime.key(), result.affected)) + .map(|result| format!("{}={}", result.runtime.id(), result.affected)) .collect::>() .join(", "); tracing::warn!( diff --git a/app/flowix-desktop/src/commands/agent/chat.rs b/app/flowix-desktop/src/commands/agent/chat.rs index 7a6fcdfed..e590ece3f 100644 --- a/app/flowix-desktop/src/commands/agent/chat.rs +++ b/app/flowix-desktop/src/commands/agent/chat.rs @@ -48,7 +48,7 @@ pub async fn chat_with_agent_stream( tracing::info!( "[Command] chat_with_agent_stream called for thread: {}, agent_type: {}", threadId, - runtime.key() + runtime.id() ); if let Some(title) = message @@ -61,46 +61,18 @@ pub async fn chat_with_agent_stream( manager .ensure_thread( &threadId, - crate::agent_types::AgentId::new(runtime.key()), + crate::agent_types::AgentId::new(runtime.id()), title, ) .await .map_err(|error| error.to_string())?; } - // Refresh security-scoped access at every run, not only at startup. Start - // access before validating because a macOS bookmark may be what makes the - // directory visible to this process. Explicit runtime paths must never - // silently fall back to the application cwd when a frozen path disappears. - let runtime_cwd = message - .cwd_for_runtime(runtime.key()) - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string); - let runtime_workspace_paths = message.workspace_paths_for_runtime(runtime.key()); - if let Some(path) = runtime_cwd.as_deref() { - state - .security_bookmarks - .start_accessing_for_path(Path::new(path)); - } - for path in &runtime_workspace_paths { - state - .security_bookmarks - .start_accessing_for_path(Path::new(path)); - } - if let Some(path) = runtime_cwd.as_deref() { - if !Path::new(path).is_dir() { - return Err(format!("Agent working directory is unavailable: {path}")); - } - } - for path in &runtime_workspace_paths { - if !Path::new(path).is_dir() { - return Err(format!("Agent workspace directory is unavailable: {path}")); - } - } + let (runtime_cwd, runtime_workspace_paths) = + validate_runtime_workspace(&message, runtime.id(), &state)?; if let Some(path) = runtime_cwd.as_deref() { - sync_native_agent_instructions(Path::new(path), runtime.key(), &runtime_workspace_paths)?; + sync_native_agent_instructions(Path::new(path), runtime.id(), &runtime_workspace_paths)?; } // runtime 的 `chat_stream` 内部已经 `tokio::spawn` ── IPC 立即返回, @@ -113,12 +85,103 @@ pub async fn chat_with_agent_stream( .await; tracing::info!( "[Command] {} chat_with_agent_stream result: {:?}", - runtime.key(), + runtime.id(), result.is_ok() ); result.map(|response| AgentChatResponse { response }) } +fn validate_runtime_workspace( + message: &AgentUserMessage, + runtime: &str, + state: &AppState, +) -> Result<(Option, Vec), String> { + // Explicit runtime paths must never silently fall back to the application + // cwd when a frozen path disappears. + let runtime_cwd = message + .cwd_for_runtime(runtime) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string); + let runtime_workspace_paths = message.workspace_paths_for_runtime(runtime); + { + let memo = state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned")?; + let workspace = memo.read_notebook_workspace().map_err(|e| e.to_string())?; + let scope = message + .runtime_config + .as_ref() + .and_then(|config| config.notebook_id.as_deref()); + check_workspace_paths( + &workspace, + scope, + runtime_cwd + .iter() + .chain(runtime_workspace_paths.iter()) + .map(String::as_str), + )?; + } + if let Some(path) = runtime_cwd.as_deref() { + if !Path::new(path).is_dir() { + return Err(format!("Agent working directory is unavailable: {path}")); + } + } + for path in &runtime_workspace_paths { + if !Path::new(path).is_dir() { + return Err(format!("Agent workspace directory is unavailable: {path}")); + } + } + + Ok((runtime_cwd, runtime_workspace_paths)) +} + +fn check_workspace_paths<'a>( + workspace: &flowix_core::memo_file::NotebookWorkspaceSnapshot, + scope: Option<&str>, + paths: impl Iterator, +) -> Result<(), String> { + let mut roots = Vec::new(); + for notebook in &workspace.notebooks { + if scope.is_some_and(|id| id != notebook.id) { + continue; + } + roots.push(notebook.path.as_str()); + if let Some(config) = workspace.notebook_configs.get(¬ebook.id) { + roots.extend( + config + .add_dirs + .iter() + .filter(|d| d.enabled && !d.missing) + .map(|d| d.path.as_str()), + ); + } + } + for path in paths { + let path = Path::new(path); + if !roots + .iter() + .any(|root| crate::config::path_is_inside(path, Path::new(root))) + { + return Err(format!( + "Agent workspace directory is no longer mounted: {}", + path.display() + )); + } + if workspace.notebooks.iter().any(|notebook| { + crate::config::path_is_inside_reserved_directory( + path, + Path::new(¬ebook.path), + ".flowix", + ) + }) { + return Err("Agent workspace cannot use notebook internal storage".into()); + } + } + Ok(()) +} + /// Append input to the currently active steerable turn without creating a new run. #[tauri::command] #[allow(non_snake_case)] @@ -130,6 +193,7 @@ pub async fn steer_agent_stream( state: State<'_, AppState>, app_handle: tauri::AppHandle, ) -> Result<(), String> { + validate_runtime_workspace(&message, runtime_from_message(&message)?.id(), &state)?; match message.agent_type.as_deref() { Some("codex") => { state @@ -137,7 +201,7 @@ pub async fn steer_agent_stream( .steer_codex(&threadId, message, clientUserMessageId, &app_handle) .await } - Some("deepseek-harness") | Some("deepseek_harness") | Some("dsh") => { + Some("dsh") => { state .external_runtimes .steer_deepseek_harness(&threadId, message, clientUserMessageId, &app_handle) @@ -276,10 +340,11 @@ pub async fn execute_deepseek_harness_command( .await?; if !matches!( message.agent_type.as_deref(), - Some("deepseek-harness") | Some("deepseek_harness") | Some("dsh") | None + Some("dsh") | None ) { return Err("DSH slash commands require the deepseek-harness agent".to_string()); } + validate_runtime_workspace(&message, "dsh", &state)?; state .deepseek_harness .execute_command(&threadId, &command, &message, &app_handle) @@ -296,7 +361,7 @@ pub async fn deepseek_harness_skill_catalog( ) -> Result { if !matches!( message.agent_type.as_deref(), - Some("deepseek-harness") | Some("deepseek_harness") | Some("dsh") | None + Some("dsh") | None ) { return Err("DSH skills require the deepseek-harness agent".to_string()); } @@ -333,7 +398,7 @@ pub async fn stop_agent_stream( tracing::info!( "[Command] stop_agent_stream called for thread: {}, agent_type: {}, run_id: {}", threadId, - runtime.map(AgentRuntime::key).unwrap_or("unknown"), + runtime.map(AgentRuntime::id).unwrap_or("unknown"), runId.as_deref().unwrap_or("") ); let signalled = match runtime { @@ -463,3 +528,73 @@ pub async fn codex_slash_command( ) .await } + +#[cfg(test)] +mod workspace_tests { + use super::*; + use flowix_core::memo_file::{ + NotebookAddDir, NotebookConfig, NotebookWorkspaceConfig, NotebookWorkspaceSnapshot, + }; + #[test] + fn runtime_paths_follow_notebook_mounts_and_protect_internal_storage() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("notes"); + let repo = temp.path().join("repo"); + std::fs::create_dir_all(root.join(".flowix")).unwrap(); + std::fs::create_dir(&repo).unwrap(); + let root = root.to_string_lossy().into_owned(); + let repo = repo.to_string_lossy().into_owned(); + let mut workspace = NotebookWorkspaceSnapshot { + notebooks: vec![NotebookConfig { + id: "one".into(), + name: "One".into(), + icon: None, + path: root.clone(), + is_default: true, + sort: 0, + created_at: 0, + updated_at: 0, + }], + notebook_configs: HashMap::from([( + "one".into(), + NotebookWorkspaceConfig { + version: 1, + revision: 1, + add_dirs: vec![NotebookAddDir { + id: "repo".into(), + path: repo.clone(), + label: "Repo".into(), + enabled: true, + missing: false, + }], + }, + )]), + }; + assert!(check_workspace_paths( + &workspace, + Some("one"), + [root.as_str(), repo.as_str()].into_iter() + ) + .is_ok()); + assert!( + check_workspace_paths(&workspace, Some("other"), [repo.as_str()].into_iter()).is_err() + ); + let internal = Path::new(&root) + .join(".flowix") + .to_string_lossy() + .into_owned(); + assert!( + check_workspace_paths(&workspace, Some("one"), [internal.as_str()].into_iter()) + .is_err() + ); + workspace + .notebook_configs + .get_mut("one") + .unwrap() + .add_dirs + .clear(); + assert!( + check_workspace_paths(&workspace, Some("one"), [repo.as_str()].into_iter()).is_err() + ); + } +} diff --git a/app/flowix-desktop/src/commands/agent/external_config.rs b/app/flowix-desktop/src/commands/agent/external_config.rs index 45afa46df..f77ebd650 100644 --- a/app/flowix-desktop/src/commands/agent/external_config.rs +++ b/app/flowix-desktop/src/commands/agent/external_config.rs @@ -13,25 +13,64 @@ const CODEX_VERSION_TOO_LOW_CODE: &str = "version-too-low"; const CODEX_VERSION_TOO_LOW_REASON: &str = "Codex CLI version below 0.150.0"; const MIN_CODEX_CLI_VERSION: (u64, u64, u64) = (0, 150, 0); +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum InstallationState { + Unknown, + Installed, + Missing, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ReadinessState { + Unknown, + Ready, + Blocked, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct AgentRuntimeAvailability { - available: bool, - installed: bool, +pub struct AgentRuntimeSnapshot { + installation: InstallationState, + readiness: ReadinessState, reason_code: Option, reason: Option, } +impl AgentRuntimeSnapshot { + fn new( + installation: InstallationState, + readiness: ReadinessState, + reason_code: Option<&str>, + reason: Option, + ) -> Self { + Self { + installation, + readiness, + reason_code: reason_code.map(str::to_string), + reason, + } + } +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeAgents { + pi: AgentRuntimeSnapshot, + codex: AgentRuntimeSnapshot, + #[serde(rename = "claudecode")] + claude_code: AgentRuntimeSnapshot, + opencode: AgentRuntimeSnapshot, + hermes: AgentRuntimeSnapshot, + #[serde(rename = "dsh")] + dsh: AgentRuntimeSnapshot, +} + #[derive(Clone, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentRuntimeStatus { - codex: AgentRuntimeAvailability, - claude: AgentRuntimeAvailability, - hermes: AgentRuntimeAvailability, - opencode: AgentRuntimeAvailability, - pi: AgentRuntimeAvailability, - #[serde(rename = "deepseek-harness")] - deepseek_harness: AgentRuntimeAvailability, + agents: AgentRuntimeAgents, } fn executable_available(path: &Path) -> bool { @@ -50,24 +89,37 @@ fn executable_available(path: &Path) -> bool { /// 基于 `agent-external-config` 里�?录的 path 算单�?external agent 的可用性�?/// `path = None` -> �?���?(�?��探测没探�?; `path = Some` 但失�?-> not found; /// �?�� -> `None` (调用方再叠加 preflight 错�?, �?codex �?Node 依赖)�? -fn external_availability(entry: AgentExternalEntry, label: &str) -> AgentRuntimeAvailability { +fn external_availability(entry: AgentExternalEntry, label: &str) -> AgentRuntimeSnapshot { let available = entry .path .as_ref() .map(|p| executable_available(p)) .unwrap_or(false); - let reason = match &entry.path { - None => Some(format!( - "{label} not configured (click Redetect in preferences)" - )), - Some(p) if !available => Some(format!("{label} not found ({})", p.display())), - Some(_) => None, - }; - AgentRuntimeAvailability { - available, - installed: available, - reason_code: None, - reason, + match (&entry.path, available, entry.detected) { + (None, _, false) => AgentRuntimeSnapshot::new( + InstallationState::Unknown, + ReadinessState::Unknown, + Some("path-not-detected"), + Some(format!("{label} path has not been detected")), + ), + (None, _, true) => AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("runtime-not-installed"), + Some(format!("{label} was not found")), + ), + (Some(path), false, _) => AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("configured-path-missing"), + Some(format!("{label} not found ({})", path.display())), + ), + (Some(_), true, _) => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Ready, + None, + None, + ), } } @@ -109,16 +161,20 @@ pub async fn agent_runtime_status( // preflight can still add dependency details without hiding the entry. let cfg = &state.agent_external_config; let mut codex = external_availability(cfg.get_entry("codex"), "Codex CLI"); - if codex.available { + if matches!(codex.installation, InstallationState::Installed) { if codex_version_is_too_low().await { - codex.available = false; + codex.readiness = ReadinessState::Blocked; codex.reason_code = Some(CODEX_VERSION_TOO_LOW_CODE.to_string()); codex.reason = Some(CODEX_VERSION_TOO_LOW_REASON.to_string()); } else { - codex.reason = crate::agent_external::codex::preflight_codex().err(); + if let Err(reason) = crate::agent_external::codex::preflight_codex() { + codex.readiness = ReadinessState::Blocked; + codex.reason_code = Some("preflight-failed".to_string()); + codex.reason = Some(reason); + } } } - let claude = external_availability(cfg.get_entry("claude"), "Claude Code CLI"); + let claude_code = external_availability(cfg.get_entry("claudecode"), "Claude Code CLI"); let hermes = external_availability(cfg.get_entry("hermes"), "Hermes Agent CLI"); let opencode = external_availability(cfg.get_entry("opencode"), "OpenCode CLI"); let pi = { @@ -147,25 +203,33 @@ pub async fn agent_runtime_status( ) .is_some(); let installed_flowix = crate::agent_external::pi::installed_flowix_pi_binary().is_some(); - let available = bundled || env_path_available || path_available || installed_flowix; - AgentRuntimeAvailability { - available, - installed: available, - reason_code: None, - reason: (!available) - .then(|| "Pi runtime not installed (or not included in this build)".to_string()), + let installed = bundled || env_path_available || path_available || installed_flowix; + if installed { + AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Ready, + None, + None, + ) + } else { + AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("runtime-not-installed"), + Some("Pi runtime is not installed or included in this build".to_string()), + ) } }; let dsh_status = crate::dsh::status(); let deepseek_harness = if !dsh_status.installed { - AgentRuntimeAvailability { - available: false, - installed: false, - reason_code: None, - reason: dsh_status + AgentRuntimeSnapshot::new( + InstallationState::Missing, + ReadinessState::Unknown, + Some("runtime-not-installed"), + dsh_status .message .or_else(|| Some("DeepSeek Harness runtime is not installed".to_string())), - } + ) } else { match state.deepseek_harness.dsh_model_configs().await { Ok(configs) @@ -177,41 +241,43 @@ pub async fn agent_runtime_status( .is_ok() }) => { - AgentRuntimeAvailability { - available: true, - installed: true, - reason_code: None, - reason: None, - } + AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Ready, + None, + None, + ) } - Ok(configs) if configs.is_empty() => AgentRuntimeAvailability { - available: false, - installed: true, - reason_code: None, - reason: Some("No DeepSeek Harness model is configured".to_string()), - }, - Ok(_) => AgentRuntimeAvailability { - available: false, - installed: true, - reason_code: None, - reason: Some("DeepSeek Harness has no usable model configuration".to_string()), - }, - Err(error) => AgentRuntimeAvailability { - available: false, - installed: true, - reason_code: None, - reason: Some(format!("Could not read DeepSeek Harness models: {error}")), - }, + Ok(configs) if configs.is_empty() => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Blocked, + Some("no-model-configured"), + Some("No DeepSeek Harness model is configured".to_string()), + ), + Ok(_) => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Blocked, + Some("no-usable-model"), + Some("DeepSeek Harness has no usable model configuration".to_string()), + ), + Err(error) => AgentRuntimeSnapshot::new( + InstallationState::Installed, + ReadinessState::Unknown, + Some("status-check-failed"), + Some(format!("Could not read DeepSeek Harness models: {error}")), + ), } }; Ok(AgentRuntimeStatus { - codex, - claude, - hermes, - opencode, - pi, - deepseek_harness, + agents: AgentRuntimeAgents { + pi, + codex, + claude_code, + opencode, + hermes, + dsh: deepseek_harness, + }, }) } diff --git a/app/flowix-desktop/src/commands/agent/flowix_instructions.rs b/app/flowix-desktop/src/commands/agent/flowix_instructions.rs index d493877a2..3f869b9ce 100644 --- a/app/flowix-desktop/src/commands/agent/flowix_instructions.rs +++ b/app/flowix-desktop/src/commands/agent/flowix_instructions.rs @@ -64,7 +64,7 @@ pub(crate) fn sync_native_agent_instructions( agent_type: &str, workspace_paths: &[String], ) -> Result<(), String> { - if !matches!(agent_type, "codex" | "deepseek-harness" | "pi") { + if !matches!(agent_type, "codex" | "dsh" | "pi") { return Ok(()); } if !cwd.is_dir() { diff --git a/app/flowix-desktop/src/commands/agent/model_catalog.rs b/app/flowix-desktop/src/commands/agent/model_catalog.rs index 6cf4cd870..127bb9edc 100644 --- a/app/flowix-desktop/src/commands/agent/model_catalog.rs +++ b/app/flowix-desktop/src/commands/agent/model_catalog.rs @@ -33,7 +33,7 @@ pub async fn agent_supported_models( "codex" => state.codex_app_server.supported_models().await, "opencode" => state.opencode.supported_models().await, "pi" => state.pi.supported_models(&app).await, - "deepseek-harness" | "deepseek_harness" | "dsh" => { + "dsh" => { state.deepseek_harness.supported_models().await } _ => Ok(Vec::new()), diff --git a/app/flowix-desktop/src/commands/agent/runtime.rs b/app/flowix-desktop/src/commands/agent/runtime.rs index dda9e65f1..cdbc02121 100644 --- a/app/flowix-desktop/src/commands/agent/runtime.rs +++ b/app/flowix-desktop/src/commands/agent/runtime.rs @@ -39,9 +39,9 @@ pub(crate) async fn start_plugin_chat( app_handle: &tauri::AppHandle, ) -> Result<(), String> { let runtime = runtime_from_message(&message)?; - if let Some(cwd) = message.cwd_for_runtime(runtime.key()) { - let workspace_paths = message.workspace_paths_for_runtime(runtime.key()); - sync_native_agent_instructions(std::path::Path::new(cwd), runtime.key(), &workspace_paths)?; + if let Some(cwd) = message.cwd_for_runtime(runtime.id()) { + let workspace_paths = message.workspace_paths_for_runtime(runtime.id()); + sync_native_agent_instructions(std::path::Path::new(cwd), runtime.id(), &workspace_paths)?; } state.external_runtimes .dispatch_chat(thread_id, runtime, message, app_handle) diff --git a/app/flowix-desktop/src/commands/agent_access.rs b/app/flowix-desktop/src/commands/agent_access.rs deleted file mode 100644 index 7db72ba6c..000000000 --- a/app/flowix-desktop/src/commands/agent_access.rs +++ /dev/null @@ -1,633 +0,0 @@ -//! Agent 访问�?�� IPC —读写 `~/.flowix/agent-access.json`�?//! -//! �?`commands::settings` 同形: 写操作成功后 emit `agent-access-changed` -//! 事件, 其它窗口�?React 树收到后从�?盘重�?load�?前�? `set_agent_access` -//! 走乐观更�?(改本地后�?await), 失败�?store �?`loadInitial` 回滚 ── -//! 瑙?`app/flowix-web/lib/store/agent-access-store.ts`銆? -use std::collections::HashMap; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; - -use crate::events as dispatcher; -use tauri::{AppHandle, State}; - -use crate::config::{AgentAccessConfig, AgentAccessEntry, AgentAccessKind}; - -use crate::app::state::AppState; - -/// 跨窗口同步事�?── 任一窗口成功写入 agent-access.json �?emit, 其它窗口 -/// 收到后从磁盘重新 load�?payload �?`()` (�?payload), 监听者直�?/// `loadInitial()` 拉整�?config ── 比按 entry diff 简单且不会错过任何字�?�? -pub(super) const AGENT_ACCESS_CHANGED_EVENT: &str = "agent-access-changed"; - -const NOTEBOOK_AGENT_CONFIG_VERSION: u32 = 1; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NotebookAddDir { - pub id: String, - pub path: String, - pub label: String, - #[serde(default = "default_true")] - pub enabled: bool, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NotebookAgentConfig { - pub version: u32, - #[serde(default)] - pub revision: u64, - #[serde(default)] - pub add_dirs: Vec, -} - -impl Default for NotebookAgentConfig { - fn default() -> Self { - Self { - version: NOTEBOOK_AGENT_CONFIG_VERSION, - revision: 0, - add_dirs: Vec::new(), - } - } -} - -fn default_true() -> bool { - true -} - -fn notebook_agent_path(root: &Path) -> PathBuf { - root.join(".flowix").join("agent.json") -} - -fn read_notebook_agent_config(root: &Path) -> Result, String> { - let flowix = root.join(".flowix"); - if fs::symlink_metadata(&flowix) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Flowix directory is a symbolic link: {}", - flowix.display() - )); - } - let path = notebook_agent_path(root); - if fs::symlink_metadata(&path) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Notebook agent config is a symbolic link: {}", - path.display() - )); - } - if !path.exists() { - return Ok(None); - } - let content = - fs::read_to_string(&path).map_err(|error| format!("read {}: {error}", path.display()))?; - let config: NotebookAgentConfig = serde_json::from_str(&content) - .map_err(|error| format!("parse {}: {error}", path.display()))?; - if config.version != NOTEBOOK_AGENT_CONFIG_VERSION { - return Err(format!( - "unsupported notebook agent config version {} in {}", - config.version, - path.display() - )); - } - Ok(Some(config)) -} - -fn validate_notebook_agent_config( - root: &Path, - config: &mut NotebookAgentConfig, -) -> Result<(), String> { - let notebook_canonical = fs::canonicalize(root).map_err(|error| error.to_string())?; - let mut seen = std::collections::HashSet::new(); - for directory in &mut config.add_dirs { - directory.path = directory - .path - .trim() - .trim_end_matches(['/', '\\']) - .to_string(); - if directory.path.is_empty() - || !Path::new(&directory.path).is_absolute() - || !Path::new(&directory.path).is_dir() - { - return Err(format!("add-dir is unavailable: {}", directory.path)); - } - let canonical = fs::canonicalize(&directory.path).map_err(|error| error.to_string())?; - if canonical == notebook_canonical { - return Err("the notebook itself cannot be an add-dir".to_string()); - } - if notebook_canonical.starts_with(&canonical) { - return Err(format!( - "an ancestor of the notebook cannot be an add-dir: {}", - directory.path - )); - } - let key = canonical.to_string_lossy().to_lowercase(); - if !seen.insert(key) { - return Err(format!("duplicate add-dir: {}", directory.path)); - } - if directory.id.trim().is_empty() { - directory.id = format!("dir_{}", nanoid::nanoid!(8)); - } - if directory.label.trim().is_empty() { - directory.label = Path::new(&directory.path) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(&directory.path) - .to_string(); - } - } - Ok(()) -} - -fn check_notebook_revision(expected: u64, current: u64) -> Result<(), String> { - if expected != current { - return Err(format!( - "notebook agent config conflict: expected revision {expected}, found {current}" - )); - } - Ok(()) -} - -fn write_notebook_agent_config(root: &Path, config: &NotebookAgentConfig) -> Result<(), String> { - let flowix = root.join(".flowix"); - if fs::symlink_metadata(&flowix) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Flowix directory is a symbolic link: {}", - flowix.display() - )); - } - fs::create_dir_all(&flowix).map_err(|error| format!("create {}: {error}", flowix.display()))?; - let path = notebook_agent_path(root); - if fs::symlink_metadata(&path) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Notebook agent config is a symbolic link: {}", - path.display() - )); - } - let temporary = flowix.join("agent.json.tmp"); - if fs::symlink_metadata(&temporary) - .map(|metadata| metadata.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(format!( - "Notebook agent temporary file is a symbolic link: {}", - temporary.display() - )); - } - let content = serde_json::to_vec_pretty(config).map_err(|error| error.to_string())?; - { - let mut file = fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&temporary) - .map_err(|error| format!("open {}: {error}", temporary.display()))?; - file.write_all(&content) - .and_then(|_| file.sync_all()) - .map_err(|error| format!("write {}: {error}", temporary.display()))?; - } - fs::rename(&temporary, &path).map_err(|error| format!("replace {}: {error}", path.display())) -} - -fn legacy_add_dirs(config: &AgentAccessConfig, notebook_id: &str) -> Vec { - let Some(defaults) = config.defaults.as_ref() else { - return Vec::new(); - }; - let Some(files) = defaults.get("files") else { - return Vec::new(); - }; - let selected = if files.get("folders").is_some() { - files - } else { - files - .get(notebook_id) - .or_else(|| files.get("_global")) - .unwrap_or(&serde_json::Value::Null) - }; - selected - .get("folders") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .filter_map(serde_json::Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty()) - .enumerate() - .map(|(index, path)| NotebookAddDir { - id: format!("legacy_{}", index + 1), - path: path.trim_end_matches(['/', '\\']).to_string(), - label: Path::new(path) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(path) - .to_string(), - enabled: true, - }) - .collect() -} - -fn load_or_migrate_notebook_agent_config( - root: &Path, - notebook_id: &str, - legacy: &AgentAccessConfig, -) -> Result { - if let Some(config) = read_notebook_agent_config(root)? { - return Ok(config); - } - let mut config = NotebookAgentConfig { - version: NOTEBOOK_AGENT_CONFIG_VERSION, - revision: 1, - add_dirs: legacy_add_dirs(legacy, notebook_id), - }; - // Legacy defaults were user-editable JSON and may contain stale or unsafe - // paths. Migrate only entries that pass the same validation as new writes; - // never widen access merely because an old file listed a directory. - let mut safe_dirs = Vec::with_capacity(config.add_dirs.len()); - for directory in config.add_dirs.drain(..) { - let mut candidate = NotebookAgentConfig { - version: config.version, - revision: config.revision, - add_dirs: vec![directory], - }; - if validate_notebook_agent_config(root, &mut candidate).is_ok() { - safe_dirs.extend(candidate.add_dirs); - } - } - config.add_dirs = safe_dirs; - write_notebook_agent_config(root, &config)?; - Ok(config) -} - -fn remove_legacy_file_defaults(config: &mut AgentAccessConfig) -> bool { - let removed = config - .defaults - .as_mut() - .and_then(serde_json::Value::as_object_mut) - .and_then(|defaults| defaults.remove("files")) - .is_some(); - if removed - && config - .defaults - .as_ref() - .and_then(serde_json::Value::as_object) - .is_some_and(|defaults| defaults.is_empty()) - { - config.defaults = None; - } - removed -} - -#[tauri::command] -pub fn get_notebook_agent_configs( - state: State, - app: AppHandle, -) -> Result, String> { - let notebooks = state - .memo_file - .read() - .map_err(|_| "memo file lock poisoned".to_string())? - .read_notebook_configs() - .map_err(|error| error.to_string())?; - let legacy = state.agent_access.get_config(); - let mut result = HashMap::new(); - let mut all_notebooks_migrated = true; - for notebook in notebooks { - let root = PathBuf::from(¬ebook.path); - if !root.is_dir() { - all_notebooks_migrated = false; - continue; - } - let config = load_or_migrate_notebook_agent_config(&root, ¬ebook.id, &legacy)?; - result.insert(notebook.id, config); - } - // Once every registered, existing notebook has a local config, remove the - // obsolete global files defaults. Runtime defaults remain untouched. This - // is deliberately skipped when a notebook path is unavailable so a - // partially migrated installation keeps its rollback source. - if all_notebooks_migrated && !result.is_empty() { - let mut cleaned = legacy.clone(); - let removed = remove_legacy_file_defaults(&mut cleaned); - if removed { - state - .agent_access - .replace_config(cleaned) - .map_err(|error| format!("remove legacy file defaults: {error}"))?; - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - } - } - Ok(result) -} - -#[tauri::command] -pub fn set_notebook_agent_config( - notebook_id: String, - expected_revision: u64, - mut config: NotebookAgentConfig, - state: State, - app: AppHandle, -) -> Result { - let notebook = state - .memo_file - .read() - .map_err(|_| "memo file lock poisoned".to_string())? - .get_notebook_config_by_id(¬ebook_id) - .ok_or_else(|| format!("notebook not found: {notebook_id}"))?; - let root = PathBuf::from(notebook.path); - let current = read_notebook_agent_config(&root)?.unwrap_or_default(); - check_notebook_revision(expected_revision, current.revision)?; - config.version = NOTEBOOK_AGENT_CONFIG_VERSION; - config.revision = current.revision.saturating_add(1); - validate_notebook_agent_config(&root, &mut config)?; - write_notebook_agent_config(&root, &config)?; - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - Ok(config) -} - -/// 拉取当前 agent_access 整份 config�?每�?都从 store �? `missing` 字�? -/// �?`get_config` 内重新算, 失联�?��会立刻拿到最�?disk 状态�? -#[tauri::command] -pub fn get_agent_access(state: State) -> AgentAccessConfig { - state.agent_access.get_config() -} - -/// 用整份新 config 覆盖 (前�?走乐观更�? 整份 set 避免一�?IPC 一份的 -/// 复杂协�?)�?先落�? �?emit, 成功才更新内�?(�?user_config �?set -/// �?��完全对齐)�? -#[tauri::command] -pub fn set_agent_access( - config: AgentAccessConfig, - state: State, - app: AppHandle, -) -> Result<(), String> { - state - .agent_access - .replace_config(config) - .map(|_| { - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - Ok(()) - }) - .map_err(|e| e.to_string())? -} - -#[tauri::command] -pub async fn add_agent_access_folder_from_picker( - app: AppHandle, - state: State<'_, AppState>, -) -> Result, String> { - let picked = crate::commands::dialog::select_directory(app.clone()).await; - let Some(path) = picked else { - return Ok(None); - }; - let trimmed = path.trim_end_matches(|c| c == '/' || c == '\\').to_string(); - if trimmed.is_empty() { - return Ok(None); - } - - let mut config = state.agent_access.get_config(); - if let Some(existing) = reusable_tracked_folder(&config, &trimmed)? { - // Folder entries form a global metadata/bookmark pool while - // notebook `.flowix/agent.json` owns per-notebook attachments. - // Returning the existing folder lets a removed folder be attached - // again and lets multiple notebooks reference the same directory. - return Ok(Some(existing)); - } - - let now = chrono::Utc::now().timestamp_millis(); - let name = Path::new(&trimmed) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(&trimmed) - .to_string(); - let entry = AgentAccessEntry { - id: format!("fld_{}", nanoid::nanoid!(6)), - kind: AgentAccessKind::Folder, - path: trimmed, - name, - enabled: true, - workspace: false, - added_at: now, - updated_at: now, - missing: false, - }; - config.entries.push(entry.clone()); - state - .agent_access - .replace_config(config) - .map_err(|e| format!("agent access persist failed: {e}"))?; - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - Ok(Some(entry)) -} - -#[cfg(test)] -mod notebook_agent_tests { - use super::*; - - fn legacy_with_folder(path: &str) -> AgentAccessConfig { - AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(serde_json::json!({ - "files": { "folders": [path] } - })), - } - } - - #[test] - fn migrates_legacy_defaults_into_notebook_config() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let add_dir = temp.path().join("资料"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&add_dir).unwrap(); - let config = load_or_migrate_notebook_agent_config( - ¬ebook, - "nb-1", - &legacy_with_folder(add_dir.to_str().unwrap()), - ) - .unwrap(); - assert_eq!(config.revision, 1); - assert_eq!(config.add_dirs.len(), 1); - assert_eq!(config.add_dirs[0].path, add_dir.to_string_lossy()); - assert!(notebook.join(".flowix/agent.json").is_file()); - } - - #[test] - fn migration_skips_unsafe_legacy_paths() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let safe = temp.path().join("资料"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&safe).unwrap(); - let legacy = AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(serde_json::json!({ - "files": { "folders": [notebook, safe] } - })), - }; - let config = load_or_migrate_notebook_agent_config(¬ebook, "nb-1", &legacy).unwrap(); - assert_eq!(config.add_dirs.len(), 1); - assert_eq!(config.add_dirs[0].path, safe.to_string_lossy()); - } - - #[test] - fn removes_only_legacy_file_defaults_and_keeps_runtime_defaults() { - let mut config = AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(serde_json::json!({ - "files": { "folders": ["/old"] }, - "runtime": { "codex": { "model": { "key": "gpt" } } } - })), - }; - assert!(remove_legacy_file_defaults(&mut config)); - let defaults = config.defaults.unwrap(); - assert!(defaults.get("files").is_none()); - assert!(defaults.get("runtime").is_some()); - } - - #[test] - fn rejects_notebook_and_ancestor_add_dirs() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("root/notebook"); - fs::create_dir_all(¬ebook).unwrap(); - for path in [¬ebook, notebook.parent().unwrap()] { - let mut config = NotebookAgentConfig::default(); - config.add_dirs.push(NotebookAddDir { - id: "x".into(), - path: path.to_string_lossy().into_owned(), - label: String::new(), - enabled: true, - }); - let error = validate_notebook_agent_config(¬ebook, &mut config).unwrap_err(); - assert!(error.contains("cannot be an add-dir")); - } - } - - #[test] - fn rejects_duplicate_add_dirs_and_revision_is_detected_before_write() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let add_dir = temp.path().join("资料"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&add_dir).unwrap(); - let mut config = NotebookAgentConfig::default(); - config.add_dirs = vec![ - NotebookAddDir { - id: "a".into(), - path: add_dir.to_string_lossy().into_owned(), - label: String::new(), - enabled: true, - }, - NotebookAddDir { - id: "b".into(), - path: add_dir.to_string_lossy().into_owned(), - label: String::new(), - enabled: true, - }, - ]; - assert!(validate_notebook_agent_config(¬ebook, &mut config) - .unwrap_err() - .contains("duplicate add-dir")); - let persisted = NotebookAgentConfig { - revision: 3, - ..Default::default() - }; - write_notebook_agent_config(¬ebook, &persisted).unwrap(); - let current = read_notebook_agent_config(¬ebook).unwrap().unwrap(); - let error = check_notebook_revision(2, current.revision).unwrap_err(); - assert!(error.contains("conflict")); - } - - #[cfg(unix)] - #[test] - fn rejects_symlink_flowix_directory() { - let temp = tempfile::tempdir().unwrap(); - let notebook = temp.path().join("notebook"); - let target = temp.path().join("target"); - fs::create_dir_all(¬ebook).unwrap(); - fs::create_dir_all(&target).unwrap(); - std::os::unix::fs::symlink(&target, notebook.join(".flowix")).unwrap(); - let error = - write_notebook_agent_config(¬ebook, &NotebookAgentConfig::default()).unwrap_err(); - assert!(error.contains("symbolic link")); - } -} - -fn reusable_tracked_folder( - config: &AgentAccessConfig, - path: &str, -) -> Result, String> { - let comparable = path - .trim_end_matches(|c| c == '/' || c == '\\') - .to_ascii_lowercase(); - let Some(existing) = config.entries.iter().find(|entry| { - entry - .path - .trim_end_matches(|c| c == '/' || c == '\\') - .to_ascii_lowercase() - == comparable - }) else { - return Ok(None); - }; - if existing.kind == AgentAccessKind::Folder { - Ok(Some(existing.clone())) - } else { - Err("path already tracked as notebook".to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn entry(kind: AgentAccessKind, path: &str) -> AgentAccessEntry { - AgentAccessEntry { - id: "entry".to_string(), - kind, - path: path.to_string(), - name: "Entry".to_string(), - enabled: true, - workspace: false, - added_at: 1, - updated_at: 1, - missing: false, - } - } - - #[test] - fn existing_folder_is_reusable_across_notebooks_and_after_removal() { - let config = AgentAccessConfig { - version: 1, - entries: vec![entry(AgentAccessKind::Folder, "/tmp/reference")], - defaults: None, - }; - - let reused = reusable_tracked_folder(&config, "/tmp/reference/") - .unwrap() - .expect("folder should be reused"); - - assert_eq!(reused.path, "/tmp/reference"); - } - - #[test] - fn notebook_path_is_not_reused_as_folder_metadata() { - let config = AgentAccessConfig { - version: 1, - entries: vec![entry(AgentAccessKind::Notebook, "/tmp/notebook")], - defaults: None, - }; - - assert!(reusable_tracked_folder(&config, "/tmp/notebook").is_err()); - } -} diff --git a/app/flowix-desktop/src/commands/agent_workspace.rs b/app/flowix-desktop/src/commands/agent_workspace.rs new file mode 100644 index 000000000..61c4edd1c --- /dev/null +++ b/app/flowix-desktop/src/commands/agent_workspace.rs @@ -0,0 +1,87 @@ +//! Notebook workspace IPC. All active data is stored in db/local.db. +use crate::{app::state::AppState, events as dispatcher}; +use flowix_core::memo_file::NotebookWorkspaceSnapshot; +use tauri::{AppHandle, State}; + +pub(crate) const AGENT_WORKSPACE_CHANGED_EVENT: &str = "agent-workspace-changed"; + +#[tauri::command] +pub fn get_agent_workspace(state: State) -> Result { + state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned".to_string())? + .read_notebook_workspace() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_legacy_agent_preferences( + state: State, +) -> Result, String> { + state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned".to_string())? + .read_legacy_agent_runtime() + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn attach_notebook_directory( + notebook_id: String, + path: String, + state: State, + app: AppHandle, +) -> Result { + update(&state, &app, ¬ebook_id, &path, true) +} + +#[tauri::command] +pub fn detach_notebook_directory( + notebook_id: String, + path: String, + state: State, + app: AppHandle, +) -> Result { + update(&state, &app, ¬ebook_id, &path, false) +} + +#[tauri::command] +pub async fn pick_notebook_directory( + notebook_id: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result, String> { + // Reject an invalid scope before showing a picker or creating any state. + if state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned")? + .get_notebook_config_by_id(¬ebook_id) + .is_none() + { + return Err("notebook not found".into()); + } + let Some(path) = super::dialog::select_directory(app.clone()).await else { + return Ok(None); + }; + update(&state, &app, ¬ebook_id, &path, true).map(Some) +} + +fn update( + state: &AppState, + app: &AppHandle, + notebook_id: &str, + path: &str, + attach: bool, +) -> Result { + let snapshot = state + .memo_file + .read() + .map_err(|_| "memo file lock poisoned")? + .update_notebook_add_dir(notebook_id, path, attach) + .map_err(|e| e.to_string())?; + dispatcher::emit_to(app, AGENT_WORKSPACE_CHANGED_EVENT, ()); + Ok(snapshot) +} diff --git a/app/flowix-desktop/src/commands/boot.rs b/app/flowix-desktop/src/commands/boot.rs index 659283e0a..87754b55f 100644 --- a/app/flowix-desktop/src/commands/boot.rs +++ b/app/flowix-desktop/src/commands/boot.rs @@ -2,7 +2,7 @@ use serde::Serialize; use std::sync::Arc; use tauri::{Manager, State}; -use crate::app::startup::StartupStatus; +use crate::app::startup::{StartupPhase, StartupStatus}; use crate::app::state::AppState; use crate::device_registration::DeviceRegistry; @@ -35,6 +35,38 @@ pub fn set_boot_onboarding_completed( registry.set_onboarding_completed(true) } +#[tauri::command] +pub fn get_version_initialization_completed( + registry: State<'_, Arc>, +) -> Result { + if !crate::commands::product::is_dev_build() { + return Err( + "version initialization settings are only available in a development build".to_string(), + ); + } + Ok(registry.is_version_initialization_complete()) +} + +#[tauri::command] +pub fn set_version_initialization_completed( + completed: bool, + registry: State<'_, Arc>, + state: State<'_, AppState>, +) -> Result<(), String> { + if !crate::commands::product::is_dev_build() { + return Err( + "version initialization settings are only available in a development build".to_string(), + ); + } + if matches!( + state.startup.status().phase, + StartupPhase::Pending | StartupPhase::Running + ) { + return Err("version initialization cannot be edited while startup is running".to_string()); + } + registry.set_version_initialization_completed(completed) +} + #[tauri::command] pub fn get_startup_status(state: State<'_, AppState>) -> StartupStatus { state.startup.status() @@ -67,8 +99,16 @@ pub fn record_startup_stage( "initial-document-superseded", "initial-document-timeout", "first-workspace-paint", + "foreground-degraded-paint", + "foreground-failed-paint", ]; - if !ALLOWED.contains(&stage.as_str()) { + // Task events extend automatically when a first-screen dependency is added. + // Keep identifiers bounded; task IDs contain only ASCII letters and separators. + let task_event = stage.starts_with("task:") + && stage.len() <= 96 + && stage.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_:".contains(&b)) + && [":start", ":complete", ":failed"].iter().any(|suffix| stage.ends_with(*suffix)); + if !ALLOWED.contains(&stage.as_str()) && !task_event { return; } if startup_attempt_id.len() > 64 || !startup_attempt_id.is_ascii() { @@ -94,3 +134,8 @@ pub async fn wait_for_startup_ready(app: tauri::AppHandle) -> Result<(), String> .await .map_err(|error| format!("startup wait task failed: {error}"))? } + +#[tauri::command] +pub fn retry_startup(state: State<'_, AppState>) -> Result<(), String> { + state.startup.request_retry() +} diff --git a/app/flowix-desktop/src/commands/cloud.rs b/app/flowix-desktop/src/commands/cloud.rs index e22840277..dbd9e3343 100644 --- a/app/flowix-desktop/src/commands/cloud.rs +++ b/app/flowix-desktop/src/commands/cloud.rs @@ -18,10 +18,11 @@ use flowix_core::memo_file::{ notebook_path_from_relative, IsMd, }; use flowix_sync::{ - v2_content_hash, v2_local_content_diverged, CloudCheckout, - CloudMembership, CloudNotebook, CloudProduct, CloudState, SyncError, V2AccountSyncReport, + v2_content_hash, v2_local_content_diverged, CloudCheckout, CloudMembership, + CloudNotebook, CloudOrder, CloudOrderCheck, CloudProduct, CloudState, SyncError, V2AccountSyncReport, V2LocalNote, V2LocalNotebook, V2RemoteApply, V2SyncedNotebook, }; +use crate::agent_external::pi::config::{self as pi_model_config, PiModelEntry, PiProviderConfig}; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; use tokio::sync::{mpsc, oneshot}; @@ -163,7 +164,9 @@ pub async fn cloud_start_google_sign_in(app: AppHandle) -> Result<(), String> { use base64::Engine; use sha2::{Digest, Sha256}; - let generation = app.state::().cloud_sync.begin_google_sign_in(); + let app_state = app.state::(); + let generation = app_state.cloud_sync.begin_google_sign_in(); + let cloud_api_base = app_state.cloud_environment.api_base(); let state = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); let code_verifier = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes())); @@ -172,8 +175,7 @@ pub async fn cloud_start_google_sign_in(app: AppHandle) -> Result<(), String> { Some(PendingGoogleLogin::new(state.clone(), code_verifier, generation)); } let mut url = url::Url::parse(&format!( - "{}/v1/auth/google/start", - flowix_sync::DEFAULT_CLOUD_API_BASE + "{cloud_api_base}/v1/auth/google/start" )) .map_err(|error| error.to_string())?; url.query_pairs_mut() @@ -199,12 +201,19 @@ pub(crate) async fn complete_google_deep_link( attempt.claim(&oauth_state).map_err(str::to_string)? }; let Some((code_verifier, generation)) = claimed else { return Ok(()); }; - let state = app.state::(); - state.cloud_sync + let app_state = app.state::(); + app_state.cloud_sync .sign_in_with_google_desktop(&code, &oauth_state, &code_verifier, generation) .await - .map_err(sync_error) - .map(|_| ()) + .map_err(sync_error)?; + if let Err(error) = crate::commands::window::open_preferences_window_inner( + &app, + &app_state, + Some("cloudSync"), + ) { + tracing::warn!("Google sign-in succeeded but account preferences could not open: {error}"); + } + Ok(()) } GoogleCallback::Failure { error, state: oauth_state } => { let accepted = { @@ -239,10 +248,15 @@ pub async fn cloud_link_apple( #[tauri::command] pub async fn cloud_logout( + app: AppHandle, state: State<'_, AppState>, ) -> Result { let logout_result = state.cloud_sync.logout().await.map_err(sync_error); let next_state = state.cloud_sync.state().map_err(sync_error)?; + if let Err(error) = pi_model_config::delete_cloud_provider(state.cloud_environment.api_base()) { + tracing::warn!("failed to remove the local Flowix Cloud Pi credential: {error}"); + } + let _ = app.emit(crate::commands::settings::USER_CONFIG_CHANGED_EVENT, "pi_config"); logout_result?; Ok(next_state) } @@ -344,11 +358,117 @@ pub async fn cloud_refresh_membership( membership_result.map_err(sync_error) } +#[tauri::command] +pub async fn cloud_connect_pi_model_provider( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let api_base = state.cloud_environment.api_base().trim_end_matches('/'); + let providers = pi_model_config::list()?; + let legacy_cloud_id = pi_model_config::legacy_cloud_provider_id(api_base)?; + let credential = state.cloud_sync.create_ai_credential().await.map_err(sync_error)?; + let catalog = if credential.models.is_empty() { + vec![credential.model.clone()] + } else { + credential.models.clone() + }; + let model_ids: Vec = catalog.iter().map(|model| model.id.clone()).collect(); + let current_cloud = providers + .iter() + .find(|provider| provider.source.as_deref() == Some("flowix-cloud")) + .or_else(|| { + legacy_cloud_id + .as_deref() + .and_then(|id| providers.iter().find(|provider| provider.id == id)) + }); + let saved_default = current_cloud + .and_then(|provider| provider.default_model_id.clone()) + .filter(|id| model_ids.iter().any(|model_id| model_id == id)); + let default_model_id = saved_default + .or_else(|| model_ids.iter().find(|id| **id == credential.model.id).cloned()) + .or_else(|| model_ids.first().cloned()); + let provider_id = current_cloud + .map(|provider| provider.id.clone()) + .unwrap_or_else(|| { + let existing_ids: std::collections::HashSet<&str> = + providers.iter().map(|provider| provider.id.as_str()).collect(); + let base_id = "flowix-cloud-member"; + if !existing_ids.contains(base_id) { + return base_id.to_string(); + } + let mut suffix = 2; + loop { + let candidate = format!("{base_id}-{suffix}"); + if !existing_ids.contains(candidate.as_str()) { + return candidate; + } + suffix += 1; + } + }); + let save_result = pi_model_config::save(PiProviderConfig { + id: provider_id, + display_name: "Flowix Cloud".into(), + api: "openai-completions".into(), + base_url: format!("{api_base}/v1/ai"), + source: Some("flowix-cloud".into()), + api_key: Some(credential.api_key), + credential_configured: true, + default_model_id, + models: catalog.into_iter().map(|model| PiModelEntry { + id: model.id.clone(), + name: model.id, + api: None, + reasoning: false, + vision: false, + context_window: None, + max_tokens: None, + }).collect(), + }, &app); + if let Err(error) = save_result { + if let Err(revoke_error) = state.cloud_sync.revoke_ai_credential().await { + tracing::warn!("failed to revoke an unconfigured Flowix Cloud Pi credential: {}", sync_error(revoke_error)); + } + return Err(error); + } + let _ = app.emit(crate::commands::settings::USER_CONFIG_CHANGED_EVENT, "pi_config"); + Ok(()) +} + +#[tauri::command] +pub async fn cloud_disconnect_pi_model_provider( + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + state.cloud_sync.revoke_ai_credential().await.map_err(sync_error)?; + pi_model_config::delete_cloud_provider(state.cloud_environment.api_base())?; + let _ = app.emit(crate::commands::settings::USER_CONFIG_CHANGED_EVENT, "pi_config"); + Ok(()) +} + #[tauri::command] pub async fn cloud_list_products(state: State<'_, AppState>) -> Result, String> { state.cloud_sync.products().await.map_err(sync_error) } +#[tauri::command] +pub async fn cloud_list_billing_orders( + state: State<'_, AppState>, +) -> Result, String> { + state.cloud_sync.billing_orders().await.map_err(sync_error) +} + +#[tauri::command] +pub async fn cloud_check_billing_order( + order_id: String, + state: State<'_, AppState>, +) -> Result { + state + .cloud_sync + .check_billing_order(&order_id) + .await + .map_err(sync_error) +} + #[tauri::command] pub async fn cloud_create_checkout( product_id: String, @@ -362,6 +482,19 @@ pub async fn cloud_create_checkout( checkout_result.map_err(sync_error) } +#[tauri::command] +pub async fn cloud_submit_feedback( + device_id: String, + kind: String, + content: String, + app_version: String, + platform: String, + attachments: Vec, + state: State<'_, AppState>, +) -> Result<(), String> { + state.cloud_sync.submit_feedback(&device_id, &kind, &content, &app_version, &platform, &attachments).await.map_err(sync_error) +} + #[tauri::command] pub async fn cloud_sync_now( notebook_id: Option, diff --git a/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs b/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs index c04b1ae94..d077b0f6e 100644 --- a/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs +++ b/app/flowix-desktop/src/commands/cloud/local_adapter/remote_apply.rs @@ -410,16 +410,7 @@ pub(crate) fn apply_v2_report( let deleted = notebook_metadata .get(&config.id) .is_some_and(|(_, _, _, deleted)| *deleted); - if deleted { - changed = true; - if state.agent_access.remove_notebook(&config.id) { - crate::events::emit_to( - app, - crate::commands::agent_access::AGENT_ACCESS_CHANGED_EVENT, - (), - ); - } - } + if deleted { changed = true; } !deleted }); for config in &mut configs { @@ -451,6 +442,7 @@ pub(crate) fn apply_v2_report( .write_notebook_configs(&configs) .map_err(sync_error)?; drop(memo_file); + crate::events::emit_to(app, crate::commands::agent_workspace::AGENT_WORKSPACE_CHANGED_EVENT, ()); crate::events::emit_to(app, crate::commands::notebook::NOTEBOOKS_CHANGED_EVENT, ()); crate::commands::helpers::refresh_watcher_roots(state, app); } diff --git a/app/flowix-desktop/src/commands/dialog.rs b/app/flowix-desktop/src/commands/dialog.rs index c1397c221..f30920a87 100644 --- a/app/flowix-desktop/src/commands/dialog.rs +++ b/app/flowix-desktop/src/commands/dialog.rs @@ -6,14 +6,13 @@ use tauri_plugin_opener::OpenerExt; use crate::lock_utils::read_lock; -use super::helpers::start_security_bookmark_access; use crate::app::state::AppState; // ==================== 鍩熷唴 helper ==================== +mod attachment_fingerprint; mod attachments; mod base64_input; -mod attachment_fingerprint; mod upload_quota; pub(crate) mod upload_sessions; @@ -21,60 +20,56 @@ pub(crate) mod upload_sessions; #[tauri::command] pub async fn select_directory(app: tauri::AppHandle) -> Option { - use std::sync::mpsc; - #[cfg(not(target_os = "macos"))] use tauri_plugin_dialog::DialogExt; - #[cfg(not(target_os = "macos"))] - use tokio::task; - - let (tx, rx) = mpsc::channel(); - - #[cfg(target_os = "macos")] - { - let handle = app.clone(); - let state_handle = handle.clone(); - handle - .run_on_main_thread(move || { - let result = crate::config::pick_directory_with_bookmark("选择笔记本文件夹").map( - |(path, bookmark)| { - let state = state_handle.state::(); - if let Err(e) = state - .security_bookmarks - .record_directory_bookmark(Path::new(&path), bookmark) - { - tracing::warn!("[select_directory] failed to persist bookmark: {e}"); - } - path - }, - ); - tx.send(result).ok(); + tokio::task::spawn_blocking(move || { + app.dialog() + .file() + .set_title("选择笔记本文件夹") + .blocking_pick_folder() + .map(|p| { + let path = Path::new(&p.to_string()).to_path_buf(); + record_bookmark_directory(&app, &path); + path.to_string_lossy().into_owned() }) - .ok()?; - return tokio::task::spawn_blocking(move || rx.recv().ok().flatten()) - .await - .ok() - .flatten(); - } + }) + .await + .ok() + .flatten() +} - #[cfg(not(target_os = "macos"))] - // Run blocking dialog in a background thread to avoid freezing the UI +#[tauri::command] +pub async fn select_import_directory( + app: tauri::AppHandle, + window: tauri::WebviewWindow, +) -> Option { + let generation = app + .state::() + .document_access + .generation(window.label()); + use tauri_plugin_dialog::DialogExt; let handle = app.clone(); - #[cfg(not(target_os = "macos"))] - task::spawn_blocking(move || { - let result = handle + let window_label = window.label().to_owned(); + tokio::task::spawn_blocking(move || { + handle .dialog() .file() - .set_title("选择笔记本文件夹") + .set_title("选择要导入的文件夹") .blocking_pick_folder() - .map(|p| p.to_string()); - tx.send(result).ok(); - }); - - #[cfg(not(target_os = "macos"))] - tokio::task::spawn_blocking(move || rx.recv().ok().flatten()) - .await - .ok() - .flatten() + .and_then(|path| { + let path = Path::new(&path.to_string()).to_path_buf(); + let granted = handle + .state::() + .document_access + .grant_directory_for_generation(&window_label, generation, &path); + if granted { + record_bookmark_directory(&handle, &path); + } + granted.then(|| path.to_string_lossy().into_owned()) + }) + }) + .await + .ok() + .flatten() } #[tauri::command] @@ -133,11 +128,22 @@ pub async fn select_files( .into_iter() .filter_map(|path| { let path = path.to_string(); - handle + let granted = handle .state::() .document_access - .grant_for_generation(window.label(), generation, Path::new(&path)) - .then_some(path) + .grant_for_generation(window.label(), generation, Path::new(&path)); + if granted { + if let Err(error) = handle + .state::() + .security_bookmarks + .record_file(Path::new(&path)) + { + tracing::warn!(%error, "cannot persist selected file bookmark"); + } + Some(path) + } else { + None + } }) .collect::>() }); @@ -148,6 +154,22 @@ pub async fn select_files( .flatten() } +fn record_bookmark_directory(app: &tauri::AppHandle, path: &Path) { + #[cfg(target_os = "macos")] + if let Err(error) = app + .state::() + .security_bookmarks + .record_directory(path) + { + tracing::warn!( + "failed to persist macOS folder bookmark for {}: {error}", + path.display() + ); + } + #[cfg(not(target_os = "macos"))] + let _ = (app, path); +} + #[tauri::command] pub async fn save_file_dialog( app: tauri::AppHandle, @@ -215,7 +237,6 @@ pub async fn save_notebook_asset( ) -> Result, String> { let permit = upload_quota::UPLOAD_QUOTA.acquire()?; let source = Path::new(&source_path); - start_security_bookmark_access(&state, source); let source = dunce::canonicalize(source).map_err(|error| error.to_string())?; if !super::helpers::can_access_document_path(&source, window.label(), &state) { return Err("Notebook asset source is not authorized".to_string()); @@ -240,7 +261,6 @@ pub async fn save_notebook_asset( .map_err(|error| error.to_string())?; attachments::notebook_root(&store, Some(&owner)).map_err(|error| error.to_string())? }; - start_security_bookmark_access(&state, &root); // Notebook-local assets are already durable workspace files. Return their // canonical path instead of making an attachment copy. if reuse_notebook_file.unwrap_or(false) && source.starts_with(&root) { @@ -291,8 +311,6 @@ pub async fn copy_attachment_file( .map_err(|error| error.to_string())?; let source = source.as_path(); - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, Path::new(&target_path)); let mut reader = fs::File::open(source).map_err(|error| error.to_string())?; state .export_access @@ -319,7 +337,6 @@ pub async fn open_attachment_file( .map_err(|error| error.to_string())?; let source = source.as_path(); - start_security_bookmark_access(&state, source); app.opener() .open_path(source.display().to_string(), None::) .map_err(|e| e.to_string()) diff --git a/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs b/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs index f94635e53..49af01751 100644 --- a/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs +++ b/app/flowix-desktop/src/commands/dialog/attachment_fingerprint.rs @@ -1,6 +1,6 @@ +use sha2::{Digest, Sha256}; use std::fs::File; use std::io::{self, Read, Seek, SeekFrom}; -use sha2::{Digest, Sha256}; pub(super) fn fingerprint(file: &mut File) -> io::Result<(String, u64)> { file.seek(SeekFrom::Start(0))?; diff --git a/app/flowix-desktop/src/commands/dialog/upload_sessions.rs b/app/flowix-desktop/src/commands/dialog/upload_sessions.rs index 5c01c4858..eaa35ecdd 100644 --- a/app/flowix-desktop/src/commands/dialog/upload_sessions.rs +++ b/app/flowix-desktop/src/commands/dialog/upload_sessions.rs @@ -222,9 +222,6 @@ pub fn begin_attachment_upload( let store = read_lock(&state.memo_file, "memo_file"); let (notebook_id, owner_note) = attachments::resolve_note_owner(&store, None, Some(¬e_path)) .map_err(|error| error.to_string())?; - let root = attachments::notebook_root(&store, Some(¬ebook_id)) - .map_err(|error| error.to_string())?; - super::start_security_bookmark_access(&state, &root); state.upload_sessions.begin( window.label(), generation, diff --git a/app/flowix-desktop/src/commands/external_document.rs b/app/flowix-desktop/src/commands/external_document.rs index 0d8f2e804..f5cad0972 100644 --- a/app/flowix-desktop/src/commands/external_document.rs +++ b/app/flowix-desktop/src/commands/external_document.rs @@ -8,17 +8,35 @@ use crate::app::state::AppState; use crate::commands::external_document_watch::ExternalDocumentWatchState; use crate::commands::helpers::{ can_access_document_path, can_access_scoped_file, refresh_notebook_note_index, - start_security_bookmark_access, }; use crate::lock_utils::read_lock; use flowix_core::memo_file::{FileWriteOutcome, MergedFileWriteOutcome}; #[tauri::command] -pub fn get_external_document_mime_type(file_path: String) -> String { - mime_guess::from_path(file_path) - .first_or_octet_stream() - .essence_str() - .to_string() +pub async fn detect_external_document_text( + window: tauri::WebviewWindow, + file_path: String, + #[allow(non_snake_case)] scopePath: Option, + app: tauri::AppHandle, +) -> Result { + let window_label = window.label().to_string(); + crate::document_io::run("external_document_text_detection", move || { + let state = app.state::(); + let path = PathBuf::from(file_path); + if !path.is_absolute() || !path.is_file() { + return Err("external document is unavailable".to_string()); + } + if !can_access_document_path(&path, &window_label, &state) + && !can_access_scoped_file(&path, scopePath.as_deref(), &state) + { + return Err("external document is outside its authorized scope".to_string()); + } + + // A local file has no HTTP Content-Type header. This is only a text + // readability probe for unknown paths, not a MIME type resolver. + Ok(looks_like_utf8_text_file(&path)) + }) + .await? } #[derive(Debug, Clone, Serialize)] @@ -185,7 +203,6 @@ pub(crate) fn exact_existing_external_path( { return Err("external document is outside its authorized scope".to_string()); } - start_security_bookmark_access(state.inner(), &requested); if !supported_text_document_path(&requested) { return Err("external document is not a supported text file".to_string()); } diff --git a/app/flowix-desktop/src/commands/file.rs b/app/flowix-desktop/src/commands/file.rs index 50ea0a54e..2fe112664 100644 --- a/app/flowix-desktop/src/commands/file.rs +++ b/app/flowix-desktop/src/commands/file.rs @@ -22,12 +22,29 @@ use sha2::{Digest, Sha256}; use tokio::sync::{Notify, Semaphore}; use super::helpers::{ - can_access_document_path, can_access_scoped_file, is_agent_access_folder, + can_access_document_path, can_access_scoped_file, is_attached_notebook_directory, is_internal_notebook_path, is_registered_notebook_path, is_registered_notebook_root, - refresh_notebook_note_index, start_security_bookmark_access, + refresh_notebook_note_index, FileOperationFailure, }; use crate::app::state::AppState; +pub mod folder_import; + +#[cfg(target_os = "macos")] +fn is_cloud_file_not_downloaded(path: &Path) -> bool { + use std::os::macos::fs::MetadataExt; + + // macOS marks cloud-only placeholders with UF_DATALESS. + const UF_DATALESS: u32 = 0x4000_0000; + fs::symlink_metadata(path) + .is_ok_and(|metadata| metadata.st_flags() & UF_DATALESS != 0) +} + +#[cfg(not(target_os = "macos"))] +fn is_cloud_file_not_downloaded(_path: &Path) -> bool { + false +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct DocTreeItem { @@ -444,20 +461,14 @@ pub(crate) fn default_create_folder_for_notebook(root: &Path) -> Result Result<(), String> { - if read_notebook_view_preferences(root) - .file_management - .legacy_watcher_migrated - { - return Ok(()); - } - with_registered_notebook_change(root, memo, "migrate_file_management_preferences", || { - migrate_legacy_watcher_rules_locked(root, config) - }) +pub(crate) fn write_default_notebook_view_preferences(root: &Path) -> Result<(), String> { + let path = notebook_preferences_path(root)?; + fs::create_dir_all(path.parent().ok_or("INVALID_NOTEBOOK_CONFIG_DIRECTORY")?) + .map_err(|error| format!("create notebook config directory failed: {error}"))?; + let bytes = serde_json::to_vec_pretty(&NotebookViewPreferences::default()) + .map_err(|error| format!("serialize default notebook preferences failed: {error}"))?; + flowix_core::memo_file::atomic_write_bytes(&path, &bytes) + .map_err(|error| format!("write default notebook preferences failed: {error}")) } fn with_registered_notebook_change( @@ -470,61 +481,6 @@ fn with_registered_notebook_change( .map_err(|error| error.to_string())? } -fn migrate_legacy_watcher_rules_locked( - root: &Path, - config: &crate::watcher::WhitelistConfig, -) -> Result<(), String> { - let mut preferences = read_notebook_view_preferences(root); - if preferences.file_management.legacy_watcher_migrated { - return Ok(()); - } - let policy = &mut preferences.file_management; - policy.legacy_skip_dirs = config - .skip_dirs - .iter() - .filter(|name| { - !matches!( - name.as_str(), - ".flowix" - | ".plugin-output" - | ".git" - | ".DS_Store" - | "node_modules" - | ".cache" - | ".trash" - | "attachments" - | "attachments-cache" - ) - }) - .cloned() - .collect(); - policy.legacy_skip_files = config - .skip_files - .iter() - .filter(|name| { - !matches!( - name.as_str(), - "*.tmp" | "*.swp" | "*~" | ".DS_Store" | "Thumbs.db" | "*.bak" | "*.lock" - ) - }) - .cloned() - .collect(); - policy.legacy_watcher_migrated = true; - let path = notebook_preferences_path(root)?; - fs::create_dir_all(path.parent().ok_or("INVALID_NOTEBOOK_CONFIG_DIRECTORY")?) - .map_err(|error| error.to_string())?; - if !policy.legacy_skip_dirs.is_empty() || !policy.legacy_skip_files.is_empty() { - flowix_core::memo_file::atomic_write_bytes( - &root.join(".flowix/file-management-refresh-pending"), - uuid::Uuid::new_v4().to_string().as_bytes(), - ) - .map_err(|error| error.to_string())?; - } - let bytes = serde_json::to_vec_pretty(&preferences).map_err(|error| error.to_string())?; - flowix_core::memo_file::atomic_write_bytes(&path, &bytes).map_err(|error| error.to_string())?; - Ok(()) -} - fn normalize_relative_folder_paths(folders: Vec) -> Result, String> { let mut normalized = Vec::new(); for folder in folders { @@ -571,11 +527,6 @@ pub fn get_notebook_view_preferences( if !is_registered_notebook_root(root, &state) { return Err("NOTEBOOK_NOT_REGISTERED".to_string()); } - migrate_legacy_watcher_rules( - root, - &state.user_config.get_preference().watcher, - &read_lock(&state.memo_file, "memo_file"), - )?; if root .join(".flowix/file-management-refresh-pending") .exists() @@ -656,11 +607,6 @@ pub fn get_file_management_candidates( if !is_registered_notebook_root(root, &state) { return Err("NOTEBOOK_NOT_REGISTERED".to_string()); } - migrate_legacy_watcher_rules( - root, - &state.user_config.get_preference().watcher, - &read_lock(&state.memo_file, "memo_file"), - )?; let policy = read_notebook_view_preferences(root).file_management; let defaults = FileManagementPolicy::default(); let mut pending = vec![root.to_path_buf()]; @@ -689,7 +635,6 @@ pub fn get_file_management_candidates( let is_directory = metadata.is_dir(); let name = Path::new(entry.file_name().as_os_str()).to_path_buf(); let own_name_ignored = defaults.is_ignored(&name) - || policy.matches_legacy_entry(&path) || FileManagementPolicy::has_hidden_attribute(&path); if FileManagementPolicy::is_locked_name(&entry.file_name().to_string_lossy()) { continue; @@ -783,7 +728,6 @@ fn collect_notebook_settings_tree( let relative_path = relative.to_string_lossy().replace('\\', "/"); let hidden = policy.is_tree_hidden_at(root, relative); let default_hidden = FileManagementPolicy::default().is_ignored_at(root, relative) - || policy.matches_legacy_entry(&path) || FileManagementPolicy::has_hidden_attribute(&path); if metadata.is_dir() { let is_flowix_dir = relative_path == ".flowix"; @@ -857,10 +801,6 @@ pub fn set_notebook_view_preferences( &read_lock(&state.memo_file, "memo_file"), "set_notebook_view_preferences", || { - migrate_legacy_watcher_rules_locked( - &root, - &state.user_config.get_preference().watcher, - )?; let previous_preferences = read_notebook_view_preferences(&root); let previous_policy = previous_preferences.file_management.clone(); let path = notebook_preferences_path(&root)?; @@ -883,9 +823,6 @@ pub fn set_notebook_view_preferences( excluded_index_paths: normalize_excluded_index_paths( preferences.file_management.excluded_index_paths, )?, - legacy_skip_dirs: previous_policy.legacy_skip_dirs.clone(), - legacy_skip_files: previous_policy.legacy_skip_files.clone(), - legacy_watcher_migrated: previous_policy.legacy_watcher_migrated, }, refresh_pending: false, }; @@ -975,7 +912,6 @@ fn normalize_default_create_folder(folder: Option) -> Result) -> Option> { let path = Path::new(&space_path); - start_security_bookmark_access(&state, path); if !path.exists() || is_internal_notebook_path(path, &state) || !is_browsable_scope(path, &state) @@ -996,7 +932,6 @@ pub fn get_file_tree(space_path: String, state: State) -> Option) -> Vec { let path = Path::new(&dir_path); - start_security_bookmark_access(&state, path); if !path.exists() || is_internal_notebook_path(path, &state) || !is_browsable_scope(path, &state) @@ -1019,7 +954,7 @@ pub fn get_dir_children(dir_path: String, state: State) -> Vec) -> bool { !is_internal_notebook_path(path, state) - && (is_registered_notebook_path(path, state) || is_agent_access_folder(path, state)) + && (is_registered_notebook_path(path, state) || is_attached_notebook_directory(path, state)) } #[tauri::command] @@ -1032,7 +967,6 @@ pub fn read_file( eprintln!("[read_file] refused out-of-scope path: {}", file_path); return None; } - start_security_bookmark_access(&state, Path::new(&file_path)); fs::read_to_string(&file_path).ok() } @@ -1065,7 +999,6 @@ fn authorized_docx_path( { return Err("DOCX_FORBIDDEN: file is outside its authorized space".to_string()); } - start_security_bookmark_access(state, &path); Ok(path) } @@ -1278,7 +1211,6 @@ fn create_docx_markdown_with_images( { return Err("DOCX_FORBIDDEN: attachment directory escapes this space".to_string()); } - start_security_bookmark_access(state, &attachment_directory); for (index, image) in prepared_images.iter().enumerate() { match create_docx_image_file(&attachment_directory, space_path, state, image, index) { @@ -1401,7 +1333,6 @@ pub fn read_image_file( eprintln!("[read_image_file] refused file: {}", file_path); return None; } - start_security_bookmark_access(&state, path); let bytes = fs::read(path).ok()?; Some(format!( "data:{mime};base64,{}", @@ -1422,7 +1353,6 @@ pub async fn read_image_preview( if !can_access_scoped_file(path, space_path.as_deref(), &state) || !path.is_file() { return Ok(None); } - start_security_bookmark_access(&state, path); let path = path.to_path_buf(); let max_dimension = preview_max_dimension(max_dimension); let slots = MEDIA_PREVIEW_SLOTS @@ -1552,7 +1482,6 @@ pub async fn get_media_thumbnail( if !is_image && !is_video { return Err("unsupported media thumbnail kind".to_string()); } - start_security_bookmark_access(&state, &path); let metadata = fs::metadata(&path).map_err(|error| error.to_string())?; let modified_ns = metadata @@ -1901,7 +1830,6 @@ pub fn read_video_preview( eprintln!("[read_video_preview] refused file: {}", file_path); return None; } - start_security_bookmark_access(&state, path); #[cfg(target_os = "macos")] { @@ -1957,7 +1885,6 @@ pub fn write_file( eprintln!("[write_file] refused out-of-scope path: {}", file_path); return false; } - start_security_bookmark_access(&state, Path::new(&file_path)); let memo_file = read_lock(&state.memo_file, "memo_file"); let path = Path::new(&file_path); let notebook_ids = match memo_file.notebook_ids_for_paths(&[path]) { @@ -2002,7 +1929,6 @@ pub fn create_file( if !path.parent().is_some_and(Path::is_dir) { return Err("FILE_NOT_FOUND: parent directory does not exist".to_string()); } - start_security_bookmark_access(&state, path); let memo_file = read_lock(&state.memo_file, "memo_file"); let notebook_ids = memo_file .notebook_ids_for_paths(&[path]) @@ -2031,7 +1957,6 @@ pub fn delete_file(file_path: String, space_path: Option, state: State String { format!("{code}: {error}") } +fn target_directory_error(error: std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + "TARGET_NOT_DIRECTORY".to_string() + } else { + file_mutation_error(error) + } +} + fn validate_file_name(name: &str) -> Result<(), String> { if name.trim().is_empty() || name == "." @@ -2348,7 +2280,6 @@ pub async fn rename_file( { return Err("FILE_PERMISSION_DENIED".to_string()); } - start_security_bookmark_access(&state, source); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_file() @@ -2372,7 +2303,7 @@ pub fn move_file( space_path: String, state: State, app: tauri::AppHandle, -) -> Result { +) -> Result { let source = Path::new(&file_path); let target_directory = Path::new(&target_directory_path); let scope = Path::new(&space_path); @@ -2380,35 +2311,38 @@ pub fn move_file( || !can_access_scoped_file(source, Some(&space_path), &state) || !can_access_scoped_file(target_directory, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, target_directory); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_file() { - return Err("SOURCE_NOT_REGULAR_FILE".to_string()); + return Err(FileOperationFailure::code("SOURCE_NOT_REGULAR_FILE")); } if !fs::symlink_metadata(target_directory) - .map_err(file_mutation_error)? + .map_err(target_directory_error)? .is_dir() { - return Err("TARGET_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); } - let file_name = source.file_name().ok_or("INVALID_FILE_PATH")?; + let file_name = source + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; let target = target_directory.join(file_name); if !can_access_scoped_file(&target, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } if source == target { return Ok(source.to_string_lossy().into_owned()); } if fs::symlink_metadata(&target).is_ok() { - return Err(file_mutation_error(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "target already exists", - ))); + return Err( + file_mutation_error(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target already exists", + )) + .into(), + ); } rename_path_and_notify(source, &target, state.inner(), &app)?; Ok(target.to_string_lossy().into_owned()) @@ -2422,7 +2356,7 @@ pub fn move_folder( space_path: String, state: State, app: tauri::AppHandle, -) -> Result { +) -> Result { let source = Path::new(&folder_path); let target_directory = Path::new(&target_directory_path); let scope = Path::new(&space_path); @@ -2433,38 +2367,41 @@ pub fn move_folder( || is_internal_notebook_path(source, &state) || is_internal_notebook_path(target_directory, &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, target_directory); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_dir() { - return Err("SOURCE_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("SOURCE_NOT_DIRECTORY")); } if !fs::symlink_metadata(target_directory) - .map_err(file_mutation_error)? + .map_err(target_directory_error)? .is_dir() { - return Err("TARGET_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); } if path_is_inside(target_directory, source) { - return Err("INVALID_MOVE_TARGET".to_string()); + return Err(FileOperationFailure::code("INVALID_MOVE_TARGET")); } - let folder_name = source.file_name().ok_or("INVALID_FILE_PATH")?; + let folder_name = source + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; let target = target_directory.join(folder_name); if !path_is_inside(&target, scope) || is_internal_notebook_path(&target, &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } if source == target { return Ok(source.to_string_lossy().into_owned()); } if fs::symlink_metadata(&target).is_ok() { - return Err(file_mutation_error(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "target already exists", - ))); + return Err( + file_mutation_error(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target already exists", + )) + .into(), + ); } rename_path_and_notify(source, &target, state.inner(), &app)?; Ok(target.to_string_lossy().into_owned()) @@ -2480,14 +2417,14 @@ pub async fn import_file( target_directory_path: String, space_path: String, state: State<'_, AppState>, -) -> Result { +) -> Result { let source = Path::new(&file_path); let target_directory = Path::new(&target_directory_path); let scope = Path::new(&space_path); if !is_browsable_scope(scope, &state) || !can_access_scoped_file(target_directory, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } // WebView2 can invoke this command just before Tauri's Drop RunEvent has // recorded the exact external path in DocumentAccess. Wait briefly for @@ -2503,35 +2440,41 @@ pub async fn import_file( || can_access_scoped_file(source, Some(&space_path), &state); } if !source_allowed { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } - start_security_bookmark_access(&state, source); - start_security_bookmark_access(&state, target_directory); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_file() { - return Err("SOURCE_NOT_REGULAR_FILE".to_string()); + return Err(FileOperationFailure::code("SOURCE_NOT_REGULAR_FILE")); + } + if is_cloud_file_not_downloaded(source) { + return Err(FileOperationFailure::code("CLOUD_FILE_NOT_DOWNLOADED")); } if !fs::symlink_metadata(target_directory) - .map_err(file_mutation_error)? + .map_err(target_directory_error)? .is_dir() { - return Err("TARGET_NOT_DIRECTORY".to_string()); + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); } - let file_name = source.file_name().ok_or("INVALID_FILE_PATH")?; + let file_name = source + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; let target = target_directory.join(file_name); if !can_access_scoped_file(&target, Some(&space_path), &state) { - return Err("FILE_PERMISSION_DENIED".to_string()); + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); } if source == target { return Ok(target.to_string_lossy().into_owned()); } if fs::symlink_metadata(&target).is_ok() { - return Err(file_mutation_error(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "target already exists", - ))); + return Err( + file_mutation_error(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "target already exists", + )) + .into(), + ); } let source = source.to_path_buf(); @@ -2614,7 +2557,6 @@ pub async fn rename_folder( { return Err("FILE_PERMISSION_DENIED".to_string()); } - start_security_bookmark_access(&state, source); if !fs::symlink_metadata(source) .map_err(file_mutation_error)? .is_dir() @@ -2661,7 +2603,6 @@ pub fn create_folder( ); return None; } - start_security_bookmark_access(&state, &target_path); let memo_file = read_lock(&state.memo_file, "memo_file"); let notebook_ids = memo_file.notebook_ids_for_paths(&[&target_path]).ok()?; if notebook_ids.is_empty() { @@ -2715,7 +2656,6 @@ pub fn create_document( ); return Err("FILE_PERMISSION_DENIED".to_string()); } - start_security_bookmark_access(&state, &target_path); let memo_file = read_lock(&state.memo_file, "memo_file"); let notebook_ids = memo_file .notebook_ids_for_paths(&[&target_path]) diff --git a/app/flowix-desktop/src/commands/file/folder_import.rs b/app/flowix-desktop/src/commands/file/folder_import.rs new file mode 100644 index 000000000..ca77f15d7 --- /dev/null +++ b/app/flowix-desktop/src/commands/file/folder_import.rs @@ -0,0 +1,198 @@ +use super::*; + +const MAX_IMPORT_FOLDER_FILES: usize = 2_000; +const MAX_IMPORT_FOLDER_ENTRIES: usize = 5_000; +const MAX_IMPORT_FOLDER_DEPTH: usize = 32; +const MAX_IMPORT_FOLDER_BYTES: u64 = 5 * 1024 * 1024 * 1024; + +/// Import a selected directory while preserving its internal folder structure. +/// Limits are checked before creating anything in the destination. +#[tauri::command] +pub async fn import_folder( + window: WebviewWindow, + source_directory_path: String, + target_directory_path: String, + space_path: String, + state: State<'_, AppState>, +) -> Result { + let source_directory = Path::new(&source_directory_path); + let target_directory = Path::new(&target_directory_path); + let scope = Path::new(&space_path); + if !can_access_document_path(source_directory, window.label(), &state) + || !is_browsable_scope(scope, &state) + || !can_access_scoped_file(target_directory, Some(&space_path), &state) + { + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); + } + let source_metadata = fs::symlink_metadata(source_directory).map_err(file_mutation_error)?; + if !source_metadata.is_dir() || source_metadata.file_type().is_symlink() { + return Err(FileOperationFailure::code("SOURCE_NOT_DIRECTORY")); + } + if !fs::symlink_metadata(target_directory) + .map_err(target_directory_error)? + .is_dir() + { + return Err(FileOperationFailure::code("TARGET_NOT_DIRECTORY")); + } + let folder_name = source_directory + .file_name() + .ok_or_else(|| FileOperationFailure::code("INVALID_FILE_PATH"))?; + let target_root = target_directory.join(folder_name); + if !can_access_scoped_file(&target_root, Some(&space_path), &state) { + return Err(FileOperationFailure::code("FILE_PERMISSION_DENIED")); + } + if fs::symlink_metadata(&target_root).is_ok() { + return Err(FileOperationFailure::code("FILE_EXISTS")); + } + + let source = dunce::canonicalize(source_directory).map_err(file_mutation_error)?; + let memo_file = state.memo_file.clone(); + + tokio::task::spawn_blocking(move || { + use walkdir::WalkDir; + + let mut directories = Vec::new(); + let mut files = Vec::new(); + let mut entry_count = 0usize; + let mut total_bytes = 0u64; + let walker = WalkDir::new(&source) + .follow_links(false) + .min_depth(1) + .into_iter() + .filter_entry(|entry| { + entry.depth() == 0 + || !matches!(entry.file_name().to_str(), Some(".flowix" | ".DS_Store")) + }); + for entry in walker { + let entry = entry.map_err(|_| FileOperationFailure::code("IMPORT_FOLDER_SCAN_FAILED"))?; + let relative = entry + .path() + .strip_prefix(&source) + .map_err(|_| FileOperationFailure::code("INVALID_FILE_PATH"))?; + entry_count += 1; + if entry_count > MAX_IMPORT_FOLDER_ENTRIES { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_MANY_ENTRIES")); + } + let relative_depth = relative.components().count(); + let depth = if entry.file_type().is_dir() { + relative_depth + } else { + relative_depth.saturating_sub(1) + }; + if depth > MAX_IMPORT_FOLDER_DEPTH { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_DEEP")); + } + if entry.file_type().is_symlink() { + continue; + } + if entry.file_type().is_dir() { + directories.push(relative.to_path_buf()); + continue; + } + if !entry.file_type().is_file() { + continue; + } + if is_cloud_file_not_downloaded(entry.path()) { + return Err(FileOperationFailure::code("CLOUD_FILE_NOT_DOWNLOADED")); + } + let size = entry + .metadata() + .map_err(|_| FileOperationFailure::code("IMPORT_FOLDER_SCAN_FAILED"))? + .len(); + total_bytes = total_bytes.saturating_add(size); + if total_bytes > MAX_IMPORT_FOLDER_BYTES { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_LARGE")); + } + files.push((entry.path().to_path_buf(), relative.to_path_buf())); + if files.len() > MAX_IMPORT_FOLDER_FILES { + return Err(FileOperationFailure::code("IMPORT_FOLDER_TOO_MANY_FILES")); + } + } + + // Create the full tree only after the source passes all import limits. + fs::create_dir(&target_root).map_err(file_mutation_error)?; + let copy_result = (|| -> Result<(), FileOperationFailure> { + for relative in &directories { + fs::create_dir_all(target_root.join(relative)).map_err(file_mutation_error)?; + } + for (source_file, relative) in &files { + let target = target_root.join(relative); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(file_mutation_error)?; + } + let store = read_lock(&memo_file, "memo_file"); + let ids = store + .notebook_ids_for_paths(&[&target]) + .map_err(file_mutation_error)?; + let copy = |destination: &Path| -> std::io::Result<()> { + let metadata = fs::symlink_metadata(source_file)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "source is not a regular file", + )); + } + let canonical_source = dunce::canonicalize(source_file)?; + if !canonical_source.starts_with(&source) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "source escaped selected folder", + )); + } + let mut input = fs::File::open(canonical_source)?; + let mut output = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + if let Err(error) = std::io::copy(&mut input, &mut output) { + let _ = fs::remove_file(destination); + return Err(error); + } + output.sync_all() + }; + match ids.as_slice() { + [notebook_id] => store.with_file_write( + notebook_id, + &target, + flowix_core::memo_file::FileLockIntent::Create, + "import_folder_file", + copy, + ), + [] => { + let _guard = store + .acquire_cross_process_write_lock() + .map_err(file_mutation_error)?; + copy(&target) + } + _ => store.with_registered_path_change( + &[&target], + "import_folder_nested_notebooks", + || copy(&target), + ), + } + .map_err(file_mutation_error)?; + } + Ok(()) + })(); + if let Err(import_error) = copy_result { + if let Err(cleanup_error) = fs::remove_dir_all(&target_root) { + tracing::warn!( + target = %target_root.display(), + error = %cleanup_error, + "failed to clean up partially imported folder" + ); + return Err(FileOperationFailure { + code: "IMPORT_FOLDER_CLEANUP_FAILED".to_string(), + message: Some(format!( + "Import failed ({}) and partial files could not be removed ({cleanup_error})", + import_error.code + )), + }); + } + return Err(import_error); + } + Ok(target_root.to_string_lossy().into_owned()) + }) + .await + .map_err(|error| FileOperationFailure::code(&format!("FILE_OPERATION_FAILED: {error}")))? +} diff --git a/app/flowix-desktop/src/commands/file_browser_watch.rs b/app/flowix-desktop/src/commands/file_browser_watch.rs index d36cf3f4b..7003eb12d 100644 --- a/app/flowix-desktop/src/commands/file_browser_watch.rs +++ b/app/flowix-desktop/src/commands/file_browser_watch.rs @@ -19,8 +19,7 @@ use tauri::{Emitter, Manager}; use crate::app::state::AppState; use crate::commands::helpers::{ - is_agent_access_folder_with_state, is_registered_notebook_path_with_state, - start_security_bookmark_access, + is_attached_notebook_directory_with_state, is_registered_notebook_path_with_state, }; use crate::config::path_is_inside; @@ -354,6 +353,11 @@ fn emit_directory_changes( return; }; + // A mounted tab can outlive the mount itself; stop publishing revoked roots. + let Some(state) = app.try_state::() else { return; }; + if !is_registered_notebook_path_with_state(&root_path, &state) + && !is_attached_notebook_directory_with_state(&root_path, &state) { return; } + let mut directories = directories.into_iter().collect::>(); directories.sort(); let mut paths = paths.into_iter().collect::>(); @@ -390,10 +394,9 @@ pub fn watch_file_browser_root( root_path: String, ) -> Result { let path = PathBuf::from(&root_path); - start_security_bookmark_access(&app_state, &path); if !path.is_dir() || (!is_registered_notebook_path_with_state(&path, &app_state) - && !is_agent_access_folder_with_state(&path, &app_state)) + && !is_attached_notebook_directory_with_state(&path, &app_state)) { return Err(format!( "file browser root is not accessible: {}", diff --git a/app/flowix-desktop/src/commands/helpers.rs b/app/flowix-desktop/src/commands/helpers.rs index 5fda54d86..61902e29a 100644 --- a/app/flowix-desktop/src/commands/helpers.rs +++ b/app/flowix-desktop/src/commands/helpers.rs @@ -2,19 +2,90 @@ use std::path::Path; +use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use crate::config::{path_is_inside, path_is_inside_reserved_directory}; -use crate::lock_utils::{read_lock, write_lock}; +use crate::lock_utils::read_lock; use crate::watcher::runtime::current_watcher; use crate::app::state::AppState; -use flowix_core::memo_file::{ - notebook_relative_path, MemoFile, NotebookConfig, -}; +use flowix_core::memo_file::{notebook_relative_path, MemoFile, NotebookConfig}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileOperationFailure { + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl FileOperationFailure { + pub fn code(code: &str) -> Self { + Self { + code: code.to_string(), + message: None, + } + } +} + +impl From for FileOperationFailure { + fn from(value: String) -> Self { + let (code, message) = value.split_once(": ").unwrap_or((value.as_str(), "")); + Self { + code: code.to_string(), + message: (!message.is_empty()).then(|| message.to_string()), + } + } +} + +impl From<&str> for FileOperationFailure { + fn from(value: &str) -> Self { + Self::from(value.to_string()) + } +} + +impl From for FileOperationFailure { + fn from(error: flowix_core::FlowixError) -> Self { + use flowix_core::FlowixError; + + match error { + FlowixError::Io(error) => { + let code = match error.kind() { + std::io::ErrorKind::AlreadyExists => "FILE_EXISTS", + std::io::ErrorKind::NotFound => "FILE_NOT_FOUND", + std::io::ErrorKind::PermissionDenied => "FILE_PERMISSION_DENIED", + _ => "FILE_OPERATION_FAILED", + }; + Self { + code: code.to_string(), + message: Some(error.to_string()), + } + } + FlowixError::NotFound(message) => Self { + code: "FILE_NOT_FOUND".to_string(), + message: Some(message), + }, + FlowixError::PermissionDenied(message) => Self { + code: "FILE_PERMISSION_DENIED".to_string(), + message: Some(message), + }, + FlowixError::InvalidInput(message) => Self { + code: "INVALID_MOVE_TARGET".to_string(), + message: Some(message), + }, + error => Self { + code: "FILE_OPERATION_FAILED".to_string(), + message: Some(error.to_string()), + }, + } + } +} pub(crate) fn watch_created_notebook(state: &AppState, app: &AppHandle, config: &NotebookConfig) { - start_security_bookmark_access(state, Path::new(&config.path)); + state + .security_bookmarks + .record_accessible_directory(Path::new(&config.path)); if let Some(watcher) = current_watcher(app) { if let Ok(mut guard) = watcher.write() { if guard.add_notebook_root(config) { @@ -25,10 +96,6 @@ pub(crate) fn watch_created_notebook(state: &AppState, app: &AppHandle, config: refresh_watcher_roots(state, app); } -pub(crate) fn start_security_bookmark_access(state: &AppState, path: &Path) { - state.security_bookmarks.start_accessing_for_path(path); -} - /// Resolve a notebook-relative Markdown address without consulting the legacy /// memo ID table. `None` means the path is outside notebooks or is not Markdown. pub(crate) fn notebook_note_address( @@ -48,7 +115,10 @@ pub(crate) fn notebook_note_address( continue; }; let relative = Path::new(&relative_path); - if memo_file.file_management_policy(¬ebook.id).is_index_ignored_at(&root, relative) { + if memo_file + .file_management_policy(¬ebook.id) + .is_index_ignored_at(&root, relative) + { return Err("document path is inside an ignored notebook directory".into()); } if !relative @@ -92,9 +162,6 @@ pub(crate) fn refresh_watcher_roots(state: &AppState, app: &AppHandle) { let memo_file = read_lock(&state.memo_file, "memo_file"); memo_file.read_notebook_configs().unwrap_or_default() }; - for config in &configs { - start_security_bookmark_access(state, Path::new(&config.path)); - } if let Some(watcher) = current_watcher(app) { if let Ok(mut guard) = watcher.write() { guard.rebind_all(app.clone(), configs.clone()); @@ -106,11 +173,16 @@ pub(crate) fn refresh_watcher_roots(state: &AppState, app: &AppHandle) { for config in &configs { match memo_file.reconcile_note_index(&config.id) { Ok(report) if report.added + report.updated + report.removed > 0 => { - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": config.id, "relativePath": "", "deleted": false, - })); + let _ = app.emit( + "flowix:path-note-changed", + serde_json::json!({ + "notebookId": config.id, "relativePath": "", "deleted": false, + }), + ); + } + Err(error) => { + tracing::warn!(notebook_id = %config.id, "watcher rebind reconciliation failed: {error}") } - Err(error) => tracing::warn!(notebook_id = %config.id, "watcher rebind reconciliation failed: {error}"), _ => {} } } @@ -131,7 +203,6 @@ pub(crate) fn retry_notebook_watch(state: &AppState, app: &AppHandle, notebook_i if watcher.is_watching(notebook_id) { return; } - start_security_bookmark_access(state, Path::new(&config.path)); if !watcher.add_notebook_root(&config) { tracing::warn!(notebook_id, "notebook watch retry failed"); return; @@ -139,11 +210,17 @@ pub(crate) fn retry_notebook_watch(state: &AppState, app: &AppHandle, notebook_i drop(watcher); match read_lock(&state.memo_file, "memo_file").reconcile_note_index(notebook_id) { Ok(report) if report.added + report.updated + report.removed > 0 => { - let _ = app.emit("flowix:path-note-changed", serde_json::json!({ - "notebookId": notebook_id, "relativePath": "", "deleted": false, - })); + let _ = app.emit( + "flowix:path-note-changed", + serde_json::json!({ + "notebookId": notebook_id, "relativePath": "", "deleted": false, + }), + ); } - Err(error) => tracing::warn!(notebook_id, "notebook watch retry reconciliation failed: {error}"), + Err(error) => tracing::warn!( + notebook_id, + "notebook watch retry reconciliation failed: {error}" + ), _ => {} } } @@ -171,24 +248,21 @@ pub(crate) fn refresh_notebook_watcher(state: &AppState, app: &AppHandle, notebo pub(crate) fn switch_notebook_trusting_index( state: &AppState, - app: &AppHandle, notebook_id: Option, ) -> Result<(), String> { - switch_notebook(state, app, notebook_id) + switch_notebook(state, notebook_id) } -fn switch_notebook( - state: &AppState, - app: &AppHandle, - notebook_id: Option, -) -> Result<(), String> { +fn switch_notebook(state: &AppState, notebook_id: Option) -> Result<(), String> { let prev = read_lock(&state.memo_file, "memo_file").current_notebook_id_value(); if let Some(target_id) = notebook_id.as_deref() { let target_path = read_lock(&state.memo_file, "memo_file") .get_notebook_config_by_id(target_id) .map(|config| std::path::PathBuf::from(config.path)) .ok_or_else(|| format!("notebook {target_id} not found"))?; - start_security_bookmark_access(state, &target_path); + state + .security_bookmarks + .record_accessible_directory(&target_path); if !target_path.is_dir() { return Err(format!( "notebook {target_id} path is missing: {}", @@ -198,20 +272,6 @@ fn switch_notebook( } if prev == notebook_id { - if let Some(notebook_id) = notebook_id.as_deref() { - { - let memo_file = read_lock(&state.memo_file, "memo_file"); - let report = memo_file - .ensure_notebook_migrations(notebook_id) - .map_err(|error| format!("notebook migration failed: {error}"))?; - tracing::debug!( - notebook = %notebook_id, - moved_files = report.moved_files, - rebuilt_tags = report.rebuilt_tags, - "notebook migrations checked" - ); - }; - } return Ok(()); } @@ -224,23 +284,6 @@ fn switch_notebook( }) .set_current_notebook(notebook_id.clone()); - if let Some(notebook_id) = notebook_id.as_deref() { - { - let memo_file = read_lock(&state.memo_file, "memo_file"); - let report = memo_file - .ensure_notebook_migrations(notebook_id) - .map_err(|error| format!("notebook migration failed: {error}"))?; - if report.moved_files > 0 || report.rebuilt_tags > 0 { - tracing::info!( - notebook = %notebook_id, - moved_files = report.moved_files, - rebuilt_tags = report.rebuilt_tags, - "notebook migrations completed" - ); - } - }; - } - Ok(()) } @@ -286,9 +329,14 @@ pub(crate) fn is_registered_notebook_path_with_state(path: &Path, state: &AppSta } pub(crate) fn can_access_document_path(path: &Path, window: &str, state: &State) -> bool { - is_registered_notebook_path(path, state) - || is_agent_access_folder(path, state) - || state.document_access.contains(window, path) + state.security_bookmarks.start_accessing_for_path(path); + let allowed = is_registered_notebook_path(path, state) + || is_attached_notebook_directory(path, state) + || state.document_access.contains(window, path); + if allowed && path.is_dir() { + state.security_bookmarks.record_accessible_directory(path); + } + allowed } pub(crate) fn can_access_scoped_file( @@ -312,7 +360,7 @@ pub(crate) fn can_access_scoped_file_with_state( }; let root = Path::new(space_path); (is_registered_notebook_path_with_state(root, state) - || is_agent_access_folder_with_state(root, state)) + || is_attached_notebook_directory_with_state(root, state)) && path_is_inside(file_path, root) } @@ -333,19 +381,14 @@ pub(crate) fn is_internal_notebook_path_with_state(path: &Path, state: &AppState .any(|config| path_is_inside_reserved_directory(path, Path::new(&config.path), ".flowix")) } -/// 侧栏"资料"文件夹作用域 ── agent access 配置里登记的 folder entry。 -/// `get_file_tree` / `read_file` 等文件树 IPC 用它放行用户添加的资料 -/// 文件夹 (这些目录不在注册笔记本列表里, `is_registered_notebook_path` -/// 对它们返回 false)。 -pub(crate) fn is_agent_access_folder(path: &Path, state: &State) -> bool { - is_agent_access_folder_with_state(path, state.inner()) +/// External file scopes come from enabled notebook mounts in local.db. +pub(crate) fn is_attached_notebook_directory(path: &Path, state: &State) -> bool { + is_attached_notebook_directory_with_state(path, state.inner()) } -pub(crate) fn is_agent_access_folder_with_state(path: &Path, state: &AppState) -> bool { - let config = state.agent_access.get_config(); - config.entries.iter().any(|entry| { - entry.kind == crate::config::AgentAccessKind::Folder - && entry.enabled - && path_is_inside(path, Path::new(&entry.path)) - }) +pub(crate) fn is_attached_notebook_directory_with_state(path: &Path, state: &AppState) -> bool { + let memo = read_lock(&state.memo_file, "memo_file"); + memo.attached_directory_paths() + .map(|roots| roots.iter().any(|root| path_is_inside(path, root))) + .unwrap_or(false) } diff --git a/app/flowix-desktop/src/commands/kv.rs b/app/flowix-desktop/src/commands/kv.rs index ad8792c6f..46dfb032c 100644 --- a/app/flowix-desktop/src/commands/kv.rs +++ b/app/flowix-desktop/src/commands/kv.rs @@ -4,7 +4,6 @@ //! stored in each notebook's `.flowix/system.json`. The legacy global system //! store is read only to migrate a notebook the first time it is accessed. -use std::collections::HashMap; use std::path::PathBuf; use tauri::State; @@ -12,7 +11,7 @@ use tauri::State; use crate::app::state::AppState; use crate::system_data::{ NotebookCustomViewsData, NotebookFeaturedNotesData, - NotebookFileTreePreferences, NotebookTagSystemData, SystemFile, TagLayoutItem, TagSystemData, + NotebookFileTreePreferences, NotebookTagSystemData, SystemFile, TagLayoutItem, }; const FEATURED_NOTES_ALLOWED_OPERATORS: [&str; 3] = ["equals", "contains", "excludes"]; @@ -88,15 +87,9 @@ fn load_notebook_system(notebook_id: &str, state: &State) -> Result Result<(), String> { +pub(crate) fn validate_notebook_template(template: &NotebookTemplate) -> Result<(), String> { if template.id.trim().is_empty() || template.id.len() > 128 || template.id.chars().any(char::is_control) @@ -334,6 +335,8 @@ fn validate_notebook_template(template: &NotebookTemplate) -> Result<(), String> } fn read_notebook_template_index() -> Result, String> { + let home = dirs::home_dir().ok_or_else(|| "template directory not available".to_string())?; + template_store::initialize_notebook_templates(&home.join(USER_CONFIG_DIR_NAME))?; let directory = notebook_template_dir().ok_or_else(|| "template directory not available".to_string())?; let path = directory.join("index.json"); @@ -355,8 +358,6 @@ fn read_notebook_template_index() -> Result, String> { #[tauri::command] pub fn list_notebook_templates() -> Result, String> { - let home = dirs::home_dir().ok_or_else(|| "template directory not available".to_string())?; - template_store::initialize_notebook_templates(&home.join(USER_CONFIG_DIR_NAME))?; read_notebook_template_index() } @@ -935,9 +936,6 @@ pub async fn initialize_notebook_template( let index_report = { let memo_file = read_lock(&state.memo_file, "memo_file"); - memo_file - .ensure_notebook_migrations(¬ebook_id) - .map_err(|error| format!("notebook import migration failed: {error}"))?; memo_file .reconcile_note_index(¬ebook_id) .map_err(|error| format!("notebook path reconciliation failed: {error}"))? @@ -1190,7 +1188,7 @@ pub fn move_memo_to_directory( parent_relative_path: String, state: State, app: AppHandle, -) -> Result { +) -> Result { let requested_path = Path::new(&file_path); if !requested_path.is_absolute() { return Err("absolute document path required".into()); @@ -1209,11 +1207,11 @@ pub fn move_memo_to_directory( let mut service = NoteService::new(&memo_file); let original = service .get(&source_notebook_id, &relative_path) - .map_err(|error| error.to_string())?; + .map_err(FileOperationFailure::from)?; mark_self_write_for(&app, &original.path); let moved = service .move_to(&source_notebook_id, &relative_path, &parent_relative_path) - .map_err(|error| error.to_string())?; + .map_err(FileOperationFailure::from)?; drop(service); mark_self_write_for(&app, &moved.path); let compatibility_memo = before.as_ref().and_then(|_| { diff --git a/app/flowix-desktop/src/commands/memo/reads.rs b/app/flowix-desktop/src/commands/memo/reads.rs index 4667ef8f1..c0e50ab07 100644 --- a/app/flowix-desktop/src/commands/memo/reads.rs +++ b/app/flowix-desktop/src/commands/memo/reads.rs @@ -15,7 +15,6 @@ use flowix_core::service::NoteSaveOutcome; use flowix_core::{FlowixError, MemoPage, MemoService, NoteService}; use crate::app::state::AppState; -use crate::commands::helpers::start_security_bookmark_access; use crate::watcher::runtime::mark_self_write_for; use super::helpers::*; @@ -217,7 +216,6 @@ pub async fn read_document( return None; } let requested_path = Path::new(&file_path); - start_security_bookmark_access(&state, requested_path); let memo_file = read_lock(&state.memo_file, "memo_file"); match notebook_note_address(&memo_file, requested_path) { Ok(Some((notebook_id, relative_path))) => { @@ -257,7 +255,6 @@ pub async fn get_document_modified_at( if !crate::commands::helpers::can_access_document_path(path, window.label(), &state) { return None; } - start_security_bookmark_access(&state, path); fs::metadata(path).ok()?.modified().ok()? .duration_since(std::time::UNIX_EPOCH).ok() .map(|duration| duration.as_millis() as u64) @@ -317,7 +314,6 @@ fn write_document_internal( if !Path::new(file_path).is_absolute() { return Err("absolute document path required".into()); } - start_security_bookmark_access(state.inner(), Path::new(file_path)); let requested_path = Path::new(file_path); let (notebook_id, relative_path) = notebook_note_address(&read_lock(&state.memo_file, "memo_file"), requested_path)? diff --git a/app/flowix-desktop/src/commands/memo/versions.rs b/app/flowix-desktop/src/commands/memo/versions.rs index fece75a8a..dc6db2a9f 100644 --- a/app/flowix-desktop/src/commands/memo/versions.rs +++ b/app/flowix-desktop/src/commands/memo/versions.rs @@ -10,7 +10,6 @@ use crate::lock_utils::read_lock; use flowix_core::memo_file::{atomic_write_bytes, FileLockIntent, IsMd, MemoVersionSource, PathVersionMeta}; use crate::app::state::AppState; -use crate::commands::helpers::start_security_bookmark_access; use crate::watcher::runtime::mark_self_write_for; @@ -35,7 +34,6 @@ pub fn list_path_versions(notebook_id: String, relative_path: String, state: Sta #[tauri::command] pub fn create_path_version(notebook_id: String, relative_path: String, source: Option, state: State) -> Option { let path = path_archive_document(&state, ¬ebook_id, &relative_path)?; - start_security_bookmark_access(&state, &path); let memo_file = read_lock(&state.memo_file, "memo_file"); memo_file.with_file_write(¬ebook_id, &path, FileLockIntent::Existing, "create_path_version", |locked_path| { @@ -49,7 +47,6 @@ pub fn create_path_version(notebook_id: String, relative_path: String, source: O pub fn restore_path_version(notebook_id: String, relative_path: String, version_id: String, expected_content: Option, state: State, app: AppHandle) -> Option { let path = path_archive_document(&state, ¬ebook_id, &relative_path)?; - start_security_bookmark_access(&state, &path); let memo_file = read_lock(&state.memo_file, "memo_file"); let target = memo_file.read_path_version(¬ebook_id, &relative_path, &version_id)?; let restored = memo_file.with_file_write(¬ebook_id, &path, FileLockIntent::Existing, diff --git a/app/flowix-desktop/src/commands/mod.rs b/app/flowix-desktop/src/commands/mod.rs index 063709cfd..a15bc65d7 100644 --- a/app/flowix-desktop/src/commands/mod.rs +++ b/app/flowix-desktop/src/commands/mod.rs @@ -28,7 +28,7 @@ // 子模块一�?`pub` —`tauri::generate_handler![commands::::xxx]` �?// `lib.rs::run()` 里走完整�?��, 需�?`pub` �??性。`#[tauri::command]` �?// 生成�?`__cmd__xxx` 兄弟宏也要求子模块是 `pub`, 否则宏解析不到�? pub mod agent; -pub mod agent_access; +pub mod agent_workspace; pub mod artifact; pub mod boot; pub mod cli; diff --git a/app/flowix-desktop/src/commands/notebook.rs b/app/flowix-desktop/src/commands/notebook.rs index ca2b22710..45aa1096c 100644 --- a/app/flowix-desktop/src/commands/notebook.rs +++ b/app/flowix-desktop/src/commands/notebook.rs @@ -1,9 +1,4 @@ -//! Notebook IPC —增删改查 + 切换当前 notebook�?//! -//! `set_current_notebook` �?`switch_notebook_and_rebuild` helper, 触发 -//! watcher rebind + 磁盘对账 + 后台索引 rebuild�?//! -//! �?/ �?/ �?/ 清空 四个写操作都会同步更�?`agent_access` store -//! (`~/.flowix/agent-access.json`), 任何 entry 真改了之�?emit -//! `agent-access-changed` 事件, 其它窗口 React 树收到后从�?盘重�?load�? +//! Notebook lifecycle and device-local registration in db/local.db. use crate::events as dispatcher; use serde::Serialize; use std::path::{Path, PathBuf}; @@ -16,7 +11,7 @@ use flowix_core::memo_file::{ use flowix_core::MemoService; use flowix_sync::V2LocalNotebook; -use super::agent_access::AGENT_ACCESS_CHANGED_EVENT; +use super::agent_workspace::AGENT_WORKSPACE_CHANGED_EVENT; use super::helpers::{ refresh_watcher_roots, switch_notebook_trusting_index, watch_created_notebook, }; @@ -256,7 +251,9 @@ fn create_notebook_registry_with_id_and_template( // 创建顺序: 1) 先�?现有 configs 验证�?��不冲�? 2) �?next sort; // 3) 组�? NotebookConfig 写盘。sort �?MAX(sort)+10 让新行落到末�? // (ORDER BY sort ASC), 不取 len �?���?reorder �?sort �?��疏的�? - let mut configs = memo_file.read_notebook_configs().unwrap_or_default(); + let mut configs = memo_file + .read_notebook_configs() + .map_err(|error| format!("INDEX_READ_FAILED: {error}"))?; if configs .iter() .any(|notebook| comparable_notebook_path(¬ebook.path) == comparable_path) @@ -358,11 +355,8 @@ fn cloud_restore_directory_is_empty(path: &Path) -> Result { Ok(true) } -fn sync_notebook_agent_access(config: &NotebookConfig, state: &AppState, app: &AppHandle) { - // 同�?往 agent_access 列表里加一�?(默�? enabled), 写盘后才�? // 同�?完成 ── store 内部走原子写, 失败会回滚内存�? - if state.agent_access.add_or_update_notebook(config) { - dispatcher::emit_to(app, AGENT_ACCESS_CHANGED_EVENT, ()); - } +fn notify_notebook_workspace_changed(app: &AppHandle) { + dispatcher::emit_to(app, AGENT_WORKSPACE_CHANGED_EVENT, ()); } fn set_current_notebook_inner( @@ -382,8 +376,8 @@ fn set_current_notebook_inner( // Fast path for ordinary switching: trust memo index and avoid synchronous // disk reconciliation. Search index rebuild is lazy, triggered by search. - if let Err(error) = switch_notebook_trusting_index(state, app, notebook_id.clone()) { - if let Err(restore_error) = switch_notebook_trusting_index(state, app, previous_id) { + if let Err(error) = switch_notebook_trusting_index(state, notebook_id.clone()) { + if let Err(restore_error) = switch_notebook_trusting_index(state, previous_id) { tracing::error!( "[set_current_notebook] failed to restore previous notebook after switch error: {restore_error}" ); @@ -397,7 +391,7 @@ fn set_current_notebook_inner( { // Keep the process-local and persisted selection aligned if the second // half of the switch fails. The original error remains authoritative. - if let Err(restore_error) = switch_notebook_trusting_index(state, app, previous_id) { + if let Err(restore_error) = switch_notebook_trusting_index(state, previous_id) { tracing::error!( "[set_current_notebook] failed to restore previous notebook after persistence error: {restore_error}" ); @@ -437,9 +431,15 @@ fn run_notebook_import(app: AppHandle, notebook_id: String, seed_onboarding_docs let mut onboarding_docs_seeded = false; { let memo_file = read_lock(&app_state.memo_file, "memo_file"); - memo_file - .ensure_notebook_migrations(¬ebook_id) - .map_err(|error| format!("notebook import migration failed: {error}"))?; + match memo_file.has_legacy_notebook_internal_data(¬ebook_id) { + Ok(true) => { + if let Err(error) = memo_file.ensure_notebook_structure_migration(¬ebook_id) { + tracing::warn!(notebook = %notebook_id, %error, "legacy plugin artifact migration deferred"); + } + } + Ok(false) => {} + Err(error) => tracing::warn!(notebook = %notebook_id, %error, "legacy plugin artifact check deferred"), + } tracing::info!( "[create_notebook] import/reconcile start id={}", notebook_id @@ -747,9 +747,6 @@ pub async fn create_notebook( .ok_or_else(|| "PATH_INVALID_UTF8".to_string())? } }; - let has_bookmark_access = state - .security_bookmarks - .start_accessing_for_path(Path::new(trimmed_path)); if !Path::new(trimmed_path).is_dir() { return Err("PATH_MISSING".to_string()); } @@ -772,13 +769,8 @@ pub async fn create_notebook( { return Err("NOTEBOOK_PRESET_OVERWRITE_CONFIRM_REQUIRED".to_string()); } - if !has_bookmark_access { - state - .security_bookmarks - .record_directory(Path::new(trimmed_path)) - .map_err(|e| format!("BOOKMARK_WRITE_FAILED: {e}"))?; - } - + // Also handles dependency deletion while the window stayed open. + state.user_config.ensure_runtime_storage()?; let config = { let memo_file = write_lock(&state.memo_file, "memo_file"); create_notebook_registry_with_id_and_template( @@ -791,7 +783,7 @@ pub async fn create_notebook( &memo_file, )? }; - sync_notebook_agent_access(&config, state.inner(), &app); + notify_notebook_workspace_changed(&app); if activate.unwrap_or(true) { activate_created_notebook(&config, state.inner(), &app)?; } else { @@ -805,6 +797,9 @@ pub async fn create_notebook( }) .await .map_err(|error| format!("notebook creation task failed: {error}"))?; + if let Err(error) = &result { + crate::runtime_log::record_event("error", "notebook.create.failed", error); + } crate::runtime_log::record_event( "info", "notebook.create.timing", @@ -879,6 +874,7 @@ pub fn create_notebook_from_cloud( name: String, path: String, icon: Option, + activate: Option, state: State, app: AppHandle, ) -> Result { @@ -895,26 +891,22 @@ pub fn create_notebook_from_cloud( } let directory = Path::new(trimmed_path); - let has_bookmark_access = state.security_bookmarks.start_accessing_for_path(directory); if !directory.is_dir() { return Err("PATH_MISSING".to_string()); } if !cloud_restore_directory_is_empty(directory)? { return Err("PATH_NOT_EMPTY".to_string()); } - if !has_bookmark_access { - state - .security_bookmarks - .record_directory(directory) - .map_err(|e| format!("BOOKMARK_WRITE_FAILED: {e}"))?; - } - let config = { let memo_file = write_lock(&state.memo_file, "memo_file"); create_notebook_registry_with_id(trimmed_name, trimmed_path, icon, Some(&id), &memo_file)? }; - sync_notebook_agent_access(&config, state.inner(), &app); - activate_created_notebook(&config, state.inner(), &app)?; + notify_notebook_workspace_changed(&app); + if activate.unwrap_or(true) { + activate_created_notebook(&config, state.inner(), &app)?; + } else { + watch_created_notebook(state.inner(), &app, &config); + } dispatcher::emit_to(&app, NOTEBOOKS_CHANGED_EVENT, ()); Ok(notebook_from_config(config)) @@ -946,10 +938,7 @@ pub fn update_notebook( let updated = configs[index].clone(); drop(memo_file); - // 名字 / �?��变更都同步到 agent_access ── store �?��判定�?��真改�? - if state.agent_access.add_or_update_notebook(&updated) { - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - } + notify_notebook_workspace_changed(&app); refresh_watcher_roots(state.inner(), &app); record_notebook_metadata_change(state.inner(), &app, &updated); @@ -1064,10 +1053,7 @@ pub fn delete_notebook( crate::commands::cloud::schedule_notebook_sync(app.clone(), id.clone()); } - // 同�?把�?应的 agent_access entry 也删�? 状态栏�?文件权限"子菜�? // 会少一�?── 用户没主动去勾�? 不应该留�??儿在那里�? - if state.agent_access.remove_notebook(&id) { - dispatcher::emit_to(&app, AGENT_ACCESS_CHANGED_EVENT, ()); - } + notify_notebook_workspace_changed(&app); refresh_watcher_roots(state.inner(), &app); dispatcher::emit_to(&app, NOTEBOOKS_CHANGED_EVENT, ()); Ok(true) @@ -1170,20 +1156,14 @@ pub fn clear_notebooks(state: State, app: AppHandle) -> Result ProductInfo { +pub(crate) fn is_dev_build() -> bool { + option_env!("FLOWIX_DEV_MODE") == Some("1") +} + +fn product_info(cloud_environment: flowix_sync::CloudEnvironment) -> ProductInfo { ProductInfo { product_name: runtime_log::PRODUCT_NAME, version: runtime_log::APP_VERSION, @@ -31,12 +37,37 @@ fn product_info() -> ProductInfo { log_dir: runtime_log::log_dir().display().to_string(), os: std::env::consts::OS, arch: std::env::consts::ARCH, + is_dev_build: is_dev_build(), + cloud_environment: cloud_environment.name(), } } #[tauri::command] -pub fn get_product_info() -> ProductInfo { - product_info() +pub fn get_product_info(state: State) -> ProductInfo { + product_info(state.cloud_environment) +} + +#[tauri::command] +pub fn set_cloud_environment( + environment: String, + state: State, +) -> Result<(), String> { + if !is_dev_build() { + return Err("Cloud environment can only be changed in a development build".to_string()); + } + let Some(target) = flowix_sync::CloudEnvironment::parse(&environment) else { + return Err(format!("unsupported Cloud environment: {environment}")); + }; + + let saved_environment = state.user_config.get_preference().cloud_environment; + if saved_environment == target.name() { + return Ok(()); + } + + state + .user_config + .patch_preference(serde_json::json!({ "cloudEnvironment": target.name() })) + .map_err(|error| error.to_string()) } #[tauri::command] @@ -70,7 +101,6 @@ pub fn open_file_with_default_app( return Err("File is outside its authorized scope".to_string()); } - start_security_bookmark_access(state.inner(), &path); let mut command = if cfg!(target_os = "macos") { Command::new("open") } else if cfg!(target_os = "windows") { diff --git a/app/flowix-desktop/src/commands/settings.rs b/app/flowix-desktop/src/commands/settings.rs index bdb53fe0c..e81af58f3 100644 --- a/app/flowix-desktop/src/commands/settings.rs +++ b/app/flowix-desktop/src/commands/settings.rs @@ -62,7 +62,7 @@ pub async fn get_deepseek_harness_config( state: State<'_, AppState>, ) -> Result { let mut configs = load_dsh_model_configs(&state).await?; - hydrate_dsh_credentials_without_blocking(&state, &mut configs).await; + hydrate_dsh_credential_statuses_without_blocking(&state, &mut configs).await; Ok(configs.into_iter().next().unwrap_or_default()) } @@ -71,7 +71,7 @@ pub async fn get_deepseek_harness_configs( state: State<'_, AppState>, ) -> Result, String> { let mut configs = load_dsh_model_configs(&state).await?; - hydrate_dsh_credentials_without_blocking(&state, &mut configs).await; + hydrate_dsh_credential_statuses_without_blocking(&state, &mut configs).await; Ok(configs) } @@ -79,21 +79,12 @@ async fn load_dsh_model_configs(state: &State<'_, AppState>) -> Result, configs: &mut [AiConfigFile], ) { - if let Err(error) = state - .deepseek_harness - .migrate_legacy_credentials(configs) - .await - { - tracing::warn!(%error, "could not migrate legacy Flowix credentials to DSH"); - } if let Err(error) = state .deepseek_harness .hydrate_credential_statuses(configs) @@ -222,6 +213,11 @@ pub fn save_pi_features(features: crate::agent_external::pi::config::PiFeatures) pi_model_config::save_features(features) } +#[tauri::command] +pub fn clear_pi_legacy_code_mode() -> Result<(), String> { + pi_model_config::clear_legacy_code_mode() +} + #[tauri::command] pub fn get_pi_model_catalog( app: AppHandle, diff --git a/app/flowix-desktop/src/commands/thread.rs b/app/flowix-desktop/src/commands/thread.rs index cbc4ea776..e45a94ad7 100644 --- a/app/flowix-desktop/src/commands/thread.rs +++ b/app/flowix-desktop/src/commands/thread.rs @@ -256,20 +256,28 @@ pub async fn agent_conversation_list( #[tauri::command] pub async fn agent_conversation_list_page( - notebook_id: Option, + notebook_id: String, agent_type: Option, cursor: Option, limit: usize, state: State<'_, AppState>, ) -> Result { let page_size = limit.clamp(1, 100); - let notebook_id = notebook_id.filter(|value| !value.trim().is_empty()); + let notebook_id = notebook_id.trim(); + if notebook_id.is_empty() { + return Err("NOTEBOOK_ID_REQUIRED".to_string()); + } let agent_type = agent_type .map(|value| value.trim().to_ascii_lowercase()) .filter(|value| !value.is_empty()); let manager = &state.thread_manager; let items = manager - .list_agent_conversation_instances_page(notebook_id, agent_type, cursor, page_size + 1) + .list_agent_conversation_instances_page( + Some(notebook_id.to_string()), + agent_type, + cursor, + page_size + 1, + ) .await .map_err(|e| e.to_string())?; let has_more = items.len() > page_size; @@ -287,24 +295,32 @@ pub async fn agent_conversation_list_page( #[tauri::command] pub async fn agent_conversation_count_by_notebook( - notebook_id: Option, + notebook_id: String, state: State<'_, AppState>, ) -> Result { + let notebook_id = notebook_id.trim(); + if notebook_id.is_empty() { + return Err("NOTEBOOK_ID_REQUIRED".to_string()); + } let manager = &state.thread_manager; manager - .count_agent_conversation_instances_by_notebook(notebook_id) + .count_agent_conversation_instances_by_notebook(Some(notebook_id.to_string())) .await .map_err(|e| e.to_string()) } #[tauri::command] pub async fn agent_conversation_type_counts_by_notebook( - notebook_id: Option, + notebook_id: String, state: State<'_, AppState>, ) -> Result, String> { + let notebook_id = notebook_id.trim(); + if notebook_id.is_empty() { + return Err("NOTEBOOK_ID_REQUIRED".to_string()); + } state .thread_manager - .list_agent_conversation_type_counts_by_notebook(notebook_id) + .list_agent_conversation_type_counts_by_notebook(Some(notebook_id.to_string())) .await .map_err(|e| e.to_string()) } @@ -507,7 +523,7 @@ pub async fn claude_thread_session_id( let manager = &state.thread_manager; manager - .get_external_session(&thread_id, "claude") + .get_external_session(&thread_id, "claudecode") .await .map_err(|e| e.to_string()) } @@ -617,7 +633,7 @@ pub async fn deepseek_harness_thread_session_id( ) -> Result, String> { state .thread_manager - .get_external_session(&thread_id, "deepseek-harness") + .get_external_session(&thread_id, "dsh") .await .map_err(|error| error.to_string()) } @@ -637,7 +653,7 @@ pub async fn deepseek_harness_thread_fork( let child = state .thread_manager .create_thread( - crate::agent_types::AgentId("deepseek-harness".to_string()), + crate::agent_types::AgentId("dsh".to_string()), format!("{} (fork)", source.info.title), ) .await diff --git a/app/flowix-desktop/src/commands/window.rs b/app/flowix-desktop/src/commands/window.rs index 289a26327..e9b284337 100644 --- a/app/flowix-desktop/src/commands/window.rs +++ b/app/flowix-desktop/src/commands/window.rs @@ -55,6 +55,14 @@ pub async fn open_preferences_window( app: tauri::AppHandle, state: tauri::State<'_, AppState>, tab: Option, +) -> Result<(), String> { + open_preferences_window_inner(&app, &state, tab.as_deref()) +} + +pub(crate) fn open_preferences_window_inner( + app: &tauri::AppHandle, + state: &AppState, + tab: Option<&str>, ) -> Result<(), String> { use tauri::WebviewWindowBuilder; @@ -69,23 +77,22 @@ pub async fn open_preferences_window( Some(theme) => format!("index.html?bootTheme={theme}"), None => "index.html".to_string(), }; - let url = match tab { - Some(tab) => format!("{base}#preferences/{tab}"), - None => format!("{base}#preferences"), + let hash = match tab { + Some(tab) => format!("preferences/{tab}"), + None => "preferences".to_string(), }; + let url = format!("{base}#{hash}"); if let Some(window) = app.get_webview_window("preferences") { - window.set_focus().ok(); window - .eval(preferences_navigation_script( - url.split_once('#').map(|(_, hash)| hash).unwrap_or(""), - )?) - .ok(); + .eval(preferences_navigation_script(&hash)?) + .map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; return Ok(()); } let builder = - WebviewWindowBuilder::new(&app, "preferences", tauri::WebviewUrl::App(url.into())) + WebviewWindowBuilder::new(app, "preferences", tauri::WebviewUrl::App(url.into())) .title("Preferences") .inner_size(800.0, 600.0) .center() diff --git a/app/flowix-desktop/src/config/access.rs b/app/flowix-desktop/src/config/access.rs deleted file mode 100644 index 4dbbf862d..000000000 --- a/app/flowix-desktop/src/config/access.rs +++ /dev/null @@ -1,420 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::RwLock; - -use flowix_core::memo_file::{MemoFile, NotebookConfig}; -use serde::{Deserialize, Serialize}; - -use crate::config::user::{atomic_write_json, UserConfigError}; - -pub const AGENT_ACCESS_FILE_NAME: &str = "agent-access.json"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AgentAccessKind { - Notebook, - Folder, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAccessEntry { - pub id: String, - pub kind: AgentAccessKind, - pub path: String, - pub name: String, - pub enabled: bool, - /// Legacy field; notebook workspace ownership now lives in `.flowix`. - #[serde(default, skip_serializing)] - pub workspace: bool, - pub added_at: i64, - pub updated_at: i64, - #[serde(default, skip_deserializing)] - pub missing: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentAccessConfig { - pub version: u32, - #[serde(default)] - pub entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub defaults: Option, -} - -impl Default for AgentAccessConfig { - fn default() -> Self { - Self { - version: 1, - entries: Vec::new(), - defaults: None, - } - } -} - -pub struct AgentAccessStore { - config_dir: PathBuf, - inner: RwLock, -} - -impl AgentAccessStore { - pub fn new(config_dir: PathBuf, memo_file: &MemoFile) -> Self { - let mut config: AgentAccessConfig = read_from_disk(&config_dir) - .ok() - .flatten() - .unwrap_or_default(); - - match config.version { - 1 => {} - other => tracing::warn!( - "agent-access.json version {} is unknown, treating as v1", - other - ), - } - - let notebook_configs = memo_file.read_notebook_configs().unwrap_or_default(); - let pre_reconcile_entries = config.entries.clone(); - reconcile_with_notebook_configs(&mut config.entries, ¬ebook_configs); - let is_dirty = !entries_semantically_equal(&pre_reconcile_entries, &config.entries); - - let store = Self { - config_dir, - inner: RwLock::new(config), - }; - if is_dirty { - if let Err(e) = store.persist() { - tracing::warn!("failed to persist agent_access on startup: {e}"); - } else { - tracing::debug!( - "agent_access reconciled on startup; entries count changed: {} -> {}", - pre_reconcile_entries.len(), - store.get_config().entries.len(), - ); - } - } - store - } - - pub fn get_config(&self) -> AgentAccessConfig { - let guard = self.inner.read().unwrap_or_else(|p| p.into_inner()); - let mut config = guard.clone(); - refresh_missing_flags(&mut config.entries); - config - } - - pub fn replace_config( - &self, - mut config: AgentAccessConfig, - ) -> Result { - // `missing` is derived from disk and must never trust a stale Webview - // snapshot. Recompute before both persistence and the in-memory swap. - refresh_missing_flags(&mut config.entries); - let content = serde_json::to_string_pretty(&config)?; - let path = self.config_dir.join(AGENT_ACCESS_FILE_NAME); - atomic_write_json(&path, &content)?; - let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner()); - *guard = config.clone(); - Ok(config) - } - - pub fn add_or_update_notebook(&self, nb: &NotebookConfig) -> bool { - let now = chrono::Utc::now().timestamp_millis(); - let trimmed_path = trim_path(&nb.path); - let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner()); - let mut changed = false; - - match guard - .entries - .iter_mut() - .find(|e| e.kind == AgentAccessKind::Notebook && e.id == nb.id) - { - Some(entry) => { - if entry.path != trimmed_path || entry.name != nb.name { - entry.path = trimmed_path; - entry.name = nb.name.clone(); - entry.updated_at = now; - changed = true; - } - } - None => { - guard.entries.push(AgentAccessEntry { - id: nb.id.clone(), - kind: AgentAccessKind::Notebook, - path: trimmed_path, - name: nb.name.clone(), - enabled: true, - workspace: false, - added_at: now, - updated_at: now, - missing: false, - }); - changed = true; - } - } - - if changed { - let _ = self.persist_locked(&guard); - } - changed - } - - pub fn remove_notebook(&self, notebook_id: &str) -> bool { - let mut guard = self.inner.write().unwrap_or_else(|p| p.into_inner()); - let before = guard.entries.len(); - guard - .entries - .retain(|e| !(e.kind == AgentAccessKind::Notebook && e.id == notebook_id)); - let removed = guard.entries.len() != before; - if removed { - let _ = self.persist_locked(&guard); - } - removed - } - - fn persist(&self) -> Result<(), UserConfigError> { - let guard = self.inner.read().unwrap_or_else(|p| p.into_inner()); - self.persist_locked(&guard) - } - - fn persist_locked(&self, config: &AgentAccessConfig) -> Result<(), UserConfigError> { - let content = serde_json::to_string_pretty(config)?; - let path = self.config_dir.join(AGENT_ACCESS_FILE_NAME); - Ok(atomic_write_json(&path, &content)?) - } -} - -fn read_from_disk(config_dir: &Path) -> std::io::Result> { - let path = config_dir.join(AGENT_ACCESS_FILE_NAME); - if !path.exists() { - return Ok(None); - } - let content = std::fs::read_to_string(&path)?; - if content.trim().is_empty() { - return Ok(None); - } - let cfg: AgentAccessConfig = serde_json::from_str(&content).unwrap_or_default(); - Ok(Some(cfg)) -} - -fn reconcile_with_notebook_configs( - entries: &mut Vec, - notebooks: &[NotebookConfig], -) { - let now = chrono::Utc::now().timestamp_millis(); - let registry_ids: std::collections::HashSet<&str> = - notebooks.iter().map(|n| n.id.as_str()).collect(); - - entries.retain(|e| { - !(e.kind == AgentAccessKind::Notebook && !registry_ids.contains(e.id.as_str())) - }); - - for nb in notebooks { - let trimmed_path = trim_path(&nb.path); - match entries - .iter_mut() - .find(|e| e.kind == AgentAccessKind::Notebook && e.id == nb.id) - { - Some(entry) => { - if entry.path != trimmed_path { - entry.path = trimmed_path.clone(); - entry.updated_at = now; - } - if entry.name != nb.name { - entry.name = nb.name.clone(); - entry.updated_at = now; - } - } - None => { - entries.push(AgentAccessEntry { - id: nb.id.clone(), - kind: AgentAccessKind::Notebook, - path: trimmed_path, - name: nb.name.clone(), - enabled: true, - workspace: false, - added_at: now, - updated_at: now, - missing: false, - }); - } - } - } -} - -fn entries_semantically_equal(a: &[AgentAccessEntry], b: &[AgentAccessEntry]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter().zip(b.iter()).all(|(x, y)| { - x.id == y.id - && x.kind == y.kind - && x.path == y.path - && x.name == y.name - && x.enabled == y.enabled - }) -} - -fn trim_path(path: &str) -> String { - path.trim_end_matches(|c| c == '/' || c == '\\').to_string() -} - -fn refresh_missing_flags(entries: &mut [AgentAccessEntry]) { - for entry in entries { - entry.missing = !Path::new(&entry.path).is_dir(); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn nb(id: &str, name: &str, path: &str) -> NotebookConfig { - NotebookConfig { - id: id.to_string(), - name: name.to_string(), - icon: Some("馃摌".to_string()), - path: path.to_string(), - is_default: false, - sort: 0, - created_at: 0, - updated_at: 0, - } - } - - fn entry( - id: &str, - kind: AgentAccessKind, - path: &str, - name: &str, - enabled: bool, - updated_at: i64, - ) -> AgentAccessEntry { - AgentAccessEntry { - id: id.to_string(), - kind, - path: path.to_string(), - name: name.to_string(), - enabled, - workspace: false, - added_at: 1, - updated_at, - missing: false, - } - } - - #[test] - fn seed_populates_from_notebook_configs() { - let mut entries = Vec::new(); - let notebooks = vec![ - nb("nb_1", "First", "/tmp/a/"), - nb("nb_2", "Second", "/tmp/b/"), - ]; - - reconcile_with_notebook_configs(&mut entries, ¬ebooks); - - assert_eq!(entries.len(), 2); - assert_eq!(entries[0].path, "/tmp/a"); - assert!(entries.iter().all(|entry| entry.enabled)); - } - - #[test] - fn reconcile_removes_deleted_notebooks() { - let mut entries = vec![ - entry("nb_1", AgentAccessKind::Notebook, "/a", "A", true, 1), - entry("nb_2", AgentAccessKind::Notebook, "/b", "B", true, 1), - entry("fld_1", AgentAccessKind::Folder, "/c", "C", true, 1), - ]; - let notebooks = vec![nb("nb_1", "A", "/a")]; - - reconcile_with_notebook_configs(&mut entries, ¬ebooks); - - assert_eq!(entries.len(), 2); - assert!(entries.iter().any(|entry| entry.id == "nb_1")); - assert!(entries.iter().any(|entry| entry.id == "fld_1")); - } - - #[test] - fn entries_semantically_equal_ignores_updated_at_and_workspace() { - let a = vec![entry( - "nb_1", - AgentAccessKind::Notebook, - "/a", - "A", - true, - 100, - )]; - let mut b = a.clone(); - b[0].updated_at = 200; - b[0].workspace = true; - - assert!(entries_semantically_equal(&a, &b)); - } - - #[test] - fn entries_semantically_equal_detects_path_and_enabled_changes() { - let a = vec![entry( - "nb_1", - AgentAccessKind::Notebook, - "/old", - "A", - true, - 100, - )]; - let mut b = a.clone(); - b[0].path = "/new".to_string(); - assert!(!entries_semantically_equal(&a, &b)); - - let mut c = a.clone(); - c[0].enabled = false; - assert!(!entries_semantically_equal(&a, &c)); - } - - #[test] - fn config_defaults_round_trip_shape() { - let defaults = serde_json::json!({ - "runtime": { "deepseek-harness": { "model": "gpt-5" } }, - "files": { "workspacePaths": ["/tmp"] } - }); - let cfg = AgentAccessConfig { - version: 1, - entries: Vec::new(), - defaults: Some(defaults.clone()), - }; - - let encoded = serde_json::to_string(&cfg).unwrap(); - let decoded: AgentAccessConfig = serde_json::from_str(&encoded).unwrap(); - - assert_eq!(decoded.defaults, Some(defaults)); - } - - #[test] - fn refresh_missing_flags_tracks_directories_on_disk() { - let temp = tempfile::tempdir().unwrap(); - let present = temp.path().join("present"); - std::fs::create_dir_all(&present).unwrap(); - let absent = temp.path().join("absent"); - let mut entries = vec![ - entry( - "present", - AgentAccessKind::Folder, - &present.to_string_lossy(), - "Present", - true, - 1, - ), - entry( - "absent", - AgentAccessKind::Folder, - &absent.to_string_lossy(), - "Absent", - true, - 1, - ), - ]; - - refresh_missing_flags(&mut entries); - - assert!(!entries[0].missing); - assert!(entries[1].missing); - } -} diff --git a/app/flowix-desktop/src/config/mod.rs b/app/flowix-desktop/src/config/mod.rs index d4c2a557e..1e2e7cb45 100644 --- a/app/flowix-desktop/src/config/mod.rs +++ b/app/flowix-desktop/src/config/mod.rs @@ -1,43 +1,20 @@ -//! Configuration & access-control layer for the agent runtime. +//! Configuration and access-control layer for the agent runtime. //! -//! Four closely related concerns live here: -//! -//! - [`user`] 鈥?user-level settings: legacy AI migration data, preferences -//! (json), theme, agent persona. Legacy data is persisted at -//! `~/.flowix/agent-config.toml`; active DSH settings use its own YAML file. -//! Owns the `atomic_write_json` -//! helper used by sibling stores. -//! - [`access`] 鈥?agent-access registry: which folders + notebooks the AI -//! agent is allowed to see. Persisted at `~/.flowix/agent-access.json`. -//! Distinct from the notebook registry so users can grant/revoke AI access -//! without touching the notebook registry itself. -//! - [`path_scope`] 鈥?tiny pure helper: is `path` inside `root`? Used by -//! access checks, dialog code, and security-bookmark filtering. -//! - [`security_bookmark`] 鈥?macOS security-scoped bookmarks for -//! user-selected directories that survive across launches. Sibling of -//! `user`/`access` but only meaningful on macOS; on other platforms the -//! store is still constructed but never gains entries. -//! -//! All four write through `user::atomic_write_json` so disk failures never -//! leave the in-memory state ahead of disk. +//! `user` stores user-level preferences; `db/local.db` stores notebook mounts, +//! and `path_scope` provides the shared path containment checks. -pub mod access; pub mod path_scope; pub mod property_presets; pub mod security_bookmark; pub mod user; -// Re-export the public surface at the `config::` namespace so callers can -// write `crate::config::UserConfigStore` without dropping into `user`. -pub use access::{AgentAccessConfig, AgentAccessEntry, AgentAccessKind, AgentAccessStore}; pub use path_scope::{path_is_inside, path_is_inside_reserved_directory}; pub use property_presets::{PropertyPresetChange, PropertyPresetRegistry, PropertyPresetStore}; -#[cfg(target_os = "macos")] -pub use security_bookmark::pick_directory_with_bookmark; pub use security_bookmark::SecurityBookmarkStore; pub use user::{ - dsh_credential_ref_for_route, AiConfigFile, AiModelConfig, AiModelEntry, PreferenceFile, Theme, - UserConfigError, UserConfigStore, DSH_SETTINGS_FILE_NAME, + dsh_credential_ref_for_route, AiConfigFile, AiModelConfig, AiModelEntry, + CloudDevRefreshTokenStore, PreferenceFile, Theme, UserConfigError, UserConfigStore, + DSH_SETTINGS_FILE_NAME, }; pub(crate) use user::{ merge_harness_provider, DeepSeekHarnessProviderSettings, DeepSeekHarnessSettingsFile, diff --git a/app/flowix-desktop/src/config/property_presets.rs b/app/flowix-desktop/src/config/property_presets.rs index 48d61178d..90a69fba5 100644 --- a/app/flowix-desktop/src/config/property_presets.rs +++ b/app/flowix-desktop/src/config/property_presets.rs @@ -149,7 +149,7 @@ impl PropertyPresetStore { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - let mut connection = Connection::open(&self.path).map_err(|error| error.to_string())?; + let connection = Connection::open(&self.path).map_err(|error| error.to_string())?; connection .busy_timeout(Duration::from_secs(10)) .map_err(|error| error.to_string())?; @@ -158,8 +158,8 @@ impl PropertyPresetStore { "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", ) .map_err(|error| error.to_string())?; - migrate_legacy_schema(&mut connection)?; - connection.execute_batch( + let migration_connection = connection; + migration_connection.execute_batch( "CREATE TABLE IF NOT EXISTS property_presets ( scope TEXT NOT NULL CHECK(scope IN ('system', 'user', 'deleted_user')), id TEXT NOT NULL, @@ -188,25 +188,30 @@ impl PropertyPresetStore { completed_at INTEGER NOT NULL );", ).map_err(|error| error.to_string())?; - let has_position = { - let mut statement = connection - .prepare("PRAGMA table_info(property_presets)") - .map_err(|error| error.to_string())?; - let columns = statement - .query_map([], |row| row.get::<_, String>(1)) - .map_err(|error| error.to_string())? - .collect::, _>>() - .map_err(|error| error.to_string())?; - columns.iter().any(|column| column == "position") - }; - if !has_position { - connection - .execute_batch( - "ALTER TABLE property_presets ADD COLUMN position INTEGER NOT NULL DEFAULT 0", - ) - .map_err(|error| error.to_string())?; + Ok(migration_connection) + } + + /// Apply legacy SQLite shape changes from the explicit startup + /// initialization phase. Normal reads only open the current schema. + pub fn initialize_legacy_schema(&self) -> Result<(), String> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; } - Ok(connection) + let mut connection = Connection::open(&self.path).map_err(|error| error.to_string())?; + connection + .busy_timeout(Duration::from_secs(10)) + .map_err(|error| error.to_string())?; + connection + .execute_batch( + "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON;", + ) + .map_err(|error| error.to_string())?; + flowix_core::memo_file::initialize_local_migration_table(&mut connection) + .map_err(|error| error.to_string())?; + migrate_legacy_schema(&mut connection)?; + drop(connection); + let _ = self.open()?; + Ok(()) } pub fn get(&self) -> Result { @@ -471,6 +476,7 @@ impl PropertyPresetStore { /// Import the former preference.json registry once. Existing DB data wins. /// The marker and imported rows are committed atomically. pub fn migrate_legacy_once(&self, registry: &PropertyPresetRegistry) -> Result { + self.initialize_legacy_schema()?; let mut connection = self.open()?; let transaction = connection .transaction_with_behavior(TransactionBehavior::Immediate) diff --git a/app/flowix-desktop/src/config/security_bookmark.rs b/app/flowix-desktop/src/config/security_bookmark.rs index 37c01ee94..7a3858afc 100644 --- a/app/flowix-desktop/src/config/security_bookmark.rs +++ b/app/flowix-desktop/src/config/security_bookmark.rs @@ -1,7 +1,7 @@ -//! macOS security-scoped bookmarks for user-selected directories. +//! macOS security-scoped bookmarks for authorized directories and selected files. //! -//! The app stores bookmark data separately from notebook / agent-access config -//! because both features can point at the same directory. +//! OS grants are persisted separately from Flowix notebook and document grants. +//! A file bookmark never authorizes its parent directory. use std::collections::BTreeMap; use std::io; @@ -13,7 +13,14 @@ use serde::{Deserialize, Serialize}; use crate::config::user::{atomic_write_json, UserConfigError}; const BOOT_DIR_NAME: &str = "boot"; -const BOOKMARKS_FILE_NAME: &str = "security-bookmarks.json"; + +fn bookmarks_file_name() -> &'static str { + if option_env!("FLOWIX_DEV_MODE") == Some("1") { + "bookmark.dev.json" + } else { + "bookmark.json" + } +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -21,9 +28,11 @@ struct BookmarkEntry { path: String, bookmark: String, updated_at: i64, + #[serde(default = "directory_default")] + directory: bool, } -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct BookmarkConfig { #[serde(default = "default_version")] @@ -32,14 +41,33 @@ struct BookmarkConfig { entries: BTreeMap, } +impl Default for BookmarkConfig { + fn default() -> Self { + Self { + version: 1, + entries: BTreeMap::new(), + } + } +} + +fn directory_default() -> bool { + true +} + fn default_version() -> u32 { 1 } fn normalize_path(path: &Path) -> String { - path.to_string_lossy() - .trim_end_matches(|c| c == '/' || c == '\\') - .to_string() + let path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let normalized = path.to_string_lossy().to_string(); + if normalized.len() > 1 { + normalized + .trim_end_matches(|c| c == '/' || c == '\\') + .to_string() + } else { + normalized + } } pub struct SecurityBookmarkStore { @@ -56,7 +84,6 @@ impl SecurityBookmarkStore { .ok() .flatten() .unwrap_or_default(); - migrate_legacy_bookmarks_if_needed(&config_dir, &config); let store = Self { config_dir, inner: RwLock::new(config), @@ -86,12 +113,82 @@ impl SecurityBookmarkStore { Ok(()) } + pub fn record_file(&self, path: &Path) -> Result<(), UserConfigError> { + #[cfg(target_os = "macos")] + { + let url = objc2_foundation::NSURL::from_file_path(path).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid selected file URL") + })?; + let bookmark = macos::bookmark_for_url(&url).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "cannot bookmark selected file", + ) + })?; + self.upsert(path, bookmark, false)?; + self.start_accessing_path(path); + } + #[cfg(not(target_os = "macos"))] + let _ = path; + Ok(()) + } + + /// Called only for a directory already authorized by Flowix. Successful + /// enumeration means macOS has allowed access (including a TCC prompt). + pub fn record_accessible_directory(&self, path: &Path) { + #[cfg(target_os = "macos")] + { + if self.start_accessing_for_path(path) { + return; + } + if std::fs::read_dir(path).is_ok() { + if let Err(error) = self.record_directory(path) { + tracing::warn!(%error, path = %path.display(), "cannot persist granted directory access"); + } + } + } + #[cfg(not(target_os = "macos"))] + let _ = path; + } + + #[cfg(target_os = "macos")] + fn refresh_stale(&self, old_path: &str, directory: bool, access: &macos::ScopedAccess) { + if !access.is_stale() { + return; + } + let Some(bookmark) = access.refreshed_bookmark() else { + return; + }; + let mut current = self.inner.write().unwrap_or_else(|p| p.into_inner()); + let mut next = current.clone(); + next.entries.remove(old_path); + let path = access.path_key().to_string(); + next.entries.insert( + path.clone(), + BookmarkEntry { + path, + bookmark, + directory, + updated_at: chrono::Utc::now().timestamp_millis(), + }, + ); + match serde_json::to_string_pretty(&next) { + Ok(content) => { + match atomic_write_json(&bookmarks_file_path(&self.config_dir), &content) { + Ok(()) => *current = next, + Err(error) => tracing::warn!(%error, "cannot refresh stale bookmark"), + } + } + Err(error) => tracing::warn!(%error, "cannot serialize refreshed bookmark"), + } + } + pub fn record_directory_bookmark( &self, path: &Path, bookmark: String, ) -> Result<(), UserConfigError> { - self.upsert(path, bookmark)?; + self.upsert(path, bookmark, true)?; self.start_accessing_path(path); Ok(()) } @@ -111,7 +208,10 @@ impl SecurityBookmarkStore { active.clear(); for entry in entries { match macos::resolve_bookmark(&entry.bookmark) { - Some(access) => active.push(access), + Some(access) => { + self.refresh_stale(&entry.path, entry.directory, &access); + active.push(access); + } None => tracing::warn!( "[security_bookmark] failed to resolve bookmark for {}", entry.path @@ -158,10 +258,27 @@ impl SecurityBookmarkStore { #[cfg(target_os = "macos")] fn activate_bookmark(&self, key: String, bookmark: String, requested_path: &Path) -> bool { + if self + .active + .read() + .is_ok_and(|active| active.iter().any(|existing| existing.path_key() == key)) + { + return true; + } if let Some(access) = macos::resolve_bookmark(&bookmark) { + let directory = self + .inner + .read() + .unwrap_or_else(|p| p.into_inner()) + .entries + .get(&key) + .map(|entry| entry.directory) + .unwrap_or(true); + self.refresh_stale(&key, directory, &access); let mut active = self.active.write().unwrap_or_else(|p| p.into_inner()); - active.retain(|existing| existing.path_key() != key); - active.push(access); + if !active.iter().any(|existing| existing.path_key() == key) { + active.push(access); + } return true; } tracing::warn!( @@ -179,64 +296,48 @@ impl SecurityBookmarkStore { .unwrap_or_else(|p| p.into_inner()) .entries .iter() - .filter(|(_, entry)| super::path_scope::path_is_inside(path, Path::new(&entry.path))) + .filter(|(_, entry)| { + if entry.directory { + super::path_scope::path_is_inside(path, Path::new(&entry.path)) + } else { + normalize_path(path) == entry.path + } + }) .max_by_key(|(key, _)| key.len()) .map(|(key, entry)| (key.clone(), entry.bookmark.clone())) } - fn upsert(&self, path: &Path, bookmark: String) -> Result<(), UserConfigError> { + fn upsert( + &self, + path: &Path, + bookmark: String, + directory: bool, + ) -> Result<(), UserConfigError> { let key = normalize_path(path); - let mut next = self.inner.read().unwrap_or_else(|p| p.into_inner()).clone(); + let mut current = self.inner.write().unwrap_or_else(|p| p.into_inner()); + let mut next = current.clone(); next.entries.insert( key, BookmarkEntry { path: normalize_path(path), bookmark, updated_at: chrono::Utc::now().timestamp_millis(), + directory, }, ); let content = serde_json::to_string_pretty(&next)?; atomic_write_json(&bookmarks_file_path(&self.config_dir), &content)?; - *self.inner.write().unwrap_or_else(|p| p.into_inner()) = next; + *current = next; Ok(()) } } fn bookmarks_file_path(config_dir: &Path) -> PathBuf { - config_dir.join(BOOT_DIR_NAME).join(BOOKMARKS_FILE_NAME) -} - -fn legacy_bookmarks_file_path(config_dir: &Path) -> PathBuf { - config_dir.join(BOOKMARKS_FILE_NAME) -} - -fn migrate_legacy_bookmarks_if_needed(config_dir: &Path, config: &BookmarkConfig) { - let path = bookmarks_file_path(config_dir); - if path.exists() || !legacy_bookmarks_file_path(config_dir).exists() { - return; - } - match serde_json::to_string_pretty(config) { - Ok(content) => { - if let Err(e) = atomic_write_json(&path, &content) { - tracing::warn!( - "[security_bookmark] failed to migrate bookmarks to {}: {e}", - path.display() - ); - } - } - Err(e) => tracing::warn!("[security_bookmark] failed to serialize bookmarks: {e}"), - } + config_dir.join(BOOT_DIR_NAME).join(bookmarks_file_name()) } fn read_from_disk(config_dir: &Path) -> std::io::Result> { - let path = bookmarks_file_path(config_dir); - if !path.exists() { - let legacy_path = legacy_bookmarks_file_path(config_dir); - if legacy_path.exists() { - return read_bookmark_config_file(&legacy_path); - } - } - read_bookmark_config_file(&path) + read_bookmark_config_file(&bookmarks_file_path(config_dir)) } fn read_bookmark_config_file(path: &Path) -> std::io::Result> { @@ -266,12 +367,21 @@ mod macos { url: Retained, path_key: String, active: bool, + stale: bool, } impl ScopedAccess { pub fn path_key(&self) -> &str { &self.path_key } + + pub fn refreshed_bookmark(&self) -> Option { + bookmark_for_url(&self.url) + } + + pub fn is_stale(&self) -> bool { + self.stale + } } impl Drop for ScopedAccess { @@ -284,6 +394,10 @@ mod macos { pub fn bookmark_for_directory(path: &Path) -> Option { let url = NSURL::from_directory_path(path)?; + bookmark_for_url(&url) + } + + pub fn bookmark_for_url(url: &NSURL) -> Option { let data = url .bookmarkDataWithOptions_includingResourceValuesForKeys_relativeToURL_error( NSURLBookmarkCreationOptions::WithSecurityScope, @@ -292,8 +406,8 @@ mod macos { ) .map_err(|e| { tracing::warn!( - "[security_bookmark] failed to create bookmark for {}: {:?}", - path.display(), + "[security_bookmark] failed to create bookmark for {:?}: {:?}", + url, e ); }) @@ -323,38 +437,20 @@ mod macos { tracing::warn!("[security_bookmark] resolved stale bookmark"); } let active = unsafe { url.startAccessingSecurityScopedResource() }; + if !active { + tracing::warn!("[security_bookmark] macOS refused scoped access for resolved bookmark"); + return None; + } let path = url.to_file_path().unwrap_or_else(PathBuf::new); Some(ScopedAccess { path_key: normalize_path(&path), url, active, + stale: stale.as_bool(), }) } } -#[cfg(target_os = "macos")] -pub fn pick_directory_with_bookmark(title: &str) -> Option<(String, String)> { - use objc2::MainThreadMarker; - use objc2_app_kit::{NSModalResponseOK, NSOpenPanel}; - - let mtm = MainThreadMarker::new()?; - let panel = NSOpenPanel::openPanel(mtm); - panel.setCanChooseDirectories(true); - panel.setCanChooseFiles(false); - panel.setCanCreateDirectories(true); - panel.setAllowsMultipleSelection(false); - let title = objc2_foundation::NSString::from_str(title); - panel.setTitle(Some(&title)); - let ok = panel.runModal(); - if ok != NSModalResponseOK { - return None; - } - let url = panel.URL()?; - let path = url.to_file_path()?; - let bookmark = macos::bookmark_for_directory(&path)?; - Some((path.to_string_lossy().to_string(), bookmark)) -} - #[cfg(test)] mod tests { use super::*; @@ -367,6 +463,7 @@ mod tests { path: "/tmp/flowix-bookmark-test".to_string(), bookmark: "bookmark-data".to_string(), updated_at: 1, + directory: true, }, ); config @@ -384,32 +481,4 @@ mod tests { assert_eq!(loaded.entries.len(), 1); assert!(loaded.entries.contains_key("/tmp/flowix-bookmark-test")); } - - #[test] - fn migrates_legacy_bookmarks_to_boot_dir() { - let tmp = tempfile::tempdir().unwrap(); - let config = sample_config(); - let content = serde_json::to_string_pretty(&config).unwrap(); - atomic_write_json(&legacy_bookmarks_file_path(tmp.path()), &content).unwrap(); - - migrate_legacy_bookmarks_if_needed(tmp.path(), &config); - - assert!(bookmarks_file_path(tmp.path()).exists()); - let migrated = read_bookmark_config_file(&bookmarks_file_path(tmp.path())) - .unwrap() - .unwrap(); - assert_eq!(migrated.entries.len(), 1); - } - - #[cfg(target_os = "macos")] - #[test] - fn macos_bookmark_round_trips() { - let tmp = tempfile::tempdir().unwrap(); - let bookmark = macos::bookmark_for_directory(tmp.path()) - .expect("a temporary directory should produce bookmark data"); - let access = macos::resolve_bookmark(&bookmark) - .expect("newly created bookmark data should resolve immediately"); - let canonical = std::fs::canonicalize(tmp.path()).unwrap(); - assert_eq!(access.path_key(), normalize_path(&canonical)); - } } diff --git a/app/flowix-desktop/src/config/user.rs b/app/flowix-desktop/src/config/user.rs index 2ab5934ec..dfce24ffe 100644 --- a/app/flowix-desktop/src/config/user.rs +++ b/app/flowix-desktop/src/config/user.rs @@ -3,11 +3,12 @@ use std::collections::{BTreeMap, HashMap}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::RwLock; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; +use super::property_presets::{PropertyPresetChange, PropertyPresetRegistry, PropertyPresetStore}; use crate::USER_CONFIG_DIR_NAME; use flowix_core::secret::{entry_name, SecretStore}; -use super::property_presets::{PropertyPresetChange, PropertyPresetRegistry, PropertyPresetStore}; /// AI 模型配置文件�?── TOML 格式, 便于人手编辑与注释�?/// /// TOML 格式便于用户手改磁盘配置时写注释 (TOML 原生 `# ...`), 避免�?��字�?�?/// �?Flowix 的其它配�?���?(`boot/preference.json` / @@ -27,6 +28,8 @@ const PREFERENCE_FILE_NAME: &str = "preference.json"; const DEFAULT_SECRET_DB_NAME: &str = "default.db"; const SECRET_ACCOUNT_NAME: &str = "default"; const CLOUD_SECRET_PROVIDER: &str = "flowix_cloud_refresh"; +const CLOUD_DEV_STAGING_SECRET_PROVIDER: &str = "flowix_cloud_dev_staging_refresh"; +const CLOUD_DEV_PRODUCTION_SECRET_PROVIDER: &str = "flowix_cloud_dev_production_refresh"; /// ~/.flowix/boot/preference.json —用户偏好设置 /// 瀛楁鍏ㄩ儴 #[serde(default)], 鏂囦欢鎹熷潖鎴栫己澶辨椂鍥為€€鍒伴粯璁ゅ€笺€? @@ -179,6 +182,10 @@ pub struct PreferenceFile { /// Installation region detected by the frontend on first launch. #[serde(default)] pub region: String, + /// Saved Cloud target name for development builds. Unknown legacy values + /// fall back to the build default; release builds always use production. + #[serde(default)] + pub cloud_environment: String, /// Memo list card presentation ("detailed" | "compact"). #[serde(default)] pub memo_card_variant: String, @@ -219,6 +226,7 @@ impl Default for PreferenceFile { theme: Theme::default(), language: String::default(), region: String::default(), + cloud_environment: String::default(), memo_card_variant: String::default(), memo_list_view: String::default(), auto_open_created_notes_in_browser: true, @@ -586,7 +594,7 @@ pub struct UserConfigStore { ai_config: RwLock, secrets: SecretStore, property_presets: PropertyPresetStore, - property_presets_migration_complete: bool, + property_presets_migration_complete: Arc, } /// 用户配置 (boot/preference.json / agent-config.toml) 写盘错�?。`Io` �?���?/// `std::io::Error` �? `Json` �?`serde_json::Error` �?(preference.json @@ -640,10 +648,11 @@ impl UserConfigStore { } pub fn new(home_dir: PathBuf) -> Self { - // 鍑嵁 db 钀藉湪 config_dir/default.db (鐢熶骇鐜 ~/.flowix/default.db), + // 凭据数据库位于 config_dir/db/default.db (生产环境 ~/.flowix/db/default.db), // �?index.db 同目�?── �?0o700 �?�� + 0o600 文件权限保护�? let db_path = home_dir .join(USER_CONFIG_DIR_NAME) + .join("db") .join(DEFAULT_SECRET_DB_NAME); Self::new_with_secret_store(home_dir, SecretStore::new(db_path)) } @@ -653,39 +662,19 @@ impl UserConfigStore { let _ = fs::create_dir_all(&config_dir); // Restrict the configuration directory to its owner. set_dir_owner_only_perms(&config_dir); - migrate_dsh_layout(&config_dir); - - let mut preference = Self::read_preference_from_disk(&config_dir).unwrap_or_default(); - let had_legacy_property_registry = !preference.properties.system_fields.is_empty() - || !preference.properties.fields.is_empty() - || !preference.properties.deleted_fields.is_empty(); - let property_presets = PropertyPresetStore::new(config_dir.join("local.db")); - let property_presets_migration_complete = match property_presets.migrate_legacy_once(&PropertyPresetRegistry { - system_fields: preference.properties.system_fields.clone(), - fields: preference.properties.fields.clone(), - deleted_fields: preference.properties.deleted_fields.clone(), - }).and_then(|migrated| property_presets.initialize_system_presets().map(|_| migrated)) { - Ok(_) if had_legacy_property_registry => { - preference.properties = PropertiesConfig::default(); - match preference_json_without_property_registry(&preference) { - Ok(content) => if let Err(error) = atomic_write_json(&preference_file_path(&config_dir), &content) { - tracing::warn!(%error, "property presets were migrated, but legacy preference data could not be removed"); - }, - Err(error) => tracing::warn!(%error, "property presets were migrated, but legacy preference data could not be serialized"), - } - true - } - Ok(_) => { preference.properties = PropertiesConfig::default(); true } - Err(error) => { - tracing::error!(%error, "property preset migration failed; legacy preference data was retained on disk"); - false - } - }; + let preference = flowix_core::memo_file::read_or_create_json( + &preference_file_path(&config_dir), + PreferenceFile::default, + ) + .unwrap_or_else(|error| { + tracing::warn!(%error, "preferences unavailable; using defaults"); + PreferenceFile::default() + }); + let property_presets = PropertyPresetStore::new(config_dir.join("db").join("local.db")); // Once the official DSH settings document exists, do not even load - // the legacy TOML into the process. This makes the source-of-truth - // boundary explicit: agent-config.toml is read only during the - // one-time migration performed by - // read_or_migrate_deepseek_harness_settings(). + // the legacy TOML into the compatibility accessor. If it does not + // exist, this accessor may still expose the old standalone AI config; + // DSH settings reads themselves never migrate from it. let ai_config = if dsh_settings_path(&config_dir).is_file() { AiConfigFile::default() } else { @@ -697,10 +686,94 @@ impl UserConfigStore { ai_config: RwLock::new(ai_config), secrets, property_presets, - property_presets_migration_complete, + property_presets_migration_complete: Arc::new(AtomicBool::new(false)), } } + /// Run local configuration imports during the explicit version + /// initialization phase, before settings IPC is enabled. + pub fn initialize_legacy_config_migrations(&self) -> Result<(), String> { + self.with_runtime_storage_rebuild(|| self.initialize_legacy_config_migrations_inner()) + } + + fn initialize_legacy_config_migrations_inner(&self) -> Result<(), String> { + let mut preference = self.write_preference(); + let had_legacy_properties = !preference.properties.system_fields.is_empty() + || !preference.properties.fields.is_empty() + || !preference.properties.deleted_fields.is_empty(); + let registry = PropertyPresetRegistry { + system_fields: preference.properties.system_fields.clone(), + fields: preference.properties.fields.clone(), + deleted_fields: preference.properties.deleted_fields.clone(), + }; + self.property_presets + .migrate_legacy_once(®istry) + .and_then(|_| self.property_presets.initialize_system_presets()) + .map_err(|error| format!("initialize property presets failed: {error}"))?; + if had_legacy_properties { + preference.properties = PropertiesConfig::default(); + let content = preference_json_without_property_registry(&preference) + .map_err(|error| format!("serialize cleaned preferences failed: {error}"))?; + atomic_write_json(&preference_file_path(&self.config_dir), &content) + .map_err(|error| format!("write cleaned preferences failed: {error}"))?; + } + preference.properties = PropertiesConfig::default(); + Ok(()) + } + + /// Ensure current runtime defaults even when one-time migrations already ran. + /// Opening the store recreates a missing database/tables; existing presets + /// retain their identities and user values. + pub fn ensure_runtime_storage(&self) -> Result<(), String> { + self.with_runtime_storage_rebuild(|| { + self.property_presets.initialize_legacy_schema()?; + self.property_presets.initialize_system_presets() + }) + } + + fn with_runtime_storage_rebuild( + &self, + mut operation: impl FnMut() -> Result<(), String>, + ) -> Result<(), String> { + flowix_core::memo_file::with_database_rebuild(&self.config_dir.join("db/local.db"), || { + operation().map_err(|message| { + let invalid_data = [ + "no such", + "has no column", + "malformed", + "not a database", + "Invalid column", + "invalid column", + "datatype mismatch", + ] + .iter() + .any(|pattern| message.contains(pattern)); + std::io::Error::new( + if invalid_data { + std::io::ErrorKind::InvalidData + } else { + std::io::ErrorKind::Other + }, + message, + ) + }) + }) + .map_err(|error| error.to_string()) + } + + /// Restore process-local state after persistent migration completion. + /// This runs on every startup, including when migration jobs are skipped. + pub fn prepare_initialized_runtime(&self) { + self.write_preference().properties = PropertiesConfig::default(); + self.property_presets_migration_complete + .store(true, Ordering::Release); + } + + /// Migrate the old Flowix DSH directory layout in its own retryable step. + pub fn initialize_legacy_dsh_layout(&self) -> Result<(), String> { + migrate_dsh_layout(&self.config_dir) + } + #[allow(dead_code)] pub fn config_dir(&self) -> &PathBuf { &self.config_dir @@ -766,17 +839,34 @@ impl UserConfigStore { self.read_preference().clone() } + pub fn has_legacy_watcher_exclusions(&self) -> bool { + let preference = self.read_preference(); + let defaults = crate::watcher::WhitelistConfig::default(); + preference.watcher.skip_dirs != defaults.skip_dirs + || preference.watcher.skip_files != defaults.skip_files + } + pub fn get_property_presets(&self) -> Result { - self.property_presets.get().map_err(UserConfigError::InvalidConfig) + self.property_presets + .get() + .map_err(UserConfigError::InvalidConfig) } - pub fn patch_property_presets(&self, changes: &[PropertyPresetChange]) -> Result { - self.property_presets.patch(changes).map_err(UserConfigError::InvalidConfig) + pub fn patch_property_presets( + &self, + changes: &[PropertyPresetChange], + ) -> Result { + self.property_presets + .patch(changes) + .map_err(UserConfigError::InvalidConfig) } /// 先把 JSON 落盘 (tmp + fsync + rename, 0o600), 成功后才更新内存�? /// 任一写�?骤失�?�?内存保持旧�? 磁盘保持旧文�? 不出�?内存新�?盘旧"�? /// "半写�?��"的损坏状态�? pub fn set_preference(&self, mut p: PreferenceFile) -> Result<(), UserConfigError> { - let content = if self.property_presets_migration_complete { + let content = if self + .property_presets_migration_complete + .load(Ordering::Acquire) + { p.properties = PropertiesConfig::default(); preference_json_without_property_registry(&p)? } else { @@ -789,18 +879,72 @@ impl UserConfigStore { Ok(()) } + /// Clear the legacy global watcher name exclusions. New file-management + /// rules are notebook-local and are initialized from their current defaults. + pub fn clear_legacy_watcher_exclusions(&self) -> Result<(), UserConfigError> { + let mut preference = self.write_preference(); + let defaults = crate::watcher::WhitelistConfig::default(); + if preference.watcher.skip_dirs == defaults.skip_dirs + && preference.watcher.skip_files == defaults.skip_files + { + return Ok(()); + } + + // Patch the raw document so unknown or newer preference fields survive + // this one-time cleanup as well. + let path = preference_file_path(&self.config_dir); + let mut document = match fs::read(&path) { + Ok(bytes) => serde_json::from_slice::(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + serde_json::to_value(&*preference)? + } + Err(error) => return Err(error.into()), + }; + let root = document.as_object_mut().ok_or_else(|| { + UserConfigError::InvalidConfig("preference.json must contain a JSON object".into()) + })?; + let watcher = root + .entry("watcher") + .or_insert_with(|| serde_json::json!({})); + let watcher = watcher.as_object_mut().ok_or_else(|| { + UserConfigError::InvalidConfig( + "preference.json watcher must contain a JSON object".into(), + ) + })?; + watcher.insert( + "skip_dirs".into(), + serde_json::to_value(&defaults.skip_dirs)?, + ); + watcher.insert( + "skip_files".into(), + serde_json::to_value(&defaults.skip_files)?, + ); + let content = serde_json::to_string_pretty(&document)?; + atomic_write_json(&path, &content)?; + + preference.watcher.skip_dirs = defaults.skip_dirs; + preference.watcher.skip_files = defaults.skip_files; + Ok(()) + } + /// Merge a partial JSON preference patch with the current value under the /// preference lock, so separate windows cannot overwrite unrelated fields. pub fn patch_preference(&self, patch: serde_json::Value) -> Result<(), UserConfigError> { let mut preference = self.write_preference(); let legacy_properties = preference.properties.clone(); let mut value = serde_json::to_value(&*preference)?; - if self.property_presets_migration_complete { + if self + .property_presets_migration_complete + .load(Ordering::Acquire) + { value["properties"] = serde_json::to_value(PropertiesConfig::default())?; } merge_preference_patch(&mut value, patch); let mut next: PreferenceFile = serde_json::from_value(value)?; - let content = if self.property_presets_migration_complete { + let content = if self + .property_presets_migration_complete + .load(Ordering::Acquire) + { next.properties = PropertiesConfig::default(); preference_json_without_property_registry(&next)? } else { @@ -838,12 +982,10 @@ impl UserConfigStore { config } - /// Load the DeepSeek Harness model configuration from the llm-pi-ai - /// settings document. Existing installations are migrated lazily from - /// agent-config.toml on first access; after that, the legacy document is - /// not consulted again. + /// Load DeepSeek Harness model configuration from the official settings + /// document. Missing settings start empty; retired Flowix TOML is ignored. pub fn get_deepseek_harness_config(&self) -> Result { - let settings = self.read_or_migrate_deepseek_harness_settings()?; + let settings = self.read_deepseek_harness_settings()?.unwrap_or_default(); Ok(settings.to_ai_config()) } @@ -852,7 +994,7 @@ impl UserConfigStore { /// independent routes and their model cards carry different endpoints /// and credentials. pub fn get_deepseek_harness_configs(&self) -> Result, UserConfigError> { - let settings = self.read_or_migrate_deepseek_harness_settings()?; + let settings = self.read_deepseek_harness_settings()?.unwrap_or_default(); Ok(settings.to_ai_configs()) } @@ -918,24 +1060,6 @@ impl UserConfigStore { } } - /// Read the official DSH settings document, or create it once from the - /// legacy Flowix TOML when upgrading an older installation. After this - /// write succeeds, subsequent reads never consult `agent-config.toml`. - fn read_or_migrate_deepseek_harness_settings( - &self, - ) -> Result { - if let Some(settings) = self.read_deepseek_harness_settings()? { - return Ok(settings); - } - - let mut legacy = self.read_ai_config().clone(); - self.hydrate_ai_config_secrets(&mut legacy); - let settings = DeepSeekHarnessSettingsFile::from_ai_config(&legacy); - let content = serde_yaml::to_string(&settings)?; - atomic_write_yaml(&self.dsh_settings_path(), &content)?; - Ok(settings) - } - /// Render a standalone Harness settings document for a one-shot probe. /// /// A probe must be able to validate a model that has not been saved yet. @@ -1025,23 +1149,6 @@ impl UserConfigStore { .map_err(|err| UserConfigError::SecretStore(err.to_string())) } - /// Read a pre-DSH-owned credential for one route during the one-time - /// migration. New DSH settings code must not use this as a runtime source. - pub(crate) fn legacy_dsh_secret( - &self, - provider: &str, - ) -> Result, UserConfigError> { - let account = entry_name(provider, SECRET_ACCOUNT_NAME); - self.secrets - .load(&account) - .map(|value| value.map(|secret| secret.into_inner())) - .map_err(|err| UserConfigError::SecretStore(err.to_string())) - } - - pub(crate) fn delete_legacy_dsh_secret(&self, provider: &str) -> Result<(), UserConfigError> { - self.delete_provider_secret(provider) - } - pub fn save_cloud_refresh_token(&self, token: &str) -> Result<(), UserConfigError> { self.save_provider_secret(CLOUD_SECRET_PROVIDER, token) } @@ -1058,24 +1165,59 @@ impl UserConfigStore { self.delete_provider_secret(CLOUD_SECRET_PROVIDER) } - fn read_preference_from_disk(dir: &PathBuf) -> Option { - let path = preference_file_path(dir); - if !path.exists() { - return None; + fn cloud_dev_secret_provider(environment: flowix_sync::CloudEnvironment) -> &'static str { + match environment { + flowix_sync::CloudEnvironment::Staging => CLOUD_DEV_STAGING_SECRET_PROVIDER, + flowix_sync::CloudEnvironment::Prod => CLOUD_DEV_PRODUCTION_SECRET_PROVIDER, } - fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) + } + + pub fn save_cloud_dev_refresh_token( + &self, + environment: flowix_sync::CloudEnvironment, + token: &str, + ) -> Result<(), UserConfigError> { + self.save_provider_secret(Self::cloud_dev_secret_provider(environment), token) + } + + pub fn load_cloud_dev_refresh_token( + &self, + environment: flowix_sync::CloudEnvironment, + ) -> Result, UserConfigError> { + let provider = Self::cloud_dev_secret_provider(environment); + let account = entry_name(provider, SECRET_ACCOUNT_NAME); + self.secrets + .load(&account) + .map(|value| value.map(|secret| secret.into_inner())) + .map_err(|err| UserConfigError::SecretStore(err.to_string())) + } + + pub fn delete_cloud_dev_refresh_token( + &self, + environment: flowix_sync::CloudEnvironment, + ) -> Result<(), UserConfigError> { + self.delete_provider_secret(Self::cloud_dev_secret_provider(environment)) } fn read_ai_config_from_disk(dir: &PathBuf) -> Option { let path = dir.join(AI_CONFIG_FILE_NAME); - if !path.exists() { - return None; + match fs::read(&path) { + Ok(bytes) => match std::str::from_utf8(&bytes) + .ok() + .and_then(|text| toml::from_str(text).ok()) + { + Some(config) => return Some(config), + None => { + flowix_core::memo_file::backup_storage_file(&path).ok()?; + } + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return None, } - fs::read_to_string(&path) - .ok() - .and_then(|s| toml::from_str(&s).ok()) + let config = AiConfigFile::default(); + let text = toml::to_string_pretty(&config).ok()?; + flowix_core::memo_file::atomic_write_bytes(&path, text.as_bytes()).ok()?; + Some(config) } } @@ -1096,6 +1238,43 @@ impl flowix_sync::RefreshTokenStore for UserConfigStore { } } +pub struct CloudDevRefreshTokenStore { + user_config: std::sync::Arc, + environment: flowix_sync::CloudEnvironment, +} + +impl CloudDevRefreshTokenStore { + pub fn new( + user_config: std::sync::Arc, + environment: flowix_sync::CloudEnvironment, + ) -> Self { + Self { + user_config, + environment, + } + } +} + +impl flowix_sync::RefreshTokenStore for CloudDevRefreshTokenStore { + fn load(&self) -> Result, String> { + self.user_config + .load_cloud_dev_refresh_token(self.environment) + .map_err(|error| error.to_string()) + } + + fn save(&self, token: &str) -> Result<(), String> { + self.user_config + .save_cloud_dev_refresh_token(self.environment, token) + .map_err(|error| error.to_string()) + } + + fn delete(&self) -> Result<(), String> { + self.user_config + .delete_cloud_dev_refresh_token(self.environment) + .map_err(|error| error.to_string()) + } +} + fn merge_preference_patch(target: &mut serde_json::Value, patch: serde_json::Value) { match patch { serde_json::Value::Object(patch_fields) => { @@ -1155,7 +1334,9 @@ fn preference_file_path(config_dir: &Path) -> PathBuf { config_dir.join(BOOT_DIR_NAME).join(PREFERENCE_FILE_NAME) } -fn preference_json_without_property_registry(preference: &PreferenceFile) -> Result { +fn preference_json_without_property_registry( + preference: &PreferenceFile, +) -> Result { let mut value = serde_json::to_value(preference)?; if let Some(object) = value.as_object_mut() { object.remove("properties"); @@ -1172,7 +1353,7 @@ fn dsh_settings_path(config_dir: &Path) -> PathBuf { } /// 原子�?JSON: �?.tmp �?fsync �?0o600 �?rename 到目标�?/// 失败�?.tmp 残留由下次启动�?�? 不影响主文件�?/// -/// `pub(crate)` —`agent_access` 等同形态的 JSON 配置文件 (�?boot/preference.json) +/// `pub(crate)` —其他同形态的 JSON 配置文件 (�?boot/preference.json) /// 同目�? 复用这个落盘逻辑, 不�?制�?二份�? pub(crate) fn atomic_write_json(path: &Path, content: &str) -> std::io::Result<()> { if let Some(parent) = path.parent() { @@ -1241,89 +1422,134 @@ pub(crate) fn atomic_write_yaml(path: &Path, content: &str) -> std::io::Result<( /// `~/.dsh/` home without overwriting anything already created there. The /// old `~/.flowix/dsh/` directory is moved as one unit when possible; the /// older flat files are handled individually. Nothing is deleted. -fn migrate_dsh_layout(config_dir: &Path) { +fn migrate_dsh_layout(config_dir: &Path) -> Result<(), String> { let old_dsh_dir = config_dir.join("dsh"); let dsh_dir = config_dir.parent().unwrap_or(config_dir).join(DSH_DIR_NAME); let legacy_settings = config_dir.join("dsh-settings.yaml"); let legacy_plugin_settings = config_dir.join("dsh-plugin-settings.json"); let legacy_sessions = config_dir.join("dsh-sessions"); - if old_dsh_dir.is_dir() && !dsh_dir.exists() { - if fs::rename(&old_dsh_dir, &dsh_dir).is_ok() { - set_dir_owner_only_perms(&dsh_dir); - } + if path_is_directory(&old_dsh_dir)? && !path_exists(&dsh_dir)? { + fs::rename(&old_dsh_dir, &dsh_dir) + .map_err(|error| format!("move legacy DSH directory failed: {error}"))?; + set_dir_owner_only_perms(&dsh_dir); } - if legacy_settings.exists() || legacy_plugin_settings.exists() || legacy_sessions.exists() { - let _ = fs::create_dir_all(&dsh_dir); + if path_exists(&legacy_settings)? + || path_exists(&legacy_plugin_settings)? + || path_exists(&legacy_sessions)? + { + fs::create_dir_all(&dsh_dir) + .map_err(|error| format!("create DSH directory failed: {error}"))?; set_dir_owner_only_perms(&dsh_dir); } - migrate_into(&legacy_settings, &dsh_dir.join(DSH_SETTINGS_FILE_NAME)); + migrate_into(&legacy_settings, &dsh_dir.join(DSH_SETTINGS_FILE_NAME))?; migrate_into( &legacy_plugin_settings, &dsh_dir.join(DSH_PLUGIN_SETTINGS_FILE_NAME), - ); - migrate_into(&legacy_sessions, &dsh_dir.join(DSH_SESSIONS_DIR_NAME)); - flatten_dsh_session_wrappers(&dsh_dir.join(DSH_SESSIONS_DIR_NAME)); + )?; + migrate_into(&legacy_sessions, &dsh_dir.join(DSH_SESSIONS_DIR_NAME))?; + flatten_dsh_session_wrappers(&dsh_dir.join(DSH_SESSIONS_DIR_NAME))?; + Ok(()) +} + +fn path_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("inspect {} failed: {error}", path.display())), + } +} + +fn path_is_directory(path: &Path) -> Result { + match fs::metadata(path) { + Ok(metadata) => Ok(metadata.is_dir()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("inspect {} failed: {error}", path.display())), + } } /// Rename `source` to `target` when the source exists and the target is /// absent, then reapply owner-only permissions. Never overwrites an existing /// target; the directory case keeps the execute bit so it stays traversable. -fn migrate_into(source: &Path, target: &Path) { - if !source.exists() || target.exists() { - return; +fn migrate_into(source: &Path, target: &Path) -> Result<(), String> { + if !path_exists(source)? || path_exists(target)? { + return Ok(()); } if let Some(parent) = target.parent() { - let _ = fs::create_dir_all(parent); + fs::create_dir_all(parent) + .map_err(|error| format!("create DSH migration target directory failed: {error}"))?; set_dir_owner_only_perms(parent); } - if fs::rename(source, target).is_ok() { - if target.is_dir() { - set_dir_owner_only_perms(target); - } else { - set_file_owner_only_perms(target); - } + fs::rename(source, target) + .map_err(|error| format!("move DSH migration source failed: {error}"))?; + if path_is_directory(target)? { + set_dir_owner_only_perms(target); + } else { + set_file_owner_only_perms(target); } + Ok(()) } /// Remove the temporary Flowix-owned `/` wrapper that was used /// before the persistence root matched Harness' official layout. Only /// directories that clearly contain project directories are flattened; other /// entries such as `.runtime` are left untouched. -fn flatten_dsh_session_wrappers(sessions_dir: &Path) { - let Ok(entries) = fs::read_dir(sessions_dir) else { - return; +fn flatten_dsh_session_wrappers(sessions_dir: &Path) -> Result<(), String> { + let entries = match fs::read_dir(sessions_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("read DSH sessions directory failed: {error}")), }; - for entry in entries.flatten() { + for entry in entries { + let entry = entry.map_err(|error| format!("read DSH session entry failed: {error}"))?; let wrapper = entry.path(); - if !wrapper.is_dir() || entry.file_name() == ".runtime" { + let file_type = entry + .file_type() + .map_err(|error| format!("inspect DSH session entry failed: {error}"))?; + if !file_type.is_dir() || entry.file_name() == ".runtime" { continue; } - let Ok(projects) = fs::read_dir(&wrapper) else { - continue; - }; - let projects = projects - .flatten() - .filter(|project| { - let project_name = project.file_name().to_string_lossy().into_owned(); - project.path().is_dir() - && (project_name == "_no-cwd" - || (project_name.starts_with("--") && project_name.ends_with("--"))) - }) - .collect::>(); + let candidates = fs::read_dir(&wrapper) + .map_err(|error| format!("read DSH session wrapper failed: {error}"))? + .collect::, _>>() + .map_err(|error| format!("read DSH wrapped session entry failed: {error}"))?; + let mut projects = Vec::new(); + for project in candidates { + let project_name = project.file_name().to_string_lossy().into_owned(); + if (project_name == "_no-cwd" + || (project_name.starts_with("--") && project_name.ends_with("--"))) + && path_is_directory(&project.path())? + { + projects.push(project); + } + } if projects.is_empty() { continue; } for project in projects { let target = sessions_dir.join(project.file_name()); - if !target.exists() { - let _ = fs::rename(project.path(), target); + if !path_exists(&target)? { + fs::rename(project.path(), &target) + .map_err(|error| format!("flatten DSH session wrapper failed: {error}"))?; + } + } + let mut remaining = fs::read_dir(&wrapper) + .map_err(|error| format!("inspect DSH session wrapper failed: {error}"))?; + let is_empty = match remaining.next() { + None => true, + Some(Ok(_)) => false, + Some(Err(error)) => { + return Err(format!("inspect DSH session wrapper entry failed: {error}")); } + }; + if is_empty { + fs::remove_dir(&wrapper) + .map_err(|error| format!("remove empty DSH session wrapper failed: {error}"))?; } - let _ = fs::remove_dir(&wrapper); } + Ok(()) } #[cfg(unix)] @@ -1405,6 +1631,43 @@ mod tests { ) } + #[test] + fn storage_rebuild_replaces_invalid_preferences_and_toml() { + let home = tempfile::tempdir().unwrap(); + let config = home.path().join(USER_CONFIG_DIR_NAME); + std::fs::create_dir_all(config.join("boot")).unwrap(); + std::fs::write(config.join("boot/preference.json"), b"invalid json").unwrap(); + std::fs::write(config.join(AI_CONFIG_FILE_NAME), [0xff, 0xfe]).unwrap(); + let _store = test_user_config_store(home.path().to_path_buf()); + let json = std::fs::read(config.join("boot/preference.json")).unwrap(); + serde_json::from_slice::(&json).unwrap(); + let toml = std::fs::read_to_string(config.join(AI_CONFIG_FILE_NAME)).unwrap(); + toml::from_str::(&toml).unwrap(); + assert!(config.join("boot/rebuild-backups").exists()); + assert!(config.join("rebuild-backups").exists()); + } + + #[test] + fn missing_runtime_dependencies_recreate_defaults_after_initialization() { + let home = tempfile::tempdir().unwrap(); + let store = test_user_config_store(home.path().to_path_buf()); + store.initialize_legacy_config_migrations().unwrap(); + store.prepare_initialized_runtime(); + let expected = store.get_property_presets().unwrap().system_fields.len(); + assert!(expected > 0); + std::fs::remove_dir_all(store.config_dir().join("db")).unwrap(); + store.ensure_runtime_storage().unwrap(); + assert_eq!( + store.get_property_presets().unwrap().system_fields.len(), + expected + ); + store.ensure_runtime_storage().unwrap(); + assert_eq!( + store.get_property_presets().unwrap().system_fields.len(), + expected + ); + } + #[test] fn legacy_json_property_presets_migrate_to_local_database() { let home = tempfile::tempdir().unwrap(); @@ -1417,18 +1680,32 @@ mod tests { r#type: "Tag".into(), ..PropertyFieldConfig::default() }); - atomic_write_json(&preference_file_path(&config_dir), &serde_json::to_string(&legacy).unwrap()).unwrap(); + atomic_write_json( + &preference_file_path(&config_dir), + &serde_json::to_string(&legacy).unwrap(), + ) + .unwrap(); let store = test_user_config_store(home.path().to_path_buf()); let migrated = store.get_property_presets().unwrap(); assert_eq!(migrated.fields.len(), 1); - assert!(migrated.fields[0].id.as_deref().is_some_and(|id| id.starts_with("fld_"))); - assert!(config_dir.join("local.db").is_file()); + assert!(migrated.fields[0] + .id + .as_deref() + .is_some_and(|id| id.starts_with("fld_"))); + assert!(config_dir.join("db/local.db").is_file()); - store.patch_preference(serde_json::json!({"language": "zh-CN"})).unwrap(); - let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(preference_file_path(&config_dir)).unwrap()).unwrap(); + store + .patch_preference(serde_json::json!({"language": "zh-CN"})) + .unwrap(); + let saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(preference_file_path(&config_dir)).unwrap()) + .unwrap(); assert!(saved.get("properties").is_none()); - assert_eq!(store.get_property_presets().unwrap().fields[0].key, "topics"); + assert_eq!( + store.get_property_presets().unwrap().fields[0].key, + "topics" + ); } #[test] @@ -1538,7 +1815,7 @@ mod tests { } #[test] - fn first_dsh_read_migrates_legacy_toml_once() { + fn dsh_read_ignores_legacy_toml() { let home = tempfile::tempdir().unwrap(); let config_dir = home.path().join(USER_CONFIG_DIR_NAME); std::fs::create_dir_all(&config_dir).unwrap(); @@ -1558,31 +1835,13 @@ mod tests { let store = test_user_config_store(home.path().to_path_buf()); let loaded = store.get_deepseek_harness_config().unwrap(); - assert_eq!(loaded.model.provider, "legacy-provider"); - assert_eq!(loaded.model.model, "legacy-model"); - - // A later change to the old file must not affect the already-created - // DSH settings document. - let replacement = AiConfigFile { - model: AiModelConfig { - provider: "replacement-provider".into(), - model: "replacement-model".into(), - api_url: "https://replacement.example/v1".into(), - ..AiModelConfig::default() - }, - }; - std::fs::write( - config_dir.join(AI_CONFIG_FILE_NAME), - toml::to_string_pretty(&replacement).unwrap(), - ) - .unwrap(); - let loaded_again = store.get_deepseek_harness_config().unwrap(); - assert_eq!(loaded_again.model.provider, "legacy-provider"); - assert!(home + assert!(loaded.model.provider.is_empty()); + assert!(loaded.model.model.is_empty()); + assert!(!home .path() .join(DSH_DIR_NAME) .join(DSH_SETTINGS_FILE_NAME) - .is_file()); + .exists()); } #[test] @@ -1692,6 +1951,28 @@ apiKey = "k" assert_eq!(cfg.model.model, "gpt-4o"); } + #[test] + fn initialization_runtime_preparation_survives_process_restart() { + let home = tempfile::tempdir().unwrap(); + { + let store = test_user_config_store(home.path().to_path_buf()); + store.initialize_legacy_config_migrations().unwrap(); + } + // The persistent migration is already complete; the new process only + // restores runtime state before saving ordinary preferences. + let store = test_user_config_store(home.path().to_path_buf()); + store.prepare_initialized_runtime(); + store + .patch_preference(serde_json::json!({"language": "en"})) + .unwrap(); + let document: serde_json::Value = serde_json::from_slice( + &std::fs::read(preference_file_path(&store.config_dir)).unwrap(), + ) + .unwrap(); + assert!(document.get("properties").is_none()); + assert_eq!(document["language"], "en"); + } + #[test] fn set_preference_writes_to_boot_dir() { let home = tempfile::tempdir().unwrap(); diff --git a/app/flowix-desktop/src/device_registration.rs b/app/flowix-desktop/src/device_registration.rs index fc1f724de..3fc21594b 100644 --- a/app/flowix-desktop/src/device_registration.rs +++ b/app/flowix-desktop/src/device_registration.rs @@ -43,6 +43,16 @@ const DEFAULT_SUPABASE_ANON_KEY: &str = "sb_publishable_l6AmH0K0Uq8_roThQHSnnQ_2 pub struct BootFile { pub schema_version: u32, #[serde(default)] + pub version_initialization: VersionInitializationState, + /// Most recent app version whose one-time version initialization completed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_successful_app_version: Option, + /// Version of the one-time on-disk storage reset already completed. + /// This is separate from app-version initialization so later releases do + /// not repeatedly discard derived indexes or user-created plugin files. + #[serde(default)] + pub notebook_storage_reset_version: u32, + #[serde(default)] pub experimental: bool, /// Whether the first-run DeepSeek Harness introduction has been shown. /// Keep the on-disk key in snake_case for the boot.json contract. @@ -57,6 +67,16 @@ pub struct BootFile { pub user_info: UserInfo, } +/// Completion state for the one-time initialization associated with an app version. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VersionInitializationState { + #[serde(default)] + pub app_version: Option, + #[serde(default)] + pub completed: bool, +} + /// 设备登记子对象:保存随机设备 ID 和登记尝试状态。 #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -91,20 +111,32 @@ impl DeviceRegistry { let app_version = app_version.into(); if let Some(parent) = path.parent() { // best-effort, 文件已存在就能�?常�?�? 不存在时 `read_from_disk` 返回 None - let _ = std::fs::create_dir_all(parent); + let _ = flowix_core::memo_file::ensure_storage_directory(parent); } - let boot = Self::read_from_disk(&path).unwrap_or_else(|| { + let loaded = Self::read_from_disk(&path); + let mut save_defaults = loaded.is_none(); + let boot = loaded.unwrap_or_else(|| { + if let Err(error) = flowix_core::memo_file::backup_storage_file(&path) { + save_defaults = false; + tracing::warn!(%error, "could not preserve invalid boot configuration"); + } tracing::info!( "[device-reg] no boot.json at {}; creating a fresh registration record", path.display() ); Self::fresh() }); - Self { + let registry = Self { path, app_version, inner: RwLock::new(boot), + }; + if save_defaults { + if let Err(error) = registry.flush(®istry.read()) { + tracing::warn!(%error, "could not persist boot configuration"); + } } + registry } /// 异�?上报入口�?Fire-and-forget: �?�� `tauri::async_runtime::spawn`, @@ -122,6 +154,61 @@ impl DeviceRegistry { self.read().experimental } + /// App version whose one-time version initialization most recently completed. + pub fn last_successful_app_version(&self) -> Option { + self.read().last_successful_app_version.clone() + } + + /// Whether the current app version has completed its one-time initialization. + pub fn is_version_initialization_complete(&self) -> bool { + let boot = self.read(); + boot.version_initialization.app_version.as_deref() == Some(self.app_version.as_str()) + && boot.version_initialization.completed + } + + /// Update the current app version's initialization flag from dev settings. + pub fn set_version_initialization_completed(&self, completed: bool) -> Result<(), String> { + let mut boot = self.write(); + boot.version_initialization.app_version = Some(self.app_version.clone()); + boot.version_initialization.completed = completed; + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + + /// Persist an incomplete marker before running version-specific migrations. + pub fn begin_version_initialization(&self) -> Result<(), String> { + let mut boot = self.write(); + boot.version_initialization.app_version = Some(self.app_version.clone()); + boot.version_initialization.completed = false; + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + + /// Mark the current app version initialized after all one-time steps succeed. + /// The same atomic boot.json write advances the previous successful version + /// so it remains available until this initialization has fully completed. + pub fn complete_version_initialization(&self) -> Result<(), String> { + let mut boot = self.write(); + if boot.version_initialization.app_version.as_deref() != Some(self.app_version.as_str()) { + return Err("version initialization marker changed while running".to_string()); + } + boot.version_initialization.completed = true; + boot.last_successful_app_version = Some(self.app_version.clone()); + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + + pub fn notebook_storage_reset_version(&self) -> u32 { + self.read().notebook_storage_reset_version + } + + pub fn complete_notebook_storage_reset_version(&self, version: u32) -> Result<(), String> { + let mut boot = self.write(); + boot.notebook_storage_reset_version = boot.notebook_storage_reset_version.max(version); + self.flush(&boot) + .map_err(|error| format!("persist boot.json: {error}")) + } + /// Whether the DeepSeek Harness introduction has already been displayed. pub fn is_introduct_displayed(&self) -> bool { self.read().is_introduct_displayed @@ -296,6 +383,9 @@ impl DeviceRegistry { fn fresh() -> BootFile { BootFile { schema_version: BOOT_SCHEMA_VERSION, + version_initialization: VersionInitializationState::default(), + last_successful_app_version: None, + notebook_storage_reset_version: 0, experimental: false, is_introduct_displayed: false, is_onboarding_completed: false, diff --git a/app/flowix-desktop/src/lib.rs b/app/flowix-desktop/src/lib.rs index 9a31f61b7..030991ca7 100644 --- a/app/flowix-desktop/src/lib.rs +++ b/app/flowix-desktop/src/lib.rs @@ -31,6 +31,7 @@ mod runtime_log; #[cfg(windows)] mod runtime_state; mod system_data; +mod storage_initialization; mod template_store; mod update_security; mod watcher; diff --git a/app/flowix-desktop/src/maintenance/mod.rs b/app/flowix-desktop/src/maintenance/mod.rs index 48db7493a..879320b44 100644 --- a/app/flowix-desktop/src/maintenance/mod.rs +++ b/app/flowix-desktop/src/maintenance/mod.rs @@ -28,7 +28,7 @@ pub(crate) fn spawn_startup_maintenance( if let Err(error) = debug_dumps::cleanup(&user_config_dir.join("debug")) { tracing::warn!("startup debug maintenance failed: {error}"); } - if let Err(error) = backups::prune(&user_config_dir) { + if let Err(error) = backups::prune(&user_config_dir.join("db")) { tracing::warn!("startup backup maintenance failed: {error}"); } diff --git a/app/flowix-desktop/src/plugin.rs b/app/flowix-desktop/src/plugin.rs index d3a114b26..bca86fb8f 100644 --- a/app/flowix-desktop/src/plugin.rs +++ b/app/flowix-desktop/src/plugin.rs @@ -119,7 +119,7 @@ pub fn emit_run_event(app_handle: &tauri::AppHandle, event: PluginRunEvent) -> R fn plugin_root() -> Result { let home = dirs::home_dir().ok_or_else(|| "Home directory is unavailable".to_string())?; let root = get_user_config_dir(&home).join("plugin"); - fs::create_dir_all(&root).map_err(|e| format!("create plugin directory: {e}"))?; + flowix_core::memo_file::ensure_storage_directory(&root).map_err(|e| format!("create plugin directory: {e}"))?; let metadata = fs::symlink_metadata(&root) .map_err(|error| format!("inspect plugin directory: {error}"))?; if metadata.file_type().is_symlink() || !metadata.is_dir() { @@ -169,43 +169,38 @@ fn ensure_builtin_plugin( let plugin = root.join(plugin_id); match fs::symlink_metadata(&plugin) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { - return Err(format!("built-in {plugin_id} plugin path is invalid")); + flowix_core::memo_file::backup_storage_file(&plugin) + .map_err(|error| error.to_string())?; + fs::create_dir_all(&plugin).map_err(|error| error.to_string())?; } + // Existing files stay untouched; missing files use fresh-install assets. Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { fs::create_dir_all(&plugin).map_err(|e| format!("create {plugin_id} plugin: {e}"))?; } Err(error) => return Err(format!("inspect {plugin_id} plugin: {error}")), } - let manifest = plugin.join("plugin.json"); - // The built-in plugin is versioned with the host application. Older - // installations may still have the pre-declaration manifest (including - // the removed agent selector), which would fail validation and silently - // disappear from the sidebar. Reconcile it on every startup so the - // built-in definition is migrated before plugin discovery runs. - let needs_manifest_migration = match fs::read_to_string(&manifest) { - Ok(existing) => existing != expected_manifest, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(error) => { - return Err(format!("read {plugin_id} manifest: {error}")); + for (name, content) in [ + ("plugin.json", expected_manifest), + ("SKILL.md", expected_skill), + ] { + let path = plugin.join(name); + match fs::read(&path) { + Ok(bytes) if bytes == content.as_bytes() => continue, + Ok(_) => { + flowix_core::memo_file::backup_storage_file(&path) + .map_err(|error| error.to_string())?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("read {plugin_id} {name}: {error}")), } - }; - if needs_manifest_migration { - fs::write(&manifest, expected_manifest) - .map_err(|e| format!("write {plugin_id} manifest: {e}"))?; - } - let skill = plugin.join("SKILL.md"); - let needs_skill_migration = match fs::read_to_string(&skill) { - Ok(existing) => existing != expected_skill, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, - Err(error) => return Err(format!("read {plugin_id} skill: {error}")), - }; - if needs_skill_migration { - fs::write(&skill, expected_skill).map_err(|e| format!("write {plugin_id} skill: {e}"))?; + flowix_core::memo_file::atomic_create_bytes(&path, content.as_bytes()) + .map_err(|error| format!("create {plugin_id} {name}: {error}"))?; } Ok(()) } + fn read_plugin(path: &Path) -> Result { let expected_id = path .file_name() @@ -509,7 +504,8 @@ fn scan_plugin_catalog() -> Result { }; for entry in fs::read_dir(&root).map_err(|e| format!("scan plugins: {e}"))? { let entry = entry.map_err(|e| format!("scan plugin entry: {e}"))?; - if !entry.path().is_dir() || entry.file_name().to_string_lossy().starts_with('.') { + if !entry.path().is_dir() || entry.file_name().to_string_lossy().starts_with('.') + || entry.file_name() == "rebuild-backups" { continue; } match read_plugin(&entry.path()) { @@ -784,6 +780,24 @@ mod tests { PluginRuntime, MINDMAP_MANIFEST, MINDMAP_SKILL, }; + #[test] + fn missing_runtime_dependencies_recreate_invalid_plugin_files() { + let temp = tempfile::tempdir().unwrap(); + super::ensure_builtin_plugin(temp.path(), "mindmap", MINDMAP_MANIFEST, MINDMAP_SKILL).unwrap(); + let dir = temp.path().join("mindmap"); + std::fs::write(dir.join("plugin.json"), "existing manifest").unwrap(); + std::fs::remove_file(dir.join("SKILL.md")).unwrap(); + super::ensure_builtin_plugin(temp.path(), "mindmap", MINDMAP_MANIFEST, MINDMAP_SKILL).unwrap(); + assert_eq!( + std::fs::read_to_string(dir.join("plugin.json")).unwrap(), + MINDMAP_MANIFEST + ); + assert_eq!( + std::fs::read_to_string(dir.join("SKILL.md")).unwrap(), + MINDMAP_SKILL + ); + } + #[test] fn plugin_workspace_requires_a_registered_root_before_execution() { use flowix_core::memo_file::{MemoFile, NotebookConfig}; diff --git a/app/flowix-desktop/src/runtime_state.rs b/app/flowix-desktop/src/runtime_state.rs index 7a9c7378c..becc5bf1e 100644 --- a/app/flowix-desktop/src/runtime_state.rs +++ b/app/flowix-desktop/src/runtime_state.rs @@ -1,183 +1,8 @@ //! Mutable Windows state must not share the executable installation directory. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; pub fn windows_state_dir() -> Result { std::env::var_os("LOCALAPPDATA") .map(|root| PathBuf::from(root).join("FlowixData")) .ok_or_else(|| "LOCALAPPDATA is unavailable".to_string()) } - -pub fn migrate_pi_sessions(local_data: &Path) -> Result { - let old = local_data.join("Flowix").join("pi-sessions"); - let new = local_data.join("FlowixData").join("pi-sessions"); - if old.exists() { - if new.exists() { - // Coexisting stores are resolved per conversation without merging. - return Ok(new); - } - std::fs::create_dir_all(new.parent().unwrap()).map_err(|e| e.to_string())?; - // Move only this known data directory. Never overwrite, merge or remove - // installation files. Failure leaves the legacy data available. - std::fs::rename(&old, &new).map_err(|e| { - format!( - "Cannot migrate Pi sessions from {} to {}: {e}", - old.display(), - new.display() - ) - })?; - } - Ok(new) -} - -/// Select existing history in place, without overwriting or hiding a conflict. -pub fn resolve_pi_session_candidates(candidates: &[PathBuf]) -> Result { - let mut selected: Option<&PathBuf> = None; - for candidate in candidates { - if candidate.try_exists().map_err(|error| { - format!( - "cannot inspect Pi session directory {}: {error}", - candidate.display() - ) - })? { - if let Some(previous) = selected { - return Err(format!( - "Pi conversation has histories at both {} and {}; preserve both and resolve this conversation's conflict before continuing", - previous.display(), candidate.display() - )); - } - selected = Some(candidate); - } - } - selected - .or_else(|| candidates.first()) - .cloned() - .ok_or_else(|| "Pi session has no candidate directory".to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - struct Fixture(PathBuf); - impl Fixture { - fn new() -> Self { - let path = std::env::temp_dir().join(format!( - "flowix-state-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&path).unwrap(); - Self(path) - } - } - impl Drop for Fixture { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - #[test] - fn migrates_nested_sessions_once_without_touching_program_files() { - let f = Fixture::new(); - let old = f.0.join("Flowix/pi-sessions/thread"); - std::fs::create_dir_all(&old).unwrap(); - std::fs::write(old.join("history.jsonl"), b"saved conversation").unwrap(); - std::fs::write(f.0.join("Flowix/Flowix.exe"), b"program").unwrap(); - let new = migrate_pi_sessions(&f.0).unwrap(); - assert_eq!( - std::fs::read(new.join("thread/history.jsonl")).unwrap(), - b"saved conversation" - ); - assert_eq!(migrate_pi_sessions(&f.0).unwrap(), new); - assert_eq!( - std::fs::read(f.0.join("Flowix/Flowix.exe")).unwrap(), - b"program" - ); - } - #[test] - fn never_overwrites_an_existing_session_store() { - let f = Fixture::new(); - for base in ["Flowix", "FlowixData"] { - let dir = f.0.join(base).join("pi-sessions"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("session"), base).unwrap(); - } - assert_eq!( - migrate_pi_sessions(&f.0).unwrap(), - f.0.join("FlowixData/pi-sessions") - ); - assert_eq!( - std::fs::read(f.0.join("Flowix/pi-sessions/session")).unwrap(), - b"Flowix" - ); - assert_eq!( - std::fs::read(f.0.join("FlowixData/pi-sessions/session")).unwrap(), - b"FlowixData" - ); - } - - #[test] - fn coexisting_stores_resolve_distinct_conversations_and_new_sessions() { - let f = Fixture::new(); - let old_root = f.0.join("Flowix/pi-sessions"); - let new_root = f.0.join("FlowixData/pi-sessions"); - std::fs::create_dir_all(old_root.join("old-thread")).unwrap(); - std::fs::create_dir_all(new_root.join("new-thread")).unwrap(); - assert_eq!(migrate_pi_sessions(&f.0).unwrap(), new_root); - assert_eq!(migrate_pi_sessions(&f.0).unwrap(), new_root); - for (name, expected) in [ - ("old-thread", old_root.join("old-thread")), - ("new-thread", new_root.join("new-thread")), - ("fresh", new_root.join("fresh")), - ] { - assert_eq!( - resolve_pi_session_candidates(&[new_root.join(name), old_root.join(name)]).unwrap(), - expected - ); - } - assert!(!new_root.join("old-thread").exists()); - assert!(!new_root.join("fresh").exists()); - } - - #[test] - fn duplicate_conversation_preserves_both_histories() { - let f = Fixture::new(); - let candidates = [f.0.join("new/thread"), f.0.join("old/thread")]; - for (index, path) in candidates.iter().enumerate() { - std::fs::create_dir_all(path).unwrap(); - std::fs::write(path.join("history.jsonl"), index.to_string()).unwrap(); - } - assert!(resolve_pi_session_candidates(&candidates) - .unwrap_err() - .contains("both")); - for (index, path) in candidates.iter().enumerate() { - assert_eq!( - std::fs::read_to_string(path.join("history.jsonl")).unwrap(), - index.to_string() - ); - } - } - - #[test] - fn legacy_hash_directory_remains_accessible_without_renaming() { - let f = Fixture::new(); - let candidates = [ - f.0.join("FlowixData/pi-sessions/stable"), - f.0.join("FlowixData/pi-sessions/legacy"), - f.0.join("Flowix/pi-sessions/stable"), - f.0.join("Flowix/pi-sessions/legacy"), - ]; - std::fs::create_dir_all(&candidates[3]).unwrap(); - std::fs::write(candidates[3].join("history.jsonl"), b"legacy").unwrap(); - assert_eq!( - resolve_pi_session_candidates(&candidates).unwrap(), - candidates[3] - ); - assert_eq!( - std::fs::read(candidates[3].join("history.jsonl")).unwrap(), - b"legacy" - ); - assert!(!candidates[0].exists()); - } -} diff --git a/app/flowix-desktop/src/storage_initialization.rs b/app/flowix-desktop/src/storage_initialization.rs new file mode 100644 index 000000000..2b0293319 --- /dev/null +++ b/app/flowix-desktop/src/storage_initialization.rs @@ -0,0 +1,636 @@ +//! One-time cleanup and rebuilding of legacy user storage. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use flowix_core::memo_file::{MemoFile, NotebookConfig, NotebookInitializationJob}; +use rusqlite::Connection; + +use crate::device_registration::DeviceRegistry; + +const NOTEBOOK_STORAGE_RESET_VERSION: u32 = 1; +// An interruption signal, not a completion record. Keep it until the single +// reset job completion has been persisted in local.db. +const NOTEBOOK_RESET_PENDING_FILE: &str = "storage-reset-pending"; + +pub const CONFIG_LOCAL_MIGRATIONS_JOB: &str = "config_local_migrations_v1"; + +#[derive(Clone, Copy)] +enum GlobalInitializationJob { + AgentLayout, + LocalConfiguration, + WatcherSettings, + Templates, +} + +impl GlobalInitializationJob { + const ALL: [Self; 4] = [ + Self::AgentLayout, + Self::LocalConfiguration, + Self::WatcherSettings, + Self::Templates, + ]; + + fn key(self) -> &'static str { + match self { + Self::AgentLayout => "config_dsh_layout_v2", + Self::LocalConfiguration => CONFIG_LOCAL_MIGRATIONS_JOB, + Self::WatcherSettings => "config_watcher_exclusions_v1", + Self::Templates => "templates_v1", + } + } + + fn is_optional(self) -> bool { + matches!(self, Self::AgentLayout | Self::Templates) + } + + fn run( + self, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + ) -> Result<(), String> { + match self { + Self::AgentLayout => user_config.initialize_legacy_dsh_layout(), + Self::LocalConfiguration => user_config.initialize_legacy_config_migrations(), + Self::WatcherSettings => user_config + .clear_legacy_watcher_exclusions() + .map_err(|error| format!("clear legacy watcher exclusions failed: {error}")), + Self::Templates => crate::template_store::initialize(user_config_dir) + .map_err(|error| format!("initialize templates failed: {error}")), + } + } +} + +const RETIRED_INITIALIZATION_JOB_IDS: [&str; 6] = [ + "storage_reset_v1:plugin", + "storage_reset_v1:notebook_settings", + "storage_reset_v1:index", + "storage_reset_v1:recovery", + "storage_reset_v1:db_artifacts", + "builtin_plugins_v1", +]; + +/// Move global databases before any store opens a connection. +pub fn prepare_database_paths(config_dir: &Path) -> io::Result { + let database_dir = flowix_core::memo_file::prepare_database_directory( + config_dir, + &["local.db", "index.db", "sync.db", "thread.db", "default.db"], + )?; + // Credentials in the secret store must remain private after VACUUM INTO + // publishes the migrated snapshot. + #[cfg(unix)] + if database_dir.join("default.db").is_file() { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + database_dir.join("default.db"), + fs::Permissions::from_mode(0o600), + )?; + } + Ok(database_dir) +} + +/// Run unfinished one-time initialization jobs and retry notebooks that were +/// offline when initialization first ran. Notebook content is never removed. +pub fn initialize_legacy_storage( + device_registry: &DeviceRegistry, + memo_file: &MemoFile, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + app_data_dir: &Path, +) -> Result<(), String> { + initialize_legacy_storage_with_progress( + device_registry, + memo_file, + user_config, + user_config_dir, + app_data_dir, + |_, _, _| {}, + ) +} + +pub fn initialize_legacy_storage_with_progress( + device_registry: &DeviceRegistry, + memo_file: &MemoFile, + user_config: &crate::config::UserConfigStore, + user_config_dir: &Path, + _app_data_dir: &Path, + mut report: impl FnMut(usize, usize, &str), +) -> Result<(), String> { + let notebooks = memo_file + .read_notebook_configs() + .map_err(|error| error.to_string())?; + // Four global jobs, six retired markers, and two per-notebook jobs. + // Offline notebook jobs resolve as deferred units instead of stalling progress. + let total = GlobalInitializationJob::ALL.len() + + RETIRED_INITIALIZATION_JOB_IDS.len() + + notebooks.len() * NotebookInitializationJob::ALL.len(); + let mut completed = 0usize; + for job in GlobalInitializationJob::ALL { + let result = run_job_with_progress( + memo_file, + job.key(), + || job.run(user_config, user_config_dir), + &mut report, + &mut completed, + total, + ); + if let Err(error) = result { + if !job.is_optional() { + return Err(error); + } + tracing::warn!(job_id = job.key(), %error, "optional initialization job deferred"); + } + } + let reset_version = device_registry.notebook_storage_reset_version(); + // Missing completion records are not permission to delete surviving data. + // Retire the old global cleanup jobs; fresh installs and rebuilt registries + // now create only the dependencies they need. + for key in RETIRED_INITIALIZATION_JOB_IDS { + report(completed, total, key); + memo_file + .mark_local_migration_completed(key) + .map_err(|error| error.to_string())?; + completed += 1; + report(completed, total, key); + } + + reset_pending_notebooks_with_progress( + memo_file, + ¬ebooks, + &mut report, + &mut completed, + total, + )?; + if reset_version < NOTEBOOK_STORAGE_RESET_VERSION { + device_registry.complete_notebook_storage_reset_version(NOTEBOOK_STORAGE_RESET_VERSION)?; + } + tracing::info!( + completed_reset_version = reset_version.max(NOTEBOOK_STORAGE_RESET_VERSION), + "legacy notebook storage reset completed" + ); + Ok(()) +} + +fn run_job_with_progress( + memo_file: &MemoFile, + key: &str, + operation: impl FnOnce() -> Result<(), String>, + report: &mut impl FnMut(usize, usize, &str), + completed: &mut usize, + total: usize, +) -> Result<(), String> { + report(*completed, total, key); + let result = run_job(memo_file, key, operation); + *completed += 1; + report(*completed, total, key); + result +} + +fn run_job( + memo_file: &MemoFile, + key: &str, + operation: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + if memo_file + .local_migration_completed(key) + .map_err(|error| format!("read initialization job {key} progress failed: {error}"))? + { + tracing::debug!(job_id = key, "initialization job already completed"); + return Ok(()); + } + let started = std::time::Instant::now(); + tracing::info!(job_id = key, "initialization job started"); + if let Err(error) = operation() { + crate::runtime_log::record_event( + "error", + "startup.job_failed", + serde_json::json!({ + "jobId": key, "error": error, "elapsedMs": started.elapsed().as_millis() + }) + .to_string(), + ); + return Err(format!("initialization job {key} failed: {error}")); + } + memo_file + .mark_local_migration_completed(key) + .map_err(|error| format!("record initialization job {key} completion failed: {error}"))?; + tracing::info!(job_id = key, "initialization job completed"); + Ok(()) +} + +fn reset_pending_notebooks(memo_file: &MemoFile) -> Result<(), String> { + let notebooks = memo_file + .read_notebook_configs() + .map_err(|error| format!("read notebooks for storage reset retry failed: {error}"))?; + let total = notebooks.len() * 2; + let mut completed = 0; + reset_pending_notebooks_with_progress( + memo_file, + ¬ebooks, + &mut |_, _, _| {}, + &mut completed, + total, + ) +} + +fn reset_pending_notebooks_with_progress( + memo_file: &MemoFile, + notebooks: &[NotebookConfig], + report: &mut impl FnMut(usize, usize, &str), + completed: &mut usize, + total: usize, +) -> Result<(), String> { + for notebook in notebooks { + for (job_index, job) in NotebookInitializationJob::ALL.into_iter().enumerate() { + let key = job.key(¬ebook.id); + if memo_file + .local_migration_completed(&key) + .map_err(|error| error.to_string())? + { + report(*completed, total, &key); + *completed += 1; + report(*completed, total, &key); + if matches!(job, NotebookInitializationJob::ResetLegacyStorage) { + clear_notebook_reset_signal_best_effort(notebook); + } + continue; + } + if !Path::new(¬ebook.path).is_dir() { + tracing::warn!(job_id = %key, notebook = %notebook.id, + "notebook initialization deferred until the notebook is available"); + for deferred_job in NotebookInitializationJob::ALL.iter().skip(job_index) { + let deferred_key = deferred_job.key(¬ebook.id); + *completed += 1; + report(*completed, total, &format!("deferred:{deferred_key}")); + } + break; + } + run_job_with_progress( + memo_file, + &key, + || match job { + NotebookInitializationJob::ResetLegacyStorage => { + if notebook_requires_reset(memo_file, notebook)? { + reset_notebook(memo_file, notebook)?; + } + Ok(()) + } + NotebookInitializationJob::InitializeSchema => { + // Retrying schema preparation must preserve a database + // already rebuilt by a successful reset job. + memo_file + .initialize_memo_index_schema(¬ebook.id) + .and_then(|_| memo_file.initialize_note_index_schema(¬ebook.id)) + .and_then(|_| memo_file.initialize_media_resource_schema(¬ebook.id)) + .map_err(|error| { + format!( + "initialize notebook {} database schema failed: {error}", + notebook.id + ) + }) + } + }, + report, + completed, + total, + )?; + if matches!(job, NotebookInitializationJob::ResetLegacyStorage) { + clear_notebook_reset_signal_best_effort(notebook); + } + } + } + Ok(()) +} + +fn clear_notebook_reset_signal_best_effort(notebook: &NotebookConfig) { + let dir = Path::new(¬ebook.path).join(".flowix"); + match fs::symlink_metadata(&dir) { + // Never follow a replaced .flowix symlink to delete a signal elsewhere. + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => { + if let Err(error) = remove_path(&dir.join(NOTEBOOK_RESET_PENDING_FILE)) { + tracing::warn!(notebook_id = %notebook.id, %error, + "remove completed notebook reset signal failed"); + } + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => tracing::warn!(notebook_id = %notebook.id, %error, + "inspect completed notebook reset directory failed"), + } +} + +fn notebook_requires_reset( + memo_file: &MemoFile, + notebook: &NotebookConfig, +) -> Result { + let root = Path::new(¬ebook.path); + let flowix = root.join(".flowix"); + if memo_file + .has_legacy_notebook_internal_data(¬ebook.id) + .map_err(|error| format!("inspect notebook artifacts failed: {error}"))? + || [ + flowix.join("agent.json"), + flowix.join(NOTEBOOK_RESET_PENDING_FILE), + ] + .iter() + .any(|path| path.exists()) + { + return Ok(true); + } + // Read old checkpoints only to resume resets interrupted before this + // simplified implementation. New resets never write stage completions. + for stage in [ + "plugin-artifacts", + "derived-reset", + "note-index", + "media-index", + ] { + let key = format!("storage_reset_v1:notebook:{}:{stage}", notebook.id); + if memo_file + .local_migration_completed(&key) + .map_err(|error| error.to_string())? + { + return Ok(true); + } + } + legacy_notebook_database(&flowix.join("notebook.db")) + .map_err(|error| format!("inspect notebook database failed: {error}")) +} + +fn legacy_notebook_database(path: &Path) -> io::Result { + if !path.is_file() { + return Ok(false); + } + let conn = Connection::open(path).map_err(sqlite_to_io)?; + let names = { + let mut statement = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", + ) + .map_err(sqlite_to_io)?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(sqlite_to_io)?; + rows.collect::, _>>().map_err(sqlite_to_io)? + }; + let has_old_memos = names.iter().any(|name| name == "memos"); + let has_current_notes = names.iter().any(|name| name == "notes"); + let has_old_v2_projection = names.iter().any(|name| name.starts_with("v2_")); + Ok((has_old_memos && !has_current_notes) || has_old_v2_projection) +} + +fn path_entry_exists(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +// Retry the complete reset safely. Only the enclosing run_job records success. +fn reset_notebook(memo_file: &MemoFile, notebook: &NotebookConfig) -> Result<(), String> { + let root = Path::new(¬ebook.path); + let flowix_dir = root.join(".flowix"); + if fs::symlink_metadata(&flowix_dir).is_ok_and(|metadata| metadata.file_type().is_symlink()) { + return Err(format!( + "notebook .flowix directory is a symbolic link: {}", + flowix_dir.display() + )); + } + fs::create_dir_all(&flowix_dir) + .map_err(|error| format!("create notebook Flowix directory failed: {error}"))?; + // Write before moving any legacy source or deleting any derived data. + flowix_core::memo_file::atomic_write_bytes(&flowix_dir.join(NOTEBOOK_RESET_PENDING_FILE), b"1") + .map_err(|error| format!("persist notebook reset interruption signal failed: {error}"))?; + + MemoFile::ensure_notebook_manifest(notebook) + .map_err(|error| format!("restore notebook identity manifest failed: {error}"))?; + let migration = memo_file + .migrate_notebook_internal_data(¬ebook.id) + .map_err(|error| format!("preserve notebook plugin artifacts failed: {error}"))?; + if !migration.completed { + return Err(format!( + "notebook {} plugin artifact migration is incomplete: {:?}", + notebook.id, migration.warnings + )); + } + + flowix_core::memo_file::backup_database(&flowix_dir.join("notebook.db")) + .map_err(|error| format!("remove notebook derived database failed: {error}"))?; + memo_file.invalidate_caches(); + for name in [ + "view-preferences.json", + "system.json", + "file-management-refresh-pending", + ] { + flowix_core::memo_file::backup_storage_file(&flowix_dir.join(name)) + .map_err(|error| format!("preserve notebook {name} failed: {error}"))?; + } + crate::commands::file::write_default_notebook_view_preferences(root)?; + memo_file + .reconcile_note_index_blocking(¬ebook.id) + .map_err(|error| format!("rebuild notebook note index failed: {error}"))?; + memo_file + .reconcile_media_resources(¬ebook.id) + .map_err(|error| format!("rebuild notebook media index failed: {error}"))?; + + // Discard the obsolete configuration after preservation and rebuilding. + // The pending signal survives until run_job has recorded overall success. + for name in ["agent.json", "notebook-template-initializing.json"] { + remove_path(&flowix_dir.join(name)) + .map_err(|error| format!("remove notebook {name} failed: {error}"))?; + } + Ok(()) +} + +fn remove_path(path: &Path) -> io::Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => { + fs::remove_file(path) + } + Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unsupported path type: {}", path.display()), + )), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn sqlite_to_io(error: rusqlite::Error) -> io::Error { + io::Error::other(error) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (tempfile::TempDir, MemoFile, NotebookConfig) { + let temp = tempfile::tempdir().unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.initialize_registry_storage().unwrap(); + let root = temp.path().join("notebook"); + fs::create_dir_all(&root).unwrap(); + let notebook = NotebookConfig { + id: "test".into(), + name: "notebook".into(), + icon: None, + path: root.to_string_lossy().into_owned(), + is_default: true, + sort: 0, + created_at: 0, + updated_at: 0, + }; + memo.write_notebook_configs(&[notebook.clone()]).unwrap(); + (temp, memo, notebook) + } + + #[test] + fn storage_rebuild_missing_markers_preserves_plugins_and_drafts() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().to_path_buf(); + let config = home.join(crate::USER_CONFIG_DIR_NAME); + let app_data = home.join("app-data"); + fs::create_dir_all(config.join("plugin/custom")).unwrap(); + fs::write(config.join("plugin/custom/kept.txt"), b"custom plugin").unwrap(); + fs::create_dir_all(app_data.join("recovery-drafts")).unwrap(); + fs::write(app_data.join("recovery-drafts/kept.md"), b"draft").unwrap(); + let user = crate::config::UserConfigStore::new(home); + let device = DeviceRegistry::load(&config, "test"); + let memo = MemoFile::new(config.clone()); + memo.initialize_registry_storage().unwrap(); + initialize_legacy_storage(&device, &memo, &user, &config, &app_data).unwrap(); + assert_eq!( + fs::read(config.join("plugin/custom/kept.txt")).unwrap(), + b"custom plugin" + ); + assert_eq!( + fs::read(app_data.join("recovery-drafts/kept.md")).unwrap(), + b"draft" + ); + } + + #[test] + fn registry_initialization_prepares_workspace_tables() { + let temp = tempfile::tempdir().unwrap(); + let memo = MemoFile::new(temp.path().join("config")); + memo.initialize_registry_storage().unwrap(); + let conn = Connection::open(memo.registry_db_path()).unwrap(); + for table in ["notebook_add_dirs", "notebook_workspace_state"] { + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + } + + #[test] + fn current_view_preferences_do_not_require_reset() { + let (_temp, memo, notebook) = fixture(); + crate::commands::file::write_default_notebook_view_preferences(Path::new(¬ebook.path)) + .unwrap(); + assert!(!notebook_requires_reset(&memo, ¬ebook).unwrap()); + } + + #[test] + fn schema_retry_preserves_database_after_completed_reset() { + let (_temp, memo, notebook) = fixture(); + crate::commands::file::write_default_notebook_view_preferences(Path::new(¬ebook.path)) + .unwrap(); + let path = Path::new(¬ebook.path).join(".flowix/notebook.db"); + Connection::open(&path).unwrap().execute_batch("CREATE TABLE retained_probe (value TEXT); INSERT INTO retained_probe VALUES ('keep');").unwrap(); + memo.mark_local_migration_completed( + &NotebookInitializationJob::ResetLegacyStorage.key(¬ebook.id), + ) + .unwrap(); + reset_pending_notebooks(&memo).unwrap(); + let value: String = Connection::open(path) + .unwrap() + .query_row("SELECT value FROM retained_probe", [], |row| row.get(0)) + .unwrap(); + assert_eq!(value, "keep"); + assert!(!memo.has_pending_notebook_storage_initialization().unwrap()); + } + + #[test] + fn current_status_markers_do_not_reset_preferences() { + let (_temp, memo, notebook) = fixture(); + let flowix = Path::new(¬ebook.path).join(".flowix"); + fs::create_dir_all(&flowix).unwrap(); + fs::write( + flowix.join("view-preferences.json"), + b"{\"default_create_folder\":\"custom\"}", + ) + .unwrap(); + fs::write(flowix.join("file-management-refresh-pending"), b"refresh").unwrap(); + fs::write(flowix.join("notebook-template-initializing.json"), b"{}").unwrap(); + assert!(!notebook_requires_reset(&memo, ¬ebook).unwrap()); + reset_pending_notebooks(&memo).unwrap(); + assert_eq!( + fs::read(flowix.join("view-preferences.json")).unwrap(), + b"{\"default_create_folder\":\"custom\"}" + ); + assert!(flowix.join("file-management-refresh-pending").exists()); + assert!(flowix.join("notebook-template-initializing.json").exists()); + } + + fn assert_reset_rebuilds_note(memo: &MemoFile, notebook: &NotebookConfig) { + let root = Path::new(¬ebook.path); + fs::write(root.join("retained.md"), "# Retained note\nbody").unwrap(); + reset_pending_notebooks(memo).unwrap(); + let conn = Connection::open(root.join(".flowix/notebook.db")).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM notes WHERE relative_path = 'retained.md'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + assert_eq!( + fs::read_to_string(root.join("retained.md")).unwrap(), + "# Retained note\nbody" + ); + assert!(!root + .join(".flowix") + .join(NOTEBOOK_RESET_PENDING_FILE) + .exists()); + assert!(memo + .local_migration_completed( + &NotebookInitializationJob::ResetLegacyStorage.key(¬ebook.id) + ) + .unwrap()); + } + + #[test] + fn interrupted_reset_rebuilds_indexes_and_records_only_overall_completion() { + let (_temp, memo, notebook) = fixture(); + let flowix = Path::new(¬ebook.path).join(".flowix"); + fs::create_dir_all(&flowix).unwrap(); + // Simulate interruption after legacy sources and derived DB disappeared. + fs::write(flowix.join(NOTEBOOK_RESET_PENDING_FILE), b"1").unwrap(); + assert_reset_rebuilds_note(&memo, ¬ebook); + let conn = Connection::open(memo.registry_db_path()).unwrap(); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM local_migrations WHERE migration_key LIKE 'storage_reset_v1:notebook:test:%'", [], |row| row.get(0)).unwrap(); + assert_eq!(count, 0, "new resets must not write stage completions"); + } + + #[test] + fn legacy_partial_reset_is_retried_without_original_sources() { + let (_temp, memo, notebook) = fixture(); + memo.mark_local_migration_completed("storage_reset_v1:notebook:test:derived-reset") + .unwrap(); + assert_reset_rebuilds_note(&memo, ¬ebook); + } + + #[test] + fn failed_job_retries_and_completed_job_skips() { + let (_temp, memo, _) = fixture(); + assert!(run_job(&memo, "test_retry", || Err("interrupted".into())).is_err()); + assert!(!memo.local_migration_completed("test_retry").unwrap()); + run_job(&memo, "test_retry", || Ok(())).unwrap(); + run_job(&memo, "test_retry", || panic!("completed job ran again")).unwrap(); + } +} diff --git a/app/flowix-desktop/src/system_data.rs b/app/flowix-desktop/src/system_data.rs index 608375d9d..2713136d3 100644 --- a/app/flowix-desktop/src/system_data.rs +++ b/app/flowix-desktop/src/system_data.rs @@ -156,7 +156,7 @@ impl SystemData { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let data = Self::read_from_disk(&path).unwrap_or_default(); + let data = flowix_core::memo_file::read_or_create_json(&path, SystemFile::default)?; Ok(Self { path, data: RwLock::new(data), @@ -188,20 +188,6 @@ impl SystemData { }) } - fn read_from_disk(path: &PathBuf) -> Option { - if !path.exists() { - return None; - } - let content = fs::read_to_string(path).ok()?; - match serde_json::from_str::(&content) { - Ok(data) => Some(data), - Err(e) => { - tracing::warn!("system.json parse error: {e}, falling back to empty"); - None - } - } - } - fn flush(&self, data: &SystemFile) -> std::io::Result<()> { if let Some(parent) = self.path.parent() { fs::create_dir_all(parent)?; diff --git a/app/flowix-desktop/src/template_store.rs b/app/flowix-desktop/src/template_store.rs index dbbb54d69..9db4dcad3 100644 --- a/app/flowix-desktop/src/template_store.rs +++ b/app/flowix-desktop/src/template_store.rs @@ -1,27 +1,26 @@ -use std::fs::{self, OpenOptions}; -use std::io::Write; +use std::fs; use std::path::{Component, Path, PathBuf}; include!(concat!(env!("OUT_DIR"), "/notebook_template_assets.rs")); -const LEGACY_NOTES_MIGRATION_MARKER: &str = ".notes-template-migration-v1-complete"; - pub fn initialize(user_config_dir: &Path) -> Result<(), String> { initialize_notebook_templates(user_config_dir)?; let templates_dir = user_config_dir.join("templates"); let notes_templates_dir = templates_dir.join("notes"); - fs::create_dir_all(¬es_templates_dir) + flowix_core::memo_file::ensure_storage_directory(¬es_templates_dir) .map_err(|error| format!("create note template directory failed: {error}"))?; - migrate_legacy_note_templates(user_config_dir, &templates_dir, ¬es_templates_dir)?; + migrate_legacy_note_templates(user_config_dir, ¬es_templates_dir)?; Ok(()) } pub fn initialize_notebook_templates(user_config_dir: &Path) -> Result<(), String> { - let notebook_templates_dir = user_config_dir.join("templates").join("notebook-templates"); - fs::create_dir_all(¬ebook_templates_dir) + let templates_dir = user_config_dir.join("templates"); + flowix_core::memo_file::ensure_storage_directory(&templates_dir).map_err(|error| error.to_string())?; + let notebook_templates_dir = templates_dir.join("notebook-templates"); + flowix_core::memo_file::ensure_storage_directory(¬ebook_templates_dir) .map_err(|error| format!("create notebook template directory failed: {error}"))?; seed_notebook_templates_if_needed(¬ebook_templates_dir) } @@ -36,10 +35,23 @@ pub fn notes_templates_dir(user_config_dir: &Path) -> PathBuf { fn seed_notebook_templates_if_needed(target_dir: &Path) -> Result<(), String> { let index_path = target_dir.join("index.json"); + if index_path.is_file() { + let bytes = fs::read(&index_path).map_err(|error| error.to_string())?; + let valid = serde_json::from_slice::(&bytes).ok() + .and_then(|index| index.get("templates").cloned()) + .and_then(|templates| serde_json::from_value::>(templates).ok()) + .is_some_and(|templates| { + let mut ids = std::collections::HashSet::new(); + templates.iter().all(|template| crate::commands::memo::creates::validate_notebook_template(template).is_ok() && ids.insert(template.id.clone())) + }); + if !valid { + flowix_core::memo_file::backup_storage_file(&index_path).map_err(|error| error.to_string())?; + } + } if index_path.exists() { if !is_legacy_builtin_notebook_template_index(&index_path)? { migrate_builtin_scenario_ids_and_cover_urls(&index_path)?; - return Ok(()); + return ensure_bundled_template_files(target_dir); } // Replace the previous built-in collection on upgrade. A custom @@ -65,6 +77,12 @@ fn seed_notebook_templates_if_needed(target_dir: &Path) -> Result<(), String> { .map_err(|error| format!("replace legacy notebook template index failed: {error}"))?; } + ensure_bundled_template_files(target_dir) +} + +// Called when templates are used, so partial deletion does not require a +// version change or a startup scan. Existing files and custom indexes survive. +fn ensure_bundled_template_files(target_dir: &Path) -> Result<(), String> { for &(relative, content) in NOTEBOOK_TEMPLATE_ASSETS .iter() .filter(|(relative, _)| *relative != "index.json") @@ -90,7 +108,7 @@ fn seed_notebook_templates_if_needed(target_dir: &Path) -> Result<(), String> { .iter() .find_map(|(relative, content)| (*relative == "index.json").then_some(*content)) .ok_or_else(|| "bundled notebook template index is missing".to_string())?; - write_if_missing(&index_path, index_content)?; + write_if_missing(&target_dir.join("index.json"), index_content)?; Ok(()) } @@ -258,61 +276,99 @@ fn migrate_builtin_scenario_ids_and_cover_urls(index_path: &Path) -> Result<(), fn migrate_legacy_note_templates( user_config_dir: &Path, - templates_dir: &Path, notes_templates_dir: &Path, ) -> Result<(), String> { let legacy_dir = user_config_dir.join("template"); - if !legacy_dir.is_dir() { - return Ok(()); + let metadata = match fs::symlink_metadata(&legacy_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("inspect legacy note template directory failed: {error}")), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("legacy note template path is not a regular directory".to_string()); } + copy_legacy_template_tree(&legacy_dir, &legacy_dir, notes_templates_dir)?; - let marker = templates_dir.join(LEGACY_NOTES_MIGRATION_MARKER); - if marker.exists() { - return Ok(()); - } + fs::remove_dir_all(&legacy_dir) + .map_err(|error| format!("remove legacy note template directory failed: {error}")) +} - let entries = fs::read_dir(&legacy_dir) +fn copy_legacy_template_tree( + root: &Path, + source: &Path, + destination: &Path, +) -> Result<(), String> { + let entries = fs::read_dir(source) .map_err(|error| format!("read legacy note template directory failed: {error}"))?; for entry in entries { let entry = entry.map_err(|error| format!("read legacy note template failed: {error}"))?; let file_type = entry .file_type() .map_err(|error| format!("inspect legacy note template failed: {error}"))?; - if !file_type.is_file() { + if file_type.is_symlink() { continue; } - - let source = entry.path(); - let extension = source - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or_default(); - if !matches!(extension.to_ascii_lowercase().as_str(), "md" | "markdown") { + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(|error| format!("resolve legacy note template path failed: {error}"))?; + if relative.components().any(|component| { + !matches!(component, Component::Normal(_)) + }) { continue; } - - let filename = source - .file_name() - .ok_or_else(|| "invalid legacy note template filename".to_string())?; - let target = notes_templates_dir.join(filename); - let content = fs::read(&source) - .map_err(|error| format!("read legacy note template failed: {error}"))?; - write_if_missing(&target, &content)?; + let target = destination.join(relative); + if file_type.is_dir() { + fs::create_dir_all(&target) + .map_err(|error| format!("create note template directory failed: {error}"))?; + copy_legacy_template_tree(root, &path, destination)?; + } else if file_type.is_file() { + let content = fs::read(&path) + .map_err(|error| format!("read legacy note template failed: {error}"))?; + write_if_missing(&target, &content)?; + } } - - write_if_missing(&marker, b"completed\n") + Ok(()) } fn write_if_missing(path: &Path, content: &[u8]) -> Result<(), String> { - let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(()), - Err(error) => return Err(format!("create template file failed: {error}")), - }; + match flowix_core::memo_file::atomic_create_bytes(path, content) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(format!("create template file failed: {error}")), + } +} - if let Err(error) = file.write_all(content) { - let _ = fs::remove_file(path); - return Err(format!("write template file failed: {error}")); +#[cfg(test)] +mod missing_dependency_tests { + use super::*; + + #[test] + fn storage_rebuild_replaces_invalid_template_index() { + let temp = tempfile::tempdir().unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + let root = notebook_templates_dir(temp.path()); + fs::write(root.join("index.json"), b"{broken").unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + let index: serde_json::Value = + serde_json::from_slice(&fs::read(root.join("index.json")).unwrap()).unwrap(); + assert!(index["templates"].is_array()); + assert!(root.join("rebuild-backups").exists()); + } + + #[test] + fn missing_runtime_dependencies_recreate_template_file_with_existing_index() { + let temp = tempfile::tempdir().unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + let root = notebook_templates_dir(temp.path()); + let (relative, content) = NOTEBOOK_TEMPLATE_ASSETS + .iter() + .find(|(path, _)| *path != "index.json") + .unwrap(); + let index = fs::read(root.join("index.json")).unwrap(); + fs::remove_file(root.join(relative)).unwrap(); + initialize_notebook_templates(temp.path()).unwrap(); + assert_eq!(fs::read(root.join(relative)).unwrap(), *content); + assert_eq!(fs::read(root.join("index.json")).unwrap(), index); } - Ok(()) } diff --git a/app/flowix-desktop/src/watcher/manager.rs b/app/flowix-desktop/src/watcher/manager.rs index 23dedc434..e7db4b6c8 100644 --- a/app/flowix-desktop/src/watcher/manager.rs +++ b/app/flowix-desktop/src/watcher/manager.rs @@ -147,7 +147,8 @@ impl MemoWatcher { pub fn set_whitelist(&self, new_cfg: WhitelistConfig) { if let Ok(mut g) = self.whitelist.write() { let mut config = new_cfg; - // Legacy name rules are migrated into each notebook policy. + // Name exclusions now come from the new notebook-local policy. + // Keep only the internal directories in the global event filter. config.skip_dirs = vec![".flowix".into(), ".plugin-output".into()]; config.skip_files.clear(); config.allowed_filename_patterns.clear(); diff --git a/app/flowix-desktop/tauri.conf.dev.json b/app/flowix-desktop/tauri.conf.dev.json index 320956f8d..6158c24fe 100644 --- a/app/flowix-desktop/tauri.conf.dev.json +++ b/app/flowix-desktop/tauri.conf.dev.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Flowix Dev", "mainBinaryName": "Flowix Dev", - "identifier": "com.flowix.app.dev", + "identifier": "com.flowix-dev.app", "plugins": { "deep-link": { "desktop": { "schemes": ["flowix-dev"] } diff --git a/app/flowix-desktop/tauri.conf.json b/app/flowix-desktop/tauri.conf.json index 5cf71c3c8..8c4f9d9f4 100644 --- a/app/flowix-desktop/tauri.conf.json +++ b/app/flowix-desktop/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Flowix", "mainBinaryName": "Flowix", - "version": "1.5.6", + "version": "1.5.7", "identifier": "com.flowix.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/app/flowix-desktop/tauri.windows.dev.conf.json b/app/flowix-desktop/tauri.windows.dev.conf.json index 5cfa24b74..37239c729 100644 --- a/app/flowix-desktop/tauri.windows.dev.conf.json +++ b/app/flowix-desktop/tauri.windows.dev.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "Flowix Dev", "mainBinaryName": "Flowix Dev", - "identifier": "com.flowix.app.dev", + "identifier": "com.flowix-dev.app", "plugins": { "deep-link": { "desktop": { "schemes": ["flowix-dev"] } diff --git a/app/flowix-sync/Cargo.toml b/app/flowix-sync/Cargo.toml index acbc01bf4..c217e4cdd 100644 --- a/app/flowix-sync/Cargo.toml +++ b/app/flowix-sync/Cargo.toml @@ -3,6 +3,7 @@ name = "flowix-sync" version.workspace = true description = "Flowix Cloud authentication and notebook synchronization engine" edition.workspace = true +build = "build.rs" [lib] name = "flowix_sync" diff --git a/app/flowix-sync/build.rs b/app/flowix-sync/build.rs new file mode 100644 index 000000000..9adb81f7a --- /dev/null +++ b/app/flowix-sync/build.rs @@ -0,0 +1,18 @@ +fn main() { + println!("cargo:rerun-if-env-changed=FLOWIX_CLOUD_ENVIRONMENT"); + println!("cargo:rerun-if-env-changed=FLOWIX_DEV_MODE"); + + let is_dev_build = matches!(std::env::var("FLOWIX_DEV_MODE").as_deref(), Ok("1")); + let configured_environment = std::env::var("FLOWIX_CLOUD_ENVIRONMENT") + .unwrap_or_else(|_| "prod".to_owned()); + let environment = if is_dev_build { + configured_environment.as_str() + } else { + "prod" + }; + match environment { + "staging" | "prod" => (), + other => panic!("FLOWIX_CLOUD_ENVIRONMENT must be staging or prod, got {other:?}"), + } + println!("cargo:rustc-env=FLOWIX_CLOUD_ENVIRONMENT={environment}"); +} diff --git a/app/flowix-sync/src/client.rs b/app/flowix-sync/src/client.rs index 7c3ca47b9..2cce6910c 100644 --- a/app/flowix-sync/src/client.rs +++ b/app/flowix-sync/src/client.rs @@ -4,8 +4,8 @@ use serde_json::json; use crate::error::SyncError; use crate::models::{ - ApiErrorEnvelope, AppleAuthChallenge, AppleAuthorization, AuthData, CloudCheckout, - CloudProduct, DataEnvelope, EntitlementData, MeData, RefreshData, + ApiErrorEnvelope, AppleAuthChallenge, AppleAuthorization, AuthData, CloudAiCredential, CloudCheckout, + CloudOrder, CloudOrderCheck, CloudProduct, DataEnvelope, EntitlementData, MeData, RefreshData, }; use crate::v2::{ V2BlobDownloadEnvelope, V2BlobReservationEnvelope, V2Bootstrap, V2ChangesPage, V2History, @@ -133,7 +133,6 @@ impl CloudClient { .json(&json!({ "productId": product_id, "successUrl": "flowix://billing/success", - "cancelUrl": "flowix://billing/cancel", })) .send() .await?; @@ -142,6 +141,59 @@ impl CloudClient { .map(|value| value.data) } + pub(crate) async fn billing_orders( + &self, + access_token: &str, + ) -> Result, SyncError> { + self.send::>>( + Method::GET, + "/v2/billing/orders", + Some(access_token), + None, + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn check_billing_order( + &self, + access_token: &str, + order_id: &str, + ) -> Result { + self.send::>( + Method::POST, + &format!("/v2/billing/orders/{order_id}/check"), + Some(access_token), + Some(json!({})), + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn submit_feedback( + &self, + device_id: &str, + kind: &str, + content: &str, + app_version: &str, + platform: &str, + attachments: &[serde_json::Value], + ) -> Result<(), SyncError> { + self.send::( + Method::POST, + "/v1/feedback", + None, + Some(json!({ + "deviceId": device_id, + "type": kind, + "content": content, + "appVersion": app_version, + "platform": platform, + "attachments": attachments, + })), + ).await.map(|_| ()) + } + pub(crate) async fn register( &self, email: &str, @@ -279,6 +331,31 @@ impl CloudClient { .map(|value| value.data) } + pub(crate) async fn create_ai_credential( + &self, + access_token: &str, + ) -> Result { + self.send::>( + Method::POST, + "/v1/ai/credentials", + Some(access_token), + Some(json!({})), + ) + .await + .map(|value| value.data) + } + + pub(crate) async fn revoke_ai_credential(&self, access_token: &str) -> Result<(), SyncError> { + self.send::>( + Method::DELETE, + "/v1/ai/credentials/current", + Some(access_token), + None, + ) + .await + .map(|_| ()) + } + pub async fn v2_sync_status( &self, access_token: &str, diff --git a/app/flowix-sync/src/environment.rs b/app/flowix-sync/src/environment.rs new file mode 100644 index 000000000..e09a9db67 --- /dev/null +++ b/app/flowix-sync/src/environment.rs @@ -0,0 +1,35 @@ +/// Cloud target used by the desktop application. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloudEnvironment { + Staging, + Prod, +} + +impl CloudEnvironment { + pub const fn name(self) -> &'static str { + match self { + Self::Staging => "staging", + Self::Prod => "prod", + } + } + + pub const fn api_base(self) -> &'static str { + match self { + Self::Staging => "https://staging-cloud.flowix.cc", + Self::Prod => "https://cloud.flowix.cc", + } + } + + pub fn parse(name: &str) -> Option { + match name { + "staging" => Some(Self::Staging), + "prod" => Some(Self::Prod), + _ => None, + } + } +} + +pub fn default_cloud_environment() -> CloudEnvironment { + CloudEnvironment::parse(env!("FLOWIX_CLOUD_ENVIRONMENT")) + .unwrap_or(CloudEnvironment::Prod) +} diff --git a/app/flowix-sync/src/lib.rs b/app/flowix-sync/src/lib.rs index 5e1e22ab9..027029ed8 100644 --- a/app/flowix-sync/src/lib.rs +++ b/app/flowix-sync/src/lib.rs @@ -6,6 +6,7 @@ mod client; mod error; +mod environment; mod manager; mod models; mod store; @@ -13,12 +14,13 @@ mod v2; pub use client::CloudClient; pub use error::SyncError; +pub use environment::{default_cloud_environment, CloudEnvironment}; pub use manager::SyncManager; pub use models::{ - AppleAuthChallenge, AppleAuthorization, CloudAuthStatus, CloudCheckout, + AppleAuthChallenge, AppleAuthorization, CloudAiCredential, CloudAiModel, CloudAuthStatus, CloudCheckout, CloudMembership, CloudNotebook, CloudPrice, CloudProduct, CloudState, CloudUser, LocalChangeKind, - ProductDuration, ProductEntitlement, RefreshTokenStore, + CloudOrder, CloudOrderCheck, ProductDuration, ProductEntitlement, RefreshTokenStore, }; pub use store::SyncStore; pub use v2::{ @@ -35,5 +37,4 @@ pub use v2::{ V2SyncedNotebook, PROTOCOL_EPOCH, }; -pub const DEFAULT_CLOUD_API_BASE: &str = "https://cloud.flowix.cc"; pub mod text_merge; diff --git a/app/flowix-sync/src/manager.rs b/app/flowix-sync/src/manager.rs index d60ad7e73..95128c418 100644 --- a/app/flowix-sync/src/manager.rs +++ b/app/flowix-sync/src/manager.rs @@ -6,7 +6,8 @@ use crate::client::CloudClient; use crate::error::SyncError; use crate::models::{ AppleAuthChallenge, AppleAuthorization, CloudAuthStatus, CloudCheckout, CloudMembership, - CloudProduct, CloudState, LocalChangeKind, RefreshTokenStore, RuntimeSession, + CloudOrder, CloudOrderCheck, CloudProduct, CloudState, LocalChangeKind, RefreshTokenStore, + RuntimeSession, }; use crate::store::SyncStore; use crate::v2::{ diff --git a/app/flowix-sync/src/manager/auth.rs b/app/flowix-sync/src/manager/auth.rs index 8befe0b95..de7540415 100644 --- a/app/flowix-sync/src/manager/auth.rs +++ b/app/flowix-sync/src/manager/auth.rs @@ -426,7 +426,6 @@ mod tests { id: token.into(), email: "test@example.test".into(), display_name: "Test".into(), - system_role: "user".into(), }, protocol_epoch: crate::v2::PROTOCOL_EPOCH, }, diff --git a/app/flowix-sync/src/manager/catalog.rs b/app/flowix-sync/src/manager/catalog.rs index 9288a0db4..74f61c997 100644 --- a/app/flowix-sync/src/manager/catalog.rs +++ b/app/flowix-sync/src/manager/catalog.rs @@ -1,6 +1,34 @@ use super::*; impl SyncManager { + pub async fn create_ai_credential(&self) -> Result { + let generation = self.auth_generation(); + let access_token = self.access_token(generation).await?; + let first = self.client.create_ai_credential(&access_token).await; + let credential = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.create_ai_credential(&refreshed).await? + } else { + first? + }; + let _generation = self.require_auth_generation(generation)?; + Ok(credential) + } + + pub async fn revoke_ai_credential(&self) -> Result<(), SyncError> { + let generation = self.auth_generation(); + let access_token = self.access_token(generation).await?; + let first = self.client.revoke_ai_credential(&access_token).await; + if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.revoke_ai_credential(&refreshed).await?; + } else { + first?; + } + let _generation = self.require_auth_generation(generation)?; + Ok(()) + } + pub async fn refresh_membership(&self) -> Result { let generation = self.auth_generation(); let mut refresh = match self.membership_refresh.try_lock() { @@ -49,6 +77,46 @@ impl SyncManager { self.client.products().await } + pub async fn billing_orders(&self) -> Result, SyncError> { + let generation = self.auth_generation(); + let token = self.access_token(generation).await?; + let first = self.client.billing_orders(&token).await; + let result = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.billing_orders(&refreshed).await + } else { + first + }; + let _generation = self.require_auth_generation(generation)?; + result + } + + pub async fn check_billing_order(&self, order_id: &str) -> Result { + let generation = self.auth_generation(); + let token = self.access_token(generation).await?; + let first = self.client.check_billing_order(&token, order_id).await; + let result = if first.as_ref().is_err_and(SyncError::is_unauthorized) { + let refreshed = self.force_refresh_access_token(generation).await?; + self.client.check_billing_order(&refreshed, order_id).await + } else { + first + }; + let _generation = self.require_auth_generation(generation)?; + result + } + + pub async fn submit_feedback( + &self, + device_id: &str, + kind: &str, + content: &str, + app_version: &str, + platform: &str, + attachments: &[serde_json::Value], + ) -> Result<(), SyncError> { + self.client.submit_feedback(device_id, kind, content, app_version, platform, attachments).await + } + pub async fn create_checkout( &self, product_id: &str, diff --git a/app/flowix-sync/src/manager/lifecycle/tests.rs b/app/flowix-sync/src/manager/lifecycle/tests.rs index 5037a23e0..58e8603b2 100644 --- a/app/flowix-sync/src/manager/lifecycle/tests.rs +++ b/app/flowix-sync/src/manager/lifecycle/tests.rs @@ -89,7 +89,7 @@ impl Drop for Server { } fn user(id: &str) -> Value { - json!({ "id": id, "email": format!("{id}@example.test"), "displayName": id, "systemRole": "user" }) + json!({ "id": id, "email": format!("{id}@example.test"), "displayName": id }) } fn session(id: &str) -> Value { json!({ "accessToken": format!("access-{id}"), "accessTokenExpiresAt": 4_102_444_800_000_i64, diff --git a/app/flowix-sync/src/manager/state.rs b/app/flowix-sync/src/manager/state.rs index 3f9df6d47..ff279fcf3 100644 --- a/app/flowix-sync/src/manager/state.rs +++ b/app/flowix-sync/src/manager/state.rs @@ -25,6 +25,17 @@ impl SyncManager { Self::create(api_base, database_path, Some(token_store), state_notifier) } + /// Desktop startup variant: defer database migrations and credential + /// restoration to the initialization worker. + pub fn new_with_session_dependencies_deferred( + api_base: &str, + database_path: impl AsRef, + token_store: Arc, + state_notifier: Arc, + ) -> Result { + Self::create_deferred(api_base, database_path, Some(token_store), state_notifier) + } + fn create( api_base: &str, database_path: impl AsRef, @@ -32,25 +43,11 @@ impl SyncManager { state_notifier: Arc, ) -> Result { let sync_store = SyncStore::new(database_path)?; - let restore_blocked = sync_store.session_restore_blocked()?; let credential_retry = Arc::new(tokio::sync::Notify::new()); - let mut auth = AuthState::default(); - if let Some(store) = token_store.as_ref() { - if restore_blocked { - auth.pending_credential = Some(CredentialWrite::Delete); - credential_retry.notify_one(); - } else { - match store.load() { - Ok(Some(_)) => auth.status = CloudAuthStatus::Restoring, - Ok(None) => {} - Err(error) => auth.error = Some(error), - } - } - } - Ok(Self { + let manager = Self { client: CloudClient::new(api_base)?, store: sync_store, - auth: Arc::new(RwLock::new(auth)), + auth: Arc::new(RwLock::new(AuthState::default())), membership: Arc::new(RwLock::new(None)), membership_refresh: Arc::new(tokio::sync::Mutex::new(None)), refresh_lock: Arc::new(tokio::sync::Mutex::new(())), @@ -59,9 +56,55 @@ impl SyncManager { refresh_token_store: token_store, credential_retry, state_notifier, + }; + manager.initialize_storage()?; + Ok(manager) + } + + fn create_deferred( + api_base: &str, + database_path: impl AsRef, + token_store: Option>, + state_notifier: Arc, + ) -> Result { + Ok(Self { + client: CloudClient::new(api_base)?, + store: SyncStore::new_uninitialized(database_path), + auth: Arc::new(RwLock::new(AuthState::default())), + membership: Arc::new(RwLock::new(None)), + membership_refresh: Arc::new(tokio::sync::Mutex::new(None)), + refresh_lock: Arc::new(tokio::sync::Mutex::new(())), + auth_generation: Arc::new(std::sync::Mutex::new(0)), + account_sync_lock: Arc::new(tokio::sync::Mutex::new(())), + refresh_token_store: token_store, + credential_retry: Arc::new(tokio::sync::Notify::new()), + state_notifier, }) } + pub fn initialize_storage(&self) -> Result<(), SyncError> { + self.store.initialize_schema()?; + let restore_blocked = self.store.session_restore_blocked()?; + let Some(token_store) = self.refresh_token_store.as_ref() else { + return Ok(()); + }; + let mut auth = self + .auth + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if restore_blocked { + auth.pending_credential = Some(CredentialWrite::Delete); + self.credential_retry.notify_one(); + } else { + match token_store.load() { + Ok(Some(_)) => auth.status = CloudAuthStatus::Restoring, + Ok(None) => {} + Err(error) => auth.error = Some(error), + } + } + Ok(()) + } + pub fn store(&self) -> &SyncStore { &self.store } diff --git a/app/flowix-sync/src/models.rs b/app/flowix-sync/src/models.rs index 54f475f45..2706bbe05 100644 --- a/app/flowix-sync/src/models.rs +++ b/app/flowix-sync/src/models.rs @@ -6,7 +6,6 @@ pub struct CloudUser { pub id: String, pub email: String, pub display_name: String, - pub system_role: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -121,6 +120,20 @@ pub struct CloudMembership { pub read_only: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CloudAiCredential { + pub api_key: String, + pub model: CloudAiModel, + #[serde(default)] + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CloudAiModel { + pub id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloudProduct { @@ -160,6 +173,27 @@ pub struct CloudCheckout { pub expires_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudOrder { + pub id: String, + pub product_id: String, + pub product_name: String, + pub amount: i64, + pub currency: String, + pub status: String, + pub created_at: i64, + pub paid_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CloudOrderCheck { + pub status: String, + pub confirmed: bool, + pub check_status: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloudState { diff --git a/app/flowix-sync/src/store/schema.rs b/app/flowix-sync/src/store/schema.rs index d6f8e0b5f..e426364f7 100644 --- a/app/flowix-sync/src/store/schema.rs +++ b/app/flowix-sync/src/store/schema.rs @@ -2,13 +2,34 @@ use super::*; impl SyncStore { pub fn new(path: impl AsRef) -> Result { - let store = Self { - path: path.as_ref().to_path_buf(), - }; - store.open()?; + let store = Self::new_uninitialized(path); + store.initialize_schema()?; Ok(store) } + pub fn new_uninitialized(path: impl AsRef) -> Self { + Self { + path: path.as_ref().to_path_buf(), + } + } + + pub fn initialize_schema(&self) -> Result<(), SyncError> { + flowix_core::memo_file::with_database_rebuild(&self.path, || { + let connection = self.open().map_err(std::io::Error::other)?; + Self::initialize_v2_schema(&connection).map_err(std::io::Error::other)?; + connection + .query_row( + "SELECT blocked FROM session_restore_policy WHERE singleton=1", + [], + |row| row.get::<_, bool>(0), + ) + .optional() + .map_err(std::io::Error::other)?; + Ok(()) + }) + .map_err(|error| SyncError::InvalidState(format!("initialize sync database: {error}"))) + } + pub(super) fn open(&self) -> Result { if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent).map_err(|error| { @@ -24,7 +45,21 @@ impl SyncStore { PRAGMA foreign_keys = ON; "#, )?; - Self::initialize_v2_schema(&connection)?; Ok(connection) } } + +#[cfg(test)] +mod storage_rebuild_tests { + use super::*; + + #[test] + fn invalid_sync_database_is_backed_up_and_recreated() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("sync.db"); + std::fs::write(&path, b"invalid sync database").unwrap(); + let store = SyncStore::new(&path).unwrap(); + assert!(!store.session_restore_blocked().unwrap()); + assert!(temp.path().join("rebuild-backups").exists()); + } +} diff --git a/app/flowix-sync/src/store/v2_tests.rs b/app/flowix-sync/src/store/v2_tests.rs index 2abbdf8d0..4deae3a51 100644 --- a/app/flowix-sync/src/store/v2_tests.rs +++ b/app/flowix-sync/src/store/v2_tests.rs @@ -14,7 +14,6 @@ fn v2_state_uses_one_account_cursor_and_same_notebook_id() { id: "usr_1".into(), email: "user@example.com".into(), display_name: "User".into(), - system_role: "user".into(), }, protocol_epoch: PROTOCOL_EPOCH, }; @@ -419,7 +418,6 @@ fn switching_users_clears_user_scoped_sync_state() { id: id.into(), email: format!("{id}@example.com"), display_name: id.into(), - system_role: "user".into(), }, protocol_epoch: PROTOCOL_EPOCH, }; @@ -443,7 +441,6 @@ fn clearing_account_removes_user_scoped_sync_state() { id: "usr_a".into(), email: "a@example.com".into(), display_name: "A".into(), - system_role: "user".into(), }, protocol_epoch: PROTOCOL_EPOCH, }) diff --git a/app/flowix-web/app/agent-window-effects.test.ts b/app/flowix-web/app/agent-window-effects.test.ts index a88c55d2a..6f1f3e698 100644 --- a/app/flowix-web/app/agent-window-effects.test.ts +++ b/app/flowix-web/app/agent-window-effects.test.ts @@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + waitForStartupReady: vi.fn(async (): Promise => undefined), useAgentEvents: vi.fn(), refreshRuntime: vi.fn(async () => undefined), loadAccess: vi.fn(async () => undefined), + initializePiMode: vi.fn(async () => undefined), listenAccess: vi.fn(), unlistenAccess: vi.fn(), invalidateNotebookCache: vi.fn(), @@ -16,6 +18,8 @@ const mocks = vi.hoisted(() => ({ releaseMemoEventBridge: vi.fn(), })); +vi.mock('@features/agent/services/agent-catalog-service', () => ({ prefetchAvailableAgentModels: vi.fn(), prefetchNotebookSkills: vi.fn() })); +vi.mock('@features/memo/store/note-store', () => ({ useNoteStore: (selector: (state: { selectedNotebook: null }) => unknown) => selector({ selectedNotebook: null }) })); vi.mock('@features/agent/hooks/use-agent-events', () => ({ useAgentEvents: mocks.useAgentEvents, })); @@ -24,9 +28,9 @@ vi.mock('@features/agent/store/agent-runtime-store', () => ({ selector({ refresh: mocks.refreshRuntime }) ), })); -vi.mock('@features/agent/store/agent-access-store', () => ({ - useAgentAccessStore: (selector: (state: { loadInitial: typeof mocks.loadAccess }) => unknown) => ( - selector({ loadInitial: mocks.loadAccess }) +vi.mock('@features/agent/store/agent-data-store', () => ({ + useAgentDataStore: (selector: (state: { workspaceActions: { loadInitial: typeof mocks.loadAccess }; initializePiMode: typeof mocks.initializePiMode }) => unknown) => ( + selector({ workspaceActions: { loadInitial: mocks.loadAccess }, initializePiMode: mocks.initializePiMode }) ), })); vi.mock('@features/editor/extensions/note-link', () => ({ @@ -40,7 +44,8 @@ vi.mock('@features/editor/extensions/tag-mention', () => ({ invalidateMentionTags: mocks.invalidateMentionTags, })); vi.mock('@platform/tauri/client', () => ({ - listenToAgentAccessChanges: mocks.listenAccess, + boot: { waitForStartupReady: mocks.waitForStartupReady }, + listenToAgentWorkspaceChanges: mocks.listenAccess, })); vi.mock('@/lib/memo-dispatcher', () => ({ acquireMemoEventBridge: () => { @@ -50,10 +55,10 @@ vi.mock('@/lib/memo-dispatcher', () => ({ })); import { AgentWindowEffects } from './agent-window-effects'; -import { useAgentAccessWindowSync } from './use-agent-access-window-sync'; +import { useAgentWorkspaceWindowSync } from './use-agent-workspace-window-sync'; function PreferencesAccessSync() { - useAgentAccessWindowSync(); + useAgentWorkspaceWindowSync(); return null; } @@ -75,6 +80,7 @@ describe('AgentWindowEffects', () => { await act(async () => root.render(createElement(PreferencesAccessSync))); expect(mocks.loadAccess).toHaveBeenCalledOnce(); + expect(mocks.initializePiMode).toHaveBeenCalledOnce(); expect(mocks.listenAccess).toHaveBeenCalledOnce(); expect(mocks.refreshRuntime).not.toHaveBeenCalled(); expect(mocks.useAgentEvents).not.toHaveBeenCalled(); @@ -94,6 +100,43 @@ describe('AgentWindowEffects', () => { expect(mocks.unlistenAccess).toHaveBeenCalledOnce(); }); + it('waits for native startup before reading workspace data', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + let ready!: () => void; + mocks.waitForStartupReady.mockImplementationOnce(() => new Promise((resolve) => { ready = resolve; })); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + try { + await act(async () => root.render(createElement(PreferencesAccessSync))); + expect(mocks.loadAccess).not.toHaveBeenCalled(); + expect(mocks.initializePiMode).not.toHaveBeenCalled(); + expect(mocks.listenAccess).not.toHaveBeenCalled(); + await act(async () => ready()); + expect(mocks.loadAccess).toHaveBeenCalledOnce(); + } finally { + await act(async () => root.unmount()); + Reflect.deleteProperty(window, '__TAURI_INTERNALS__'); + } + }); + + it('does not read workspace data when native initialization fails', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + mocks.waitForStartupReady.mockRejectedValueOnce(new Error('migration failed')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const root = createRoot(document.createElement('div')); + try { + await act(async () => root.render(createElement(PreferencesAccessSync))); + expect(mocks.loadAccess).not.toHaveBeenCalled(); + expect(mocks.initializePiMode).not.toHaveBeenCalled(); + expect(mocks.listenAccess).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + warn.mockRestore(); + Reflect.deleteProperty(window, '__TAURI_INTERNALS__'); + } + }); + it('owns the shared Agent lifecycle for a content-capable Webview', async () => { const container = document.createElement('div'); document.body.appendChild(container); diff --git a/app/flowix-web/app/agent-window-effects.tsx b/app/flowix-web/app/agent-window-effects.tsx index 9c01b9a7f..bae4dd99a 100644 --- a/app/flowix-web/app/agent-window-effects.tsx +++ b/app/flowix-web/app/agent-window-effects.tsx @@ -3,11 +3,13 @@ import { useEffect } from 'react'; import { useAgentEvents } from '@features/agent/hooks/use-agent-events'; import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; +import { prefetchAvailableAgentModels, prefetchNotebookSkills } from '@features/agent/services/agent-catalog-service'; import { invalidateNotebookCache, prewarmNotebookCache } from '@features/editor/extensions/note-link'; import { invalidateMentionNotes } from '@features/editor/extensions/note-mention'; import { invalidateMentionTags } from '@features/editor/extensions/tag-mention'; -import { listenToAgentAccessChanges } from '@platform/tauri/client'; +import { listenToAgentWorkspaceChanges } from '@platform/tauri/client'; import { acquireMemoEventBridge } from '@/lib/memo-dispatcher'; +import { useNoteStore } from '@features/memo/store/note-store'; /** * Agent infrastructure shared by every content-capable Webview. @@ -20,12 +22,33 @@ export function AgentWindowEffects() { useAgentEvents(); useEffect(() => acquireMemoEventBridge(), []); const refreshAgentRuntime = useAgentRuntimeStore((state) => state.refresh); + const runtimeStatusByType = useAgentRuntimeStore((state) => state.statusByType); + const selectedNotebook = useNoteStore((state) => state.selectedNotebook); useEffect(() => { void refreshAgentRuntime({ force: true }); }, [refreshAgentRuntime]); useEffect(() => { - return listenToAgentAccessChanges(() => { + const available = Object.fromEntries( + Object.entries(runtimeStatusByType ?? {}).map(([agentType, status]) => [agentType, status?.readiness === 'ready']), + ); + prefetchAvailableAgentModels(available); + }, [runtimeStatusByType]); + + useEffect(() => { + const notebookId = selectedNotebook?.id; + const notebookPath = selectedNotebook?.path; + if (!notebookId || !notebookPath) return; + if (runtimeStatusByType?.codex?.readiness === 'ready') { + prefetchNotebookSkills('codex', notebookId, notebookPath); + } + if (runtimeStatusByType?.pi?.readiness === 'ready') { + prefetchNotebookSkills('pi', notebookId, notebookPath); + } + }, [runtimeStatusByType, selectedNotebook?.id, selectedNotebook?.path]); + + useEffect(() => { + return listenToAgentWorkspaceChanges(() => { invalidateNotebookCache(); invalidateMentionNotes(); invalidateMentionTags(); diff --git a/app/flowix-web/app/app.tsx b/app/flowix-web/app/app.tsx index 5f6abd94f..2eb3e2bef 100644 --- a/app/flowix-web/app/app.tsx +++ b/app/flowix-web/app/app.tsx @@ -1,28 +1,36 @@ 'use client'; -import { lazy, Suspense, useEffect, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { CenteredLoadingSpinner } from "@shared/ui/centered-loading-spinner"; +import { useWorkspaceStartupStore } from "@/lib/store/workspace-startup-store"; import { ErrorBoundary } from "@shared/error-boundary"; -import { Toaster } from "sonner"; +import { Toaster, toast } from "sonner"; import { useAppPreferencesViewModel } from "@features/preferences/public/app-api"; import { useAppAgentRuntimeViewModel } from "@features/agent/public/app-api"; import { useApplyFontSettings } from "@features/preferences/public/app-api"; import { ThemeProvider } from "@features/theme"; import { NativeEditMenuBridge, ShortcutsProvider } from "@features/shortcuts"; -import { I18nProvider } from "@/lib/i18n"; +import { I18nProvider, useI18n, type I18nKey } from "@/lib/i18n"; import { TooltipProvider } from "@shared/ui/tooltip"; import "@features/shortcuts/actions"; -import { listenToUserConfigChanges, windows } from "@platform/tauri/client"; +import { boot, listenToUserConfigChanges, windows } from "@platform/tauri/client"; import { syncUserConfigChange } from "./user-config-sync"; -import { useAgentAccessWindowSync } from "./use-agent-access-window-sync"; +import { useAgentWorkspaceWindowSync } from "./use-agent-workspace-window-sync"; import { invalidateDshModelConfigs } from "@features/agent/public/app-api"; import { createLogger } from "@/lib/logger"; import { ActionableNoticeHost } from "@features/notifications/actionable-notice-host"; +import { useAgentPersistenceError } from "@features/agent/public/app-api"; +import { translate, type AppLanguage } from "@/lib/i18n"; const DocumentSaveNotifications = lazy(() => import('@features/document/public/app-api') .then(module => ({ default: module.DocumentSaveNotifications }))); const logger = createLogger("app"); +function removeAppLoading() { + document.getElementById("app-loading")?.remove(); +} + const MainWindow = lazy(() => import("./main-window/main-window") .then((module) => ({ default: module.MainWindow })) @@ -47,7 +55,88 @@ const AgentWindowEffects = lazy(() => import("./agent-window-effects").then((module) => ({ default: module.AgentWindowEffects })) ); -function AppToaster() { +function isTauriRuntime(): boolean { + return typeof window !== 'undefined' + && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window); +} + +const startupProgressLabels: Record = { + prepareDatabasePaths: 'startup.progress.prepareDatabasePaths', + prepareThreadDatabase: 'startup.progress.prepareThreadDatabase', + prepareSyncDatabase: 'startup.progress.prepareSyncDatabase', + prepareNotebookRegistry: 'startup.progress.prepareNotebookRegistry', + runMigrations: 'startup.progress.runMigrations', + config_dsh_layout_v2: 'startup.progress.migrateAgentLayout', + config_local_migrations_v1: 'startup.progress.migrateLocalConfiguration', + config_watcher_exclusions_v1: 'startup.progress.migrateWatcherSettings', + templates_v1: 'startup.progress.prepareTemplates', + prepareRuntimeStorage: 'startup.progress.prepareRuntimeStorage', + activateRuntime: 'startup.progress.activateRuntime', + completeInitialization: 'startup.progress.completeInitialization', +}; + +function startupProgressLabel(task: string): I18nKey { + if (task.startsWith('deferred:')) return 'startup.progress.deferredNotebookJobs'; + if (task.includes(':notebook:')) return 'startup.progress.migrateNotebookStorage'; + return startupProgressLabels[task] ?? 'startup.progress.runMigrations'; +} + +function StartupLoadingOverlay() { + const nativePhase = useWorkspaceStartupStore((state) => state.nativePhase); + const nativeStage = useWorkspaceStartupStore((state) => state.nativeStage); + const nativeStep = useWorkspaceStartupStore((state) => state.nativeStep); + const nativeProgress = useWorkspaceStartupStore((state) => state.nativeProgress); + const workspaceStatus = useWorkspaceStartupStore((state) => state.status); + const { t } = useI18n(); + const visible = nativePhase === 'pending' || nativePhase === 'running' + || (nativePhase === 'ready' && (workspaceStatus === 'idle' || workspaceStatus === 'running')); + if (!visible) return null; + return ( +
+ {nativePhase === 'running' && nativeStage === 'initialization' && nativeProgress ? ( +
+ +
+
+
+ + {t(startupProgressLabel(nativeProgress.task))} + {(!['prepareDatabasePaths', 'prepareThreadDatabase', 'prepareSyncDatabase', 'prepareNotebookRegistry', + 'prepareRuntimeStorage', 'activateRuntime', 'completeInitialization'].includes(nativeProgress.task)) + ? ` (${nativeProgress.completed}/${nativeProgress.total})` + : ''} + +
+ ) : ( + + )} +
+ ); +} + +function AppToaster({ language }: { language: AppLanguage }) { + const persistenceError = useAgentPersistenceError(); + const lastPersistenceError = useRef(null); + + useEffect(() => { + if (!persistenceError) { + lastPersistenceError.current = null; + return; + } + if (lastPersistenceError.current === persistenceError) return; + lastPersistenceError.current = persistenceError; + toast.error(translate(language, "preferences.agents.cacheWriteFailed")); + }, [language, persistenceError]); + return ( <> { removeAppLoading(); @@ -112,7 +197,7 @@ function useUnexpectedControlInputGuard() { function App() { useUnexpectedControlInputGuard(); - useAgentAccessWindowSync(); + useAgentWorkspaceWindowSync(); const [hash, setHash] = useState(() => window.location.hash); const { language, @@ -124,6 +209,80 @@ function App() { const { refreshAgentRuntime } = useAppAgentRuntimeViewModel(); useApplyFontSettings(format); + useEffect(() => { + const setNativeReady = () => useWorkspaceStartupStore.setState((state) => ( + state.nativePhase === 'ready' + && state.nativeStage === 'workspacePreparation' + && state.nativeStep === 'ready' + && state.nativeError === null + && state.nativeProgress === null + ? state + : { ...state, nativePhase: 'ready', nativeStage: 'workspacePreparation', nativeStep: 'ready', nativeError: null, nativeProgress: null } + )); + if (hash.startsWith("#preferences") || !isTauriRuntime()) { + setNativeReady(); + return; + } + let active = true; + let timer: number | undefined; + const refresh = async () => { + try { + const status = await boot.getStartupStatus(); + if (!active) return; + const nativeStage = status.stage ?? (status.step === 'versionInitialization' + ? 'initialization' + : 'workspacePreparation'); + useWorkspaceStartupStore.setState((state) => ( + state.nativePhase === status.phase + && state.nativeStage === nativeStage + && state.nativeStep === status.step + && state.nativeError === status.error + && state.nativeProgress?.percent === status.progress?.percent + && state.nativeProgress?.task === status.progress?.task + && state.nativeProgress?.completed === status.progress?.completed + && state.nativeProgress?.total === status.progress?.total + ? state + : { + ...state, + nativePhase: status.phase, + nativeStage, + nativeStep: status.step, + nativeError: status.error, + nativeProgress: status.progress ?? null, + } + )); + if (status.phase === 'ready' || status.phase === 'failed') { + if (timer !== undefined) window.clearInterval(timer); + timer = undefined; + } + } catch { + if (active) setNativeReady(); + if (timer !== undefined) window.clearInterval(timer); + timer = undefined; + } + }; + void refresh(); + timer = window.setInterval(() => void refresh(), 250); + return () => { + active = false; + if (timer !== undefined) window.clearInterval(timer); + }; + }, [hash]); + + useEffect(() => { + const root = document.documentElement; + const handleFocus = () => { root.dataset.windowFocused = "true"; }; + const handleBlur = () => { root.dataset.windowFocused = "false"; }; + root.dataset.windowFocused = String(document.hasFocus()); + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + delete root.dataset.windowFocused; + }; + }, []); + useEffect(() => { void windows.applyMenuLanguage(language).catch((error) => { logger.error("apply native menu language failed", { error }); @@ -176,7 +335,7 @@ function App() { const tab = hash.split("/")[1] || undefined; return ( - + @@ -196,9 +355,10 @@ function App() { return ( - + + @@ -208,6 +368,7 @@ function App() { + {/* The root startup overlay stays mounted while this code loads. */} diff --git a/app/flowix-web/app/main-window-effects.tsx b/app/flowix-web/app/main-window-effects.tsx index a3be01c45..abb2dd461 100644 --- a/app/flowix-web/app/main-window-effects.tsx +++ b/app/flowix-web/app/main-window-effects.tsx @@ -36,10 +36,10 @@ import { resumePendingNoteLinkUpdates } from '@features/memo/public/app-api'; import { subscribeWorkspaceDocumentSaves } from '@features/document/public/workspace-api'; import { syncAppAgentConversationRestore, - openBrowserColumnNotebookNote, removeBrowserColumnTabsByPath, applyNotebookPathMove, } from '@features/workspace/public/app-api'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; const logger = createLogger('main-window-effects'); @@ -137,7 +137,11 @@ export function MainWindowEffects() { const notebookPath = await getAppNotebookPath(notebookId); const path = notebookPath && joinNotebookMemoPath(notebookPath, relativePath); if (!path) throw new Error('Created note path is unavailable'); - await openBrowserColumnNotebookNote(path, notebookId, notebookPath); + await openWorkspaceSurface({ + host: 'browser-column', + target: { kind: 'external', path, scopePath: notebookPath, transitionId: null }, + options: { notebookId }, + }); }, reportOpenFailure: (error) => { logger.warn('open created note in browser column failed', { error }); diff --git a/app/flowix-web/app/main-window-startup.test.ts b/app/flowix-web/app/main-window-startup.test.ts index 5da92136b..1caff016d 100644 --- a/app/flowix-web/app/main-window-startup.test.ts +++ b/app/flowix-web/app/main-window-startup.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { boot } from '@platform/tauri/client'; +import { useWorkspaceStartupStore } from '@/lib/store/workspace-startup-store'; import { useNoteStore } from '@features/memo/store/note-store'; import { useDocumentStore } from '@features/document/store/document-store'; import { waitForInitialDocumentLoad } from '@features/document/public/startup-api'; @@ -29,12 +30,28 @@ vi.mock('@features/workspace/public/startup-api', () => ({ setWorkspaceRestoreStatus: mocks.setWorkspaceRestoreStatus, restoreAgentConversationWorkspace: mocks.restoreAgentConversationWorkspace, })); +vi.mock('@features/workspace/public/shell-api', () => ({ + getVisibleWorkspaceSurfaceHosts: () => [], + getWorkColumnStartupIdentity: () => '', +})); +vi.mock('@features/memo/public/startup-api', () => ({ + getStartupListTask: () => 'memo-list', +})); +vi.mock('@features/surface/public/shell-api', () => ({ + beginWorkspaceSurfaceStartup: vi.fn(), + waitForWorkspaceSurfaceStartup: vi.fn().mockResolvedValue('ready'), +})); +vi.mock('@/lib/store/workspace-startup-store', async (importOriginal) => ({ + ...(await importOriginal()), + waitForStartupTask: vi.fn().mockResolvedValue('ready'), +})); import { initializeMainWindowStartup } from './main-window-startup'; describe('initializeMainWindowStartup', () => { beforeEach(() => { vi.restoreAllMocks(); + useWorkspaceStartupStore.setState({ status: 'idle', task: null, error: null }); Reflect.deleteProperty(window, '__TAURI_INTERNALS__'); useNoteStore.getState().setStartupPhase('idle'); useDocumentStore.setState({ isDocumentTransitioning: false, documentTransitionId: 0 }); @@ -54,6 +71,7 @@ describe('initializeMainWindowStartup', () => { it('runs startup stages in dependency order', async () => { await initializeMainWindowStartup(); + expect(useWorkspaceStartupStore.getState().status).toBe('ready'); expect(mocks.calls).toEqual([ 'memo-library', ]); @@ -63,6 +81,9 @@ describe('initializeMainWindowStartup', () => { mocks.initializeNoteLibrary.mockRejectedValueOnce(new Error('backend unavailable')); await expect(initializeMainWindowStartup()).rejects.toThrow('backend unavailable'); + expect(useWorkspaceStartupStore.getState()).toMatchObject({ + status: 'failed', task: 'notebook-context', error: 'backend unavailable', + }); expect(mocks.restoreExternalDocumentWorkspace).not.toHaveBeenCalled(); expect(mocks.restoreAgentConversationWorkspace).not.toHaveBeenCalled(); }); @@ -102,12 +123,63 @@ describe('initializeMainWindowStartup', () => { const startup = initializeMainWindowStartup(); await vi.waitFor(() => expect(useDocumentStore.getState().isDocumentTransitioning).toBe(true)); expect(notifyInteractive).not.toHaveBeenCalled(); + expect(useWorkspaceStartupStore.getState()).toMatchObject({ status: 'running', task: 'first-screen-readiness' }); useDocumentStore.getState().finishDocumentTransition(1); await startup; expect(notifyInteractive).toHaveBeenCalledOnce(); }); + it('does not release background maintenance after native initialization fails', async () => { + Object.defineProperty(window, '__TAURI_INTERNALS__', { value: {}, configurable: true }); + vi.spyOn(boot, 'waitForStartupReady').mockRejectedValueOnce(new Error('migration failed')); + const notify = vi.spyOn(boot, 'notifyStartupInteractive').mockResolvedValue(); + await expect(initializeMainWindowStartup()).rejects.toThrow('migration failed'); + expect(notify).not.toHaveBeenCalled(); + expect(mocks.initializeNoteLibrary).not.toHaveBeenCalled(); + expect(useWorkspaceStartupStore.getState()).toMatchObject({ status: 'failed', task: 'native-ready' }); + }); + + it('shares the startup barrier across concurrent mounts', async () => { + let finish!: () => void; + mocks.initializeNoteLibrary.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const first = initializeMainWindowStartup(); + const second = initializeMainWindowStartup(); + expect(second).toBe(first); + expect(mocks.initializeNoteLibrary).toHaveBeenCalledOnce(); + expect(useWorkspaceStartupStore.getState().status).toBe('running'); + finish(); + await first; + expect(useWorkspaceStartupStore.getState().status).toBe('ready'); + }); + + it('retains a failed restoration task and exposes a retryable startup error', async () => { + mocks.captureWorkspaceRestoreTarget.mockReturnValue({ kind: 'external', path: '/missing.md', scopePath: null }); + mocks.restoreExternalDocumentWorkspace.mockRejectedValueOnce(new Error('restore unavailable')); + await expect(initializeMainWindowStartup()).rejects.toThrow('restore unavailable'); + expect(useWorkspaceStartupStore.getState()).toMatchObject({ + status: 'failed', task: 'workspace-restore', error: 'restore unavailable', + }); + expect(mocks.setWorkspaceRestoreStatus).toHaveBeenLastCalledWith('unavailable'); + }); + + it('settles stage 2 as degraded when the initial document times out', async () => { + vi.useFakeTimers(); + try { + mocks.captureWorkspaceRestoreTarget.mockReturnValue({ kind: 'external', path: '/slow.md', scopePath: null }); + mocks.restoreExternalDocumentWorkspace.mockImplementationOnce(async () => { + useDocumentStore.setState({ isDocumentTransitioning: true, documentTransitionId: 1 }); + }); + const startup = initializeMainWindowStartup(); + await vi.advanceTimersByTimeAsync(60_000); + await startup; + expect(useWorkspaceStartupStore.getState()).toMatchObject({ status: 'degraded', task: 'first-screen-readiness' }); + expect(mocks.setWorkspaceRestoreStatus).toHaveBeenLastCalledWith('unavailable'); + } finally { + vi.useRealTimers(); + } + }); + it('releases the initial document barrier if its load stalls', async () => { vi.useFakeTimers(); try { diff --git a/app/flowix-web/app/main-window-startup.ts b/app/flowix-web/app/main-window-startup.ts index da2049e45..5ac836fce 100644 --- a/app/flowix-web/app/main-window-startup.ts +++ b/app/flowix-web/app/main-window-startup.ts @@ -5,74 +5,128 @@ import { import { waitForInitialDocumentLoad } from '@features/document/public/startup-api'; import { captureWorkspaceRestoreTarget, - restoreDocumentListWorkspace, + restoreLegacyFolderListWorkspace, restoreAgentConversationWorkspace, restoreExternalDocumentWorkspace, restoreMediaWorkspace, restoreTableWorkspace, restoreCollectionWorkspace, + restoreFolderCollectionWorkspace, setWorkspaceRestoreStatus, type PersistedWorkspaceTarget, } from '@features/workspace/public/startup-api'; import { boot } from '@platform/tauri/client'; +import { useWorkspaceStartupStore, waitForStartupTask } from '@/lib/store/workspace-startup-store'; +import { getVisibleWorkspaceSurfaceHosts, getWorkColumnStartupIdentity } from '@features/workspace/public/shell-api'; +import { getStartupListTask } from '@features/memo/public/startup-api'; +import { beginWorkspaceSurfaceStartup, waitForWorkspaceSurfaceStartup } from '@features/surface/public/shell-api'; let startupAttemptSequence = 0; +let startupPromise: Promise | null = null; /** * Run the main-window startup stages in one failure-aware transaction. * - * Notebook identity is resolved before workspace restoration. The cards query - * then loads independently inside its view, so it cannot block folders or - * conversations. + * Notebook identity is resolved before workspace restoration. Stage 2 settles + * after the current visible list and workspace surfaces report terminal states. */ -export async function initializeMainWindowStartup(): Promise { +export function initializeMainWindowStartup(): Promise { + if (startupPromise) return startupPromise; + const { status } = useWorkspaceStartupStore.getState(); + if (status === 'ready' || status === 'degraded') return Promise.resolve(); + startupPromise = performMainWindowStartup().finally(() => { startupPromise = null; }); + return startupPromise; +} + +async function performMainWindowStartup(): Promise { const startedAt = performance.now(); const nativeRuntime = isTauriRuntime(); + let nativeReady = !nativeRuntime; const startupAttemptId = `${Date.now()}-${++startupAttemptSequence}`; const logStage = (stage: string) => { const elapsedMs = Math.round(performance.now() - startedAt); console.info('[perf:startup]', stage, { elapsedMs }); if (nativeRuntime) void boot.recordStartupStage(stage, elapsedMs, startupAttemptId).catch(() => undefined); }; - let startupNotebookId: string | null = null; - if (nativeRuntime) { - await boot.waitForStartupReady(); - logStage('native-ready'); - } + // Add required first-screen loads here in dependency order. Awaiting a task + // automatically keeps stage 2 open; optional work belongs after startup. + const runTask = async (task: string, operation: () => Promise): Promise => { + useWorkspaceStartupStore.setState({ task }); + logStage(`task:${task}:start`); + const result = await operation(); + logStage(`task:${task}:complete`); + return result; + }; + useWorkspaceStartupStore.setState({ + status: 'running', task: 'native-ready', error: null, startupTasks: { + 'memo-list': 'pending', + 'folder-list': 'pending', + 'conversation-list': 'pending', + }, + }); try { if (nativeRuntime) { - try { - startupNotebookId = await boot.getStartupNotebookId(); - logStage('notebook-selected'); - } catch (error) { - markMemoLibraryStartupError(error); - throw error; - } + await boot.waitForStartupReady(); + nativeReady = true; + logStage('native-ready'); } - await initializeNotebookContext(startupNotebookId); - logStage('notebook-context-ready'); + const startupNotebookId = nativeRuntime + ? await runTask('notebook-selection', async () => { + try { + return await boot.getStartupNotebookId(); + } catch (error) { + markMemoLibraryStartupError(error); + throw error; + } + }) + : null; + logStage('notebook-selected'); + await runTask('notebook-context', () => initializeNotebookContext(startupNotebookId)); const desiredTarget = captureWorkspaceRestoreTarget(); setWorkspaceRestoreStatus('restoring'); try { - await restoreDesiredTarget(desiredTarget); + await runTask('workspace-restore', () => restoreDesiredTarget(desiredTarget)); logStage('workspace-restored'); - setWorkspaceRestoreStatus('restored'); - const outcome = await waitForInitialDocumentLoad(); - logStage(`initial-document-${outcome}`); + beginWorkspaceSurfaceStartup(getVisibleWorkspaceSurfaceHosts(), { + 'main-third': getWorkColumnStartupIdentity(), + }); + const listTask = getStartupListTask(); + const [documentOutcome, memoOutcome, surfaceOutcome] = await runTask('first-screen-readiness', () => Promise.all([ + waitForInitialDocumentLoad(), + waitForStartupTask(listTask), + waitForWorkspaceSurfaceStartup(), + ])); + const degraded = documentOutcome === 'timeout' || memoOutcome !== 'ready' || surfaceOutcome !== 'ready'; + logStage(`first-screen-${degraded ? 'degraded' : 'ready'}`); + // Empty content is ready. Errors, unavailable states and timeouts settle + // as degraded so a local retry/error state remains reachable. + setWorkspaceRestoreStatus(degraded ? 'unavailable' : 'restored'); + useWorkspaceStartupStore.setState({ + status: degraded ? 'degraded' : 'ready', + task: degraded ? 'first-screen-readiness' : null, + error: degraded ? 'Some visible workspace content did not become ready' : null, + }); } catch (error) { - // Keep desiredTarget unchanged. Temporary permission, mount, or IPC - // failures can then be retried on the next launch. setWorkspaceRestoreStatus('unavailable'); throw error; } + } catch (error) { + useWorkspaceStartupStore.setState({ + status: 'failed', + error: error instanceof Error ? error.message : String(error), + }); + logStage(`task:${useWorkspaceStartupStore.getState().task}:failed`); + throw error; } finally { - if (nativeRuntime) { - // The first callback runs before paint; the second frame confirms that - // the restored content had a chance to appear before disk scans begin. + if (nativeRuntime && nativeReady) { + // Ready content, a degraded workspace, or the retry screen can now paint. + // All three release background maintenance; native initialization failure + // does not. This signal means foreground startup has settled. await new Promise((resolve) => window.requestAnimationFrame(() => { window.requestAnimationFrame(() => resolve()); })); - logStage('first-workspace-paint'); + const status = useWorkspaceStartupStore.getState().status; + logStage(status === 'ready' ? 'first-workspace-paint' : `foreground-${status}-paint`); await boot.notifyStartupInteractive().catch(() => undefined); } } @@ -92,12 +146,16 @@ async function restoreDesiredTarget(target: PersistedWorkspaceTarget | null): Pr await restoreCollectionWorkspace(target); return; } + if (target.kind === 'folder-collection') { + await restoreFolderCollectionWorkspace(target); + return; + } if (target.kind === 'table') { await restoreTableWorkspace(target); return; } if (target.kind === 'document-list') { - restoreDocumentListWorkspace(target); + await restoreLegacyFolderListWorkspace(target); return; } await restoreAgentConversationWorkspace(); diff --git a/app/flowix-web/app/main-window/use-main-window-business-controller.ts b/app/flowix-web/app/main-window/use-main-window-business-controller.ts index 88691ac38..6540ce07b 100644 --- a/app/flowix-web/app/main-window/use-main-window-business-controller.ts +++ b/app/flowix-web/app/main-window/use-main-window-business-controller.ts @@ -15,13 +15,12 @@ import { import { clearPluginWorkbenchTarget, flushWorkspaceDocument, - openCollectionTarget, - openPluginWorkbench, reconcileDeletedNotebook, selectNotebook, useWorkspaceNavigationPhase, useWorkspaceTargetKind, } from '@features/workspace/public/main-window-api'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { switchFolderCollectionType, type CollectionType } from '@features/collection/public/folder-api'; const logger = createLogger('main-window-business-controller'); @@ -132,7 +131,7 @@ export function useMainWindowBusinessController(): MainWindowBusinessController return; } try { - await openPluginWorkbench(plugin); + await openWorkspaceSurface({ host: 'main-third', target: { kind: 'plugin-workbench', plugin } }); setMemoListVisible(true); } catch (error) { logger.warn('open plugin failed to clear document', { error }); @@ -145,10 +144,11 @@ export function useMainWindowBusinessController(): MainWindowBusinessController ) => { try { const collectionId = await switchFolderCollectionType(target.filePath, target.notebookPath, type); - await openCollectionTarget( - { notebookId: target.notebookId, collectionId, viewId: null }, - { destination: 'main-third', history: 'skip' }, - ); + await openWorkspaceSurface({ + host: 'main-third', + target: { kind: 'collection-reference', notebookId: target.notebookId, collectionId, viewId: null, collectionKind: type }, + options: { history: 'skip', refresh: true }, + }); } catch (error) { toast.error(error instanceof Error ? error.message : '切换视图类型失败'); } diff --git a/app/flowix-web/app/main-window/use-main-window-system-controller.ts b/app/flowix-web/app/main-window/use-main-window-system-controller.ts index a645f10e8..c87905ab7 100644 --- a/app/flowix-web/app/main-window/use-main-window-system-controller.ts +++ b/app/flowix-web/app/main-window/use-main-window-system-controller.ts @@ -29,8 +29,11 @@ export interface MainWindowSystemController { onboardingOpen: boolean; onboardingLoading: boolean; updater: AppUpdaterState; + appUpdatePromptOpen: boolean; dshInstaller: DshRuntimeInstallerState; closeDshInstallPrompt(): void; + openAppUpdatePrompt(): void; + closeAppUpdatePrompt(): void; markDshIntroDisplayed(): void; completeDshInstallPrompt(): void; completeOnboarding(): Promise; @@ -41,6 +44,7 @@ export function useMainWindowSystemController(): MainWindowSystemController { const [dshInstallPromptOpen, setDshInstallPromptOpen] = useState(false); const [onboardingOpen, setOnboardingOpen] = useState(false); const [onboardingLoading, setOnboardingLoading] = useState(true); + const [appUpdatePromptOpen, setAppUpdatePromptOpen] = useState(false); const { t } = useI18n(); const updatePreferences = useAppUpdatePreferences(); const updater = useAppUpdater({ @@ -116,8 +120,11 @@ export function useMainWindowSystemController(): MainWindowSystemController { onboardingOpen, onboardingLoading, updater, + appUpdatePromptOpen, dshInstaller, closeDshInstallPrompt, + openAppUpdatePrompt: () => setAppUpdatePromptOpen(true), + closeAppUpdatePrompt: () => setAppUpdatePromptOpen(false), markDshIntroDisplayed, completeDshInstallPrompt: () => setDshInstallPromptOpen(false), completeOnboarding, diff --git a/app/flowix-web/app/use-agent-access-window-sync.ts b/app/flowix-web/app/use-agent-access-window-sync.ts deleted file mode 100644 index bf7a5d548..000000000 --- a/app/flowix-web/app/use-agent-access-window-sync.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect } from 'react'; -import { useAgentAccessStore } from '@features/agent/store/agent-access-store'; -import { listenToAgentAccessChanges } from '@platform/tauri/client'; - -/** Each Webview owns a store, including the preferences window. */ -export function useAgentAccessWindowSync() { - const loadInitial = useAgentAccessStore((state) => state.loadInitial); - useEffect(() => { - const reload = () => { void loadInitial(); }; - const unlisten = listenToAgentAccessChanges(reload, { onListenerReady: reload }); - reload(); - return unlisten; - }, [loadInitial]); -} diff --git a/app/flowix-web/app/use-agent-workspace-window-sync.ts b/app/flowix-web/app/use-agent-workspace-window-sync.ts new file mode 100644 index 000000000..6051d974f --- /dev/null +++ b/app/flowix-web/app/use-agent-workspace-window-sync.ts @@ -0,0 +1,25 @@ +import { useAgentDataStore } from "@features/agent/store/agent-data-store"; +import { useEffect } from 'react'; +import { listenToAgentWorkspaceChanges, boot } from '@platform/tauri/client'; + +/** Each Webview owns a store, including the preferences window. */ +export function useAgentWorkspaceWindowSync() { + const loadInitial = useAgentDataStore((state) => state.workspaceActions.loadInitial); + const initializePiMode = useAgentDataStore((state) => state.initializePiMode); + useEffect(() => { + let active = true; + let unlisten: (() => void) | undefined; + const initialize = async () => { + if ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) { + await boot.waitForStartupReady(); + } + if (!active) return; + void initializePiMode().catch((error) => console.warn('Pi mode initialization failed:', error)); + const reload = () => { if (active) void loadInitial(); }; + unlisten = listenToAgentWorkspaceChanges(reload, { onListenerReady: reload }); + reload(); + }; + void initialize().catch((error) => console.warn('Workspace initialization deferred:', error)); + return () => { active = false; unlisten?.(); }; + }, [initializePiMode, loadInitial]); +} diff --git a/app/flowix-web/app/user-config-sync.ts b/app/flowix-web/app/user-config-sync.ts index 2c357000d..e8ff15385 100644 --- a/app/flowix-web/app/user-config-sync.ts +++ b/app/flowix-web/app/user-config-sync.ts @@ -25,7 +25,12 @@ export function syncUserConfigChange( void actions.reloadPreferences(); return; } - if (kind === 'pi_config') return; + if (kind === 'pi_config') { + void import('@features/agent/services/agent-catalog-service') + .then(({ refreshAgentModelCatalog }) => refreshAgentModelCatalog('pi')) + .catch((error) => console.warn('Failed to refresh the Pi model catalog after its configuration changed', error)); + return; + } // Runtime freshness currently has one timestamp for the complete status // snapshot. Refresh the complete snapshot so unrelated agent statuses are diff --git a/app/flowix-web/assets/skill-library.svg b/app/flowix-web/assets/skill-library.svg new file mode 100644 index 000000000..2e876bc76 --- /dev/null +++ b/app/flowix-web/assets/skill-library.svg @@ -0,0 +1,7 @@ + diff --git a/app/flowix-web/features/agent/agent-setup.ts b/app/flowix-web/features/agent/agent-setup.ts index 23d54664f..5c4cb3617 100644 --- a/app/flowix-web/features/agent/agent-setup.ts +++ b/app/flowix-web/features/agent/agent-setup.ts @@ -5,9 +5,7 @@ import { isAgentTypeComingSoon } from '@/lib/agent-types'; const AGENT_SETUP_URLS: Partial> = { codex: 'https://developers.openai.com/codex/cli', claude: 'https://code.claude.com/docs/en/setup', - gemini: 'https://google-gemini.github.io/gemini-cli/docs/cli/headless.html', hermes: 'https://hermes-agent.nousresearch.com/docs/user-guide/cli', - openclaw: 'https://docs.openclaw.ai/cli', opencode: 'https://opencode.ai/docs/cli/', }; diff --git a/app/flowix-web/features/agent/components/agent-background-terminals.tsx b/app/flowix-web/features/agent/components/agent-background-terminals.tsx index 46e6b95d0..7c4bebde8 100644 --- a/app/flowix-web/features/agent/components/agent-background-terminals.tsx +++ b/app/flowix-web/features/agent/components/agent-background-terminals.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { ArrowBendDownRightIcon, TrashSimpleIcon } from '@phosphor-icons/react'; +import { ArrowBendDownRightIcon } from '@phosphor-icons/react'; import { useI18n } from '@/lib/i18n'; import { agentClient } from '@features/agent/store/agent-client'; @@ -36,7 +36,7 @@ function readTerminals(value: unknown): BackgroundTerminal[] { }); } -export function AgentBackgroundTerminals({ threadId, agentType, enabled, queuedMessages = [], onPromoteFollowUp, onDeleteQueuedMessage }: { threadId: string | null; agentType: 'codex' | 'deepseek-harness'; enabled: boolean; queuedMessages?: readonly QueuedAgentMessage[]; onPromoteFollowUp?: (messageId: string) => void; onDeleteQueuedMessage?: (messageId: string) => void }) { +export function AgentBackgroundTerminals({ threadId, agentType, enabled, queuedMessages = [], onPromoteFollowUp, onDeleteQueuedMessage }: { threadId: string | null; agentType: 'codex' | 'dsh'; enabled: boolean; queuedMessages?: readonly QueuedAgentMessage[]; onPromoteFollowUp?: (messageId: string) => void; onDeleteQueuedMessage?: (messageId: string) => void }) { const { t } = useI18n(); const [terminals, setTerminals] = useState([]); const [expanded, setExpanded] = useState(false); @@ -145,19 +145,19 @@ function QueuedMessages({ messages, label, onPromoteFollowUp, onDeleteQueuedMess {message.deliveryStatus === 'starting' && ( {t('agent.queue.sendingSoon')} )} - {message.deliveryMode === 'follow_up' && message.deliveryStatus !== 'starting' && onPromoteFollowUp && ( - - )} {message.deliveryMode === 'steer' && onDeleteQueuedMessage && ( {message.deliveryStatus === 'cleanup_failed' ? t('agent.queue.cleanupFailed') : t('agent.queue.sendingSoon')} )} {onDeleteQueuedMessage && message.canDelete !== false && } + {message.deliveryMode === 'follow_up' && message.deliveryStatus !== 'starting' && onPromoteFollowUp && ( + + )} )}
diff --git a/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts b/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts index e0f1c0046..a9c30db42 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts +++ b/app/flowix-web/features/agent/components/agent-conversation-detail-submit.ts @@ -1,3 +1,4 @@ +import { ensureAgentWorkspaceReady } from "@features/agent/store/agent-data-store"; import type { AgentTypeKey, RuntimeConfig } from "@/types/agent"; import { useAgentSessionStore } from "@features/agent/store/agent-session-store"; import { buildInitialInstanceRuntimeConfig } from "@features/agent/store/initial-runtime-config"; @@ -64,6 +65,7 @@ export async function ensureAgentConversationDetailThread(input: { runtimeConfig: instance.runtimeConfig ?? buildInitialInstanceRuntimeConfig(ensured.typeKey), }); + await ensureAgentWorkspaceReady(); const runtimeConfig = ensureConversationWorkspaceSnapshot(input.instanceId); markConversationWorkspaceStarted(input.instanceId); return { threadId: ensured.threadId, title: ensured.title, runtimeConfig }; diff --git a/app/flowix-web/features/agent/components/agent-conversation-detail.tsx b/app/flowix-web/features/agent/components/agent-conversation-detail.tsx index 49fe42a1f..30eebb476 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-detail.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-detail.tsx @@ -2,12 +2,14 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type UIEventHandler, type WheelEventHandler } from 'react'; +import type { SurfacePresentationUpdate } from '@features/workspace/model/surface-contract'; import { DEFAULT_AGENT_TYPE_KEY } from '@/lib/agent-types'; import type { AgentTypeKey } from '@/types/agent'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/lib/toast'; import { createLogger } from '@/lib/logger'; import { agent } from '@platform/tauri/client/agent'; +import { useAgentConversationSurface } from './use-agent-conversation-surface'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; import { canActOnQueuedMessage } from '@features/agent/store/follow-up-queue-slice'; import type { AgentConversationInstance } from '@features/agent/store/agent-conversation-types'; @@ -23,7 +25,8 @@ import { ComposerSubmissionController, getComposerRuntimeState, ComposerDraftController, - ComposerImageController, + ComposerAttachmentController, + ComposerAttachmentDocument, ComposerAddMenuController, getCurrentNotebookComposerFolders, createAgentComposerDom, @@ -41,7 +44,7 @@ import { AgentBackgroundTerminals } from '@features/agent/components/agent-backg import { CodexApprovalQueue } from '@features/agent/components/codex-approval-queue'; import { buildInitialInstanceRuntimeConfig } from '@features/agent/store/initial-runtime-config'; import { defaultThreadTitle } from '@features/agent/store/thread-titles'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { OverlayScrollbar } from '@shared/ui/overlay-scrollbar'; import { agentFileScopePathForRuntime, @@ -61,6 +64,13 @@ import { Button } from '@shared/ui/button'; import { createRunId } from '@features/agent/events/agent-event-mapper'; import { completedRunUserMessageId } from '@features/agent/events/message-identity'; import { createAgentMessageAttachments, prepareUserMessage } from '@features/agent/store/user-message'; +import { isAgentThreadCardImageAttachment } from '@features/agent/thread-card/composer/composer-attachment-types'; +import { + getAgentMessageTypographyCssVars, + useAgentMessageFontStore, +} from '@features/agent/store/agent-message-font-store'; +import { ensureDownloadedFontRegistered } from '@features/preferences/public/font-api'; +import { getTypographyFontId } from '@/lib/typography-font-mode'; const BOTTOM_FOLLOW_THRESHOLD_PX = 96; const TOP_HISTORY_LOAD_THRESHOLD_PX = 48; @@ -115,48 +125,26 @@ function persistDetailDraft(instanceId: string, draft: string | null): void { */ export function AgentConversationDetail({ instanceId, + reportPresentation, }: { instanceId: string; + reportPresentation?: (update: SurfacePresentationUpdate) => void; }) { const { t } = useI18n(); - const instance = useAgentSessionStore((state) => state.getInstance(instanceId)); - const [loadState, setLoadState] = useState<{ - instanceId: string; - status: 'loading' | 'not-found' | 'error'; - } | null>(null); - const [retryCount, setRetryCount] = useState(0); - useEffect(() => { - if (instance) return; - - let cancelled = false; - setLoadState({ instanceId, status: 'loading' }); - void useAgentSessionStore.getState().hydrateInstance(instanceId) - .then((restored) => { - if (!cancelled && !restored) setLoadState({ instanceId, status: 'not-found' }); - }) - .catch(() => { - if (!cancelled) setLoadState({ instanceId, status: 'error' }); - }); - return () => { - cancelled = true; - }; - }, [instance, instanceId, retryCount]); + const { instance, status, retry } = useAgentConversationSurface(instanceId, reportPresentation); if (instance) { return ; } - const status = loadState?.instanceId === instanceId ? loadState.status : 'loading'; + if (reportPresentation) return null; return ( {t(status === 'error' ? 'status.agent.loadConversationsFailed' : 'status.agent.conversationNotFound')} - @@ -174,6 +162,16 @@ function AgentConversationDetailContent({ instance: AgentConversationInstance; }) { const { language, t } = useI18n(); + const messageFontMode = useAgentMessageFontStore((state) => state.fontMode); + const messageSizeMode = useAgentMessageFontStore((state) => state.sizeMode); + useEffect(() => { + if (messageFontMode === 'standard') return; + const fontId = getTypographyFontId(messageFontMode); + void ensureDownloadedFontRegistered(fontId).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + toast.error(t('preferences.format.fontDownloadFailed', { message })); + }); + }, [messageFontMode, t]); const threadId = instance.threadId; const renderThreadId = threadId; const projection = useAgentSessionStore((state) => ( @@ -215,7 +213,7 @@ function AgentConversationDetailContent({ const inputRef = useRef(null); const messagesControllerRef = useRef(null); const composerControllerRef = useRef(null); - const composerImagesControllerRef = useRef(null); + const composerAttachmentsControllerRef = useRef(null); const externalSettingsRef = useRef(null); const addMenuRef = useRef(null); const notebookAgentSettingsDialogRef = useRef(null); @@ -242,9 +240,9 @@ function AgentConversationDetailContent({ const currentInstance = instanceRef.current; const sourceThreadId = threadIdRef.current; const agentType = currentInstance?.agentType; - if (!currentInstance || !sourceThreadId || (agentType !== 'codex' && agentType !== 'deepseek-harness' && agentType !== 'pi')) return; + if (!currentInstance || !sourceThreadId || (agentType !== 'codex' && agentType !== 'dsh' && agentType !== 'pi')) return; if (agentType === 'codex' && !message.codexTurnId) return; - if (agentType === 'deepseek-harness' && message.sourceSequence === undefined) return; + if (agentType === 'dsh' && message.sourceSequence === undefined) return; if (agentType === 'pi' && !message.messageId) return; if (destroyedRef.current) return; @@ -252,7 +250,7 @@ function AgentConversationDetailContent({ try { const result = agentType === 'codex' ? await agent.forkCodexThread(sourceThreadId, message.codexTurnId!) - : agentType === 'deepseek-harness' + : agentType === 'dsh' ? await agent.forkDeepSeekHarnessThread(sourceThreadId, message.sourceSequence!) : await agent.forkPiThread(sourceThreadId, message.messageId!); const fork = useAgentSessionStore.getState().createInstance({ @@ -265,7 +263,7 @@ function AgentConversationDetailContent({ }, source: { kind: 'dedicated', notebookId: currentInstance.source.notebookId ?? null }, }); - await selectAndOpenAgentConversation(fork.instanceId); + await openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: fork.instanceId } }); } catch (error) { logger.error(`Failed to fork ${agentType} conversation`, { error }); toast.error(error instanceof Error ? error.message : String(error)); @@ -310,7 +308,7 @@ function AgentConversationDetailContent({ }, [instanceId]); const ensureDshCommandConversation = useCallback(async (command: string) => { - if (typeKeyRef.current !== 'deepseek-harness') { + if (typeKeyRef.current !== 'dsh') { throw new Error('This slash command is only available for DSH'); } const currentInstance = instanceRef.current; @@ -322,7 +320,7 @@ function AgentConversationDetailContent({ } const ensured = await ensureAgentConversationDetailThread({ instanceId: currentInstance.instanceId, - typeKey: 'deepseek-harness', + typeKey: 'dsh', prompt: command, runtimeHandleId: runtimeHandleRef.current, }); @@ -375,7 +373,7 @@ function AgentConversationDetailContent({ const submit = useCallback(async () => { const composer = composerControllerRef.current; - const images = composerImagesControllerRef.current; + const images = composerAttachmentsControllerRef.current; const currentInstance = instanceRef.current; if (!composer || !images || !currentInstance) return; const isDisposed = () => destroyedRef.current || composerControllerRef.current !== composer; @@ -397,7 +395,9 @@ function AgentConversationDetailContent({ content: snapshot.prompt, agentType: currentInstance.agentType, isFirstMessage: true, - attachments: createAgentMessageAttachments(snapshot.images.map((image) => image.path)), + attachments: createAgentMessageAttachments( + snapshot.attachments.filter(isAgentThreadCardImageAttachment), + ), }); userMessage.id = completedRunUserMessageId(currentInstance.agentType, runId); userMessage.renderKey = userMessage.id; @@ -465,7 +465,6 @@ function AgentConversationDetailContent({ composerMount.append(composerParts.composer); const { composer, - composerImages, composerActions, composerRoleIcon: composerRoleButton, input, @@ -516,10 +515,9 @@ function AgentConversationDetailContent({ persistDetailDraft(instanceId, draft); }, }); - const composerImagesController = new ComposerImageController({ + const composerAttachmentsController = new ComposerAttachmentController({ input, - container: composerImages, - initialImages: [], + initialAttachments: [], onChange: () => undefined, onStateChange: () => composerControllerRef.current?.setSendButtonState(), onError: (message) => toast.error(message), @@ -584,11 +582,20 @@ function AgentConversationDetailContent({ getUserHistoryMessages: () => getAgentThreadCardUserHistoryMessagesFromMessages(messagesRef.current), getSendLabel: (label) => tRef.current(`editor.threadCard.${label}`), getRuntimeState: composerRuntimeState, - getHasAttachments: () => composerImagesController.hasImages, - getHasPendingAttachments: () => composerImagesController.hasPending, + getHasAttachments: () => composerAttachmentsController.hasAttachments, + getHasPendingAttachments: () => composerAttachmentsController.hasPending, agentType: typeKeyRef.current, + getSkillsScopeId: () => { + const current = instanceRef.current; + const runtime = current?.runtimeConfig; + return runtime?.notebookId + ?? runtime?.workspaceSnapshot?.notebookId + ?? current?.source.notebookId + ?? runtime?.workspaceSnapshot?.notebookPath + ?? runtime?.cwd; + }, listDshSkills: async () => { - if (typeKeyRef.current !== 'deepseek-harness') return []; + if (typeKeyRef.current !== 'dsh') return []; const ensured = await ensureDshCommandConversation('/skill'); return listDshSkills({ threadId: ensured.threadId, @@ -632,6 +639,9 @@ function AgentConversationDetailContent({ }); messageController = surface.messages; const composerController = surface.composer; + composerAttachmentsController.connectDocument( + new ComposerAttachmentDocument(composerController.editorInstance), + ); const notePicker = new NotePickerController({ trigger: composerRoleButton, popover: rolePopover, @@ -647,7 +657,7 @@ function AgentConversationDetailContent({ popover: addPopover, notePopover: rolePopover, notePicker, - images: composerImagesController, + attachments: composerAttachmentsController, t: (key) => tRef.current(key), isDestroyed: () => destroyedRef.current, openNotebookAgentSettings: () => { @@ -672,7 +682,7 @@ function AgentConversationDetailContent({ messagesControllerRef.current = messageController; composerControllerRef.current = composerController; surfaceRef.current = surface; - composerImagesControllerRef.current = composerImagesController; + composerAttachmentsControllerRef.current = composerAttachmentsController; addMenuRef.current = addMenu; composerController.updateMultiLineState(); // Paint the selected thread's initial state in the same layout pass. The @@ -686,7 +696,7 @@ function AgentConversationDetailContent({ body.removeEventListener('click', handleMessageLinkClick); composerController.flushPendingDraft(); surface.dispose(); - composerImagesController.dispose(); + composerAttachmentsController.dispose(); notePicker.dispose(); addMenu.dispose(); externalSettings.dispose(); @@ -698,7 +708,7 @@ function AgentConversationDetailContent({ messagesControllerRef.current = null; composerControllerRef.current = null; surfaceRef.current = null; - composerImagesControllerRef.current = null; + composerAttachmentsControllerRef.current = null; addMenuRef.current = null; pendingFirstMessageRef.current = null; }; @@ -742,7 +752,10 @@ function AgentConversationDetailContent({ ); return ( -
+
{document.documentElement.dataset.platform === 'mac' ? ( @@ -807,8 +820,8 @@ function AgentConversationDetailContent({ {instance.agentType === 'codex' && } ({ ...message, actionsDisabled: !canActOnQueuedMessage( diff --git a/app/flowix-web/features/agent/components/agent-conversation-identity.tsx b/app/flowix-web/features/agent/components/agent-conversation-identity.tsx new file mode 100644 index 000000000..f3d6a0e49 --- /dev/null +++ b/app/flowix-web/features/agent/components/agent-conversation-identity.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { AgentTypeKey } from '@/types/agent'; +import { useI18n } from '@/lib/i18n'; +import { useTitleEditCaret } from '@shared/hooks/use-title-edit-caret'; +import { getAgentType } from '@/lib/agent-types'; +import { useAgentDataStore } from '@features/agent/store/agent-data-store'; +import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import { AgentIcon } from '@features/agent/components/agent-icon'; +import { BadgeHoverCard } from '@features/agent/thread-card/badge-hover-card'; +import { computeAgentThreadCardBadgeData } from '@features/agent/thread-card/runtime/run-status-presenter'; +import { createRuntimeInfoRequester } from '@features/agent/thread-card/runtime/runtime-info-requester'; +import { getResolvedExternalSessionId } from '@features/agent/services/external-agent-runtime-service'; +import { getAgentConversationRuntimeCwd } from '@features/agent/conversation-presentation'; + +export interface AgentConversationIdentityProps { + instanceId: string | null; + title: string; + typeKey: AgentTypeKey; + threadId?: string | null; + sessionId?: string | null; + cwd?: string; + renameRequest?: number; + className?: string; +} + +/** Shared Agent icon, runtime badge, conversation title, and title editing. */ +export function AgentConversationIdentity({ + instanceId, + title: fallbackTitle, + typeKey: fallbackTypeKey, + threadId: fallbackThreadId, + sessionId: fallbackSessionId, + cwd: fallbackCwd, + renameRequest = 0, + className = '', +}: AgentConversationIdentityProps) { + const { t } = useI18n(); + const instance = useAgentSessionStore((state) => + instanceId ? state.getInstance(instanceId) : null, + ); + const threadId = instance?.threadId ?? fallbackThreadId ?? null; + const projection = useAgentSessionStore((state) => + threadId ? state.threadProjections[threadId] : undefined, + ); + const codexModel = useAgentDataStore((state) => state.agents.codex.user.model?.id ?? 'inherit'); + const piCodeMode = useAgentDataStore((state) => state.agents.pi.user.mode === 'code'); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(''); + const lastRenameRequest = useRef(renameRequest); + const { inputRef: titleInputRef, captureCaret } = useTitleEditCaret(isEditingTitle); + + const typeKey = instance?.agentType ?? fallbackTypeKey; + const title = instance?.title?.trim() || fallbackTitle || t('common.untitled'); + const productThreadId = threadId ?? ''; + const sessionId = instance?.providerSessionId ?? fallbackSessionId ?? ( + productThreadId ? getResolvedExternalSessionId(productThreadId) : null + ); + const cwd = getAgentConversationRuntimeCwd(instance) ?? fallbackCwd; + const agent = getAgentType(typeKey); + + const badgeData = useMemo(() => computeAgentThreadCardBadgeData({ + threadState: projection ? { + lastRun: projection.runs.lastRun, + activeRunId: projection.runs.activeRunId, + runs: projection.runs.runs, + } : undefined, + codexModel, + typeKey, + }), [codexModel, projection, typeKey]); + + useEffect(() => { + if (lastRenameRequest.current === renameRequest) return; + lastRenameRequest.current = renameRequest; + setTitleDraft(title); + setIsEditingTitle(true); + }, [renameRequest, title]); + + const commitTitle = () => { + const nextTitle = titleDraft.trim(); + if (nextTitle && nextTitle !== title && instanceId) { + void useAgentSessionStore.getState().renameAgentConversation({ + instanceId, + threadId, + title: nextTitle, + typeKey, + }); + } + setIsEditingTitle(false); + }; + + return ( +
+ + { + const current = instanceId + ? useAgentSessionStore.getState().getInstance(instanceId) + : null; + return current?.threadId ?? fallbackThreadId; + }, + () => { + const current = instanceId + ? useAgentSessionStore.getState().getInstance(instanceId) + : null; + const currentThreadId = current?.threadId ?? fallbackThreadId; + return current?.providerSessionId ?? fallbackSessionId ?? ( + currentThreadId ? getResolvedExternalSessionId(currentThreadId) : null + ); + }, + )} + agentType={typeKey} + piMode={typeKey === 'pi' ? (piCodeMode ? 'code' : 'writing') : undefined} + cwd={cwd} + /> + + + {isEditingTitle ? ( +
+ setTitleDraft(event.target.value)} + onBlur={commitTitle} + onKeyDown={(event) => { + if (event.key === 'Enter' && (event.nativeEvent.isComposing || event.keyCode === 229)) return; + if (event.key === 'Enter') { + event.preventDefault(); + commitTitle(); + } + if (event.key === 'Escape') setIsEditingTitle(false); + }} + /> +
+ ) : ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + captureCaret(event.currentTarget, event.clientX, event.clientY); + setTitleDraft(title); + setIsEditingTitle(true); + }} + > + {title} + + )} +
+ ); +} diff --git a/app/flowix-web/features/agent/components/agent-conversation-list.tsx b/app/flowix-web/features/agent/components/agent-conversation-list.tsx index 50804c2c5..f71c0a38c 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-list.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-list.tsx @@ -13,8 +13,7 @@ import { useAgentSessionStore } from '@features/agent/store/agent-session-store' import type { AgentConversationInstance } from '@features/agent/store/agent-conversation-types'; import { createAndOpenAgentConversation } from '@features/agent/public/shell-api'; import { useWorkspaceRestoreStore } from '@features/workspace/store/workspace-restore-store'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; -import { openBrowserColumnAgentConversation } from '@features/workspace/use-cases/browser-column-navigation'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { useNoteStore } from '@features/memo/store/note-store'; import { isAgentConversationRunning } from '@features/agent/store/conversation-run-index'; import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store'; @@ -59,6 +58,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@shared/ui/dia import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@shared/ui/context-menu'; import { useFixedVirtualList, type FixedVirtualListItem } from './use-fixed-virtual-list'; import { useConversationListData } from './use-conversation-list-data'; +import { useWorkspaceStartupStore } from '@/lib/store/workspace-startup-store'; import { mergeLiveConversation, sortFavoriteConversations, @@ -144,6 +144,8 @@ export function AgentConversationList({ isActive = true }: AgentConversationList loadMoreError, hasMore, agentTypeCounts, + settledQueryKey, + queryKey, loadMore: loadMoreConversations, retryFirstPage, updateTitle, @@ -152,6 +154,14 @@ export function AgentConversationList({ isActive = true }: AgentConversationList agentType: filterType, lifecycleVersion, }); + const startupStatus = useWorkspaceStartupStore((state) => state.status); + useEffect(() => { + if (!isActive || startupStatus !== 'running' || settledQueryKey !== queryKey) return; + useWorkspaceStartupStore.getState().setStartupTask( + 'conversation-list', + loadError ? 'failed' : 'ready', + ); + }, [isActive, loadError, queryKey, settledQueryKey, startupStatus]); const [showScrollTopHint, setShowScrollTopHint] = useState(false); const latestCompletedRunIds = useAgentSessionStore((state) => state.latestCompletedRunIds); const readThroughRunIds = useAgentSessionStore((state) => state.readThroughRunIds); @@ -299,8 +309,8 @@ export function AgentConversationList({ isActive = true }: AgentConversationList const counts = new Map(); if (Object.keys(agentTypeCounts).length > 0) { for (const type of AGENT_TYPES) { - const count = agentTypeCounts[type.key]; - if (count && isAgentTypeSelectable(type.key)) counts.set(type.key, count); + const count = agentTypeCounts[type.id]; + if (count && isAgentTypeSelectable(type.id)) counts.set(type.id, count); } } else { for (const instance of scopedConversations) { @@ -309,14 +319,14 @@ export function AgentConversationList({ isActive = true }: AgentConversationList } } return AGENT_TYPES - .filter((type) => counts.has(type.key)) - .map((type) => ({ type, count: counts.get(type.key) ?? 0 })); + .filter((type) => counts.has(type.id)) + .map((type) => ({ type, count: counts.get(type.id) ?? 0 })); }, [agentTypeCounts, scopedConversations]); // 对话可能在筛选期间变成空集 (例如全部删除) ── 若当前筛选的类型已不再激活, // 自动回退到"全部",避免列表卡在空状态。 const effectiveFilter: AgentTypeKey | null = - filterType && activeAgentTypes.some((entry) => entry.type.key === filterType) + filterType && activeAgentTypes.some((entry) => entry.type.id === filterType) ? filterType : null; @@ -417,10 +427,10 @@ export function AgentConversationList({ isActive = true }: AgentConversationList const newConversationAgentTypes = useMemo( () => AGENT_TYPES.filter((type) => { - if (!isAgentTypeSelectable(type.key)) return false; - if (isAlwaysVisibleNewConversationAgent(type.key)) return true; + if (!isAgentTypeSelectable(type.id)) return false; + if (isAlwaysVisibleNewConversationAgent(type.id)) return true; return isAgentRuntimeInstalledState(normalizeAgentRuntimeStatus( - agentRuntimeStatusByType[type.key], + agentRuntimeStatusByType[type.id], agentRuntimeIsChecking, )); }), @@ -456,12 +466,12 @@ export function AgentConversationList({ isActive = true }: AgentConversationList prepareConversation(instance); - await selectAndOpenAgentConversation(instance.instanceId); + await openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: instance.instanceId } }); }, [markThreadRead, prepareConversation]); const openConversationInBrowserColumn = useCallback((instance: AgentConversationInstance) => { prepareConversation(instance); - void openBrowserColumnAgentConversation(instance.instanceId).catch((error) => { + void openWorkspaceSurface({ host: 'browser-column', target: { kind: 'agent-conversation', instanceId: instance.instanceId } }).catch((error) => { logger.error('Failed to open conversation in browser column', { error }); toast.error(error instanceof Error ? error.message : String(error)); }); @@ -493,19 +503,19 @@ export function AgentConversationList({ isActive = true }: AgentConversationList aria-label={t('document.agent.filterByAgent')} > {activeAgentTypes.map(({ type, count }) => { - const active = effectiveFilter === type.key; + const active = effectiveFilter === type.id; const label = t('document.agent.filterByAgentCount', { name: displayName(type), count, }); return ( ); })} @@ -545,19 +555,19 @@ export function AgentConversationList({ isActive = true }: AgentConversationList {newConversationAgentTypes.map((type) => { const runtimeStatus = normalizeAgentRuntimeStatus( - agentRuntimeStatusByType[type.key], + agentRuntimeStatusByType[type.id], agentRuntimeIsChecking, ); - const showNotInstalled = isAlwaysVisibleNewConversationAgent(type.key) + const showNotInstalled = isAlwaysVisibleNewConversationAgent(type.id) && runtimeStatus.state === 'not-installed'; return ( createConversation(type.key)} + onClick={() => createConversation(type.id)} className="agent-conversation-new-agent-item group h-7 items-center justify-start gap-2 rounded-lg px-2 py-0 text-left hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]" > - + {displayName(type)} {showNotInstalled && ( @@ -674,7 +684,7 @@ export function AgentConversationList({ isActive = true }: AgentConversationList 'relative flex h-4 w-4 shrink-0 items-center justify-center rounded-full', running && 'agent-conversation-list__icon--running', )}> - + {(running || (!!instance.threadId && latestCompletedRunIds[instance.threadId] !== readThroughRunIds[instance.threadId] && !(conversationDetailOpen && selectedInstanceId === instance.instanceId))) && ( diff --git a/app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx b/app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx new file mode 100644 index 000000000..64913d400 --- /dev/null +++ b/app/flowix-web/features/agent/components/agent-conversation-more-menu.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { MoreHorizontal } from 'lucide-react'; +import { + ArchiveIcon, + PencilSimpleIcon, + SquareSplitHorizontalIcon, + StarIcon, + TrashSimpleIcon, +} from '@phosphor-icons/react'; +import { useI18n } from '@/lib/i18n'; +import { toast } from '@/lib/toast'; +import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; +import type { AgentConversationInstance } from '@features/agent/store/agent-conversation-types'; +import { AgentMessageFontSwitcher, useAgentMessageFontSwitch } from '@features/agent/components/agent-message-font-switcher'; +import { + isFavoriteConversation, + subscribeToFavoriteConversationChanges, + toggleFavoriteConversation, +} from '@features/agent/conversation-favorites'; +import { openBrowserColumnAgentConversation } from '@features/workspace/use-cases/browser-column-navigation'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@shared/ui/dropdown-menu'; + +const itemClass = 'group h-7 items-center justify-start gap-2 rounded-lg px-2 py-0 text-left hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]'; + +/** Shared Agent conversation actions used by both dedicated and fullscreen embedded conversations. */ +export function AgentConversationMoreMenu({ + instance, + iconButtonClass, + onRename, +}: { + instance: AgentConversationInstance; + iconButtonClass: string; + onRename: () => void; +}) { + const { t } = useI18n(); + return ( + + + + + + + + + ); +} + +export function AgentConversationMoreMenuItems({ + instance, + onRename, +}: { + instance: AgentConversationInstance; + onRename: () => void; +}) { + const { t } = useI18n(); + const fontSwitch = useAgentMessageFontSwitch(); + const [isFavorite, setIsFavorite] = useState(() => isFavoriteConversation(instance.instanceId)); + + useEffect(() => { + setIsFavorite(isFavoriteConversation(instance.instanceId)); + return subscribeToFavoriteConversationChanges(() => { + setIsFavorite(isFavoriteConversation(instance.instanceId)); + }); + }, [instance.instanceId]); + + const removeConversation = async (action: 'archive' | 'delete') => { + if (action === 'delete' && !window.confirm(t('agent.chat.conversation.deleteConfirm'))) return; + try { + const session = useAgentSessionStore.getState(); + if (instance.threadId) { + await (action === 'archive' + ? session.archiveThread(instance.threadId) + : session.deleteThread(instance.threadId)); + } else { + session.removeInstance(instance.instanceId); + } + toast.success(t(action === 'archive' ? 'status.agent.archiveSuccess' : 'status.agent.deleteSuccess')); + } catch (error) { + toast.error(error instanceof Error + ? error.message + : t(action === 'archive' ? 'status.agent.archiveFailed' : 'status.agent.deleteFailed')); + } + }; + + return ( + <> + + void openBrowserColumnAgentConversation(instance.instanceId).catch((error) => + toast.error(error instanceof Error ? error.message : String(error)))} + className={itemClass} + > + {t('workColumn.context.openInBrowserColumn')} + + setIsFavorite(toggleFavoriteConversation(instance.instanceId).has(instance.instanceId))} + className={itemClass} + > + + {isFavorite ? t('agent.chat.conversation.unfavorite') : t('agent.chat.conversation.favorite')} + + + {t('agent.chat.conversation.rename')} + + void removeConversation('archive')} className={itemClass}> + {t('document.agent.archiveConversation')} + + void removeConversation('delete')} + className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-transparent hover:text-[var(--destructive)]" + > + {t('document.agent.deleteConversation')} + + + ); +} diff --git a/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx b/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx index b684e8374..83d1ad6cf 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-status-bar.tsx @@ -8,7 +8,7 @@ import { type ConversationRunSummary, } from '@features/agent/store/conversation-run-index'; import { useWorkspaceRestoreStore } from '@features/workspace/store/workspace-restore-store'; -import { selectAndOpenAgentConversation } from '@features/workspace/use-cases/agent-conversation-navigation'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { getAgentType } from '@/lib/agent-types'; import { useI18n } from '@/lib/i18n'; import { toast } from '@/lib/toast'; @@ -87,7 +87,7 @@ export function AgentConversationStatusBar() { const openConversation = useCallback((instance: AgentConversationInstance) => { if (instance.threadId) markThreadRead(instance.threadId); - void selectAndOpenAgentConversation(instance.instanceId).catch((error) => { + void openWorkspaceSurface({ target: { kind: 'agent-conversation', instanceId: instance.instanceId } }).catch((error) => { toast.error(error instanceof Error ? error.message : t('status.agent.conversationNotFound')); }); }, [markThreadRead, t]); @@ -130,7 +130,7 @@ export function AgentConversationStatusBar() { aria-label={title} > diff --git a/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx b/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx index c57f8fb92..c2e7003ac 100644 --- a/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx +++ b/app/flowix-web/features/agent/components/agent-conversation-titlebar.tsx @@ -1,18 +1,12 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { - ArchiveIcon, FileTextIcon, LinkBreakIcon, - PencilSimpleIcon, - SquareSplitHorizontalIcon, - StarIcon, - TrashSimpleIcon, } from '@phosphor-icons/react'; -import { useTitleEditCaret } from '@shared/hooks/use-title-edit-caret'; import { useI18n } from '@/lib/i18n'; import { isWindowsPlatform } from '@features/shortcuts'; import { SidebarToggleIcon } from '@shared/icons/sidebar-toggle-icon'; @@ -21,118 +15,23 @@ import { DOCUMENT_TITLEBAR_ICON_BUTTON_MAC, DOCUMENT_TITLEBAR_ICON_BUTTON_WIN, } from '@features/document/components/document-titlebar-shared'; -import { DEFAULT_AGENT_TYPE_KEY, getAgentType } from '@/lib/agent-types'; import { useAgentSessionStore } from '@features/agent/store/agent-session-store'; -import { useAgentAccessStore } from '@features/agent/store/agent-access-store'; -import { openNoteByNotebookPath, openNoteByPhysicalPath } from '@features/memo/use-cases/open-by-target'; -import { openExternalTarget } from '@features/workspace/use-cases/workspace-navigation'; +import { openNoteByNotebookPath, openNoteByPhysicalPath } from '@features/memo/public/note-navigation-api'; +import { openWorkspaceSurface } from '@features/workspace/public/open-surface-api'; import { getAgentConversationPresentation } from '@features/agent/conversation-presentation'; -import { AgentIcon } from '@features/agent/components/agent-icon'; -import { BadgeHoverCard } from '@features/agent/thread-card/badge-hover-card'; -import { computeAgentThreadCardBadgeData } from '@features/agent/thread-card/runtime/run-status-presenter'; -import { getResolvedExternalSessionId } from '@features/agent/services/external-agent-runtime-service'; -import { createRuntimeInfoRequester } from '@features/agent/thread-card/runtime/runtime-info-requester'; import { toast } from '@/lib/toast'; import { WorkColumnTitlebarShell } from '@features/shell/components/work-column-titlebar-shell'; -import { openBrowserColumnAgentConversation } from '@features/workspace/use-cases/browser-column-navigation'; -import { - isFavoriteConversation, - subscribeToFavoriteConversationChanges, - toggleFavoriteConversation, -} from '@features/agent/conversation-favorites'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@shared/ui/dropdown-menu'; +import { AgentConversationMoreMenu } from '@features/agent/components/agent-conversation-more-menu'; +import { AgentConversationIdentity } from '@features/agent/components/agent-conversation-identity'; function AgentConversationHeader({ instanceId }: { instanceId: string }) { const { t } = useI18n(); const isWindows = isWindowsPlatform(); const instance = useAgentSessionStore((state) => state.getInstance(instanceId)); - const projection = useAgentSessionStore((state) => { - const threadId = state.getInstance(instanceId)?.threadId; - return threadId ? state.threadProjections[threadId] : undefined; - }); - const codexModel = useAgentSessionStore((state) => state.sessionMeta.settings.agentCodexModel); - const piCodeMode = useAgentAccessStore((state) => - state.config.defaults?.runtime?.pi?.piCodeMode ?? false, - ); - const [isEditingTitle, setIsEditingTitle] = useState(false); - const { inputRef: titleInputRef, captureCaret: captureTitleCaret } = useTitleEditCaret(isEditingTitle); - const [titleDraft, setTitleDraft] = useState(''); - const [isFavorite, setIsFavorite] = useState(() => isFavoriteConversation(instanceId)); - - useEffect(() => { - setIsFavorite(isFavoriteConversation(instanceId)); - return subscribeToFavoriteConversationChanges(() => { - setIsFavorite(isFavoriteConversation(instanceId)); - }); - }, [instanceId]); - - // archive/delete 由 store 端到端完成 (含 closeAgentConversation), instance - // 消失时父级会立刻 unmount 这个组件; 不再渲染兜底空态、不再做"instance 缺 - // 失时" 的 hooks 防御。 + const [renameRequest, setRenameRequest] = useState(0); const presentation = instance ? getAgentConversationPresentation(instance, t('common.untitled')) : null; - const agent = getAgentType(instance?.agentType ?? DEFAULT_AGENT_TYPE_KEY); - const productThreadId = instance?.threadId ?? ''; - const providerSessionId = instance?.providerSessionId ?? ( - productThreadId ? getResolvedExternalSessionId(productThreadId) : null - ); - - const badgeData = useMemo(() => computeAgentThreadCardBadgeData({ - threadState: projection ? { - lastRun: projection.runs.lastRun, - activeRunId: projection.runs.activeRunId, - runs: projection.runs.runs, - } : undefined, - codexModel, - typeKey: instance?.agentType ?? DEFAULT_AGENT_TYPE_KEY, - }), [codexModel, instance?.agentType, projection]); - - const onRemove = useCallback(async ( - target: NonNullable, - action: 'archive' | 'delete', - ) => { - if (action === 'delete' && !window.confirm(t('agent.chat.conversation.deleteConfirm'))) return; - - try { - const session = useAgentSessionStore.getState(); - if (target.threadId) { - await (action === 'archive' - ? session.archiveThread(target.threadId) - : session.deleteThread(target.threadId)); - } else { - session.removeInstance(target.instanceId); - } - toast.success(t(action === 'archive' ? 'status.agent.archiveSuccess' : 'status.agent.deleteSuccess')); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t(action === 'archive' ? 'status.agent.archiveFailed' : 'status.agent.deleteFailed'), - ); - } - }, [t]); - - const onOpenInBrowserColumn = useCallback((target: NonNullable) => { - void openBrowserColumnAgentConversation(target.instanceId).catch((error) => { - toast.error(error instanceof Error ? error.message : String(error)); - }); - }, []); - - const onToggleFavorite = useCallback(() => { - const next = toggleFavoriteConversation(instanceId); - setIsFavorite(next.has(instanceId)); - }, [instanceId]); - - const onRename = useCallback((target: NonNullable) => { - setTitleDraft(target.title?.trim() || ''); - setIsEditingTitle(true); - }, []); const onOpenSourceDocument = useCallback(() => { if (!instance || !presentation?.hasSourceDocument) return; @@ -140,135 +39,57 @@ function AgentConversationHeader({ instanceId }: { instanceId: string }) { const open = source?.notebookId && source.relativePath ? openNoteByNotebookPath(source.notebookId, source.relativePath) : source?.documentPath - ? openNoteByPhysicalPath(source.documentPath).catch(() => openExternalTarget(source.documentPath!)) + ? openNoteByPhysicalPath(source.documentPath).catch(() => openWorkspaceSurface({ + host: 'main-third', + target: { kind: 'external', path: source.documentPath!, scopePath: null, transitionId: null }, + }).then(() => undefined)) : Promise.resolve(); void open.catch(() => toast.error(t('status.agent.originUnavailable'))); }, [instance, presentation, t]); - const commitTitle = useCallback(() => { - const title = titleDraft.trim(); - if (title && instance) { - void useAgentSessionStore.getState().renameAgentConversation({ - instanceId: instance.instanceId, - threadId: instance.threadId, - title, - typeKey: instance.agentType, - }); - } - setIsEditingTitle(false); - }, [instance, titleDraft]); - if (!instance || !presentation) return null; - const { hasSourceDocument, runtimeCwd } = presentation; const actionButtonClass = isWindows ? DOCUMENT_TITLEBAR_ICON_BUTTON_WIN : DOCUMENT_TITLEBAR_ICON_BUTTON_MAC; return ( -
- - useAgentSessionStore.getState().getInstance(instanceId)?.threadId, - () => { - const current = useAgentSessionStore.getState().getInstance(instanceId); - return current?.providerSessionId ?? ( - current?.threadId - ? getResolvedExternalSessionId(current.threadId) - : null - ); - }, - )} - agentType={instance.agentType} - piMode={instance.agentType === 'pi' ? (piCodeMode ? 'code' : 'writing') : undefined} - cwd={runtimeCwd} - /> - - - {isEditingTitle ? ( -
- setTitleDraft(event.target.value)} onBlur={commitTitle} - onKeyDown={(event) => { - // Enter is also emitted while an IME is confirming its current - // candidate. Let the composition finish before allowing the - // title editor to commit and blur. - if (event.key === 'Enter' && (event.nativeEvent.isComposing || event.keyCode === 229)) return; - if (event.key === 'Enter') { event.preventDefault(); commitTitle(); } - if (event.key === 'Escape') setIsEditingTitle(false); - }} /> -
- ) : ( -
event.stopPropagation()} - onMouseDown={(event) => event.stopPropagation()} - onClick={(event) => event.stopPropagation()} - onDoubleClick={(event) => { - event.preventDefault(); - event.stopPropagation(); - captureTitleCaret(event.currentTarget, event.clientX, event.clientY); - setTitleDraft(instance.title?.trim() || ''); - setIsEditingTitle(true); - }} - > - {presentation.title} -
- )} +
+
- {hasSourceDocument ? <> - - : ( - - - )} - - - - - - onOpenInBrowserColumn(instance)} className="group h-7 items-center justify-start gap-2 rounded-lg px-2 py-0 text-left hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]"> - {t('workColumn.context.openInBrowserColumn')} - - - - {isFavorite ? t('agent.chat.conversation.unfavorite') : t('agent.chat.conversation.favorite')} - - onRename(instance)} className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]"> - {t('agent.chat.conversation.rename')} - - void onRemove(instance, 'archive')} className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-[var(--brand)] hover:text-[var(--primary-foreground)]"> - {t('document.agent.archiveConversation')} - - void onRemove(instance, 'delete')} className="group h-7 items-center gap-2 rounded-lg px-2 py-0 hover:bg-transparent hover:text-[var(--destructive)]"> - {t('document.agent.deleteConversation')} - - - + {presentation.hasSourceDocument ? ( + + ) : ( + + + )} + setRenameRequest((request) => request + 1)} + />
); diff --git a/app/flowix-web/features/agent/components/agent-icon.tsx b/app/flowix-web/features/agent/components/agent-icon.tsx index 864e5520f..7a913f447 100644 --- a/app/flowix-web/features/agent/components/agent-icon.tsx +++ b/app/flowix-web/features/agent/components/agent-icon.tsx @@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'; export { isThemeAdaptiveAgentIcon, THEME_ADAPTIVE_AGENT_ICON_KEYS }; const DEFAULT_AGENT_ICON_COLORS: Partial> = { - 'deepseek-harness': '#484848', + dsh: '#484848', }; export interface AgentIconProps { diff --git a/app/flowix-web/features/agent/components/agent-message-font-switcher.tsx b/app/flowix-web/features/agent/components/agent-message-font-switcher.tsx new file mode 100644 index 000000000..55487533f --- /dev/null +++ b/app/flowix-web/features/agent/components/agent-message-font-switcher.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useI18n } from '@/lib/i18n'; +import { toast } from '@/lib/toast'; +import { subscribe } from '@platform/tauri/event-bus'; +import type { FontDownloadProgress } from '@platform/tauri/client/general'; +import { ensureDownloadedFontRegistered } from '@features/preferences/public/font-api'; +import { TypographyFontSwitcher } from '@shared/ui/typography-font-switcher'; +import { FontSizeSwitcher } from '@shared/ui/font-size-switcher'; +import { getTypographyFontId } from '@/lib/typography-font-mode'; +import { + useAgentMessageFontStore, + type AgentMessageFontMode, + type AgentMessageSizeMode, +} from '@features/agent/store/agent-message-font-store'; + +export function useAgentMessageFontSwitch() { + const { t } = useI18n(); + const fontMode = useAgentMessageFontStore((state) => state.fontMode); + const sizeMode = useAgentMessageFontStore((state) => state.sizeMode); + const setFontMode = useAgentMessageFontStore((state) => state.setFontMode); + const [downloadingMode, setDownloadingMode] = useState | null>(null); + const [percent, setPercent] = useState(null); + const requestId = useRef(0); + const downloadingFontId = useRef(null); + + useEffect(() => subscribe('font-download-progress', (progress) => { + if (progress.fontId === downloadingFontId.current) setPercent(progress.percent); + }), []); + + useEffect(() => () => { + requestId.current += 1; + downloadingFontId.current = null; + }, []); + + async function selectFontMode(mode: AgentMessageFontMode) { + const request = ++requestId.current; + const fontId = getTypographyFontId(mode); + downloadingFontId.current = mode === 'standard' ? null : fontId; + setDownloadingMode(mode === 'standard' ? null : mode); + setPercent(null); + + try { + if (mode !== 'standard') await ensureDownloadedFontRegistered(fontId); + if (requestId.current !== request) return; + setFontMode(mode); + } catch (error) { + if (requestId.current !== request) return; + const message = error instanceof Error ? error.message : String(error); + toast.error(t('preferences.format.fontDownloadFailed', { message })); + } finally { + if (requestId.current === request) { + downloadingFontId.current = null; + setDownloadingMode(null); + setPercent(null); + } + } + } + + return { fontMode, sizeMode, downloadingMode, percent, selectFontMode }; +} + +export function AgentMessageFontSwitcher({ + fontMode, + sizeMode, + downloadingMode, + percent, + onSelect, +}: { + fontMode: AgentMessageFontMode; + sizeMode: AgentMessageSizeMode; + downloadingMode: Exclude | null; + percent: number | null; + onSelect: (mode: AgentMessageFontMode) => void; +}) { + const setSizeMode = useAgentMessageFontStore((state) => state.setSizeMode); + return ( +
+ + +
+ ); +} diff --git a/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx b/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx index 76bc46c6c..eff8742dc 100644 --- a/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx +++ b/app/flowix-web/features/agent/components/agent-runtime-status-list.tsx @@ -4,8 +4,7 @@ import type { ReactNode } from 'react'; import { AGENT_TYPES, isAgentTypeComingSoon } from '@/lib/agent-types'; import { cn } from '@/lib/utils'; import { useI18n, type I18nKey } from '@/lib/i18n'; -import type { AgentTypeKey } from '@/types/agent'; -import type { AgentRuntimeAvailability } from '@platform/tauri/client'; +import type { AgentRuntimeSnapshot, AgentTypeKey } from '@/types/agent'; import { normalizeAgentRuntimeStatus, type AgentRuntimeStatusView, @@ -14,7 +13,7 @@ import { Button } from '@shared/ui/button'; import { useAgentVisibilityPreferences } from '@features/preferences/public/runtime-api'; import { AgentIcon } from '@features/agent/components/agent-icon'; -type AgentRuntimeStatusByType = Partial>; +type AgentRuntimeStatusByType = Partial>; interface AgentRuntimeStatusListProps { statusByType: AgentRuntimeStatusByType; @@ -42,9 +41,8 @@ interface AgentRuntimeStatusListProps { */ onCardClick?: (typeKey: AgentTypeKey) => void; /** - * 需要隐藏的 agent key 集合 ── 调用方传进来后, 这些 agent 既不渲染行 - * 也不计入后续的 `renderAfterRow` / `onCardClick` 调用。偏好设置用来 - * 临时下线尚未发布的 agent (openclaw / gemini) 而不影响其他入口。 + * 需要隐藏的 agent key 集合。隐藏项既不渲染,也不会传给 + * `renderAfterRow` / `onCardClick`。 */ hiddenKeys?: ReadonlySet; } @@ -56,7 +54,7 @@ export function getAgentRuntimeStatusText({ t, }: { typeKey?: AgentTypeKey; - status: AgentRuntimeAvailability | undefined; + status: AgentRuntimeSnapshot | undefined; isChecking: boolean; t?: (key: I18nKey) => string; }): string { @@ -97,7 +95,7 @@ export function AgentRuntimeStatusList({ }: AgentRuntimeStatusListProps) { const isVisible = (typeKey: AgentTypeKey) => !hiddenKeys?.has(typeKey); - const visibleAgentTypes = AGENT_TYPES.filter((type) => isVisible(type.key)); + const visibleAgentTypes = AGENT_TYPES.filter((type) => isVisible(type.id)); const { t } = useI18n(); const { enabledByType: agentVisibility, setEnabled } = useAgentVisibilityPreferences(); @@ -116,21 +114,21 @@ export function AgentRuntimeStatusList({ return (
{visibleAgentTypes.map((type) => { - const status = statusByType[type.key]; - const comingSoon = isAgentTypeComingSoon(type.key); + const status = statusByType[type.id]; + const comingSoon = isAgentTypeComingSoon(type.id); const runtimeStatus = normalizeAgentRuntimeStatus(status, isChecking); const unavailable = comingSoon || isRuntimeUnavailable(runtimeStatus); - const statusText = getAgentRuntimeStatusText({ typeKey: type.key, status, isChecking, t }); + const statusText = getAgentRuntimeStatusText({ typeKey: type.id, status, isChecking, t }); const canSetup = !comingSoon && isRuntimeUnavailable(runtimeStatus) && Boolean(onSetupClick); const available = !comingSoon && runtimeStatus.state === 'ready'; - const slashEnabled = agentVisibility[type.key] ?? true; + const slashEnabled = agentVisibility[type.id] ?? true; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - onCardClick(type.key); + onCardClick(type.id); } } : undefined} className={cn( @@ -158,7 +156,7 @@ export function AgentRuntimeStatusList({
setAgentSlashEnabled(type.key, !slashEnabled)} + onClick={() => setAgentSlashEnabled(type.id, !slashEnabled)} className={cn( 'relative h-5 w-9 rounded-full border transition-colors outline-none', 'focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2', @@ -209,7 +207,7 @@ export function AgentRuntimeStatusList({ )} /> - {headerAction?.(type.key)} + {headerAction?.(type.id)}
) : canSetup ? (
onSetupClick?.(type.key)} + onClick={() => onSetupClick?.(type.id)} > {t('agent.status.setup')} - {headerAction?.(type.key)} + {headerAction?.(type.id)}
) : (
{statusText} - {headerAction?.(type.key)} + {headerAction?.(type.id)}
)}
- {renderAfterRow?.(type.key)} + {renderAfterRow?.(type.id)}
); })} @@ -253,19 +251,19 @@ export function AgentRuntimeStatusList({ return (
{visibleAgentTypes.map((type) => { - const status = statusByType[type.key]; - const comingSoon = isAgentTypeComingSoon(type.key); + const status = statusByType[type.id]; + const comingSoon = isAgentTypeComingSoon(type.id); const runtimeStatus = normalizeAgentRuntimeStatus(status, isChecking); const unavailable = comingSoon || isRuntimeUnavailable(runtimeStatus); - const statusText = getAgentRuntimeStatusText({ typeKey: type.key, status, isChecking, t }); + const statusText = getAgentRuntimeStatusText({ typeKey: type.id, status, isChecking, t }); const interactive = !comingSoon && isRuntimeUnavailable(runtimeStatus) && Boolean(onSetupClick); return ( - +
{error && } @@ -417,7 +420,7 @@ function AgentTabs({ active, onChange, ariaLabel, keys = AGENT_RESOURCE_KEYS }: : "text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]", )} > - {agentKey === "codex" ? "Codex" : agentKey === "deepseek-harness" ? "DeepSeek Harness" : "通用"} + {agentKey === "codex" ? "Codex" : agentKey === "dsh" ? "DeepSeek Harness" : "通用"} ); })} @@ -575,40 +578,71 @@ function CodexSkillGroup({ title, items }: { title: string; items: readonly Code ); } +const EMPTY_CODEX_SKILLS: readonly CodexSkillCatalogItem[] = []; + +function normalizeNotebookPath(path: string): string { + return path.trim().replace(/[\\/]+$/, "").toLowerCase(); +} + +function getNotebookSkillScopeId(notebookPath: string): string { + const normalizedPath = normalizeNotebookPath(notebookPath); + const notebookId = useNoteStore.getState().notebooks.find( + (notebook) => normalizeNotebookPath(notebook.path) === normalizedPath, + )?.id; + const workspaceNotebookId = Object.values(useAgentDataStore.getState().workspace.byNotebookId).find( + (notebook) => normalizeNotebookPath(notebook.notebook.path) === normalizedPath, + )?.notebook.id; + return notebookId ?? workspaceNotebookId ?? normalizedPath; +} + function CodexSkillsList({ notebookPath }: { notebookPath: string }) { + const normalizedPath = normalizeNotebookPath(notebookPath); + const notebookId = useNoteStore((state) => + state.notebooks.find((notebook) => normalizeNotebookPath(notebook.path) === normalizedPath)?.id ?? null, + ); + const workspaceNotebookId = useAgentDataStore((state) => + Object.values(state.workspace.byNotebookId).find( + (notebook) => normalizeNotebookPath(notebook.notebook.path) === normalizedPath, + )?.notebook.id ?? null, + ); + const scopeId = notebookId ?? workspaceNotebookId ?? normalizedPath; + const catalog = useAgentDataStore((state) => + scopeId ? state.agents.codex.catalog.skillsByNotebookId[scopeId] : undefined, + ); + const skills = (catalog?.items ?? EMPTY_CODEX_SKILLS) as readonly CodexSkillCatalogItem[]; const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); - const [skills, setSkills] = useState([]); const [error, setError] = useState(null); useEffect(() => { let active = true; - setStatus("loading"); - setSkills([]); + const cached = useAgentDataStore.getState().agents.codex.catalog.skillsByNotebookId[scopeId]; + setStatus(cached ? "ready" : "loading"); setError(null); - void (async () => { - try { - const capabilities = await agent.getCodexProjectCapabilities(notebookPath); - if (!capabilities.skills.ok) { - throw new Error(capabilities.skills.error ?? "Codex Skill 加载失败"); - } + void loadNotebookSkillCatalog("codex", scopeId, notebookPath) + .then(() => { if (!active) return; - setSkills(parseCodexSkillCatalog(capabilities.skills.value)); setStatus("ready"); - } catch (cause) { + }) + .catch((cause) => { if (!active) return; + const latest = useAgentDataStore.getState().agents.codex.catalog.skillsByNotebookId[scopeId]; + if (latest) { + setStatus("ready"); + setError(cause instanceof Error ? cause.message : String(cause)); + return; + } setError(cause instanceof Error ? cause.message : String(cause)); setStatus("error"); - } - })(); + }); return () => { active = false; }; - }, [notebookPath]); + }, [notebookPath, scopeId]); - if (status === "loading") { + if (status === "loading" && !catalog) { return
加载中…
; } - if (status === "error") { + if (status === "error" && !catalog) { return
暂时无法读取 Codex Skill 列表
; } if (!skills.length) { @@ -1077,6 +1111,15 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: setWorkspace(next); setOriginal(cloneWorkspace(next)); closeEditor(); + const skillScopeId = getNotebookSkillScopeId(notebookPath); + if (skillScopeId) { + for (const agentType of ["codex", "pi"] as const) { + invalidateNotebookSkills(agentType, skillScopeId); + void loadNotebookSkillCatalog(agentType, skillScopeId, notebookPath, { force: true }).catch((cause) => { + console.warn(`Failed to refresh ${agentType} skills after saving notebook Agent settings`, cause); + }); + } + } } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -1145,7 +1188,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: {tab === "mcp" && <> openMcp(null)} disabled={!!editor}>添加 : null} /> { setMcpAgent(agentKey); closeEditor(); }} /> - {mcpAgent === "codex" ? : mcpAgent === "deepseek-harness" ? : <> + {mcpAgent === "codex" ? : mcpAgent === "dsh" ? : <> {editor?.kind === "mcp" && mcpForm && setMcpForm((current) => current ? { ...current, ...patch } : current)} onCancel={closeEditor} onSave={saveMcpDraft} error={formError} saving={false} />} openMcp(null)} onEdit={openMcp} onDelete={deleteMcp} onToggle={toggleMcp} /> } @@ -1153,7 +1196,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: {tab === "skills" && <> openFile("skill", null)} disabled={!!editor}>创建 : null} /> { setSkillAgent(agentKey); closeEditor(); }} /> - {skillAgent === "codex" ? : skillAgent === "deepseek-harness" ? :
+ {skillAgent === "codex" ? : skillAgent === "dsh" ? :
{editor?.kind === "file" && editor.fileKind === "skill" && fileForm && setFileForm((current) => current ? { ...current, ...patch } : current)} onCancel={closeEditor} onSave={saveFileDraft} error={formError} />} openFile("skill", null)} onEdit={(id) => openFile("skill", id)} onDelete={(id) => deleteFile("skill", id)} onToggle={(id, enabled) => toggleFile("skill", id, enabled)} />
} @@ -1161,7 +1204,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath: {tab === "agents" && <> openFile("agent", null)} disabled={!!editor}>创建 : null} /> { setChildAgent(agentKey); closeEditor(); }} /> - {childAgent === "codex" ? : childAgent === "deepseek-harness" ? :
+ {childAgent === "codex" ? : childAgent === "dsh" ? :
{editor?.kind === "file" && editor.fileKind === "agent" && fileForm && setFileForm((current) => current ? { ...current, ...patch } : current)} onCancel={closeEditor} onSave={saveFileDraft} error={formError} />} openFile("agent", null)} onEdit={(id) => openFile("agent", id)} onDelete={(id) => deleteFile("agent", id)} onToggle={(id, enabled) => toggleFile("agent", id, enabled)} />
} @@ -1187,7 +1230,7 @@ function NotebookAgentSettingsDialog({ notebookPath, onClose }: { notebookPath:
{dirty ? 有未保存更改 : }
-
+
diff --git a/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts b/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts index 52a7afb1d..e92f1ed06 100644 --- a/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts +++ b/app/flowix-web/features/agent/thread-card/view/agent-thread-card-dom-factory.ts @@ -44,7 +44,7 @@ export interface AgentThreadCardDomParts { loadingIndicator: HTMLDivElement; queuedMessages: HTMLDivElement; composer: HTMLElement; - composerImages: HTMLDivElement; + inputRow: HTMLDivElement; composerActions: HTMLDivElement; composerRoleIcon: HTMLButtonElement; composerAddPopover: HTMLDivElement; @@ -58,7 +58,7 @@ export function createAgentThreadCardDom( options: AgentThreadCardDomFactoryOptions, ): AgentThreadCardDomParts { const dom = document.createElement("section"); - dom.className = "agent-thread-card"; + dom.className = "agent-thread-card agent-conversation-surface"; dom.contentEditable = "false"; dom.tabIndex = -1; dom.dataset.agentThreadCard = "true"; @@ -203,7 +203,7 @@ export function createAgentThreadCardDom( }); const { composer, - composerImages, + inputRow, composerActions, composerRoleIcon, composerAddPopover, @@ -263,7 +263,7 @@ export function createAgentThreadCardDom( loadingIndicator, queuedMessages, composer, - composerImages, + inputRow, composerActions, composerRoleIcon, composerAddPopover, diff --git a/app/flowix-web/features/collection/dataset-filter-location.test.ts b/app/flowix-web/features/collection/dataset-filter-location.test.ts new file mode 100644 index 000000000..00da8c121 --- /dev/null +++ b/app/flowix-web/features/collection/dataset-filter-location.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from 'vitest'; +import { resolveDatasetFilterForLocation } from './dataset-filter-location'; +import { matchesMediaCollectionFilter, createMediaLibrary, mediaCollectionFilterForSave } from '../media-library/model'; + +it('follows a moved folder index without changing conditions or the stored document', () => { + const filter = { path: 'Old', match: 'any', conditions: [{ field: 'file_name', op: 'contains', value: 'photo' }] }; + const effective = resolveDatasetFilterForLocation(filter, 'folder', '/book/New/.index.yaml', '/book'); + expect(matchesMediaCollectionFilter('New/photo.png', 'image', effective)).toBe(true); + expect(matchesMediaCollectionFilter('Old/photo.png', 'image', effective)).toBe(false); + expect(matchesMediaCollectionFilter('New/other.png', 'image', effective)).toBe(false); + expect(filter.path).toBe('Old'); + expect(resolveDatasetFilterForLocation(filter, 'standalone', '/book/New/file.index.yaml', '/book')).toBe(filter); +}); + +it('resolves legacy path rules and notebook root locations', () => { + const filter = { all: [{ field: 'path', op: 'contains', value: 'Old' }], any: [] }; + const effective = resolveDatasetFilterForLocation(filter, 'folder', '/book/New/.index.yaml', '/book'); + expect(matchesMediaCollectionFilter('New/photo.png', 'image', effective)).toBe(true); + expect(resolveDatasetFilterForLocation({ path: 'Old', match: 'all', conditions: [] }, 'folder', '/book/.index.yaml', '/book')?.path).toBe(''); +}); + +it('checks the latest media filter before replacing it and synchronizes folder paths on save', () => { + const document = createMediaLibrary('Media'); + const before = document.dataset.filter; + expect(mediaCollectionFilterForSave(document, { file_name_contains: 'photo' }, '/book/media.index.yaml', '/book')).toMatchObject({ path: null }); + document.dataset.filter = { all: [], any: [{ field: 'file_name', op: 'contains', value: 'external' }] }; + expect(() => mediaCollectionFilterForSave(document, { file_name_contains: 'photo' }, '/book/media.index.yaml', '/book')).toThrow('已取消保存'); + expect(document.dataset.filter.any).toHaveLength(1); + document.dataset.filter = before; + document.collection.placement = 'folder'; + expect(mediaCollectionFilterForSave(document, {}, '/book/New/.index.yaml', '/book')).toEqual({ path: 'New', match: 'all', conditions: [] }); +}); diff --git a/app/flowix-web/features/collection/dataset-filter-location.ts b/app/flowix-web/features/collection/dataset-filter-location.ts new file mode 100644 index 000000000..1a9be41f5 --- /dev/null +++ b/app/flowix-web/features/collection/dataset-filter-location.ts @@ -0,0 +1,24 @@ +import { notebookRelativeDirectoryPath, parentDirectoryPath } from '@/lib/path'; +import type { CollectionPlacement } from './model'; + +/** Folder paths follow the current index location, including filesystem moves. */ +export function resolveDatasetFilterForLocation( + filter: Record | null, + placement: CollectionPlacement, + filePath: string | null, + notebookPath: string | null, +): Record | null { + if (!filter || placement !== 'folder' || !filePath || !notebookPath) return filter; + const path = notebookRelativeDirectoryPath(notebookPath, parentDirectoryPath(filePath, notebookPath)); + if (path === null) return filter; + if (Array.isArray(filter.conditions)) return { ...filter, path }; + const relocate = (rule: Record): Record => { + if (rule.field === 'path') return { ...rule, value: path }; + return { + ...rule, + ...(Array.isArray(rule.all) ? { all: rule.all.map(relocate) } : {}), + ...(Array.isArray(rule.any) ? { any: rule.any.map(relocate) } : {}), + }; + }; + return relocate(filter); +} diff --git a/app/flowix-web/features/collection/folder-collection.ts b/app/flowix-web/features/collection/folder-collection.ts index 11c5a76bb..fbf553e4c 100644 --- a/app/flowix-web/features/collection/folder-collection.ts +++ b/app/flowix-web/features/collection/folder-collection.ts @@ -2,19 +2,24 @@ import { createTableDocument, serializeTableDocument } from '@features/multidime import { createMediaLibrary, serializeMediaLibrary } from '@features/media-library/model'; import { parseCollectionDocumentV2, serializeCollectionDocumentV2, type CollectionType } from './model'; import { externalDocuments } from '@platform/tauri/client'; -import { canonicalPath } from '@/lib/path'; +import { canonicalPath, notebookRelativeDirectoryPath } from '@/lib/path'; -export function createFolderCollectionContent(kind: 'notes' | 'media', folderName: string): string { +export function createFolderCollectionContent(kind: 'notes' | 'media', folderName: string, folderPath: string | null = null): string { if (kind === 'notes') { const document = createTableDocument('table', folderName, 'folder'); document.dataset.source = { scope: 'containing_folder', depth: 'descendants' }; - document.dataset.filter = { all: [{ field: 'kind', op: 'eq', value: 'note' }], any: [] }; + document.dataset.filter = { + path: folderPath, + match: 'all', + conditions: [{ field: 'kind', op: 'eq', value: 'note' }], + }; return serializeTableDocument(document); } const document = createMediaLibrary(folderName); document.collection.placement = 'folder'; document.dataset.source = { scope: 'containing_folder', depth: 'descendants' }; + document.dataset.filter = { path: folderPath, match: 'all', conditions: [] }; return serializeMediaLibrary(document); } @@ -25,7 +30,9 @@ export async function switchFolderCollectionType(filePath: string, notebookPath: throw new Error('仅文件夹集合支持切换视图类型'); } if (current.collection.type === type) return current.collection.id; - const template = parseCollectionDocumentV2(createFolderCollectionContent(type === 'table' ? 'notes' : 'media', current.collection.name)); + const folderPath = canonicalPath(filePath).replace(/[\\/][^\\/]*$/, ''); + const relativeFolder = notebookRelativeDirectoryPath(notebookPath, folderPath) ?? ''; + const template = parseCollectionDocumentV2(createFolderCollectionContent(type === 'table' ? 'notes' : 'media', current.collection.name, relativeFolder)); const next = { ...template.collection, id: current.collection.id, diff --git a/app/flowix-web/features/collection/model.ts b/app/flowix-web/features/collection/model.ts index 614fd5b14..e202aeeb2 100644 --- a/app/flowix-web/features/collection/model.ts +++ b/app/flowix-web/features/collection/model.ts @@ -91,6 +91,19 @@ function ruleFilterMatchesCollectionType(filter: unknown, type: CollectionType): return false; }); } +function datasetFilterMatchesCollectionType(filter: unknown, type: CollectionType): boolean { + if (!isRecord(filter) || !hasOnlyKeys(filter, ['path', 'match', 'conditions'])) { + return ruleFilterMatchesCollectionType(filter, type); + } + if (!(filter.path === null || filter.path === '' || isNotebookRelativePath(filter.path)) + || (filter.match !== 'all' && filter.match !== 'any') + || !Array.isArray(filter.conditions) + || filter.conditions.some((condition) => !isRecord(condition) || 'all' in condition || 'any' in condition)) return false; + if (type === 'media_library' && filter.match !== 'all') return false; + const legacyShape = { all: filter.conditions, any: [] }; + if (!filter.conditions.every((condition) => isRecord(condition) && condition.field !== 'path')) return false; + return ruleFilterMatchesCollectionType(legacyShape, type); +} export function hasOnlyKeys(value: object, keys: readonly string[]): boolean { return Object.keys(value).every((key) => keys.includes(key)); } @@ -186,7 +199,7 @@ function validateV2Envelope(value: Record): { collection: Colle || (collection.type === 'media_library' && (sort.field !== 'created_at' || sort.direction !== 'desc')))))) throw new Error('集合视图筛选、排序或布局无效'); // A deleted remembered table view is recoverable; the table model selects the first view. if (collection.type !== 'table' && !viewItems.some((view) => isRecord(view) && view.id === views.default_id)) throw new Error('默认集合视图不存在'); - if (value.payload.dataset.filter !== null && value.payload.dataset.filter !== undefined && !ruleFilterMatchesCollectionType(value.payload.dataset.filter, collection.type)) { + if (value.payload.dataset.filter !== null && value.payload.dataset.filter !== undefined && !datasetFilterMatchesCollectionType(value.payload.dataset.filter, collection.type)) { throw new Error('集合规则筛选格式无效'); } diff --git a/app/flowix-web/features/collection/recovery.test.ts b/app/flowix-web/features/collection/recovery.test.ts new file mode 100644 index 000000000..9e27e531d --- /dev/null +++ b/app/flowix-web/features/collection/recovery.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import YAML from 'yaml'; +import { externalDocuments } from '@platform/tauri/client'; +import { createTableDocument, parseTableDocument, serializeTableDocument } from '@features/multidimensional-table/model'; +import { createMediaLibrary, parseMediaLibrary, serializeMediaLibrary } from '@features/media-library/model'; +import { createFolderCollectionContent } from './folder-collection'; +import { repairCollectionFile } from './recovery'; + +vi.mock('@platform/tauri/client', () => ({ externalDocuments: { read: vi.fn(), write: vi.fn() } })); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(externalDocuments.write).mockImplementation(async ({ filePath, content }) => ({ status: 'saved', path: filePath, content })); +}); + +it.each([ + ['notes', 'standalone'], ['notes', 'folder'], ['media', 'standalone'], ['media', 'folder'], +] as const)('recovers %s content with invalid placement at a %s location', async (type, placement) => { + const validSource = placement === 'folder' ? createFolderCollectionContent(type, '项目', '11测试') + : type === 'notes' ? serializeTableDocument(createTableDocument('calendar', '项目', 'standalone')) + : serializeMediaLibrary(createMediaLibrary('项目')); + const wire = YAML.parse(validSource); + const record = { + id: 'rec_01a12e0b2ab97d089eeff757ebd4e643', updated_at: '2026-10-12T02:57:27.436Z', + ...(type === 'notes' ? { note_path: 'Welcome111.md' } : { relative_path: 'photo.png', resource_id: 'image-1' }), + }; + wire.payload.dataset.records.include.push(record); + wire.collection.placement = 'standalone1'; + const damaged = YAML.stringify(wire); + vi.mocked(externalDocuments.read).mockResolvedValue(damaged); + const path = `/book/11测试/${placement === 'folder' ? '.index.yaml' : '新建多维日历.index.yaml'}`; + const result = await repairCollectionFile(path, '/book'); + const write = vi.mocked(externalDocuments.write).mock.calls[0][0]; + const repaired = type === 'notes' ? parseTableDocument(write.content) : parseMediaLibrary(write.content); + expect(write).toMatchObject({ filePath: path, scopePath: '/book', expectedContent: damaged }); + expect(result.placement).toBe(placement); + expect(repaired.collection.id).toBe(wire.collection.id); + expect(repaired.dataset.records.include).toEqual([record]); + expect(repaired.views).toEqual(wire.payload.views); + if (placement === 'folder') expect(repaired.dataset.source).toEqual({ scope: 'containing_folder', depth: 'descendants' }); +}); + +it('falls back to a valid table when the file has no recoverable YAML or collection type', async () => { + vi.mocked(externalDocuments.read).mockResolvedValue('collection: [\ninvalid: {'); + const result = await repairCollectionFile('/book/11测试/broken.index.yaml', '/book'); + expect(result).toMatchObject({ collectionType: 'table', placement: 'standalone' }); + const write = vi.mocked(externalDocuments.write).mock.calls[0][0]; + expect(() => parseTableDocument(write.content)).not.toThrow(); +}); diff --git a/app/flowix-web/features/collection/recovery.ts b/app/flowix-web/features/collection/recovery.ts new file mode 100644 index 000000000..acd3d9dbd --- /dev/null +++ b/app/flowix-web/features/collection/recovery.ts @@ -0,0 +1,280 @@ +import YAML from 'yaml'; +import { externalDocuments } from '@platform/tauri/client'; +import { canonicalPath, notebookRelativeDirectoryPath } from '@/lib/path'; +import { createFolderCollectionContent } from './folder-collection'; +import { + createCollectionId, + createUuidV7, + isRecord, + parseCollectionDocumentV2, + serializeCollectionDocumentV2, + validateCollectionProperty, + type CollectionDocumentV2, + type CollectionMetadata, + type CollectionPlacement, + type CollectionProperty, + type CollectionType, +} from './model'; +import { createTableDocument, createTableView, parseTableDocument, serializeTableDocument, type MultidimensionalTableDocument } from '@features/multidimensional-table/model'; +import { createMediaLibrary, parseMediaLibrary, serializeMediaLibrary } from '@features/media-library/model'; + +export interface CollectionRecoveryResult { + collectionId: string; + collectionType: CollectionType; + placement: CollectionPlacement; + name: string; +} + +function parseLooseDocument(source: string): Record | null { + try { + const value: unknown = YAML.parse(source); + return isRecord(value) ? value : null; + } catch { + // parseDocument can retain a useful partial YAML tree when a later block + // is malformed. Keep that partial tree for record-level salvage. + try { + const value: unknown = YAML.parseDocument(source, { prettyErrors: false }).toJS(); + return isRecord(value) ? value : null; + } catch { return null; } + } +} + +function collectionTypeFromWire(value: unknown): CollectionType | null { + if (value === 'notes' || value === 'table') return 'table'; + if (value === 'media' || value === 'media_library') return 'media_library'; + return null; +} + +function detectCollectionType(source: string, loose: Record | null): CollectionType { + const collection = loose && isRecord(loose.collection) ? loose.collection : null; + const parsed = collectionTypeFromWire(collection?.type); + if (parsed) return parsed; + // A syntax error elsewhere in the YAML should not hide a readable type. + const collectionBlock = source.match(/^collection:\s*\r?\n((?:^[ \t]+.*(?:\r?\n|$))*)/m)?.[1] ?? ''; + const wireType = collectionBlock.match(/^[ \t]{2,}type:\s*(notes|media|table|media_library)\s*$/m)?.[1]; + return collectionTypeFromWire(wireType) ?? 'table'; +} + +/** Identify the renderer without requiring the damaged document to validate. */ +export function inspectCollectionRecoveryType(source: string): CollectionType { + return detectCollectionType(source, parseLooseDocument(source)); +} + +function detectPlacement(filePath: string): CollectionPlacement { + // Placement is tied to the collection's location. A standalone collection + // cannot become a folder collection merely because its metadata is corrupt. + return canonicalPath(filePath).endsWith('/.index.yaml') ? 'folder' : 'standalone'; +} + +function validNotebookRelativePath(value: unknown): value is string { + return typeof value === 'string' && Boolean(value) && !value.startsWith('/') && !value.includes('\\') + && value.split('/').every((part) => part && part !== '.' && part !== '..'); +} + +function validTimestamp(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + +function validRecordId(value: unknown): value is string { + return typeof value === 'string' && /^rec_[0-9a-f]{32}$/.test(value); +} + +function sourceRecords( + loose: Record | null, +): { include: unknown[]; exclude: unknown[] } { + const payload = loose && isRecord(loose.payload) ? loose.payload : null; + const dataset = payload && isRecord(payload.dataset) ? payload.dataset : null; + const records = dataset && isRecord(dataset.records) ? dataset.records : null; + if (records) return { + include: Array.isArray(records.include) ? records.include : [], + exclude: Array.isArray(records.exclude) ? records.exclude : [], + }; + // Read the common v1 shape as well as v2. Legacy data rows become explicit + // includes; malformed fields are stripped by the type-specific sanitizer. + const legacyRecords = payload && isRecord(payload.records) ? payload.records : null; + return { include: Array.isArray(legacyRecords?.data) ? legacyRecords.data : [], exclude: [] }; +} + +function sanitizeRecords( + loose: Record | null, + type: CollectionType, +): { include: Record[]; exclude: Record[] } { + const records = sourceRecords(loose); + const sanitize = (values: unknown[]) => { + const seen = new Set(); + const seenIds = new Set(); + const output: Record[] = []; + for (const raw of values) { + if (!isRecord(raw)) continue; + const path = type === 'table' ? raw.note_path : raw.relative_path; + if (!validNotebookRelativePath(path) || seen.has(path)) continue; + if (type === 'media_library' && (typeof raw.resource_id !== 'string' || !raw.resource_id.trim())) continue; + seen.add(path); + const recordId = validRecordId(raw.id) && !seenIds.has(raw.id) ? raw.id : `rec_${createUuidV7()}`; + seenIds.add(recordId); + output.push({ + id: recordId, + updated_at: validTimestamp(raw.updated_at) ? raw.updated_at : new Date().toISOString(), + ...(type === 'table' ? { note_path: path } : { resource_id: (raw.resource_id as string).trim(), relative_path: path }), + }); + } + return output; + }; + return { include: sanitize(records.include), exclude: sanitize(records.exclude) }; +} + +function recoveredMetadata( + loose: Record | null, + type: CollectionType, + placement: CollectionPlacement, + fallbackName: string, +): CollectionMetadata { + const raw = loose && isRecord(loose.collection) ? loose.collection : {}; + const now = new Date().toISOString(); + const properties: Record = {}; + if (isRecord(raw.properties)) { + for (const [key, value] of Object.entries(raw.properties)) { + if (validateCollectionProperty(value)) properties[key] = value; + } + } + const rawName = typeof raw.name === 'string' ? raw.name.trim() : ''; + return { + id: typeof raw.id === 'string' && /^col_[0-9a-f]{32}$/.test(raw.id) ? raw.id : createCollectionId(), + type, + placement, + name: rawName || fallbackName, + revision: Number.isSafeInteger(raw.revision) && Number(raw.revision) >= 0 ? Number(raw.revision) + 1 : 1, + created_at: validTimestamp(raw.created_at) ? raw.created_at : now, + updated_at: now, + properties, + }; +} + +function fallbackCollectionName(filePath: string, type: CollectionType): string { + const filename = filePath.split(/[\\/]/).filter(Boolean).pop() ?? ''; + const withoutExtension = filename.replace(/(?:\.index\.ya?ml|\.(?:table|lib)\.ya?ml)$/i, '').trim(); + return withoutExtension || (type === 'table' ? '多维表格' : '媒体库'); +} + +function createTemplate( + filePath: string, + notebookPath: string, + type: CollectionType, + placement: CollectionPlacement, + name: string, +): CollectionDocumentV2 { + if (placement === 'folder') { + const directory = canonicalPath(filePath).replace(/[\\/][^\\/]*$/, ''); + const relativeDirectory = notebookRelativeDirectoryPath(notebookPath, directory) ?? ''; + return parseCollectionDocumentV2(createFolderCollectionContent(type === 'table' ? 'notes' : 'media', name, relativeDirectory)); + } + if (type === 'table') return parseCollectionDocumentV2(serializeTableDocument(createTableDocument('table', name, 'standalone'))); + const media = createMediaLibrary(name); + media.collection.placement = 'standalone'; + // A damaged standalone media library with no readable filter should reopen + // with the notebook's discoverable media instead of an empty gallery. + media.dataset.filter = { all: [{ field: 'kind', op: 'in', value: ['image', 'video'] }], any: [] }; + return parseCollectionDocumentV2(serializeMediaLibrary(media)); +} + +function tryCollection(candidate: CollectionDocumentV2): CollectionDocumentV2 | null { + try { + const source = serializeCollectionDocumentV2(candidate); + const parsed = parseCollectionDocumentV2(source); + if (parsed.collection.type === 'table') parseTableDocument(source); + else parseMediaLibrary(source); + return parsed; + } + catch { return null; } +} + +function buildRecoveredDocument( + filePath: string, + notebookPath: string, + loose: Record | null, + type: CollectionType, + placement: CollectionPlacement, +): CollectionDocumentV2 { + const name = fallbackCollectionName(filePath, type); + const template = createTemplate(filePath, notebookPath, type, placement, name); + const collection = recoveredMetadata(loose, type, placement, name); + const dataset = template.payload.dataset; + const incoming = loose && isRecord(loose.payload) && isRecord(loose.payload.dataset) ? loose.payload.dataset : null; + let candidate: CollectionDocumentV2 = { + ...template, + collection, + payload: { ...template.payload, dataset: { ...dataset, records: sanitizeRecords(loose, type) } }, + }; + + // Keep each optional dataset section only when it passes the full collection + // validator in combination with the repaired metadata and records. + if (incoming && isRecord(incoming.source) && placement === 'standalone') { + const next = { ...candidate, payload: { ...candidate.payload, dataset: { ...candidate.payload.dataset, source: incoming.source } } }; + candidate = tryCollection(next) ?? candidate; + } + if (incoming && (incoming.filter === null || isRecord(incoming.filter))) { + const next = { ...candidate, payload: { ...candidate.payload, dataset: { ...candidate.payload.dataset, filter: incoming.filter as Record | null } } }; + candidate = tryCollection(next) ?? candidate; + } + if (incoming && isRecord(incoming.schema)) { + const datasetWithSchema = { ...candidate.payload.dataset, schema: incoming.schema }; + let next: CollectionDocumentV2 = { ...candidate, payload: { ...candidate.payload, dataset: datasetWithSchema } }; + if (type === 'table') { + try { + const tableDocument = { + collection, + dataset: datasetWithSchema, + views: { default_id: '', items: [] }, + } as unknown as MultidimensionalTableDocument; + const defaultView = createTableView(tableDocument, 'table'); + next = { ...next, payload: { ...next.payload, views: { default_id: defaultView.id, items: [defaultView] } } }; + } catch { /* Invalid custom fields fall back to the known template below. */ } + } + candidate = tryCollection(next) ?? candidate; + } + + // Retain custom views only as a valid set. A broken or stale view set falls + // back to the generated table/media view, which is always known to validate. + const incomingViews = loose && isRecord(loose.payload) && isRecord(loose.payload.views) ? loose.payload.views : null; + if (incomingViews && Array.isArray(incomingViews.items)) { + const requestedDefault = typeof incomingViews.default_id === 'string' ? incomingViews.default_id : ''; + const validViews = incomingViews.items.filter(isRecord); + const defaultExists = validViews.some((view) => view.id === requestedDefault); + const nextViews = { + default_id: defaultExists ? requestedDefault : (typeof validViews[0]?.id === 'string' ? validViews[0].id : candidate.payload.views.default_id), + items: validViews, + }; + const next = { ...candidate, payload: { ...candidate.payload, views: nextViews } }; + candidate = tryCollection(next) ?? candidate; + } + return tryCollection(candidate) ?? template; +} + +/** Repair a malformed collection in place while retaining every valid record/config section. */ +export async function repairCollectionFile( + filePath: string, + notebookPath: string, +): Promise { + const root = canonicalPath(notebookPath); + const path = canonicalPath(filePath); + const source = await externalDocuments.read(path, root); + const loose = parseLooseDocument(source); + const collectionType = detectCollectionType(source, loose); + const placement = detectPlacement(path); + const name = fallbackCollectionName(path, collectionType); + const document = buildRecoveredDocument(path, root, loose, collectionType, placement); + const content = serializeCollectionDocumentV2(document); + // Validate the exact bytes before touching disk; serialization must produce + // a complete document accepted by the normal reader. + const verified = parseCollectionDocumentV2(content); + const result = await externalDocuments.write({ filePath: path, scopePath: root, expectedContent: source, content }); + if (result.status === 'conflict') throw new Error('集合文件已变化,请重新点击“重置当前视图”'); + if (result.status === 'missing') throw new Error('集合文件已不存在'); + if (result.status === 'error') throw new Error(result.message); + return { + collectionId: verified.collection.id, + collectionType: verified.collection.type, + placement: verified.collection.placement, + name: verified.collection.name || name, + }; +} diff --git a/app/flowix-web/features/collection/rule-filter.ts b/app/flowix-web/features/collection/rule-filter.ts index 555d2100e..5282fb3ae 100644 --- a/app/flowix-web/features/collection/rule-filter.ts +++ b/app/flowix-web/features/collection/rule-filter.ts @@ -5,6 +5,15 @@ export interface CollectionRuleFilter { export type CollectionRuleEntry = CollectionRuleFilter | { field: string; op: string; value: unknown }; +export interface DatasetRuleFilter { + /** Mandatory notebook-relative directory prefix; null means no extra path restriction. */ + path: string | null; + match: 'all' | 'any'; + conditions: Array<{ field: string; op: string; value: unknown }>; +} + +export type DatasetFilter = CollectionRuleFilter | DatasetRuleFilter; + function isGroup(entry: CollectionRuleEntry): entry is CollectionRuleFilter { return 'all' in entry && 'any' in entry; } @@ -20,17 +29,40 @@ export function matchesCollectionFilter( return filter.all.every(matches) && (filter.any.length === 0 || filter.any.some(matches)); } -export function collectionFilterUsesField(filter: CollectionRuleFilter | null, field: string): boolean { +/** Dataset paths are mandatory; the remaining rules use one explicit match mode. */ +export function matchesDatasetFilter( + filter: DatasetFilter, + matchesRule: (rule: { field: string; op: string; value: unknown }) => boolean, +): boolean { + if ('conditions' in filter && 'match' in filter) { + const pathMatches = filter.path === null + || matchesRule({ field: 'path', op: 'contains', value: filter.path }); + if (!pathMatches) return false; + if (filter.conditions.length === 0) return true; + return filter.match === 'all' + ? filter.conditions.every(matchesRule) + : filter.conditions.some(matchesRule); + } + return matchesCollectionFilter(filter, matchesRule as (rule: Exclude) => boolean); +} + +export function collectionFilterUsesField(filter: DatasetFilter | null, field: string): boolean { if (!filter) return false; + if ('conditions' in filter && 'match' in filter) { + return filter.conditions.some((rule) => rule.field === field); + } const uses = (entry: CollectionRuleEntry): boolean => isGroup(entry) ? collectionFilterUsesField(entry, field) : entry.field === field; return [...filter.all, ...filter.any].some(uses); } -export function renameCollectionFilterField(filter: CollectionRuleFilter, previous: string, next: string): CollectionRuleFilter { +export function renameCollectionFilterField(filter: T, previous: string, next: string): T { + if ('conditions' in filter && 'match' in filter) { + return { ...filter, conditions: filter.conditions.map((rule) => rule.field === previous ? { ...rule, field: next } : rule) } as T; + } const rename = (entry: CollectionRuleEntry): CollectionRuleEntry => isGroup(entry) ? renameCollectionFilterField(entry, previous, next) : entry.field === previous ? { ...entry, field: next } : entry; - return { ...filter, all: filter.all.map(rename), any: filter.any.map(rename) }; + return { ...filter, all: filter.all.map(rename), any: filter.any.map(rename) } as T; } diff --git a/app/flowix-web/features/content/components/content-icon.tsx b/app/flowix-web/features/content/components/content-icon.tsx new file mode 100644 index 000000000..8047fc14e --- /dev/null +++ b/app/flowix-web/features/content/components/content-icon.tsx @@ -0,0 +1,186 @@ +import { forwardRef } from 'react'; +import type { Icon, IconProps } from '@phosphor-icons/react'; +import { Folder, Globe } from 'lucide-react'; +import { + FileCodeIcon, + FileDocIcon, + FileHtmlIcon, + FileImageIcon, + FileMdIcon, + FilePdfIcon, + FilePptIcon, + FileTextIcon, + FileVideoIcon, + FileXlsIcon, + FileZipIcon, +} from '@phosphor-icons/react'; +import { classifyFileContent, type FileContentKind } from '@features/content/model/content-kind'; +import { NoteContentIcon } from './note-content-icon'; + +export type ContentFileIconKind = FileContentKind | 'fallback'; + +const PHOSPHOR_ICON_BY_KIND: Record = { + note: FileMdIcon, + html: FileHtmlIcon, + document: FileDocIcon, + presentation: FilePptIcon, + spreadsheet: FileXlsIcon, + pdf: FilePdfIcon, + video: FileVideoIcon, + archive: FileZipIcon, + image: FileImageIcon, + code: FileCodeIcon, + fallback: FileTextIcon, + text: FileTextIcon, + unknown: FileTextIcon, +}; + +/** Flat icon categories; special resources sit beside file categories. */ +export type ContentKind = FileContentKind | 'folder' | 'web' | 'media-library' | 'multidimensional-table'; + +export type ContentDescription = + | { kind: 'file'; path: string; mimeType?: string | null } + | { kind: ContentKind }; + +/** One renderer for content icons; callers provide facts and layout only. */ +export function ContentIcon({ content, className }: { content: ContentDescription; className?: string }) { + const kind = content.kind === 'file' + ? getFileIconKind(content.path, content.mimeType) + : content.kind; + if (kind === 'folder') return
; @@ -117,7 +118,7 @@ export function PropertyPresetMenu({ }} placeholder={t('document.properties.picker.search')} aria-label={t('document.properties.picker.search')} - className="agent-thread-card__composer-note-search multidimensional-table__preset-search shrink-0" + className="agent-composer__note-search multidimensional-table__preset-search shrink-0" />