Repository: ChanIok/SpinningMomo Branch: main Commit: 80c6506b1c77 Files: 819 Total size: 3.1 MB Directory structure: gitextract_0hhlykuf/ ├── .clang-format ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── build-release.yml │ └── deploy-docs.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .vscode/ │ ├── c_cpp_properties.json │ ├── launch.json │ └── settings.json ├── AGENTS.md ├── CREDITS.md ├── LEGAL.md ├── LICENSE ├── README.md ├── cliff.toml ├── docs/ │ ├── .vitepress/ │ │ ├── config.ts │ │ ├── seo.ts │ │ └── theme/ │ │ ├── custom.css │ │ └── index.ts │ ├── en/ │ │ ├── about/ │ │ │ ├── credits.md │ │ │ └── legal.md │ │ ├── developer/ │ │ │ └── architecture.md │ │ ├── features/ │ │ │ ├── recording.md │ │ │ ├── screenshot.md │ │ │ └── window.md │ │ ├── guide/ │ │ │ └── getting-started.md │ │ └── index.md │ ├── index.md │ ├── package.json │ ├── public/ │ │ ├── robots.txt │ │ └── version.txt │ ├── v0/ │ │ ├── en/ │ │ │ └── index.md │ │ ├── index.md │ │ └── zh/ │ │ ├── advanced/ │ │ │ ├── custom-settings.md │ │ │ └── troubleshooting.md │ │ └── guide/ │ │ ├── features.md │ │ ├── getting-started.md │ │ └── introduction.md │ └── zh/ │ ├── about/ │ │ ├── credits.md │ │ └── legal.md │ ├── developer/ │ │ └── architecture.md │ ├── features/ │ │ ├── recording.md │ │ ├── screenshot.md │ │ └── window.md │ └── guide/ │ └── getting-started.md ├── installer/ │ ├── Bundle.wxs │ ├── CleanupAppDataRoot.js │ ├── DetectRunningSpinningMomo.js │ ├── License.rtf │ ├── Package.en-us.wxl │ ├── Package.wxs │ └── bundle/ │ ├── payloads/ │ │ └── 2052/ │ │ └── thm.wxl │ └── thm.wxl ├── package.json ├── resources/ │ ├── app.manifest │ └── app.rc ├── scripts/ │ ├── build-msi.ps1 │ ├── build-portable.js │ ├── fetch-third-party.ps1 │ ├── format-cpp.js │ ├── format-web.js │ ├── generate-checksums.js │ ├── generate-embedded-locales.js │ ├── generate-map-injection-cpp.js │ ├── generate-migrations.js │ ├── prepare-dist.js │ ├── quick-cleanup-spinningmomo.ps1 │ └── release-version.js ├── src/ │ ├── app.cpp │ ├── app.ixx │ ├── core/ │ │ ├── async/ │ │ │ ├── async.cpp │ │ │ ├── async.ixx │ │ │ ├── state.ixx │ │ │ └── ui_awaitable.ixx │ │ ├── commands/ │ │ │ ├── builtin.cpp │ │ │ ├── registry.cpp │ │ │ ├── registry.ixx │ │ │ └── state.ixx │ │ ├── database/ │ │ │ ├── data_mapper.ixx │ │ │ ├── database.cpp │ │ │ ├── database.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── dialog_service/ │ │ │ ├── dialog_service.cpp │ │ │ ├── dialog_service.ixx │ │ │ └── state.ixx │ │ ├── events/ │ │ │ ├── events.cpp │ │ │ ├── events.ixx │ │ │ ├── handlers/ │ │ │ │ ├── feature_handlers.cpp │ │ │ │ ├── feature_handlers.ixx │ │ │ │ ├── settings_handlers.cpp │ │ │ │ ├── settings_handlers.ixx │ │ │ │ ├── system_handlers.cpp │ │ │ │ └── system_handlers.ixx │ │ │ ├── registrar.cpp │ │ │ ├── registrar.ixx │ │ │ └── state.ixx │ │ ├── http_client/ │ │ │ ├── http_client.cpp │ │ │ ├── http_client.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── http_server/ │ │ │ ├── http_server.cpp │ │ │ ├── http_server.ixx │ │ │ ├── routes.cpp │ │ │ ├── routes.ixx │ │ │ ├── sse_manager.cpp │ │ │ ├── sse_manager.ixx │ │ │ ├── state.ixx │ │ │ ├── static.cpp │ │ │ ├── static.ixx │ │ │ └── types.ixx │ │ ├── i18n/ │ │ │ ├── embedded/ │ │ │ │ ├── en_us.ixx │ │ │ │ └── zh_cn.ixx │ │ │ ├── i18n.cpp │ │ │ ├── i18n.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── initializer/ │ │ │ ├── database.cpp │ │ │ ├── database.ixx │ │ │ ├── initializer.cpp │ │ │ └── initializer.ixx │ │ ├── migration/ │ │ │ ├── generated/ │ │ │ │ ├── schema.ixx │ │ │ │ ├── schema_001.ixx │ │ │ │ ├── schema_002.ixx │ │ │ │ └── schema_003.ixx │ │ │ ├── migration.cpp │ │ │ ├── migration.ixx │ │ │ └── scripts/ │ │ │ ├── scripts.cpp │ │ │ └── scripts.ixx │ │ ├── rpc/ │ │ │ ├── endpoints/ │ │ │ │ ├── clipboard/ │ │ │ │ │ ├── clipboard.cpp │ │ │ │ │ └── clipboard.ixx │ │ │ │ ├── dialog/ │ │ │ │ │ ├── dialog.cpp │ │ │ │ │ └── dialog.ixx │ │ │ │ ├── extensions/ │ │ │ │ │ ├── extensions.cpp │ │ │ │ │ └── extensions.ixx │ │ │ │ ├── file/ │ │ │ │ │ ├── file.cpp │ │ │ │ │ └── file.ixx │ │ │ │ ├── gallery/ │ │ │ │ │ ├── asset.cpp │ │ │ │ │ ├── asset.ixx │ │ │ │ │ ├── folder.cpp │ │ │ │ │ ├── folder.ixx │ │ │ │ │ ├── gallery.cpp │ │ │ │ │ ├── gallery.ixx │ │ │ │ │ ├── tag.cpp │ │ │ │ │ └── tag.ixx │ │ │ │ ├── registry/ │ │ │ │ │ ├── registry.cpp │ │ │ │ │ └── registry.ixx │ │ │ │ ├── runtime_info/ │ │ │ │ │ ├── runtime_info.cpp │ │ │ │ │ └── runtime_info.ixx │ │ │ │ ├── settings/ │ │ │ │ │ ├── settings.cpp │ │ │ │ │ └── settings.ixx │ │ │ │ ├── tasks/ │ │ │ │ │ ├── tasks.cpp │ │ │ │ │ └── tasks.ixx │ │ │ │ ├── update/ │ │ │ │ │ ├── update.cpp │ │ │ │ │ └── update.ixx │ │ │ │ ├── webview/ │ │ │ │ │ ├── webview.cpp │ │ │ │ │ └── webview.ixx │ │ │ │ └── window_control/ │ │ │ │ ├── window_control.cpp │ │ │ │ └── window_control.ixx │ │ │ ├── notification_hub.cpp │ │ │ ├── notification_hub.ixx │ │ │ ├── registry.cpp │ │ │ ├── registry.ixx │ │ │ ├── rpc.cpp │ │ │ ├── rpc.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── runtime_info/ │ │ │ ├── runtime_info.cpp │ │ │ └── runtime_info.ixx │ │ ├── shutdown/ │ │ │ ├── shutdown.cpp │ │ │ └── shutdown.ixx │ │ ├── state/ │ │ │ ├── app_state.cpp │ │ │ ├── app_state.ixx │ │ │ └── runtime_info.ixx │ │ ├── tasks/ │ │ │ ├── state.ixx │ │ │ ├── tasks.cpp │ │ │ └── tasks.ixx │ │ ├── webview/ │ │ │ ├── events.ixx │ │ │ ├── host.cpp │ │ │ ├── host.ixx │ │ │ ├── rpc_bridge.cpp │ │ │ ├── rpc_bridge.ixx │ │ │ ├── state.ixx │ │ │ ├── static.cpp │ │ │ ├── static.ixx │ │ │ ├── types.ixx │ │ │ ├── webview.cpp │ │ │ └── webview.ixx │ │ └── worker_pool/ │ │ ├── state.ixx │ │ ├── worker_pool.cpp │ │ └── worker_pool.ixx │ ├── extensions/ │ │ └── infinity_nikki/ │ │ ├── game_directory.cpp │ │ ├── game_directory.ixx │ │ ├── generated/ │ │ │ └── map_injection_script.ixx │ │ ├── map_service.cpp │ │ ├── map_service.ixx │ │ ├── photo_extract/ │ │ │ ├── infra.cpp │ │ │ ├── infra.ixx │ │ │ ├── photo_extract.cpp │ │ │ ├── photo_extract.ixx │ │ │ ├── scan.cpp │ │ │ └── scan.ixx │ │ ├── photo_service.cpp │ │ ├── photo_service.ixx │ │ ├── screenshot_hardlinks.cpp │ │ ├── screenshot_hardlinks.ixx │ │ ├── task_service.cpp │ │ ├── task_service.ixx │ │ └── types.ixx │ ├── features/ │ │ ├── gallery/ │ │ │ ├── asset/ │ │ │ │ ├── infinity_nikki_metadata_dict.cpp │ │ │ │ ├── infinity_nikki_metadata_dict.ixx │ │ │ │ ├── repository.cpp │ │ │ │ ├── repository.ixx │ │ │ │ ├── service.cpp │ │ │ │ ├── service.ixx │ │ │ │ ├── thumbnail.cpp │ │ │ │ └── thumbnail.ixx │ │ │ ├── color/ │ │ │ │ ├── extractor.cpp │ │ │ │ ├── extractor.ixx │ │ │ │ ├── filter.cpp │ │ │ │ ├── filter.ixx │ │ │ │ ├── repository.cpp │ │ │ │ ├── repository.ixx │ │ │ │ └── types.ixx │ │ │ ├── folder/ │ │ │ │ ├── repository.cpp │ │ │ │ ├── repository.ixx │ │ │ │ ├── service.cpp │ │ │ │ └── service.ixx │ │ │ ├── gallery.cpp │ │ │ ├── gallery.ixx │ │ │ ├── ignore/ │ │ │ │ ├── matcher.cpp │ │ │ │ ├── matcher.ixx │ │ │ │ ├── repository.cpp │ │ │ │ ├── repository.ixx │ │ │ │ ├── service.cpp │ │ │ │ └── service.ixx │ │ │ ├── original_locator.cpp │ │ │ ├── original_locator.ixx │ │ │ ├── recovery/ │ │ │ │ ├── repository.cpp │ │ │ │ ├── repository.ixx │ │ │ │ ├── service.cpp │ │ │ │ ├── service.ixx │ │ │ │ └── types.ixx │ │ │ ├── scan_common.cpp │ │ │ ├── scan_common.ixx │ │ │ ├── scanner.cpp │ │ │ ├── scanner.ixx │ │ │ ├── state.ixx │ │ │ ├── static_resolver.cpp │ │ │ ├── static_resolver.ixx │ │ │ ├── tag/ │ │ │ │ ├── repository.cpp │ │ │ │ ├── repository.ixx │ │ │ │ ├── service.cpp │ │ │ │ └── service.ixx │ │ │ ├── types.ixx │ │ │ ├── watcher.cpp │ │ │ └── watcher.ixx │ │ ├── letterbox/ │ │ │ ├── letterbox.cpp │ │ │ ├── letterbox.ixx │ │ │ ├── state.ixx │ │ │ ├── usecase.cpp │ │ │ └── usecase.ixx │ │ ├── notifications/ │ │ │ ├── constants.ixx │ │ │ ├── notifications.cpp │ │ │ ├── notifications.ixx │ │ │ └── state.ixx │ │ ├── overlay/ │ │ │ ├── capture.cpp │ │ │ ├── capture.ixx │ │ │ ├── geometry.cpp │ │ │ ├── geometry.ixx │ │ │ ├── interaction.cpp │ │ │ ├── interaction.ixx │ │ │ ├── overlay.cpp │ │ │ ├── overlay.ixx │ │ │ ├── rendering.cpp │ │ │ ├── rendering.ixx │ │ │ ├── shaders.ixx │ │ │ ├── state.ixx │ │ │ ├── threads.cpp │ │ │ ├── threads.ixx │ │ │ ├── types.ixx │ │ │ ├── usecase.cpp │ │ │ ├── usecase.ixx │ │ │ ├── window.cpp │ │ │ └── window.ixx │ │ ├── preview/ │ │ │ ├── capture.cpp │ │ │ ├── capture.ixx │ │ │ ├── interaction.cpp │ │ │ ├── interaction.ixx │ │ │ ├── preview.cpp │ │ │ ├── preview.ixx │ │ │ ├── rendering.cpp │ │ │ ├── rendering.ixx │ │ │ ├── shaders.ixx │ │ │ ├── state.ixx │ │ │ ├── types.ixx │ │ │ ├── usecase.cpp │ │ │ ├── usecase.ixx │ │ │ ├── viewport.cpp │ │ │ ├── viewport.ixx │ │ │ ├── window.cpp │ │ │ └── window.ixx │ │ ├── recording/ │ │ │ ├── audio_capture.cpp │ │ │ ├── audio_capture.ixx │ │ │ ├── recording.cpp │ │ │ ├── recording.ixx │ │ │ ├── state.ixx │ │ │ ├── types.ixx │ │ │ ├── usecase.cpp │ │ │ └── usecase.ixx │ │ ├── replay_buffer/ │ │ │ ├── disk_ring_buffer.cpp │ │ │ ├── disk_ring_buffer.ixx │ │ │ ├── motion_photo.cpp │ │ │ ├── motion_photo.ixx │ │ │ ├── muxer.cpp │ │ │ ├── muxer.ixx │ │ │ ├── replay_buffer.cpp │ │ │ ├── replay_buffer.ixx │ │ │ ├── state.ixx │ │ │ ├── types.ixx │ │ │ ├── usecase.cpp │ │ │ └── usecase.ixx │ │ ├── screenshot/ │ │ │ ├── screenshot.cpp │ │ │ ├── screenshot.ixx │ │ │ ├── state.ixx │ │ │ ├── usecase.cpp │ │ │ └── usecase.ixx │ │ ├── settings/ │ │ │ ├── background.cpp │ │ │ ├── background.ixx │ │ │ ├── compute.cpp │ │ │ ├── compute.ixx │ │ │ ├── events.ixx │ │ │ ├── menu.cpp │ │ │ ├── menu.ixx │ │ │ ├── migration.cpp │ │ │ ├── migration.ixx │ │ │ ├── registry.ixx │ │ │ ├── settings.cpp │ │ │ ├── settings.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── update/ │ │ │ ├── state.ixx │ │ │ ├── types.ixx │ │ │ ├── update.cpp │ │ │ └── update.ixx │ │ └── window_control/ │ │ ├── state.ixx │ │ ├── usecase.cpp │ │ ├── usecase.ixx │ │ ├── window_control.cpp │ │ └── window_control.ixx │ ├── locales/ │ │ ├── en-US.json │ │ └── zh-CN.json │ ├── main.cpp │ ├── migrations/ │ │ ├── 001_initial_schema.sql │ │ ├── 002_watch_root_recovery_state.sql │ │ └── 003_infinity_nikki_params_nuan5_columns.sql │ ├── ui/ │ │ ├── context_menu/ │ │ │ ├── context_menu.cpp │ │ │ ├── context_menu.ixx │ │ │ ├── d2d_context.cpp │ │ │ ├── d2d_context.ixx │ │ │ ├── interaction.cpp │ │ │ ├── interaction.ixx │ │ │ ├── layout.cpp │ │ │ ├── layout.ixx │ │ │ ├── message_handler.cpp │ │ │ ├── message_handler.ixx │ │ │ ├── painter.cpp │ │ │ ├── painter.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── floating_window/ │ │ │ ├── d2d_context.cpp │ │ │ ├── d2d_context.ixx │ │ │ ├── events.ixx │ │ │ ├── floating_window.cpp │ │ │ ├── floating_window.ixx │ │ │ ├── layout.cpp │ │ │ ├── layout.ixx │ │ │ ├── message_handler.cpp │ │ │ ├── message_handler.ixx │ │ │ ├── painter.cpp │ │ │ ├── painter.ixx │ │ │ ├── state.ixx │ │ │ └── types.ixx │ │ ├── tray_icon/ │ │ │ ├── state.ixx │ │ │ ├── tray_icon.cpp │ │ │ ├── tray_icon.ixx │ │ │ └── types.ixx │ │ └── webview_window/ │ │ ├── webview_window.cpp │ │ └── webview_window.ixx │ ├── utils/ │ │ ├── crash_dump/ │ │ │ ├── crash_dump.cpp │ │ │ └── crash_dump.ixx │ │ ├── crypto/ │ │ │ ├── crypto.cpp │ │ │ └── crypto.ixx │ │ ├── dialog/ │ │ │ ├── dialog.cpp │ │ │ └── dialog.ixx │ │ ├── file/ │ │ │ ├── file.cpp │ │ │ ├── file.ixx │ │ │ ├── mime.cpp │ │ │ └── mime.ixx │ │ ├── graphics/ │ │ │ ├── capture.cpp │ │ │ ├── capture.ixx │ │ │ ├── capture_region.cpp │ │ │ ├── capture_region.ixx │ │ │ ├── d3d.cpp │ │ │ └── d3d.ixx │ │ ├── image/ │ │ │ ├── image.cpp │ │ │ └── image.ixx │ │ ├── logger/ │ │ │ ├── logger.cpp │ │ │ └── logger.ixx │ │ ├── lru_cache.ixx │ │ ├── media/ │ │ │ ├── audio_capture.cpp │ │ │ ├── audio_capture.ixx │ │ │ ├── encoder.cpp │ │ │ ├── encoder.ixx │ │ │ ├── raw_encoder.cpp │ │ │ ├── raw_encoder.ixx │ │ │ ├── state.ixx │ │ │ ├── types.ixx │ │ │ ├── video_asset.cpp │ │ │ ├── video_asset.ixx │ │ │ ├── video_scaler.cpp │ │ │ └── video_scaler.ixx │ │ ├── path/ │ │ │ ├── path.cpp │ │ │ └── path.ixx │ │ ├── string/ │ │ │ └── string.ixx │ │ ├── system/ │ │ │ ├── system.cpp │ │ │ └── system.ixx │ │ ├── throttle/ │ │ │ └── throttle.ixx │ │ ├── time.ixx │ │ └── timer/ │ │ ├── timeout.cpp │ │ └── timeout.ixx │ └── vendor/ │ ├── build_config.ixx │ ├── shellapi.ixx │ ├── version.ixx │ ├── wil.ixx │ ├── windows.ixx │ ├── winhttp.ixx │ └── xxhash.ixx ├── tasks/ │ ├── build-all.lua │ ├── release.lua │ └── vs.lua ├── version.json ├── web/ │ ├── .gitignore │ ├── .prettierrc.json │ ├── .vscode/ │ │ └── extensions.json │ ├── README.md │ ├── components.json │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── App.vue │ │ ├── components/ │ │ │ ├── WindowTitlePickerButton.vue │ │ │ ├── layout/ │ │ │ │ ├── ActivityBar.vue │ │ │ │ ├── AppHeader.vue │ │ │ │ ├── AppLayout.vue │ │ │ │ ├── ContentArea.vue │ │ │ │ ├── GalleryDebugOverlay.vue │ │ │ │ ├── WindowResizeOverlay.vue │ │ │ │ └── index.ts │ │ │ └── ui/ │ │ │ ├── accordion/ │ │ │ │ ├── Accordion.vue │ │ │ │ ├── AccordionContent.vue │ │ │ │ ├── AccordionItem.vue │ │ │ │ ├── AccordionTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── alert/ │ │ │ │ ├── Alert.vue │ │ │ │ ├── AlertDescription.vue │ │ │ │ ├── AlertTitle.vue │ │ │ │ └── index.ts │ │ │ ├── alert-dialog/ │ │ │ │ ├── AlertDialog.vue │ │ │ │ ├── AlertDialogAction.vue │ │ │ │ ├── AlertDialogCancel.vue │ │ │ │ ├── AlertDialogContent.vue │ │ │ │ ├── AlertDialogDescription.vue │ │ │ │ ├── AlertDialogFooter.vue │ │ │ │ ├── AlertDialogHeader.vue │ │ │ │ ├── AlertDialogTitle.vue │ │ │ │ ├── AlertDialogTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── badge/ │ │ │ │ ├── Badge.vue │ │ │ │ └── index.ts │ │ │ ├── button/ │ │ │ │ ├── Button.vue │ │ │ │ └── index.ts │ │ │ ├── checkbox/ │ │ │ │ ├── Checkbox.vue │ │ │ │ └── index.ts │ │ │ ├── color-picker/ │ │ │ │ ├── ColorPicker.vue │ │ │ │ └── colorUtils.ts │ │ │ ├── context-menu/ │ │ │ │ ├── ContextMenu.vue │ │ │ │ ├── ContextMenuCheckboxItem.vue │ │ │ │ ├── ContextMenuContent.vue │ │ │ │ ├── ContextMenuGroup.vue │ │ │ │ ├── ContextMenuItem.vue │ │ │ │ ├── ContextMenuLabel.vue │ │ │ │ ├── ContextMenuPortal.vue │ │ │ │ ├── ContextMenuRadioGroup.vue │ │ │ │ ├── ContextMenuRadioItem.vue │ │ │ │ ├── ContextMenuSeparator.vue │ │ │ │ ├── ContextMenuShortcut.vue │ │ │ │ ├── ContextMenuSub.vue │ │ │ │ ├── ContextMenuSubContent.vue │ │ │ │ ├── ContextMenuSubTrigger.vue │ │ │ │ ├── ContextMenuTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── dialog/ │ │ │ │ ├── Dialog.vue │ │ │ │ ├── DialogClose.vue │ │ │ │ ├── DialogContent.vue │ │ │ │ ├── DialogDescription.vue │ │ │ │ ├── DialogFooter.vue │ │ │ │ ├── DialogHeader.vue │ │ │ │ ├── DialogOverlay.vue │ │ │ │ ├── DialogScrollContent.vue │ │ │ │ ├── DialogTitle.vue │ │ │ │ ├── DialogTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── dropdown-menu/ │ │ │ │ ├── DropdownMenu.vue │ │ │ │ ├── DropdownMenuCheckboxItem.vue │ │ │ │ ├── DropdownMenuContent.vue │ │ │ │ ├── DropdownMenuGroup.vue │ │ │ │ ├── DropdownMenuItem.vue │ │ │ │ ├── DropdownMenuLabel.vue │ │ │ │ ├── DropdownMenuRadioGroup.vue │ │ │ │ ├── DropdownMenuRadioItem.vue │ │ │ │ ├── DropdownMenuSeparator.vue │ │ │ │ ├── DropdownMenuShortcut.vue │ │ │ │ ├── DropdownMenuSub.vue │ │ │ │ ├── DropdownMenuSubContent.vue │ │ │ │ ├── DropdownMenuSubTrigger.vue │ │ │ │ ├── DropdownMenuTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── input/ │ │ │ │ ├── Input.vue │ │ │ │ └── index.ts │ │ │ ├── item/ │ │ │ │ ├── Item.vue │ │ │ │ ├── ItemActions.vue │ │ │ │ ├── ItemContent.vue │ │ │ │ ├── ItemDescription.vue │ │ │ │ ├── ItemFooter.vue │ │ │ │ ├── ItemGroup.vue │ │ │ │ ├── ItemHeader.vue │ │ │ │ ├── ItemMedia.vue │ │ │ │ ├── ItemSeparator.vue │ │ │ │ ├── ItemTitle.vue │ │ │ │ └── index.ts │ │ │ ├── label/ │ │ │ │ ├── Label.vue │ │ │ │ └── index.ts │ │ │ ├── popover/ │ │ │ │ ├── Popover.vue │ │ │ │ ├── PopoverAnchor.vue │ │ │ │ ├── PopoverContent.vue │ │ │ │ ├── PopoverTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── scroll-area/ │ │ │ │ ├── ScrollArea.vue │ │ │ │ ├── ScrollBar.vue │ │ │ │ └── index.ts │ │ │ ├── select/ │ │ │ │ ├── Select.vue │ │ │ │ ├── SelectContent.vue │ │ │ │ ├── SelectGroup.vue │ │ │ │ ├── SelectItem.vue │ │ │ │ ├── SelectItemText.vue │ │ │ │ ├── SelectLabel.vue │ │ │ │ ├── SelectScrollDownButton.vue │ │ │ │ ├── SelectScrollUpButton.vue │ │ │ │ ├── SelectSeparator.vue │ │ │ │ ├── SelectTrigger.vue │ │ │ │ ├── SelectValue.vue │ │ │ │ └── index.ts │ │ │ ├── separator/ │ │ │ │ ├── Separator.vue │ │ │ │ └── index.ts │ │ │ ├── sheet/ │ │ │ │ ├── Sheet.vue │ │ │ │ ├── SheetClose.vue │ │ │ │ ├── SheetContent.vue │ │ │ │ ├── SheetDescription.vue │ │ │ │ ├── SheetFooter.vue │ │ │ │ ├── SheetHeader.vue │ │ │ │ ├── SheetOverlay.vue │ │ │ │ ├── SheetTitle.vue │ │ │ │ ├── SheetTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── sidebar/ │ │ │ │ ├── Sidebar.vue │ │ │ │ ├── SidebarContent.vue │ │ │ │ ├── SidebarFooter.vue │ │ │ │ ├── SidebarGroup.vue │ │ │ │ ├── SidebarGroupAction.vue │ │ │ │ ├── SidebarGroupContent.vue │ │ │ │ ├── SidebarGroupLabel.vue │ │ │ │ ├── SidebarHeader.vue │ │ │ │ ├── SidebarInput.vue │ │ │ │ ├── SidebarInset.vue │ │ │ │ ├── SidebarMenu.vue │ │ │ │ ├── SidebarMenuAction.vue │ │ │ │ ├── SidebarMenuBadge.vue │ │ │ │ ├── SidebarMenuButton.vue │ │ │ │ ├── SidebarMenuButtonChild.vue │ │ │ │ ├── SidebarMenuItem.vue │ │ │ │ ├── SidebarMenuSkeleton.vue │ │ │ │ ├── SidebarMenuSub.vue │ │ │ │ ├── SidebarMenuSubButton.vue │ │ │ │ ├── SidebarMenuSubItem.vue │ │ │ │ ├── SidebarProvider.vue │ │ │ │ ├── SidebarRail.vue │ │ │ │ ├── SidebarSeparator.vue │ │ │ │ ├── SidebarTrigger.vue │ │ │ │ ├── index.ts │ │ │ │ └── utils.ts │ │ │ ├── skeleton/ │ │ │ │ ├── Skeleton.vue │ │ │ │ └── index.ts │ │ │ ├── slider/ │ │ │ │ ├── Slider.vue │ │ │ │ └── index.ts │ │ │ ├── sonner/ │ │ │ │ ├── Sonner.vue │ │ │ │ └── index.ts │ │ │ ├── split/ │ │ │ │ ├── Split.vue │ │ │ │ ├── index.ts │ │ │ │ └── useSplitResize.ts │ │ │ ├── switch/ │ │ │ │ ├── Switch.vue │ │ │ │ └── index.ts │ │ │ ├── tabs/ │ │ │ │ ├── Tabs.vue │ │ │ │ ├── TabsContent.vue │ │ │ │ ├── TabsList.vue │ │ │ │ ├── TabsTrigger.vue │ │ │ │ └── index.ts │ │ │ ├── textarea/ │ │ │ │ ├── Textarea.vue │ │ │ │ └── index.ts │ │ │ ├── toggle/ │ │ │ │ ├── Toggle.vue │ │ │ │ └── index.ts │ │ │ ├── toggle-group/ │ │ │ │ ├── ToggleGroup.vue │ │ │ │ ├── ToggleGroupItem.vue │ │ │ │ └── index.ts │ │ │ └── tooltip/ │ │ │ ├── Tooltip.vue │ │ │ ├── TooltipContent.vue │ │ │ ├── TooltipProvider.vue │ │ │ ├── TooltipTrigger.vue │ │ │ └── index.ts │ │ ├── composables/ │ │ │ ├── useI18n.ts │ │ │ ├── useRpc.ts │ │ │ └── useToast.ts │ │ ├── core/ │ │ │ ├── clipboard.ts │ │ │ ├── env/ │ │ │ │ └── index.ts │ │ │ ├── i18n/ │ │ │ │ ├── index.ts │ │ │ │ ├── locales/ │ │ │ │ │ ├── en-US/ │ │ │ │ │ │ ├── about.json │ │ │ │ │ │ ├── app.json │ │ │ │ │ │ ├── common.json │ │ │ │ │ │ ├── extensions.json │ │ │ │ │ │ ├── gallery.json │ │ │ │ │ │ ├── home.json │ │ │ │ │ │ ├── map.json │ │ │ │ │ │ ├── menu.json │ │ │ │ │ │ ├── onboarding.json │ │ │ │ │ │ └── settings.json │ │ │ │ │ └── zh-CN/ │ │ │ │ │ ├── about.json │ │ │ │ │ ├── app.json │ │ │ │ │ ├── common.json │ │ │ │ │ ├── extensions.json │ │ │ │ │ ├── gallery.json │ │ │ │ │ ├── home.json │ │ │ │ │ ├── map.json │ │ │ │ │ ├── menu.json │ │ │ │ │ ├── onboarding.json │ │ │ │ │ └── settings.json │ │ │ │ └── types.ts │ │ │ ├── rpc/ │ │ │ │ ├── core.ts │ │ │ │ ├── index.ts │ │ │ │ ├── transport/ │ │ │ │ │ ├── http.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── webview.ts │ │ │ │ └── types.ts │ │ │ └── tasks/ │ │ │ ├── store.ts │ │ │ └── types.ts │ │ ├── extensions/ │ │ │ └── infinity_nikki/ │ │ │ └── index.ts │ │ ├── features/ │ │ │ ├── about/ │ │ │ │ └── pages/ │ │ │ │ └── AboutPage.vue │ │ │ ├── common/ │ │ │ │ └── pages/ │ │ │ │ └── NotFoundPage.vue │ │ │ ├── gallery/ │ │ │ │ ├── api/ │ │ │ │ │ ├── dto.ts │ │ │ │ │ └── urls.ts │ │ │ │ ├── api.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── asset/ │ │ │ │ │ │ ├── AssetCard.vue │ │ │ │ │ │ ├── AssetDetailsContent.vue │ │ │ │ │ │ ├── AssetHistogram.vue │ │ │ │ │ │ ├── AssetListRow.vue │ │ │ │ │ │ ├── AssetReviewControls.vue │ │ │ │ │ │ └── MediaStatusChips.vue │ │ │ │ │ ├── dialogs/ │ │ │ │ │ │ └── GalleryScanDialog.vue │ │ │ │ │ ├── folders/ │ │ │ │ │ │ └── FolderTreeItem.vue │ │ │ │ │ ├── infinity_nikki/ │ │ │ │ │ │ ├── AssetInfinityNikkiDetails.vue │ │ │ │ │ │ ├── InfinityNikkiGuidePanel.vue │ │ │ │ │ │ └── InfinityNikkiMetadataExtractDialog.vue │ │ │ │ │ ├── lightbox/ │ │ │ │ │ │ ├── GalleryLightbox.vue │ │ │ │ │ │ ├── LightboxFilmstrip.vue │ │ │ │ │ │ ├── LightboxImage.vue │ │ │ │ │ │ ├── LightboxToolbar.vue │ │ │ │ │ │ └── LightboxVideo.vue │ │ │ │ │ ├── menus/ │ │ │ │ │ │ ├── GalleryAssetContextMenuContent.vue │ │ │ │ │ │ ├── GalleryAssetDropdownMenuContent.vue │ │ │ │ │ │ └── GallerySharedContextMenu.vue │ │ │ │ │ ├── shell/ │ │ │ │ │ │ ├── GalleryContent.vue │ │ │ │ │ │ ├── GalleryDetails.vue │ │ │ │ │ │ ├── GalleryScrollbarRail.vue │ │ │ │ │ │ ├── GallerySidebar.vue │ │ │ │ │ │ ├── GalleryToolbar.vue │ │ │ │ │ │ └── GalleryViewer.vue │ │ │ │ │ ├── tags/ │ │ │ │ │ │ ├── ReviewFilterPopover.vue │ │ │ │ │ │ ├── TagInlineEditor.vue │ │ │ │ │ │ ├── TagSelectorPopover.vue │ │ │ │ │ │ └── TagTreeItem.vue │ │ │ │ │ └── viewer/ │ │ │ │ │ ├── AdaptiveView.vue │ │ │ │ │ ├── GridTimelineRailBridge.vue │ │ │ │ │ ├── GridView.vue │ │ │ │ │ ├── ListView.vue │ │ │ │ │ └── MasonryView.vue │ │ │ │ ├── composables/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── timelineRail.ts │ │ │ │ │ ├── useAdaptiveVirtualizer.ts │ │ │ │ │ ├── useGalleryAssetActions.ts │ │ │ │ │ ├── useGalleryContextMenu.ts │ │ │ │ │ ├── useGalleryData.ts │ │ │ │ │ ├── useGalleryDragPayload.ts │ │ │ │ │ ├── useGalleryLayout.ts │ │ │ │ │ ├── useGalleryLightbox.ts │ │ │ │ │ ├── useGallerySelection.ts │ │ │ │ │ ├── useGallerySidebar.ts │ │ │ │ │ ├── useGalleryView.ts │ │ │ │ │ ├── useGridVirtualizer.ts │ │ │ │ │ ├── useHeroTransition.ts │ │ │ │ │ ├── useListVirtualizer.ts │ │ │ │ │ └── useMasonryVirtualizer.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pages/ │ │ │ │ │ └── GalleryPage.vue │ │ │ │ ├── queryFilters.ts │ │ │ │ ├── routes.ts │ │ │ │ ├── store/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── interactionSlice.ts │ │ │ │ │ ├── layoutSlice.ts │ │ │ │ │ ├── navigationSlice.ts │ │ │ │ │ ├── persistence.ts │ │ │ │ │ └── querySlice.ts │ │ │ │ └── types.ts │ │ │ ├── home/ │ │ │ │ └── pages/ │ │ │ │ └── HomePage.vue │ │ │ ├── map/ │ │ │ │ ├── README.md │ │ │ │ ├── api.ts │ │ │ │ ├── bridge/ │ │ │ │ │ └── protocol.ts │ │ │ │ ├── components/ │ │ │ │ │ └── MapIframeHost.vue │ │ │ │ ├── composables/ │ │ │ │ │ ├── useMapBridge.ts │ │ │ │ │ └── useMapScene.ts │ │ │ │ ├── domain/ │ │ │ │ │ ├── coordinates.ts │ │ │ │ │ ├── defaults.ts │ │ │ │ │ └── markerMapper.ts │ │ │ │ ├── injection/ │ │ │ │ │ ├── mapDevEvalScript.ts │ │ │ │ │ └── source/ │ │ │ │ │ ├── bridgeScript.js │ │ │ │ │ ├── cluster.js │ │ │ │ │ ├── devEvalRuntimeScript.js │ │ │ │ │ ├── iframeBootstrap.js │ │ │ │ │ ├── index.d.ts │ │ │ │ │ ├── index.js │ │ │ │ │ ├── paneStyle.js │ │ │ │ │ ├── photoCardHtml.js │ │ │ │ │ ├── popup.js │ │ │ │ │ ├── render.js │ │ │ │ │ ├── runtimeCore.js │ │ │ │ │ └── toolbar.js │ │ │ │ ├── pages/ │ │ │ │ │ └── MapPage.vue │ │ │ │ └── store.ts │ │ │ ├── onboarding/ │ │ │ │ ├── api.ts │ │ │ │ ├── pages/ │ │ │ │ │ └── OnboardingPage.vue │ │ │ │ └── types.ts │ │ │ ├── playground/ │ │ │ │ ├── components/ │ │ │ │ │ ├── ApiMethodList.vue │ │ │ │ │ ├── ApiTestPanel.vue │ │ │ │ │ ├── JsonResponseViewer.vue │ │ │ │ │ ├── ParamFormBuilder.vue │ │ │ │ │ ├── ParamFormField.vue │ │ │ │ │ ├── ParamInputPanel.vue │ │ │ │ │ └── ToastDemo.vue │ │ │ │ ├── composables/ │ │ │ │ │ ├── useApiMethods.ts │ │ │ │ │ ├── useApiTest.ts │ │ │ │ │ ├── useIntegrationTest.ts │ │ │ │ │ └── useMethodSignature.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pages/ │ │ │ │ │ ├── ApiPlaygroundPage.vue │ │ │ │ │ ├── IntegrationTestPage.vue │ │ │ │ │ └── PlaygroundPage.vue │ │ │ │ ├── routes.ts │ │ │ │ └── types/ │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── settings/ │ │ │ ├── api.ts │ │ │ ├── appearance.ts │ │ │ ├── backgroundPath.ts │ │ │ ├── components/ │ │ │ │ ├── AppearanceContent.vue │ │ │ │ ├── CaptureSettingsContent.vue │ │ │ │ ├── DraggableSettingsList.vue │ │ │ │ ├── ExtensionsContent.vue │ │ │ │ ├── FloatingWindowContent.vue │ │ │ │ ├── GeneralSettingsContent.vue │ │ │ │ ├── HotkeyRecorder.vue │ │ │ │ ├── HotkeySettingsContent.vue │ │ │ │ ├── OverlayPaletteEditor.vue │ │ │ │ ├── ResetSettingsDialog.vue │ │ │ │ ├── SettingsSidebar.vue │ │ │ │ └── WindowSceneContent.vue │ │ │ ├── composables/ │ │ │ │ ├── useAppearanceActions.ts │ │ │ │ ├── useExtensionActions.ts │ │ │ │ ├── useFunctionActions.ts │ │ │ │ ├── useGeneralActions.ts │ │ │ │ ├── useMenuActions.ts │ │ │ │ └── useTheme.ts │ │ │ ├── constants.ts │ │ │ ├── featuresApi.ts │ │ │ ├── overlayPalette.ts │ │ │ ├── overlayPaletteSampler.ts │ │ │ ├── pages/ │ │ │ │ └── SettingsPage.vue │ │ │ ├── store.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ └── hotkeyUtils.ts │ │ ├── index.css │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── main.ts │ │ ├── router/ │ │ │ ├── guards.ts │ │ │ ├── index.ts │ │ │ └── viewTransition.ts │ │ └── types/ │ │ └── webview.d.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts └── xmake.lua ================================================ FILE CONTENTS ================================================ ================================================ FILE: .clang-format ================================================ # SpinningMomo C++ Code Style # Based on Google style with minimal overrides BasedOnStyle: Google Language: Cpp # 主要差异:使用100字符行宽而非Google默认的80 ColumnLimit: 100 # 保持include分组结构,只在组内排序(适合模块化项目) IncludeBlocks: Preserve ================================================ FILE: .gitattributes ================================================ # 默认强制所有文本文件使用 LF (Unix-style) 行尾,避免跨平台问题 * text=auto eol=lf # 为必须使用 CRLF (Windows-style) 的文件设置例外 src/resources/app.rc text eol=crlf src/resources/app.manifest text eol=crlf # *.bat text eol=crlf # *.cmd text eol=crlf # 标记二进制文件,防止 Git 对其进行文本处理 *.png binary *.jpg binary *.jpeg binary *.webp binary *.ico binary *.exe binary *.dll binary *.lib binary *.pdb binary # GitHub Linguist 优化:从语言统计中排除第三方库和文档 third_party/** linguist-vendored docs/** linguist-documentation documentation/** linguist-documentation ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: 问题报告 description: 报告使用过程中遇到的问题或错误 title: "[Bug] " labels: ["bug"] body: - type: textarea id: description attributes: label: 问题描述 description: 简单描述你遇到的问题,以及如何复现这个问题? placeholder: 请详细描述问题... validations: required: true - type: textarea id: logs attributes: label: 日志文件 description: 请以附件的形式上传 `app.log` 文件(安装版位于 `%LOCALAPPDATA%\SpinningMomo\logs` 目录下,便携版位于程序所在的 `data\logs` 目录下) placeholder: 如有必要,请先在“设置 -> 通用 -> 日志级别”中切换为 DEBUG 后复现问题 validations: required: false - type: textarea id: additional attributes: label: 其他信息 description: 截图或其他有助于解决问题的信息(可选) placeholder: 添加截图或其他信息... validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: 功能建议 description: 提出新功能或改进建议 title: "[Feature] " labels: ["enhancement"] body: - type: textarea id: feature-description attributes: label: 功能描述 description: 描述你想要的功能,以及在什么情况下使用? placeholder: 请详细描述您的功能建议... validations: required: true - type: textarea id: additional attributes: label: 其他信息 description: 其他相关信息或想法(可选) placeholder: 添加其他相关信息... validations: required: false ================================================ FILE: .github/workflows/build-release.yml ================================================ # Build and Release workflow # Triggers on version tags (v*) or manual dispatch name: Build Release on: push: tags: - 'v*' workflow_dispatch: inputs: version: description: 'Version override (e.g., 1.0.0). Leave empty to extract from version.json' required: false type: string permissions: contents: write jobs: build: runs-on: windows-latest env: # Keep xmake/vcpkg caches in workspace for reliable cache restore on GitHub runners. XMAKE_GLOBALDIR: ${{ github.workspace }}\.xmake-global VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}\.vcpkg\archives steps: - name: Checkout uses: actions/checkout@v4 - name: Fetch third-party dependencies shell: pwsh run: .\scripts\fetch-third-party.ps1 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24 cache: 'npm' cache-dependency-path: | web/package-lock.json - name: Setup xmake uses: xmake-io/github-action-setup-xmake@v1 with: xmake-version: latest - name: Cache xmake/vcpkg dependencies id: cache_xmake_vcpkg uses: actions/cache@v4 with: path: | .xmake .xmake-global .vcpkg/archives key: ${{ runner.os }}-${{ runner.arch }}-xmake-vcpkg-${{ hashFiles('xmake.lua', 'xmake-requires.lock') }} restore-keys: | ${{ runner.os }}-${{ runner.arch }}-xmake-vcpkg- - name: Ensure cache directories exist shell: pwsh run: | New-Item -ItemType Directory -Force -Path ".xmake" | Out-Null New-Item -ItemType Directory -Force -Path ".xmake-global" | Out-Null New-Item -ItemType Directory -Force -Path ".vcpkg/archives" | Out-Null - name: Cache diagnostics (concise) shell: pwsh run: | Write-Host "xmake/vcpkg cache hit: ${{ steps.cache_xmake_vcpkg.outputs.cache-hit }}" Write-Host "binary cache dir: $env:VCPKG_DEFAULT_BINARY_CACHE" Write-Host ".xmake -> $((Test-Path '.xmake') ? 'exists' : 'missing')" Write-Host ".xmake-global -> $((Test-Path '.xmake-global') ? 'exists' : 'missing')" Write-Host ".vcpkg/archives -> $((Test-Path '.vcpkg/archives') ? 'exists' : 'missing')" - name: Setup .NET (for WiX) uses: actions/setup-dotnet@v4 with: dotnet-version: '8.0.x' - name: Install WiX Toolset v6 run: | dotnet tool install --global wix --version 6.0.2 wix extension add WixToolset.UI.wixext/6.0.2 --global wix extension add WixToolset.Util.wixext/6.0.2 --global wix extension add WixToolset.BootstrapperApplications.wixext/6.0.2 --global - name: Resolve final version id: final_version shell: pwsh run: | $version = "${{ inputs.version }}" if ([string]::IsNullOrEmpty($version)) { $versionJson = Get-Content "version.json" -Raw | ConvertFrom-Json $rawVersion = $versionJson.version if ([string]::IsNullOrWhiteSpace($rawVersion)) { Write-Error "Could not extract version from version.json" exit 1 } # Convert 1.0.0.0 to 1.0.0 for MSI (max 3 parts) $versionParts = $rawVersion.Split('.') if ($versionParts.Count -lt 3) { Write-Error "version.json version must contain at least 3 parts: $rawVersion" exit 1 } $version = "$($versionParts[0]).$($versionParts[1]).$($versionParts[2])" } Write-Host "Final version: $version" echo "VERSION=$version" >> $env:GITHUB_OUTPUT - name: Install dependencies run: | npm install cd web && npm ci - name: Build all (C++ + Web + Dist) run: npm run build:ci - name: Create Portable ZIP run: npm run build:portable - name: Build MSI and Bundle Installer shell: pwsh run: .\scripts\build-msi.ps1 -Version "${{ steps.final_version.outputs.VERSION }}" - name: Generate checksums run: npm run build:checksums - name: Upload artifacts (for debugging) uses: actions/upload-artifact@v4 with: name: SpinningMomo-All path: | dist/*-Setup.exe dist/*-Portable.zip dist/SHA256SUMS.txt build/windows/x64/release/SpinningMomo.pdb if-no-files-found: error - name: Create GitHub Release if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') uses: softprops/action-gh-release@v1 with: files: | dist/*-Setup.exe dist/*-Portable.zip dist/SHA256SUMS.txt draft: false prerelease: ${{ contains(github.ref, '-') }} generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload release files to Cloudflare R2 if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') shell: pwsh env: AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} AWS_DEFAULT_REGION: auto AWS_ENDPOINT_URL: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com run: | $version = "${{ steps.final_version.outputs.VERSION }}" $bucket = "${{ secrets.R2_BUCKET_NAME }}" $dest = "s3://$bucket/releases/v$version" aws s3 cp "dist/SpinningMomo-$version-x64-Portable.zip" "$dest/SpinningMomo-$version-x64-Portable.zip" aws s3 cp "dist/SpinningMomo-$version-x64-Setup.exe" "$dest/SpinningMomo-$version-x64-Setup.exe" aws s3 cp "dist/SHA256SUMS.txt" "$dest/SHA256SUMS.txt" Write-Host "Uploaded release files to R2: $dest" ================================================ FILE: .github/workflows/deploy-docs.yml ================================================ # 工作流名称 name: Deploy VitePress Documentation # 触发条件:在 main 分支的 push 事件,且仅当 docs 目录有变更时 # 同时支持手动触发 on: push: branches: [main] paths: - 'docs/**' # 添加手动触发 workflow_dispatch: inputs: reason: description: '触发原因(可选)' required: false type: string # 设置 GITHUB_TOKEN 的权限 permissions: contents: read pages: write id-token: write # 确保同时只有一个部署任务在运行 concurrency: group: pages cancel-in-progress: false jobs: # 构建任务 build: runs-on: ubuntu-latest env: VITE_BASE_PATH: /SpinningMomo/ steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 24 cache: npm cache-dependency-path: docs/package-lock.json - name: Setup Pages uses: actions/configure-pages@v5 - name: Install dependencies run: | cd docs npm ci - name: Build run: | cd docs npm run build - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: docs/.vitepress/dist # 部署任务 deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} needs: build runs-on: ubuntu-latest name: Deploy steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ # Build directories out/ [Bb]uild/ [Dd]ebug/ [Rr]elease/ dist/ # Visual Studio files .vs/ *.user *.suo *.sdf *.opensdf *.VC.db *.VC.opendb # Compiled files *.exe *.dll *.lib *.pdb *.ilk *.obj *.idb *.pch # CMake generated files CMakeCache.txt CMakeFiles/ cmake_install.cmake compile_commands.json # Xmake generated files .xmake/ vsxmake2022/ # WiX generated files *.msi *.wixpdb # VitePress docs docs/.vitepress/dist/ docs/.vitepress/cache/ docs/node_modules/ # OS generated files .DS_Store .DS_Store? ._* .Spotlight-V100 .Trashes ehthumbs.db Thumbs.db third_party/ test/ /playground/ # AI .windsurfrules .cursor .claude # Node.js dependencies node_modules/ package-lock.json # Web App (Vite + React) web/node_modules/ web/dist/ web/dist-ssr/ web/.env web/.env.local web/.env.development.local web/.env.test.local web/.env.production.local web/*.log web/coverage/ web/.cache/ web/.parcel-cache/ web/.vite/ web/stats* web_react/ # Generated HarmonyOS subset font artifacts web/src/assets/fonts/harmonyos-sans-sc/*.woff2 web/src/assets/fonts/harmonyos-sans-sc/charset-generated.txt web/src/assets/fonts/harmonyos-sans-sc/manifest.json web/src/assets/fonts/harmonyos-sans-sc/LICENSE.txt ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .vscode/c_cpp_properties.json ================================================ { "configurations": [ { "name": "Win64", "includePath": [ "${workspaceFolder}/src/**" ], "defines": [ "_DEBUG", "UNICODE", "_UNICODE" ], "windowsSdkVersion": "10.0.22621.0", "compilerPath": "cl.exe", "cStandard": "c23", "cppStandard": "c++23", "compileCommands": ".vscode/compile_commands.json", "intelliSenseMode": "windows-msvc-x64", "configurationProvider": "ms-vscode.cmake-tools" } ], "version": 4 } ================================================ FILE: .vscode/launch.json ================================================ { // 使用 IntelliSense 了解相关属性。 // 悬停以查看现有属性的描述。 // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "chrome", "request": "launch", "name": "Launch Chrome against localhost", "url": "http://localhost:5173", "webRoot": "${workspaceFolder}" } ] } ================================================ FILE: .vscode/settings.json ================================================ { "cmake.configureOnOpen": true, "C_Cpp.default.cppStandard": "c++23", "C_Cpp.clang_format_style": "file", "C_Cpp.default.compilerArgs": ["/std:c++latest", "/experimental:module"], "C_Cpp.experimentalFeatures": "enabled", "C_Cpp.intelliSenseEngine": "Tag Parser", "C_Cpp.enhancedColorization": "enabled", "C_Cpp.errorSquiggles": "disabled", "files.associations": { "vector": "cpp", "xstring": "cpp", "algorithm": "cpp", "array": "cpp", "atomic": "cpp", "bit": "cpp", "cctype": "cpp", "charconv": "cpp", "chrono": "cpp", "clocale": "cpp", "cmath": "cpp", "compare": "cpp", "concepts": "cpp", "coroutine": "cpp", "cstddef": "cpp", "cstdint": "cpp", "cstdio": "cpp", "cstdlib": "cpp", "cstring": "cpp", "ctime": "cpp", "cwchar": "cpp", "exception": "cpp", "filesystem": "cpp", "format": "cpp", "forward_list": "cpp", "initializer_list": "cpp", "iomanip": "cpp", "ios": "cpp", "iosfwd": "cpp", "istream": "cpp", "iterator": "cpp", "limits": "cpp", "list": "cpp", "locale": "cpp", "map": "cpp", "memory": "cpp", "new": "cpp", "optional": "cpp", "ostream": "cpp", "ratio": "cpp", "sstream": "cpp", "stdexcept": "cpp", "stop_token": "cpp", "streambuf": "cpp", "string": "cpp", "system_error": "cpp", "thread": "cpp", "tuple": "cpp", "type_traits": "cpp", "typeinfo": "cpp", "unordered_map": "cpp", "utility": "cpp", "xfacet": "cpp", "xhash": "cpp", "xiosbase": "cpp", "xlocale": "cpp", "xlocbuf": "cpp", "xlocinfo": "cpp", "xlocmes": "cpp", "xlocmon": "cpp", "xlocnum": "cpp", "xloctime": "cpp", "xmemory": "cpp", "xtr1common": "cpp", "xtree": "cpp", "xutility": "cpp", "deque": "cpp", "functional": "cpp", "mutex": "cpp", "queue": "cpp", "set": "cpp", "any": "cpp", "bitset": "cpp", "cfenv": "cpp", "cinttypes": "cpp", "complex": "cpp", "condition_variable": "cpp", "csignal": "cpp", "cstdarg": "cpp", "cwctype": "cpp", "fstream": "cpp", "future": "cpp", "iostream": "cpp", "numeric": "cpp", "random": "cpp", "regex": "cpp", "scoped_allocator": "cpp", "span": "cpp", "stack": "cpp", "typeindex": "cpp", "unordered_set": "cpp", "valarray": "cpp", "variant": "cpp", "barrier": "cpp", "codecvt": "cpp", "csetjmp": "cpp", "cuchar": "cpp", "execution": "cpp", "expected": "cpp", "latch": "cpp", "mdspan": "cpp", "memory_resource": "cpp", "numbers": "cpp", "print": "cpp", "ranges": "cpp", "semaphore": "cpp", "shared_mutex": "cpp", "source_location": "cpp", "spanstream": "cpp", "stacktrace": "cpp", "stdfloat": "cpp", "strstream": "cpp", "syncstream": "cpp", "resumable": "cpp", "*.ipp": "cpp", "*.rh": "cpp", "generator": "cpp" } } ================================================ FILE: AGENTS.md ================================================ # AGENTS.md This file provides guidance to AI when working with code in this repository. ## 第一性原理 请使用第一性原理思考。你不能总是假设我非常清楚自己想要什么和该怎么得到。请保持审慎,从原始需求和问题出发,如果动机和目标不清晰,停下来和我讨论。 ## 方案规范 当需要你给出修改或重构方案时必须符合以下规范: - 不允许给出兼容性或补丁性的方案 - 不允许过度设计,保持最短路径实现且不能违反第一条要求 ## Project Overview SpinningMomo (旋转吧大喵) is a Windows-only desktop tool for the game "Infinity Nikki" (无限暖暖), focused on photography, screenshots, recording, and related workflow tooling around the game window. The current repository is a native Win32 C++ application with an embedded web frontend, plus supporting docs, packaging, and playground tooling. The codebase is bilingual — code comments and UI strings are predominantly in Chinese. ## Build & Development ### Prerequisites - Visual Studio 2022+ with Windows SDK 10.0.22621.0+ - xmake (primary build system) - Node.js / npm (for web frontend, docs, and formatting scripts) - .NET SDK 8.0+ and WiX v5+ when building installers locally ### Build Commands ``` # C++ backend — debug (default) xmake config -m debug xmake build # C++ backend — release xmake release # builds release then restores debug config # Full native + web build via xmake task xmake build-all # Web frontend (in web/ directory) cd web && npm run build # Full build: C++ release + web + assemble dist/ npm run build # Release artifacts npm run build:portable npm run build:installer npm run release # Generate VS project files (vsxmake, debug+release) xmake vs ``` A husky pre-commit hook runs `lint-staged` which auto-formats staged C++ (`.cpp`, `.ixx`, `.h`, `.hpp`) and web files. ### Web Frontend Dev Server ``` cd web && npm run dev ``` Vite dev server proxies `/rpc` and `/static` to the C++ backend at `localhost:51206`. ### Docs Dev Server ``` cd docs && npm run dev ``` `docs/` is a separate VitePress documentation site and is not part of the runtime app bundle. ## Architecture ### Two-Process Model The application is a **native Win32 C++ backend** that hosts an embedded **WebView2** frontend. Communication happens over **JSON-RPC 2.0** through two transport layers: - **WebView bridge** — used when the Vue app runs inside WebView2 (production) - **HTTP + SSE** — used when the Vue app runs in a browser during development (uWebSockets on port 51206). SSE provides server-to-client push notifications. The frontend auto-detects its environment (`window.chrome.webview` presence) and selects the appropriate transport. ### C++ Module System The backend uses **C++23 modules** (`.ixx` interface files, `.cpp` implementation files). Module names follow a dotted hierarchy that mirrors the directory structure: - `Core.*` — framework infrastructure (async runtime, database, events, HTTP client, HTTP server, RPC, WebView, i18n, commands, migration, worker pool, tasks, runtime info, shutdown, state) - `Features.*` — business logic modules such as gallery, letterbox, notifications, overlay, preview, recording, replay_buffer, screenshot, settings, update, and window_control - `UI.*` — native Win32 UI (floating_window, tray_icon, context_menu, webview_window) - `Utils.*` — shared utilities such as logger, file, graphics, image, media, path, string, system, throttle, timer, dialog, crash_dump, and crypto - `Vendor.*` — thin wrappers re-exporting Win32 API and third-party types through the module system (e.g. `Vendor.Windows` wraps ``) ### Design Philosophy The C++ backend does **NOT** use OOP class hierarchies. Instead it follows: - **POD Structs + Free Functions**: plain data structs with free functions operating on them. - **Centralized State**: all state lives in `AppState`, passed by reference. - **Feature Independence**: features depend on `Core.*` but must NOT depend on each other. ### Central AppState `Core::State::AppState` is the single root state object. It owns all subsystem states as `std::unique_ptr` members. Functions are free functions that accept `AppState&`. ### Key Patterns - **Error handling**: `std::expected` throughout; no exception-based control flow. - **Async**: Asio-based coroutine runtime (`Core::Async`). RPC handlers return `asio::awaitable>`. - **Events**: Type-erased event bus (`Core::Events`) with sync `send()` and async `post()` (wakes the Win32 message loop via `PostMessageW`). - **RPC registration**: `Core::RPC::register_method()` auto-generates JSON Schema from C++ types via reflect-cpp. Field names are auto-converted between `snake_case` (C++) and `camelCase` (JSON). - **Commands**: `Core::Commands` registry binds actions, toggle states, i18n keys, and optional hotkeys. Context menu and tray icon are driven by this registry. - **Database**: SQLite via SQLiteCpp with thread-local connections, a `DataMapper` for ORM-like row mapping, and an auto-generated migration system (`scripts/generate-migrations.js`). - **Vendor wrappers**: Win32 macros/functions are re-exported as proper C++ functions/constants in `Vendor::Windows` to stay compatible with the module system. - **String encoding**: internal processing uses UTF-8 (`std::string`); Win32 API calls use UTF-16 (`std::wstring`). Convert via utilities in `Utils.String`. ### Web Frontend (web/) The main frontend lives in `web/` and uses Vue 3 + TypeScript + Pinia + Tailwind CSS v4 + shadcn-vue/reka-ui. It is built with a Vite-compatible toolchain. Key directories: - `web/src/core/rpc/` — JSON-RPC client with WebView and HTTP transports - `web/src/core/i18n/` — client-side i18n - `web/src/core/env/` — runtime environment detection - `web/src/core/tasks/` — frontend task orchestration - `web/src/features/` — feature modules (gallery, settings, home, about, map, onboarding, common, playground) - `web/src/composables/` — shared composables (`useRpc`, `useI18n`, `useToast`) - `web/src/extensions/` — game-specific integrations (infinity_nikki) - `web/src/router/` — routes - `web/src/types/` — shared TS types - `web/src/lib/` — shared UI/helpers - `web/src/assets/` — static assets ### Additional Repo Surfaces - `docs/` — VitePress documentation site for user and developer docs - `playground/` — standalone Node/TypeScript scripts for backend HTTP/RPC debugging and experiments - `installer/` — WiX source files for MSI and bundle installer generation - `tasks/` — custom xmake tasks such as `build-all`, `release`, and `vs` ### Gallery Module `gallery` is one of the core vertical slices of the project. It is not just a page: it spans backend indexing/scanning/watchers/static file serving and frontend browsing/filtering/lightbox/detail workflows. - **Backend entry points**: - `src/features/gallery/gallery.ixx/.cpp` is the orchestration layer for initialization, cleanup, scanning, thumbnail maintenance, file actions, and watcher registration. - `src/features/gallery/state.ixx` holds gallery runtime state such as thumbnail directory, asset path LRU cache, and per-root folder watcher state. - `src/features/gallery/scanner.*` handles full scans and index updates. - `src/features/gallery/watcher.*` restores folder watchers from DB, starts/stops them, and keeps the index in sync after startup. - `src/features/gallery/static_resolver.*` exposes thumbnails and original files to both HTTP dev mode and embedded WebView mode via `/static/assets/thumbnails/...` and `/static/assets/originals/`. - **Backend subdomains**: - `asset/` is the largest data domain and owns querying, timeline views, home stats, review state, descriptions, color extraction, thumbnails, and Infinity Nikki metadata access. - `folder/` owns folder tree persistence, display names, and root watch management. - `tag/` owns tag tree CRUD and asset-tag relations. - `ignore/` contains ignore-rule matching and persistence used by scans. - `color/` contains extracted main-color models and filtering support. - **RPC shape**: - Gallery RPC is split by concern under `src/core/rpc/endpoints/gallery/`: `gallery.cpp` for scanning/maintenance, `asset.cpp` for asset queries and actions, `folder.cpp` for folder tree/navigation actions, and `tag.cpp` for tag management. - Frontend code should usually enter gallery through RPC methods prefixed with `gallery.*`. - Backend sends `gallery.changed` notifications after scan/index mutations; the frontend listens to this event and refreshes folder tree plus current asset view. - **Startup behavior**: - During app initialization, the gallery module is initialized first, then watcher registrations are restored from DB, then Infinity Nikki photo-source registration runs, and finally all registered gallery watchers are started near the end of startup. - **Frontend entry points**: - `web/src/features/gallery/api.ts` is the RPC facade and static URL helper layer. - `web/src/features/gallery/store/index.ts` is the single source of truth for gallery UI state. Store internals are split into `store/querySlice.ts`, `store/navigationSlice.ts`, `store/interactionSlice.ts`, and shared helpers in `store/persistence.ts`. - `web/src/features/gallery/composables/` coordinates behavior around data loading, selection, layout, sidebar, lightbox, virtualized grids, and asset actions. - `web/src/features/gallery/pages/GalleryPage.vue` hosts the three-pane shell (sidebar, viewer, details); child views live under `web/src/features/gallery/components/` in subfolders: `shell/` (sidebar, viewer, details, toolbar, content), `viewer/` (grid/list/masonry/adaptive), `asset/`, `tags/`, `folders/`, `dialogs/`, `menus/`, `infinity_nikki/`, and `lightbox/`. - `web/src/features/gallery/routes.ts` defines the `/gallery` route; `web/src/router/index.ts` spreads it so there is a single source of truth for path, name (`gallery`), and meta. - **Frontend data flow**: - Prefer the existing pattern `component -> composable -> api -> RPC` and let components read state directly from the Pinia store. - `useGalleryData()` loads data and writes into store state. - `useGallerySelection()` and `useGalleryAssetActions()` implement higher-level UI behaviors on top of the store instead of duplicating state in components. - When changing filters/sort/view mode, check `store/index.ts` plus related slices under `store/` and `queryFilters.ts`; when changing visible behavior, check the relevant composable before editing large Vue components. - **Domain model summary**: - The gallery centers on `Asset`, `FolderTreeNode`, `TagTreeNode`, scan/task progress, and flexible `QueryAssetsFilters`. - The same conceptual model exists on both sides: C++ types in `src/features/gallery/types.ixx`, mirrored by TS types in `web/src/features/gallery/types.ts`. - Infinity Nikki-specific enrichments such as photo params and map points are exposed as part of gallery asset queries rather than as a completely separate frontend feature. ### RPC Endpoint Organization Endpoints live under `src/core/rpc/endpoints//`, each domain exposes a `register_all(state)` called from `registry.cpp`. Current domains include file, clipboard, dialog, runtime_info, settings, tasks, update, webview, gallery, extensions, registry, and window_control. Game-specific adapters in `src/extensions/` (currently `infinity_nikki`) are exposed via `rpc/endpoints/extensions/`. ### Initialization Order Initialization still follows the top-level chain `main.cpp` → `Application::Initialize()` → `Core::Initializer::initialize_application()`. In practice this sets up core infrastructure first (events, async/runtime, worker pool, RPC, HTTP, database, settings, update, commands), then native UI surfaces, then feature services such as recording/replay/gallery, then the Infinity Nikki extension, onboarding gate, hotkeys, and startup update checks. ## Build Output - Release: `build\windows\x64\release\` - Debug: `build\windows\x64\debug\` - Distribution: `dist/` (exe + web resources) ## Installer Installers are built via `scripts/build-msi.ps1`. The script builds an MSI package and, by default, a WiX bundle-based setup `.exe`, both under `dist/`. ## Code Generation Scripts These must be re-run when their source files change: - `node scripts/generate-migrations.js` — after modifying `src/migrations/*.sql` - `node scripts/generate-embedded-locales.js` — after modifying `src/locales/*.json` (zh-CN / en-US) - `node scripts/generate-map-injection-cpp.js` — after modifying `web/src/features/map/injection/source/*.js` (regenerates minified JS and C++ map injection module) ## Naming Conventions - **C++ module names**: PascalCase with dots — `Features.Gallery`, `Core.RPC.Types` - **C++ files/functions**: snake_case — `gallery.ixx`, `initialize()` - **No anonymous namespaces**: Do not use `namespace { ... }` in C++; put helpers in the module's named namespace. - **Frontend components**: PascalCase — `GalleryPage.vue` - **Frontend modules**: camelCase — `galleryApi.ts` - **Module import order** in `.ixx`: `std` → `Vendor.*` → `Core.*` → `Features.*` / `UI.*` / `Utils.*` ## Testing No automated test suite. Manual testing only: 1. Build and run the exe. 2. Use the `web/src/features/playground/` pages for interactive RPC endpoint testing during development. 3. Use the root-level `playground/` scripts for backend HTTP/RPC debugging and ad-hoc experiments. ## Adding a New Feature 1. Create a directory under `src/features//` with at minimum a `.ixx` module interface and `.cpp` implementation. 2. Add a state struct in `/state.ixx` and register it in `Core::State::AppState`. 3. Add RPC endpoint file under `src/core/rpc/endpoints//`, implement `register_all(state)`, and wire it in `registry.cpp`. 4. Register commands in `src/core/commands/builtin.cpp` if the feature needs hotkeys/menu entries. 5. If the feature needs initialization, add it to `Core::Initializer::initialize_application`. 6. On the web side, add a feature directory under `web/src/features//` with `api.ts`, `store/index.ts`, `types.ts`, components, and pages. ================================================ FILE: CREDITS.md ================================================ # 第三方开源项目鸣谢 (Third-Party Open Source Software) SpinningMomo(旋转吧大喵)的开发离不开以下优秀的开源项目,向它们的作者表示由衷的感谢。 (SpinningMomo is built upon the following excellent open-source projects. We express our sincere gratitude to their authors.) ## C++ Backend (原生后端) | Project | License | Url | |---------|---------|-----| | [xmake](https://github.com/xmake-io/xmake) | Apache-2.0 | https://github.com/xmake-io/xmake | | [uWebSockets](https://github.com/uNetworking/uWebSockets) | Apache-2.0 | https://github.com/uNetworking/uWebSockets | | [uSockets](https://github.com/uNetworking/uSockets) | Apache-2.0 | https://github.com/uNetworking/uSockets | | [reflect-cpp](https://github.com/getml/reflect-cpp) | MIT | https://github.com/getml/reflect-cpp | | [spdlog](https://github.com/gabime/spdlog) | MIT | https://github.com/gabime/spdlog | | [asio](https://github.com/chriskohlhoff/asio) | BSL-1.0 | https://github.com/chriskohlhoff/asio | | [yyjson](https://github.com/ibireme/yyjson) | MIT | https://github.com/ibireme/yyjson | | [fmt](https://github.com/fmtlib/fmt) | MIT | https://github.com/fmtlib/fmt | | [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Microsoft | https://developer.microsoft.com/en-us/microsoft-edge/webview2/ | | [wil (Windows Implementation Libraries)](https://github.com/Microsoft/wil) | MIT | https://github.com/Microsoft/wil | | [xxHash](https://github.com/Cyan4973/xxHash) | BSD-2-Clause | https://github.com/Cyan4973/xxHash | | [SQLiteCpp](https://github.com/SRombauts/SQLiteCpp) | MIT | https://github.com/SRombauts/SQLiteCpp | | [SQLite3](https://www.sqlite.org/index.html) | Public Domain | https://www.sqlite.org/index.html | | [libwebp](https://chromium.googlesource.com/webm/libwebp) | BSD-3-Clause | https://chromium.googlesource.com/webm/libwebp | | [zlib](https://zlib.net/) | zlib License | https://zlib.net/ | | [libuv](https://libuv.org/) | MIT | https://github.com/libuv/libuv | ## Web Frontend (Web 前端) | Project | License | Url | |---------|---------|-----| | [Vue.js](https://vuejs.org/) | MIT | https://github.com/vuejs/core | | [Vite](https://vitejs.dev/) | MIT | https://github.com/vitejs/vite | | [Tailwind CSS](https://tailwindcss.com/) | MIT | https://github.com/tailwindlabs/tailwindcss | | [Pinia](https://pinia.vuejs.org/) | MIT | https://github.com/vuejs/pinia | | [Vue Router](https://router.vuejs.org/) | MIT | https://github.com/vuejs/router | | [VueUse](https://vueuse.org/) | MIT | https://github.com/vueuse/vueuse | | [TanStack Virtual](https://tanstack.com/virtual) | MIT | https://github.com/TanStack/virtual | | [Lucide](https://lucide.dev/) | ISC | https://github.com/lucide-icons/lucide | | [Inter Font](https://rsms.me/inter/) | OFL-1.1 (Open Font License) | https://github.com/rsms/inter | | [Reka UI](https://reka-ui.com/) | MIT | https://github.com/unovue/reka-ui | | [shadcn-vue](https://www.shadcn-vue.com/) | MIT | https://github.com/radix-vue/shadcn-vue | | [vue-sonner](https://github.com/xiaoluoboding/vue-sonner) | MIT | https://github.com/xiaoluoboding/vue-sonner | -- *Full license texts for the above projects can be found in their respective repositories or distribution packages.* ================================================ FILE: LEGAL.md ================================================ # SpinningMomo Legal & Privacy Notice Latest version date: 2026-03-23 - Chinese: https://spin.infinitymomo.com/zh/about/legal - English: https://spin.infinitymomo.com/en/about/legal ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

SpinningMomo Logo
🎮 旋转吧大喵

《无限暖暖》游戏摄影与录像工具

Platform Release License

📖 使用文档🛠️ 构建指南🌐 English

Screenshot
## 🎯 项目简介 旋转吧大喵(SpinningMomo) ▸ 一键切换游戏窗口比例/尺寸,完美适配竖构图拍摄、相册浏览等场景 ▸ 突破原生限制,支持生成 8K-12K 超高清游戏截图和录制 ▸ 专为《无限暖暖》优化,同时兼容多数窗口化运行的其他游戏 > 🚧 v2.0 正在翻工中,部分功能尚未就绪。 > 如需稳定版,请下载 [v0.7.7 旧版本](https://github.com/ChanIok/SpinningMomo/releases/tag/v0.7.7),使用说明见 [v0.7.7 文档](https://chaniok.github.io/SpinningMomo/v0/)。 ### 📥 下载地址 - **GitHub Release**:[点击下载最新版本](https://github.com/ChanIok/SpinningMomo/releases/latest) - **百度网盘**:[点击下载](https://pan.baidu.com/s/1UL9EJa2ogSZ4DcnGa2XcRQ?pwd=momo)(提取码:momo) ### 📖 使用指南 查看 [使用文档](https://chaniok.github.io/SpinningMomo) 了解更多详细信息。 ### 🛠️ 构建指南 查看 [构建指南](https://chaniok.github.io/SpinningMomo/dev/build-guide) 了解环境要求和构建步骤。 ## 🗺️ 开发状态 ✅ **已完成**:录制功能、图库功能(基础) 🔨 **进行中**:地图功能、UI优化、HDR支持 ## 🙏 致谢 照片数据解析服务由 [NUAN5.PRO](https://NUAN5.PRO) 强力驱动。 ## 📄 声明 本项目采用 [GPL 3.0 协议](LICENSE) 开源。项目图标来自游戏《无限暖暖》,版权归属游戏开发商。使用前请阅读 [法律与隐私说明](https://spin.infinitymomo.com/zh/about/legal)。 ================================================ FILE: cliff.toml ================================================ # git-cliff configuration # https://git-cliff.org/docs/configuration [changelog] header = "" body = """ {% for group, commits in commits | group_by(attribute="group") %} ### {{ group | upper_first }} {% for commit in commits %} - {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}\ {% endfor %} {% endfor %} """ footer = "" trim = true [git] conventional_commits = true filter_unconventional = true split_commits = false commit_parsers = [ { message = "^feat", group = "新功能 | Features" }, { message = "^fix", group = "修复 | Fixes" }, { message = "^perf", group = "优化 | Performance" }, { message = "^refactor", group = "重构 | Refactor" }, { message = "^docs", group = "文档 | Documentation" }, { message = "^test", group = "测试 | Tests" }, { message = "^ci", group = "CI" }, { message = "^chore|^build|^style", group = "其他 | Chores" }, ] filter_commits = false tag_pattern = "v[0-9].*" sort_commits = "oldest" ================================================ FILE: docs/.vitepress/config.ts ================================================ import { defineConfig } from "vitepress"; import type { HeadConfig } from "vitepress"; import { SITE_ORIGIN, getBilingualPathnames, isLegacyDocPath, mdRelativeToPathname, pageLocale, toAbsoluteUrl, } from "./seo"; const baseEnv = process.env.VITE_BASE_PATH || "/"; const base = baseEnv.endsWith("/") ? baseEnv : `${baseEnv}/`; const is_canonical_build = base === "/"; const withBasePath = (p: string) => `${base}${p.replace(/^\//, "")}`; const SITE_NAME = "旋转吧大喵"; const SITE_NAME_EN = "SpinningMomo"; const SITE_DESCRIPTION_ZH = "《无限暖暖》游戏摄影与录像工具"; const SITE_DESCRIPTION_EN = "Infinity Nikki photography and recording tool"; export default defineConfig({ title: SITE_NAME, description: SITE_DESCRIPTION_ZH, // Cloudflare Pages 等托管支持无 .html 的干净 URL cleanUrls: true, // 允许通过环境变量自定义基础路径,默认为根路径 base, head: [ ["link", { rel: "icon", href: withBasePath("/logo.png") }], ["link", { rel: "apple-touch-icon", href: withBasePath("/logo.png") }], ["meta", { property: "og:site_name", content: SITE_NAME }], ["meta", { name: "application-name", content: SITE_NAME }], ["meta", { name: "apple-mobile-web-app-title", content: SITE_NAME }], ], // 忽略死链接检查 ignoreDeadLinks: true, sitemap: is_canonical_build ? { hostname: SITE_ORIGIN, transformItems(items) { // VitePress 在此使用相对路径(如 v0/zh/...),不含前导 /v0 return items.filter((item) => item.url !== "v0" && !item.url.startsWith("v0/")); }, } : undefined, async transformHead(ctx): Promise { const { pageData, title, description } = ctx; const relativePath = pageData.relativePath; if (!relativePath || pageData.isNotFound) { return []; } const pathname = mdRelativeToPathname(relativePath); const canonical = toAbsoluteUrl(SITE_ORIGIN, "/", pathname); const head: HeadConfig[] = [ ["link", { rel: "canonical", href: canonical }], ]; if (!is_canonical_build) { head.push(["meta", { name: "robots", content: "noindex, nofollow" }]); } else if (isLegacyDocPath(relativePath)) { head.push(["meta", { name: "robots", content: "noindex, follow" }]); } const bilingual = getBilingualPathnames(relativePath); if (bilingual) { const zhUrl = toAbsoluteUrl(SITE_ORIGIN, "/", bilingual.zhPathname); const enUrl = toAbsoluteUrl(SITE_ORIGIN, "/", bilingual.enPathname); head.push(["link", { rel: "alternate", hreflang: "zh-CN", href: zhUrl }]); head.push(["link", { rel: "alternate", hreflang: "en-US", href: enUrl }]); head.push(["link", { rel: "alternate", hreflang: "x-default", href: enUrl }]); } head.push(["meta", { property: "og:title", content: title }]); head.push(["meta", { property: "og:site_name", content: SITE_NAME }]); head.push(["meta", { property: "og:description", content: description }]); head.push(["meta", { property: "og:url", content: canonical }]); head.push(["meta", { property: "og:type", content: "website" }]); const loc = pageLocale(relativePath); if (loc) { head.push([ "meta", { property: "og:locale", content: loc.replace("-", "_") }, ]); head.push([ "meta", { property: "og:locale:alternate", content: loc === "zh-CN" ? "en_US" : "zh_CN", }, ]); } head.push(["meta", { name: "twitter:card", content: "summary" }]); head.push(["meta", { name: "twitter:title", content: title }]); head.push(["meta", { name: "twitter:description", content: description }]); if (relativePath === "index.md" || relativePath === "en/index.md") { const websiteJsonLd = { "@context": "https://schema.org", "@type": "WebSite", name: SITE_NAME, alternateName: SITE_NAME_EN, url: SITE_ORIGIN, }; head.push([ "script", { type: "application/ld+json" }, JSON.stringify(websiteJsonLd), ]); } return head; }, locales: { root: { label: "简体中文", lang: "zh-CN", title: SITE_NAME, description: SITE_DESCRIPTION_ZH, }, en: { label: "English", lang: "en-US", link: "/en/", title: SITE_NAME_EN, description: SITE_DESCRIPTION_EN, themeConfig: { siteTitle: SITE_NAME_EN, nav: [ { text: "Guide", link: "/en/guide/getting-started" }, { text: "Legal", link: "/en/about/legal" }, { text: "Version", items: [ { text: "v2.0 (Current)", link: "/en/" }, { text: "v0.7.7 (Legacy)", link: "/v0/en/" }, ], }, ], sidebar: { "/en/": [ { text: "Guide", items: [{ text: "Getting Started", link: "/en/guide/getting-started" }], }, { text: "Features", items: [ { text: "Window & Resolution", link: "/en/features/window" }, { text: "Screenshots", link: "/en/features/screenshot" }, { text: "Video Recording", link: "/en/features/recording" }, ], }, { text: "Developer", items: [{ text: "Architecture", link: "/en/developer/architecture" }], }, { text: "About", items: [ { text: "Legal & Privacy", link: "/en/about/legal" }, { text: "Open Source Credits", link: "/en/about/credits" }, ], }, ], }, }, }, }, themeConfig: { logo: withBasePath("/logo.png"), siteTitle: SITE_NAME, // 社交链接 socialLinks: [ { icon: "github", link: "https://github.com/ChanIok/SpinningMomo" }, ], // 导航栏 nav: [ { text: "指南", link: "/zh/guide/getting-started" }, { text: "开发者", link: "/zh/developer/architecture" }, { text: "版本", items: [ { text: "v2.0 (当前)", link: "/" }, { text: "v0.7.7 (旧版)", link: "/v0/index.md" } ] }, { text: "下载", link: "https://github.com/ChanIok/SpinningMomo/releases", }, ], sidebar: { "/zh/": [ { text: "🚀 快速上手", items: [ { text: "安装与运行", link: "/zh/guide/getting-started" }, ], }, { text: "⚡ 功能", items: [ { text: "比例与分辨率调整", link: "/zh/features/window" }, { text: "超清截图", link: "/zh/features/screenshot" }, { text: "视频录制", link: "/zh/features/recording" }, ], }, { text: "🛠️ 开发者指南", items: [ { text: "架构与构建", link: "/zh/developer/architecture" }, ], }, { text: "📄 关于", items: [ { text: "法律与隐私", link: "/zh/about/legal" }, { text: "开源鸣谢", link: "/zh/about/credits" }, ] }, ], // 保留旧版本的配置 "/v0/zh/": [ { text: "指南 (v0.7.7)", items: [ { text: "项目介绍", link: "/v0/zh/guide/introduction" }, { text: "快速开始", link: "/v0/zh/guide/getting-started" }, { text: "基本功能", link: "/v0/zh/guide/features" }, ], }, { text: "进阶使用", items: [ { text: "自定义设置", link: "/v0/zh/advanced/custom-settings" }, { text: "常见问题", link: "/v0/zh/advanced/troubleshooting" }, ], }, ], "/v0/en/": [ { text: "Guide (v0.7.7)", items: [{ text: "Overview", link: "/v0/en/" }], }, { text: "Legal", items: [ { text: "Legal & Privacy Notice", link: "/v0/en/legal/notice" }, { text: "Third-Party Licenses", link: "/v0/en/credits" } ], }, ] }, }, }); ================================================ FILE: docs/.vitepress/seo.ts ================================================ /** 正式站点的绝对源(canonical / hreflang / sitemap),不含尾斜杠 */ export const SITE_ORIGIN = "https://spin.infinitymomo.com"; export function mdRelativeToPathname(relativePath: string): string { const p = relativePath.replace(/\\/g, "/"); if (p === "index.md") return "/"; if (p.endsWith("/index.md")) { const dir = p.slice(0, -"/index.md".length); return `/${dir}/`; } if (p.endsWith(".md")) { return `/${p.slice(0, -3)}`; } return `/${p}`; } export function toAbsoluteUrl(siteOrigin: string, base: string, pathname: string): string { const origin = siteOrigin.replace(/\/$/, ""); const normalizedBase = base === "/" || base === "" ? "" : base.endsWith("/") ? base.slice(0, -1) : base; const path = pathname.startsWith("/") ? pathname : `/${pathname}`; return `${origin}${normalizedBase}${path}`; } /** v0 文档:不参与 hreflang,且应 noindex */ export function isLegacyDocPath(relativePath: string): boolean { return relativePath.replace(/\\/g, "/").startsWith("v0/"); } /** * 返回当前 v2 页面对应的中英 canonical 路径(含前导 /,已考虑 index.md)。 * 若无对页(非 v2 双语结构),返回 null。 */ export function getBilingualPathnames(relativePath: string): { zhPathname: string; enPathname: string; } | null { const p = relativePath.replace(/\\/g, "/"); if (isLegacyDocPath(p)) return null; if (p === "index.md") { return { zhPathname: "/", enPathname: "/en/" }; } if (p === "en/index.md") { return { zhPathname: "/", enPathname: "/en/" }; } if (p.startsWith("zh/")) { const rest = p.slice("zh/".length); return { zhPathname: mdRelativeToPathname(p), enPathname: mdRelativeToPathname(`en/${rest}`), }; } if (p.startsWith("en/")) { const rest = p.slice("en/".length); return { zhPathname: mdRelativeToPathname(`zh/${rest}`), enPathname: mdRelativeToPathname(p), }; } return null; } export function pageLocale(relativePath: string): "zh-CN" | "en-US" | null { const p = relativePath.replace(/\\/g, "/"); if (isLegacyDocPath(p)) return null; if (p === "index.md") return "zh-CN"; if (p.startsWith("en/")) return "en-US"; if (p.startsWith("zh/")) return "zh-CN"; return null; } ================================================ FILE: docs/.vitepress/theme/custom.css ================================================ :root { --vp-c-brand: #ff9f4f; --vp-c-brand-light: #ffb06a; --vp-c-brand-lighter: #ffc088; --vp-c-brand-dark: #f59440; --vp-c-brand-darker: #e68a3c; --vp-c-text-1: #333; --vp-c-text-2: #444; --vp-c-text-3: #666; --vp-c-bg: #fff; --vp-c-bg-soft: #f8f9fa; --vp-c-bg-mute: #f1f1f1; --vp-c-border: #eee; --vp-c-divider: #eee; --vp-button-brand-border: var(--vp-c-brand); --vp-button-brand-text: #fff; --vp-button-brand-bg: var(--vp-c-brand); --vp-button-brand-hover-border: var(--vp-c-brand-light); --vp-button-brand-hover-text: #fff; --vp-button-brand-hover-bg: var(--vp-c-brand-light); --vp-button-brand-active-border: var(--vp-c-brand-dark); --vp-button-brand-active-text: #fff; --vp-button-brand-active-bg: var(--vp-c-brand-dark); --vp-custom-block-tip-bg: #fff3e6; --vp-custom-block-tip-border: var(--vp-c-brand); --vp-code-block-bg: #f8f9fa; --vp-home-hero-name-color: var(--vp-c-brand); --vp-nav-bg-color: rgba(255, 255, 255, 0.95); --vp-c-brand-active: var(--vp-c-brand); } :root.dark { --vp-c-text-1: #f0f0f0; --vp-c-text-2: #e0e0e0; --vp-c-text-3: #aaaaaa; --vp-c-bg: #1a1a1a; --vp-c-bg-soft: #242424; --vp-c-bg-mute: #2f2f2f; --vp-c-border: #333333; --vp-c-divider: #333333; --vp-button-brand-border: var(--vp-c-brand); --vp-button-brand-text: #ffffff; --vp-button-brand-bg: var(--vp-c-brand); --vp-custom-block-tip-bg: rgba(255, 159, 79, 0.1); --vp-custom-block-tip-border: var(--vp-c-brand); --vp-code-block-bg: #242424; --vp-nav-bg-color: rgba(26, 26, 26, 0.95); } ================================================ FILE: docs/.vitepress/theme/index.ts ================================================ import { h } from 'vue' import DefaultTheme from 'vitepress/theme' import './custom.css' export default { extends: DefaultTheme, Layout: () => { return h(DefaultTheme.Layout, null, { // 如果需要自定义布局,可以在这里添加 }) }, enhanceApp({ app }) { // 注册组件等 } } ================================================ FILE: docs/en/about/credits.md ================================================ # Open Source Credits SpinningMomo is built upon the following excellent open-source projects. We extend our sincere gratitude to their authors and contributors. ### C++ Backend | Project | License | Link | |---------|---------|------| | [xmake](https://github.com/xmake-io/xmake) | Apache-2.0 | https://github.com/xmake-io/xmake | | [uWebSockets](https://github.com/uNetworking/uWebSockets) | Apache-2.0 | https://github.com/uNetworking/uWebSockets | | [uSockets](https://github.com/uNetworking/uSockets) | Apache-2.0 | https://github.com/uNetworking/uSockets | | [reflect-cpp](https://github.com/getml/reflect-cpp) | MIT | https://github.com/getml/reflect-cpp | | [spdlog](https://github.com/gabime/spdlog) | MIT | https://github.com/gabime/spdlog | | [asio](https://github.com/chriskohlhoff/asio) | BSL-1.0 | https://github.com/chriskohlhoff/asio | | [yyjson](https://github.com/ibireme/yyjson) | MIT | https://github.com/ibireme/yyjson | | [fmt](https://github.com/fmtlib/fmt) | MIT | https://github.com/fmtlib/fmt | | [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Microsoft | https://developer.microsoft.com/en-us/microsoft-edge/webview2/ | | [wil (Windows Implementation Libraries)](https://github.com/Microsoft/wil) | MIT | https://github.com/Microsoft/wil | | [xxHash](https://github.com/Cyan4973/xxHash) | BSD-2-Clause | https://github.com/Cyan4973/xxHash | | [SQLiteCpp](https://github.com/SRombauts/SQLiteCpp) | MIT | https://github.com/SRombauts/SQLiteCpp | | [SQLite3](https://www.sqlite.org/index.html) | Public Domain | https://www.sqlite.org/index.html | | [libwebp](https://chromium.googlesource.com/webm/libwebp) | BSD-3-Clause | https://chromium.googlesource.com/webm/libwebp | | [zlib](https://zlib.net/) | zlib License | https://zlib.net/ | | [libuv](https://libuv.org/) | MIT | https://github.com/libuv/libuv | ### Web Frontend | Project | License | Link | |---------|---------|------| | [Vue.js](https://vuejs.org/) | MIT | https://github.com/vuejs/core | | [Vite](https://vitejs.dev/) | MIT | https://github.com/vitejs/vite | | [Tailwind CSS](https://tailwindcss.com/) | MIT | https://github.com/tailwindlabs/tailwindcss | | [Pinia](https://pinia.vuejs.org/) | MIT | https://github.com/vuejs/pinia | | [Vue Router](https://router.vuejs.org/) | MIT | https://github.com/vuejs/router | | [VueUse](https://vueuse.org/) | MIT | https://github.com/vueuse/vueuse | | [TanStack Virtual](https://tanstack.com/virtual) | MIT | https://github.com/TanStack/virtual | | [Lucide](https://lucide.dev/) | ISC | https://github.com/lucide-icons/lucide | | [Inter Font](https://rsms.me/inter/) | OFL-1.1 | https://github.com/rsms/inter | | [Reka UI](https://reka-ui.com/) | MIT | https://github.com/unovue/reka-ui | | [shadcn-vue](https://www.shadcn-vue.com/) | MIT | https://github.com/radix-vue/shadcn-vue | | [vue-sonner](https://github.com/xiaoluoboding/vue-sonner) | MIT | https://github.com/xiaoluoboding/vue-sonner | *The full license texts for all projects listed above can be found in their respective repositories or distribution packages.* ================================================ FILE: docs/en/about/legal.md ================================================ # Legal & Privacy Last updated: 2026-03-23 Effective date: 2026-03-23 By downloading, installing, or using this software, you acknowledge that you have read and accepted this notice. ### 1. Project Nature - This software is an open-source third-party desktop tool. The source code is made available under the GPL 3.0 license. - This software has no affiliation, agency, or endorsement relationship with *Infinity Nikki* or its developers and publishers. ### 2. Data Handling (Primarily Local) This software processes data primarily on your local device, which may include: - **Configuration data**: e.g., target window title, game directory, output directory, feature toggles, and UI preferences (such as `settings.json`). - **Runtime data**: Log and crash files (e.g., the `logs/` directory). - **Feature data**: Local indexes and metadata (e.g., `database.db`, used for gallery and similar features). By default, this project does not include an account system, does not bundle advertising SDKs, and does not send feature-processing data to project-maintainer-provided network interfaces unless you enable a specific online feature. ### 3. Network Activity - When you explicitly trigger "Check for Updates / Download Update", the software will access the update source. - If you enable "Automatically check for updates", the software will access the update source at startup. - "Infinity Nikki photo metadata extraction" is an optional online feature. You can skip it, and not enabling it does not affect other core features. - The software only contacts the related service when you enable this feature or manually start an extraction. Requests may include the UID, embedded photo parameters, and basic request information required for parsing; the full image file itself is not uploaded. - After you enable this feature, it may automatically run in the background when new related photos are detected. - When accessing the update source or the related service above, your request may be logged by the respective service provider (e.g., IP address, timestamp, User-Agent). ### 4. Data Sharing & User Feedback - No local data is actively uploaded to developer servers by default, except for optional online features that you choose to enable. - If you voluntarily submit an Issue, log file, crash report, or screenshot on a public platform, you agree to make that content public on that platform. ### 5. Risks & Disclaimer - This software is provided "as is" without warranty of any kind, and without guarantee of error-free, uninterrupted, or fully compatible operation in all environments. - You assume all risks associated with its use, including but not limited to performance degradation, compatibility issues, data loss, crashes, or other unexpected behavior. - To the extent permitted by applicable law, the project maintainers shall not be liable for any indirect, incidental, or consequential damages arising from the use of or inability to use this software. ### 6. Permitted Use You may only use this software for lawful and legitimate purposes. Use for illegal, harmful, or malicious activities is strictly prohibited. ### 7. Changes & Support - This notice may be updated as the project evolves. Updated versions will be published in the repository or on the documentation site. - Continued use of the software constitutes your acceptance of the updated notice. - This project does not offer one-on-one customer support or guaranteed response times. If the repository has Issues enabled, you may submit feedback there. ================================================ FILE: docs/en/developer/architecture.md ================================================ # Architecture > 🚧 Work in Progress (WIP) ================================================ FILE: docs/en/features/recording.md ================================================ # Video Recording > 🚧 Work in Progress (WIP) ================================================ FILE: docs/en/features/screenshot.md ================================================ # High-Res Screenshots > 🚧 Work in Progress (WIP) ================================================ FILE: docs/en/features/window.md ================================================ # Window & Resolution > 🚧 Work in Progress (WIP) ================================================ FILE: docs/en/guide/getting-started.md ================================================ # Getting Started ::: info Versions and documentation The current release line is still moving quickly; some features may be unstable. If you prefer a **more predictable experience**, use [v0.7.7](https://github.com/ChanIok/SpinningMomo/releases/tag/v0.7.7) and its documentation: [v0.7.7 docs](/v0/en/) . ::: > 🚧 Work in Progress (WIP) ================================================ FILE: docs/en/index.md ================================================ --- layout: home title: SpinningMomo description: Infinity Nikki photography and recording tool hero: name: SpinningMomo text: 旋转吧大喵 tagline: Infinity Nikki Game Photography and Recording Tool image: src: /logo.png alt: SpinningMomo actions: - theme: brand text: Getting Started link: /en/guide/getting-started - theme: alt text: GitHub link: https://github.com/ChanIok/SpinningMomo features: - icon: 📐 title: Custom Window Ratios details: Supports adjusting window size to any ratio, with one-click vertical display. - icon: 📸 title: Ultra High-Res Screenshots details: Bypasses the game's native limitations, supporting 8K to 12K resolution screenshots. - icon: 🎬 title: Adaptive Recording details: Built-in recording seamlessly adapts to custom window ratios without tedious settings. --- ================================================ FILE: docs/index.md ================================================ --- layout: home title: 旋转吧大喵 description: 《无限暖暖》游戏摄影与录像工具 hero: name: 旋转吧大喵 text: SpinningMomo tagline: 《无限暖暖》游戏摄影与录像工具 image: src: /logo.png alt: SpinningMomo actions: - theme: brand text: 快速开始 link: /zh/guide/getting-started - theme: alt text: GitHub link: https://github.com/ChanIok/SpinningMomo features: - icon: 📐 title: 自定义窗口比例 details: 支持任意比例的窗口尺寸调整,一键切换竖屏显示。 - icon: 📸 title: 超高清像素截图 details: 绕过游戏原生限制,支持生成 8K 至 12K 分辨率的游戏截图。 - icon: 🎬 title: 适配录像功能 details: 内置录制功能,无缝适配各种自定义窗口比例,无需繁琐设置。 --- ================================================ FILE: docs/package.json ================================================ { "name": "docs", "version": "1.0.0", "description": "SpinningMomo Docs", "type": "module", "scripts": { "dev": "vitepress dev", "build": "vitepress build", "preview": "vitepress preview" }, "keywords": [], "author": "", "license": "GPL-3.0", "devDependencies": { "@types/node": "^24.10.2", "vitepress": "^1.5.0", "vue": "^3.5.13" } } ================================================ FILE: docs/public/robots.txt ================================================ User-agent: * Allow: / Sitemap: https://spin.infinitymomo.com/sitemap.xml ================================================ FILE: docs/public/version.txt ================================================ 2.0.8 ================================================ FILE: docs/v0/en/index.md ================================================ --- layout: doc ---
SpinningMomo Logo

🎮 SpinningMomo

A window adjustment tool to enhance photography experience in Infinity Nikki
Platform Release License
Screenshot
## 🎯 Introduction ▸ Easily switch game window aspect ratio and size, perfectly adapting to scenarios like vertical composition photography and album browsing. ▸ Break through native limitations, supporting the generation of 8K-12K ultra-high-resolution game screenshots. ▸ Optimized for Infinity Nikki, while also compatible with most other games running in windowed mode. ## Features

🎮 Portrait Mode

Perfect support for vertical UI, snapshot hourglass, and album

📸 Ultra-High Resolution

Support photo output beyond game and device resolution limits

📐 Flexible Adjustment

Multiple presets, custom ratios and resolutions

⌨️ Hotkey Support

Customizable hotkey (default: Ctrl+Alt+R)

⚙️ Floating Window

Optional floating menu for convenient window adjustment

🚀 Lightweight

Minimal resource usage, performance priority

## User Guide ### 1️⃣ Getting Started When running for the first time, you may encounter these security prompts: - **SmartScreen Alert**: Click **More info** → **Run anyway** (open-source software without commercial code signing) - **UAC Prompt**: Click **Yes** to allow administrator privileges (required for window adjustments) After startup: - Program icon will appear in system tray - Floating window is shown by default for direct window adjustment ### 2️⃣ Hotkeys | Function | Hotkey | Description | |:--|:--|:--| | Show/Hide Floating Window | `Ctrl + Alt + R` | Default hotkey, can be modified in tray menu | ### 3️⃣ Photography Modes #### 🌟 Window Resolution Mode (Recommended) Game Settings: - Display Mode: **Fullscreen Window** (Recommended) or Window Mode - Photo Quality: **Window Resolution** Steps: 1. Use ratio options to adjust composition 2. Select desired resolution preset (4K~12K) 3. Screen will exceed display bounds, press space to capture 4. Click reset window after shooting Advantages: - ✨ Support ultra-high resolution (up to 12K+) - ✨ Freely adjustable ratio and resolution #### 📷 Standard Mode Game Settings: - Display Mode: **Window Mode** or Fullscreen Window (ratio limited) - Photo Quality: **4K** Notes: - ✅ Convenient operation, suitable for daily shooting and preview - ✅ Always runs smoothly, no extra performance overhead - ❗ Can only adjust ratio, resolution based on game's 4K setting - ❗ In fullscreen window mode, output limited by monitor's native ratio ### 4️⃣ Optional Features
🔍 Preview Window 📺 Overlay Window
Function Description
▫️ Similar to Photoshop's navigator feature
▫️ Provides real-time preview when window exceeds screen
Function Description
▫️ Captures target window and renders it to a fullscreen overlay
▫️ Consumes slightly more CPU resources than Preview Window
Use Cases
✨ Viewing details when shooting at high resolution
✨ Helps positioning when window exceeds screen
Use Cases
✨ Provides seamless zooming experience
✨ Maintains good interaction even at ultra-high resolutions
💡 Performance Note
Thanks to efficient capture methods, these features cause almost no noticeable performance drop.
However, if your high resolution setting is already causing significant slowdown, consider disabling these features.
### Resolution Explanation - Resolution calculation process: 1. First determine total pixel count based on selected resolution preset (e.g., 4K, 8K) 2. Calculate final width and height based on selected ratio - Example: When selecting 8K (about 33.2M pixels) and 9:16 ratio - Results in 4320x7680 output resolution (4320x7680=33.2M pixels) - Ensures total pixel count matches preset value ### Tray Features Right-click or left-click the tray icon to: - 🎯 **Select Window**: Choose the target window from the submenu - 📐 **Window Ratio**: Select from preset ratios or custom ratios - 📏 **Resolution**: Select from preset resolutions or custom resolutions - 📍 **Capture**: Save lossless screenshots to the ScreenShot folder in program directory (mainly for debugging or games without screenshot support) - 📂 **Screenshots**: Open the game screenshot directory - 🔽 **Hide Taskbar**: Hide the taskbar to prevent overlap - ⬇️ **Lower Taskbar When Resizing**: Lower taskbar when resizing window - ⬛ **Black Border Mode**: Adds a full-screen black background to windows that do not match the screen ratio, enhancing immersion and resolving taskbar flickering issues under overlay layers. - ⌨️ **Modify Hotkey**: Set a new shortcut combination - 🔍 **Preview**: Similar to Photoshop's navigator for real-time preview when window exceeds screen - Support dragging window top area to move position - Mouse wheel to zoom window size - 🖼️ **Overlay**: Render the target window on a fullscreen overlay for seamless zooming experience - 📱 **Floating Window Mode**: Toggle floating menu visibility (enabled by default, use hotkey to open menu when disabled) - 🌐 **Language**: Switch language - ⚙️ **Open Config**: Customize ratios and resolutions - ❌ **Exit**: Close the program ### Custom Settings 1. Right-click tray icon, select "Open Config File" 2. In the config file, you can customize the following: - **Custom ratios:** Add or modify in the `AspectRatioItems` entry under the `[Menu]` section, using comma-separated format, for example: `32:9,21:9,16:9,3:2,1:1,2:3,9:16,16:10` - **Custom resolutions:** Add or modify in the `ResolutionItems` entry under the `[Menu]` section, using comma-separated format, for example: `Default,4K,6K,8K,12K,5120x2880` 3. Resolution format guide: - Supports common identifiers: `480P`, `720P`, `1080P`, `2K`, `4K`, `6K`, `8K`, etc. - Custom format: `width x height`, for example `5120x2880` 4. Save and restart software to apply changes ### Notes - System Requirements: Windows 10 or above - Higher resolutions may affect game performance, please adjust according to your device capabilities - It's recommended to test quality comparison before shooting to choose the most suitable settings ### Security Statement This program only sends requests through Windows standard window management APIs, with all adjustments executed by the Windows system itself, working similarly to: - Window auto-adjustment when changing system resolution - Window rearrangement when rotating screen - Window movement in multi-display setups ## License This project is open source under the [GPL 3.0 License](https://github.com/ChanIok/SpinningMomo/blob/main/LICENSE). The project icon is from the game "Infinity Nikki" and copyright belongs to the game developer. Please read the [Legal & Privacy Notice](https://spin.infinitymomo.com/en/about/legal) before use. ================================================ FILE: docs/v0/index.md ================================================ --- layout: home hero: name: SpinningMomo text: 旋转吧大喵 tagline: 一个为《无限暖暖》提升摄影体验的窗口调整工具 image: src: /logo.png alt: SpinningMomo actions: - theme: brand text: 快速开始 link: /v0/zh/guide/getting-started - theme: alt text: 在 GitHub 上查看 link: https://github.com/ChanIok/SpinningMomo features: - icon: 🎮 title: 竖拍支持 details: 完美支持游戏竖拍UI,留影沙漏和大喵相册 - icon: 📸 title: 超高分辨率 details: 支持突破游戏和设备分辨率的照片输出 - icon: 📐 title: 灵活调整 details: 多种预设,自定义比例和分辨率 - icon: ⌨️ title: 快捷键支持 details: 可自定义热键,便捷操作 --- ================================================ FILE: docs/v0/zh/advanced/custom-settings.md ================================================ # 自定义设置 ## ⚙️ 配置文件说明 ### 📂 文件位置 配置文件 `config.ini` 位于程序目录下,首次运行程序时会自动创建。 ::: tip 快速访问 可以通过托盘菜单的"打开配置文件"快速打开。 ::: ## 🔧 配置项说明 ### 🎯 窗口设置 ```ini [Window] # 目标窗口标题,程序会优先调整此标题的窗口 Title= ``` ### ⌨️ 快捷键设置 ```ini [Hotkey] # 修饰键组合值: # 1 = Alt # 2 = Ctrl # 3 = Ctrl + Alt # 4 = Shift # 5 = Alt + Shift # 6 = Ctrl + Shift # 7 = Ctrl + Alt + Shift Modifiers=3 # 主键的虚拟键码 # 82 = R键 # 65-90 = A-Z # 48-57 = 0-9 # 112-123 = F1-F12 Key=82 ``` ::: tip 默认快捷键 默认快捷键是 `Ctrl + Alt + R`,对应 `Modifiers=3` 和 `Key=82`。 建议通过托盘菜单的子菜单选择快捷键,也可以自行查询 KeyCode 对照表进行设置。 ::: ### 📐 自定义比例 格式说明: - 用冒号(`:`)连接宽高比 - 用逗号(`,`)分隔多个比例 - 比例值可以是整数或小数 - 支持默认预设或自定义比例 示例: ```ini # 使用默认预设并添加新的比例 AspectRatioItems=32:9,21:9,16:9,3:2,1:1,2:3,9:16,16:10 # 完全自定义比例列表 AspectRatioItems=16:9,16:10,1.618:1,1:1 ``` ### 📏 自定义分辨率 格式说明: - 支持常见标识符:`Default`, `4K`, `6K`, `8K`, `12K` - 自定义格式:用字母`x`连接宽高,例如`5120x2880` - 用逗号(`,`)分隔多个分辨率 - 分辨率必须是整数 示例: ```ini # 使用默认预设并添加自定义分辨率 ResolutionItems=Default,4K,6K,8K,12K,5120x2880 # 添加常见分辨率标识符 ResolutionItems=Default,480P,720P,1080P,2K,4K,8K ``` ### 📋 自定义浮窗菜单项 格式说明: - 用逗号(`,`)分隔多个菜单项 - 可用项包括: - `CaptureWindow`: 截图 - `OpenScreenshot`: 打开相册 - `PreviewWindow`: 预览窗 - `OverlayWindow`: 叠加层 - `LetterboxWindow`: 黑边模式 - `Reset`: 重置窗口 - `Close`: 关闭菜单 - `Exit`: 退出程序 示例: ```ini # 简化菜单(只保留常用选项) MenuItems=PreviewWindow,OverlayWindow,Reset,Close # 完整菜单 MenuItems=CaptureWindow,OpenScreenshot,PreviewWindow,OverlayWindow,LetterboxWindow,Reset,Close,Exit ``` ### 📸 相册目录设置 ```ini [Screenshot] # 游戏相册目录路径,用于快速打开游戏截图文件夹 # 可以修改为其他游戏的相册目录或程序的截图目录 GameAlbumPath= ``` 示例: ```ini # 自定义目录 GameAlbumPath=D:\Games\Screenshots ``` ### 🌐 语言设置 ```ini [Language] # 支持的语言: # zh-CN = 简体中文 # en-US = English Current=zh-CN ``` ### 🎯 浮窗设置 ```ini [Menu] # 是否使用浮窗模式 # 0 = 使用快捷菜单 # 1 = 使用浮窗 Floating=1 ``` ### 🔽 任务栏设置 ```ini [Taskbar] # 是否自动隐藏任务栏 # 0 = 不隐藏 # 1 = 自动隐藏 AutoHide=0 # 调整窗口时是否将任务栏置底 # 0 = 不置底 # 1 = 自动置底 LowerOnResize=1 ``` ### ⬛ 黑边模式设置 ```ini [Letterbox] # 是否启用黑边模式 # 0 = 禁用 # 1 = 启用 Enabled=0 ``` ### 🔊 日志级别设置 ```ini [Logger] # 日志记录级别 # DEBUG = 详细调试信息,用于开发者调试 # INFO = 一般信息(默认) # ERROR = 仅记录错误信息 Level=INFO ``` ::: warning 注意事项 - 修改配置文件后需要重启程序才能生效 - 可手动将旧的配置文件复制到新版本中,以保留自定义设置 ::: ================================================ FILE: docs/v0/zh/advanced/troubleshooting.md ================================================ # 常见问题 ::: tip 提示 在阅读本页面之前,请先阅读[功能说明](/v0/zh/guide/features)了解各功能的注意事项。 ::: ## ❓ 工作原理 ### 程序的工作原理是什么? ::: info 原理解析 本程序利用了游戏引擎的渲染机制和 Windows 系统的窗口管理特性: #### 1️⃣ 渲染机制 - 在大多数使用现代游戏引擎(如UE4/5、Unity等)开发的游戏中,窗口模式或无边框窗口模式下的渲染分辨率通常由窗口尺寸决定 ```ts // UE4/5引擎示例 r.ScreenPercentage // 控制实际渲染分辨率与窗口分辨率的比例 ``` - 当进行窗口尺寸调整时,游戏引擎会根据新的窗口尺寸重新计算渲染分辨率(这是很多游戏引擎的默认行为) - 即使窗口尺寸超出了显示器的物理大小,游戏引擎仍然会按照实际的窗口大小进行完整的渲染过程 - 这使得在拍照画质设置为"窗口分辨率"时,可以输出超高分辨率的截图 #### 2️⃣ 窗口管理 - 程序通过 Windows 系统标准的窗口管理API调整窗口尺寸和样式 ```cpp // Windows API 核心调用 SetWindowPos() // 调整窗口大小和位置 WS_POPUP // 需要时切换为无边框样式 ``` - 当窗口需要超出屏幕范围时,会自动切换为无边框样式(Alt+Enter可恢复) - 这些操作等同于系统标准行为: - 📱 系统分辨率变更时的窗口自适应 - 🔄 屏幕旋转时的窗口重排 - 🖥️ 多显示器下的窗口移动 #### 3️⃣ 安全性 程序不会: ::: danger 禁止行为 - 修改游戏内存 - 注入游戏进程 - 修改游戏文件 ::: ## ❌ 常见错误与解决方案 ### 管理员运行后无反应 ::: warning 问题描述 运行程序后没有任何反应,也没有浮窗弹出。如:[Issue](https://github.com/ChanIok/SpinningMomo/issues/5) ::: **解决方案**: 1. 查看 Windows 安全中心的保护历史记录,检查是否有相关拦截信息,有则尝试关闭保护 2. 换个渠道重新下载程序并运行 ### 热键注册失败 ::: warning 错误提示 热键注册失败。程序仍可使用,但快捷键将不可用。 ::: **解决方案**:右键系统托盘的程序图标,打开菜单,点击"修改热键",键盘输入其他未被占用的热键组合 ### 高分辨率拍照后画质未提升 ::: warning 问题描述 在无限暖暖中选择高分辨率预设后拍照,但最终截图画质没有明显提升。 ::: **解决方案**:进入游戏设置,确认"拍照-照片画质"选项已设置为"窗口分辨率"而非"4K"或其他。 ### 自定义比例拍照后比例未改变 ::: warning 问题描述 在无限暖暖中选择自定义比例后拍照,但最终拍照的比例并不正确 ::: **解决方案**: 1. 请参考 [快速开始](https://chaniok.github.io/SpinningMomo/zh/guide/getting-started.html#_3%EF%B8%8F%E2%83%A3-%E6%8B%8D%E7%85%A7%E6%A8%A1%E5%BC%8F) 的 拍照模式 进行正确的设置 2. 如果"拍照-照片画质"选项设置为"4K",务必确保"显示模式"选项设置为"窗口模式"。(照片画质为窗口分辨率时不会出现该情况) ### 预览窗或叠加层功能引发崩溃 ::: info 注意 尽管作者在多种环境下进行了测试,但由于硬件和系统配置差异,个别情况下仍可能出现问题。 ::: **解决方案**:如果遇到无法解决的崩溃,请通过 [GitHub Issues](https://github.com/ChanIok/SpinningMomo/issues) 提供详细的系统信息和崩溃前操作步骤,以便开发者定位和解决问题 ## 🎮 无限暖暖使用建议 ### 动态场景拍照问题 ::: warning 问题描述 - 在无限暖暖中,使用键盘空格键拍照可能导致截图细节模糊、锯齿或纹理不清晰 - 某些动态场景(如花焰群岛的旋转木马)下拍照尤其容易出现细节模糊、锯齿或纹理不清晰的情况 ::: **解决方案**: - 为获得高质量截图,建议使用预览窗或叠加层功能,**通过鼠标点击游戏内拍照按钮进行拍摄** - 是的,叠纸的技术力就是这么烂,空格键拍照有 BUG ### 画面错位问题 ::: warning 问题描述 在全屏窗口模式下,当从小于屏幕的尺寸(如屏幕1080P,窗口720P)切换到超出屏幕尺寸时,可能导致游戏画面错位。 ::: **解决方案**:在游戏设置中切换到 **窗口模式**,或直接按 Alt+Enter 切换到窗口模式后再调整尺寸 ### 录制超大窗口 ::: tip 建议 - 使用 [OBS](https://obsproject.com/) 可以完整捕获超出屏幕范围的游戏窗口 - 在"来源"中添加"游戏捕获"或"窗口捕获",选择无限暖暖窗口 - 每次使用 SpinningMomo 调整游戏窗口分辨率或比例后,需要在 OBS 中右键点击"调整输出大小(源大小)"以匹配新的窗口尺寸 ::: ## 💬 获取帮助 如果您遇到的问题在此页面没有找到解决方案,您可以: ::: tip 寻求帮助的方式 - 在 [GitHub Issues](https://github.com/ChanIok/SpinningMomo/issues) 提交问题 ::: ::: warning 提交问题时请注意 提供以下信息有助于我们更好地帮助您: - 系统版本 - 问题的详细描述 - 复现步骤 - 相关的错误信息 ::: ================================================ FILE: docs/v0/zh/guide/features.md ================================================ # 功能说明 ## 🎯 窗口调整 ### 选择目标窗口 ::: info 支持的游戏 程序默认选择《无限暖暖》作为目标窗口。同时兼容多数窗口化运行的其他游戏,如: - 《最终幻想14》 - 《鸣潮》 - 《崩坏:星穹铁道》(不完全适配自定义比例) - 《燕云十六声》(建议使用程序的截图功能) 如需调整其他窗口,可通过托盘菜单选择,**务必将游戏设置为窗口化运行**。 ::: ### 📐 窗口比例 提供多种预设比例,满足不同构图需求: | 比例 | 适用场景 | |:--|:--| | 32:9 | 超宽屏拍摄,全景构图 | | 21:9 | 带鱼屏拍摄,电影构图 | | 16:9 | 标准宽屏,横向构图 | | 3:2 | 经典相机比例,专业摄影 | | 1:1 | 方形构图,社交平台 | | 3:4 | 小红书推荐比例,竖屏内容 | | 2:3 | 经典相机比例,人像拍摄 | | 9:16 | 手机端竖屏,短视频格式 | ::: tip 自定义比例 想要更多比例选择?可以在配置文件中添加自定义比例,详见[自定义设置](/zh/advanced/custom-settings)。 ::: ### 📏 分辨率预设 支持多种超高清分辨率输出: | 预设 | 分辨率 | 像素数 | |:--|:--|:--| | 1080P | 1920×1080 | 约 207 万像素 | | 2K | 2560×1440 | 约 369 万像素 | | 4K | 3840×2160 | 约 830 万像素 | | 6K | 5760×3240 | 约 1870 万像素 | | 8K | 7680×4320 | 约 3320 万像素 | | 12K | 11520×6480 | 约 7460 万像素 | ::: tip 分辨率计算过程 1. 首先根据选择的分辨率预设(如 4K、8K)确定总像素数 2. 然后根据选择的比例计算最终的宽高 例如:选择 8K(约 3320 万像素)和 9:16 比例时 - 计算得到 4320×7680 的输出分辨率 - 4320×7680 ≈ 3320 万像素 - 保证总像素数与预设值相近 ::: ::: warning 性能注意事项 - 分辨率越高,系统资源占用越大,主要消耗显存、内存和虚拟内存 - 参考数据:RTX 3060 12G + 32G内存的设备,使用12K分辨率时会出现极其严重卡顿 - 如遇游戏崩溃,可以: 1. 通过任务管理器观察资源使用情况 2. 尝试增加虚拟内存大小 3. 关闭后台占用内存的程序 ::: ## 💻 界面控制 ### 📱 浮动窗口 便捷的窗口调整工具: - ✨ 默认开启,随时待命 - 🎯 快速调整窗口比例和分辨率 - ⌨️ 支持快捷键切换(默认 `Ctrl + Alt + R`) - 🎨 简洁美观的界面设计 ::: tip 快捷菜单模式 如果你更喜欢简洁的界面: - 🚀 可以关闭浮窗模式,改用热键呼出快捷菜单 - 🔍 预览窗口可以独立使用,右键点击即可呼出快捷菜单 - ⚡ 建议设置单键热键(如 ``` ` ```键),使用更加顺手 ::: ### 🔍 预览窗 类似 Photoshop 的导航器,帮助你在超大分辨率下精确定位: - 🖼️ 实时预览溢出屏幕的画面 - 🖱️ 支持拖拽窗口顶部区域移动位置 - 🖲️ 滚轮缩放窗口大小,方便调整预览范围 - 📋 右键点击呼出快捷菜单,支持快速调整窗口比例和分辨率 ### 🖼️ 叠加层 类似 Magpie 的反向缩小版,Magpie 是将小窗口放大到全屏,而本功能则是将大窗口缩小显示: - 📺 将目标窗口捕获并渲染到全屏叠加层上 - 🎯 提供无感知放大的操作体验 - ⚡ 在超大分辨率下依然保持良好交互 ::: warning 使用建议 由于需要不断设置窗口位置以实现鼠标的点击定位,会额外消耗一定的CPU资源。 如果你选择的高分辨率已经让电脑卡顿严重,建议暂时不要开启此功能。 ::: ### 🔽 任务栏控制 | 模式 | 功能 | 适用场景 | |:--|:--|:--| | 隐藏任务栏 | 完全隐藏任务栏 | 需要完整画面时 | | 调整时置底 | 仅在调整窗口时置底 | 默认开启,使用推荐 | ### ⬛ 黑边模式 为非屏幕原生比例的窗口提供沉浸式体验: - 🌃 创建全屏黑色背景,自动置于游戏窗口底部 - 🔄 解决使用叠加层时任务栏闪烁问题 ### 🔲 切换窗口边框 切换游戏窗口边框显示,可以移除窗口标题栏和边框 ## 📸 截图功能 ### 保存截图 ::: info 截图说明 - 📁 自动保存至程序目录下的 ScreenShot 文件夹中 - 🎨 主要用于调试目的,无损保存,保持原始画质 - ⚡ 适用于游戏内置截图功能无法保存高分辨率图片的情况 - ℹ️ 正常游戏中不需要使用此功能 ::: ::: tip 《无限暖暖》玩家注意 推荐使用游戏内置的**大喵相机**,在动态场景中拍照时游戏会暂停渲染,可以避免拍照时的锯齿和模糊。 ::: ### 📂 相册管理 便捷的相册访问功能: - 🚀 一键打开游戏相册目录,无需手动查找路径 - 📱 快速查看和整理截图文件 - 📁 支持[自定义相册路径](/zh/advanced/custom-settings#相册目录设置) ::: warning 使用条件 首次使用《无限暖暖》相册功能时,需要保持游戏处于运行状态 ::: ## ⚙️ 其他设置 ### ⌨️ 快捷键设置 灵活的快捷键配置: - 🎯 支持自定义组合键 - 🔢 支持单个按键(如 `Home`、`End`、`小键盘0-9`) - ⚠️ 默认为 `Ctrl + Alt + R`(可能与 NVIDIA 浮窗冲突) ### 🌐 语言切换 支持多语言界面: - 🇨🇳 简体中文 - 🇺🇸 English ### 🛠️ 配置文件 通过编辑配置文件可以实现高级自定义: - 📐 添加自定义比例 - 📏 设置自定义分辨率 - ⚙️ 调整其他高级选项 ::: tip 想了解更多? 查看[自定义设置](/v0/zh/advanced/custom-settings)了解详细配置方法。 ::: ================================================ FILE: docs/v0/zh/guide/getting-started.md ================================================ # 快速开始 ## 📥 下载安装 ### 获取程序 ::: tip 下载地址 | 下载源 | 链接 | 说明 | |:--|:--|:--| | **GitHub** | [点击下载](https://github.com/ChanIok/SpinningMomo/releases/latest) | 推荐,国内访问可能受限 | | **蓝奏云** | [点击下载](https://wwf.lanzoul.com/b0sxagp0d) | 密码:`momo` | | **百度网盘** | [点击下载](https://pan.baidu.com/s/1UL9EJa2ogSZ4DcnGa2XcRQ?pwd=momo) | 提取码:`momo` | ::: ### 系统要求 ::: warning 运行环境 - **操作系统**:Windows 10 1803 (Build 17134) 或更高版本 - **显卡/驱动**:支持 DirectX 11 的显卡和最新驱动 - **Windows 功能**: - 图形捕获功能 Windows 10 1803+ - 高级功能需要 Windows 10 2104+(Build 20348 或更高) 部分高级功能(如无边框捕获、隐藏鼠标捕获)在较新版本的 Windows 10/11 上提供更好体验。 ::: ## 🚀 使用说明 ### 1️⃣ 启动程序 #### 首次运行(系统安全提示) ::: warning Windows安全提示 首次运行时,可能会遇到以下安全提示: - **SmartScreen 提示**:点击**更多信息**→**仍要运行**(开源软件无商业代码签名) - **UAC 提示**:点击**是**允许管理员权限(程序需要此权限调整窗口) ::: 启动后: - 系统托盘会显示程序图标 - 默认显示浮动窗口,可直接调整窗口 ::: warning 无浮窗弹出? 如果运行程序后没有浮窗弹出,请参考 [故障排除指南](https://chaniok.github.io/SpinningMomo/zh/advanced/troubleshooting.html#%E7%AE%A1%E7%90%86%E5%91%98%E8%BF%90%E8%A1%8C%E5%90%8E%E6%97%A0%E5%8F%8D%E5%BA%94) ::: ### 2️⃣ 快捷键 | 功能 | 快捷键 | 说明 | |:--|:--|:--| | 显示/隐藏浮窗 | `Ctrl + Alt + R` | 默认快捷键,可在托盘菜单中修改 | ### 3️⃣ 拍照模式 #### 🌟 窗口分辨率模式(推荐) 游戏设置: - 显示模式:**全屏窗口模式** 或 窗口模式 - 拍照-照片画质:**窗口分辨率** 使用步骤: 1. 使用程序的比例选项调整构图 2. 选择需要的分辨率预设(4K~12K) 3. 画面会溢出屏幕,此时按空格拍照 4. 拍摄完成后点击重置窗口 优势特点: - ✨ 支持超高分辨率(最高12K+) - ✨ 可自由调整比例和分辨率 #### 📷 标准模式 游戏设置: - 显示模式:**窗口模式** 或 全屏窗口模式(比例受限) - 拍照-照片画质:**4K** 特点说明: - ✅ 操作便捷,适合日常拍摄和预览 - ✅ 始终保持流畅运行,无需额外性能开销 - ❗ 只能调整比例,分辨率基于游戏设置的4k - ❗ 全屏窗口模式下输出受限于显示器原始比例 ### 4️⃣ 可选功能 #### 🔍 预览窗 功能说明: - 类似 Photoshop 的导航器功能 - 在窗口溢出屏幕时提供实时预览 使用场景: - ✨ 高分辨率拍摄时查看放大后的细节 - ✨ 窗口溢出屏幕时辅助定位 #### 🖼️ 叠加层 功能说明: - 类似 Magpie 的反向缩小版 - 将目标窗口捕获并渲染到全屏叠加层上 - ⚠️ 比预览窗额外消耗一些CPU资源 使用场景: - ✨ 提供无感知放大的操作体验 - ✨ 在超大分辨率下依然保持良好交互 ::: warning 💡 性能说明 得益于高效的捕获方式,这两种功能几乎不会造成明显的性能下降。 但如果你选择的高分辨率已经让电脑卡成PPT了,建议暂时不要开启这些功能。 ::: ## ⏩ 下一步 👉 查看[功能说明](/v0/zh/guide/features)了解更多基础功能的详细说明。 ::: tip 喜欢这个工具? 欢迎到 [GitHub](https://github.com/ChanIok/SpinningMomo) 点个 Star ⭐ 支持一下~ ::: ================================================ FILE: docs/v0/zh/guide/introduction.md ================================================ # 项目介绍 ::: tip 简介 旋转吧大喵(SpinningMomo)是一个专为《无限暖暖》游戏开发的窗口调整工具,旨在提升游戏的摄影体验。 ::: ## ✨ 主要特性 ### 🎮 竖拍支持 - 完美支持游戏竖拍UI - 适配留影沙漏和大喵相册 - 无缝切换横竖构图 ### 📸 超高分辨率 - 支持突破游戏原有分辨率限制 - 可输出高达12K+的超清照片 - 完美保持画质不失真 ### 📐 灵活调整 - 提供多种预设比例和分辨率 - 支持自定义窗口设置 - 快速切换不同拍摄模式 ### ⌨️ 便捷操作 - 可自定义快捷键组合 - 提供浮动菜单快速调整 - 支持预览窗实时导航 - 支持叠加层无感知放大 ## 💻 技术特点 ::: info 技术说明 - 使用原生 Win32 API 开发,性能优先 - 极低的系统资源占用 - 纯手工打造的浮窗界面 - DirectX 11 实现的预览窗和叠加层 ::: ## 📝 开源协议 ::: tip 版权说明 本项目采用 GPL 3.0 协议开源。项目图标来自游戏《无限暖暖》,版权归游戏开发商所有。 ::: ## 🔐 法律与隐私 - [法律与隐私说明](/zh/legal/notice) ================================================ FILE: docs/zh/about/credits.md ================================================ # 开源鸣谢 SpinningMomo(旋转吧大喵)的开发离不开以下优秀的开源项目,向它们的作者表示由衷的感谢。 ### C++ Backend(原生后端) | 项目 | 协议 | 链接 | |------|------|------| | [xmake](https://github.com/xmake-io/xmake) | Apache-2.0 | https://github.com/xmake-io/xmake | | [uWebSockets](https://github.com/uNetworking/uWebSockets) | Apache-2.0 | https://github.com/uNetworking/uWebSockets | | [uSockets](https://github.com/uNetworking/uSockets) | Apache-2.0 | https://github.com/uNetworking/uSockets | | [reflect-cpp](https://github.com/getml/reflect-cpp) | MIT | https://github.com/getml/reflect-cpp | | [spdlog](https://github.com/gabime/spdlog) | MIT | https://github.com/gabime/spdlog | | [asio](https://github.com/chriskohlhoff/asio) | BSL-1.0 | https://github.com/chriskohlhoff/asio | | [yyjson](https://github.com/ibireme/yyjson) | MIT | https://github.com/ibireme/yyjson | | [fmt](https://github.com/fmtlib/fmt) | MIT | https://github.com/fmtlib/fmt | | [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/) | Microsoft | https://developer.microsoft.com/en-us/microsoft-edge/webview2/ | | [wil (Windows Implementation Libraries)](https://github.com/Microsoft/wil) | MIT | https://github.com/Microsoft/wil | | [xxHash](https://github.com/Cyan4973/xxHash) | BSD-2-Clause | https://github.com/Cyan4973/xxHash | | [SQLiteCpp](https://github.com/SRombauts/SQLiteCpp) | MIT | https://github.com/SRombauts/SQLiteCpp | | [SQLite3](https://www.sqlite.org/index.html) | Public Domain | https://www.sqlite.org/index.html | | [libwebp](https://chromium.googlesource.com/webm/libwebp) | BSD-3-Clause | https://chromium.googlesource.com/webm/libwebp | | [zlib](https://zlib.net/) | zlib License | https://zlib.net/ | | [libuv](https://libuv.org/) | MIT | https://github.com/libuv/libuv | ### Web Frontend(Web 前端) | 项目 | 协议 | 链接 | |------|------|------| | [Vue.js](https://vuejs.org/) | MIT | https://github.com/vuejs/core | | [Vite](https://vitejs.dev/) | MIT | https://github.com/vitejs/vite | | [Tailwind CSS](https://tailwindcss.com/) | MIT | https://github.com/tailwindlabs/tailwindcss | | [Pinia](https://pinia.vuejs.org/) | MIT | https://github.com/vuejs/pinia | | [Vue Router](https://router.vuejs.org/) | MIT | https://github.com/vuejs/router | | [VueUse](https://vueuse.org/) | MIT | https://github.com/vueuse/vueuse | | [TanStack Virtual](https://tanstack.com/virtual) | MIT | https://github.com/TanStack/virtual | | [Lucide](https://lucide.dev/) | ISC | https://github.com/lucide-icons/lucide | | [Inter Font](https://rsms.me/inter/) | OFL-1.1 | https://github.com/rsms/inter | | [Reka UI](https://reka-ui.com/) | MIT | https://github.com/unovue/reka-ui | | [shadcn-vue](https://www.shadcn-vue.com/) | MIT | https://github.com/radix-vue/shadcn-vue | | [vue-sonner](https://github.com/xiaoluoboding/vue-sonner) | MIT | https://github.com/xiaoluoboding/vue-sonner | *以上项目的完整许可证文本可以在其各自的存储库或分发包中找到。* ================================================ FILE: docs/zh/about/legal.md ================================================ # 法律与隐私 更新日期:2026-03-23 生效日期:2026-03-23 你下载、安装或使用本软件,即表示你已阅读并接受本说明。 ### 1. 项目性质 - 本软件是开源第三方桌面工具,代码部分按 GPL 3.0 协议提供。 - 本软件与《无限暖暖》及其开发/发行方无隶属、代理或担保关系。 ### 2. 数据处理范围(本地为主) 本软件主要在本地设备处理数据,可能包括: - 配置数据:例如目标窗口标题、游戏目录、输出目录、功能开关与界面偏好(如 `settings.json`)。 - 运行数据:日志与崩溃文件(如 `logs/` 目录)。 - 功能数据:本地索引与元数据(如 `database.db`,用于图库等功能)。 默认情况下,本项目不提供账号系统,不内置广告 SDK,也不会在未启用特定联网功能时向项目维护者提供的网络接口发送用于功能处理的数据。 ### 3. 联网行为 - 当你主动执行"检查更新/下载更新"等操作时,软件会访问更新源。 - 若你启用了"自动检查更新",软件会在启动时自动访问更新源。 - “无限暖暖照片元数据解析”是可选联网功能,可跳过,不影响其他基础功能。 - 仅在你启用该功能或手动发起解析时,软件才会访问相应服务,并发送解析所需的 UID、照片内嵌参数及基础请求信息;不会上传整张图片文件。 - 启用后,该功能可能在检测到新的相关照片时自动后台运行。 - 访问更新源或上述服务时,请求可能被对应服务提供方记录基础访问日志(例如 IP、时间、User-Agent)。 ### 4. 数据共享与用户反馈 - 默认不向开发者服务器主动上传本地数据,但你主动启用的可选联网功能除外。 - 若你主动在公开平台提交 Issue、日志、崩溃文件或截图,则视为你同意在该平台公开这些内容。 ### 5. 风险与免责 - 本软件按"现状"提供,不承诺在所有环境下无错误、无中断或完全兼容。 - 你应自行评估和承担使用风险,包括但不限于性能波动、兼容性问题、数据损失、崩溃或其他异常后果。 - 在适用法律允许范围内,项目维护者不对因使用或无法使用本软件造成的间接损失、附带损失或特殊损失承担责任。 ### 6. 使用边界 你仅可在合法、合规范围内使用本软件,不得用于违法、破坏性或恶意用途。 ### 7. 变更与支持 - 本说明可能随项目演进更新,更新后版本在仓库或文档站发布。 - 继续使用软件即视为你接受更新后的说明。 - 本项目不提供一对一客服或响应时效承诺;若仓库开放 Issues,你可自行通过 Issues 反馈。 ================================================ FILE: docs/zh/developer/architecture.md ================================================ # 架构与构建 ## 架构与代码规范说明 本项目核心采用 C++23 Modules 与 Vue 3 混合双端架构。 关于详细的设计哲学、C++ 组件系统划分以及所有的模块依赖关系,已在此仓库根目录维护了最新的 **[`AGENTS.md`](https://github.com/ChanIok/SpinningMomo/blob/main/AGENTS.md)**。 ## 环境要求 | 工具 | 要求 | 说明 | |------|------|------| | **Visual Studio 2022+** | 含「使用 C++ 的桌面开发」工作负载 | 需在工作负载中额外勾选「**C++ 模块(针对标准库的 MSVC v143)**」| | **Windows SDK** | 10.0.22621.0+(Windows 11 SDK) | | | **xmake** | 最新版 | C++ 构建系统,管理 vcpkg 依赖 | | **Node.js** | v20+ | Web 前端构建及 npm 脚本 | ### 安装 xmake ```powershell # PowerShell(推荐) iwr -useb https://xmake.io/psget.txt | iex # 或前往官网下载安装包 # https://xmake.io/#/getting_started?id=installation ``` > xmake 会通过 `xmake-requires.lock` 自动调用 vcpkg 下载和编译 C++ 依赖,**无需手动安装 vcpkg**。 --- ## 依赖准备 ### 1. 获取第三方依赖 ```powershell .\scripts\fetch-third-party.ps1 ``` ### 2. 安装 npm 依赖 ```bash # 根目录(构建脚本依赖) npm install # Web 前端依赖 cd web && npm ci ``` --- ## 构建 ### 完整构建(推荐) ```bash # 一键完成:C++ Release + Web 前端 + 打包 dist/ npm run build:ci ``` 产物位于 `dist/` 目录。 ### 分步构建 ```bash # C++ 后端 - Debug(日常开发) xmake config -m debug xmake build # C++ 后端 - Release xmake release # 构建 release 后自动恢复 debug 配置 # Web 前端 cd web && npm run build # 打包 dist/(汇总 exe + web 资源) npm run build:prepare ``` ### 构建输出路径 | 构建类型 | 路径 | |----------|------| | Debug | `build\windows\x64\debug\` | | Release | `build\windows\x64\release\` | | 打包产物 | `dist\` | --- ## 打包发布产物 ### 便携版(ZIP) ```bash npm run build:portable ``` ### MSI 安装包 需要额外安装 WiX Toolset v6: ```bash dotnet tool install --global wix --version 6.0.2 wix extension add WixToolset.UI.wixext/6.0.2 --global wix extension add WixToolset.BootstrapperApplications.wixext/6.0.2 --global ``` 然后运行: ```powershell .\scripts\build-msi.ps1 -Version "x.y.z" ``` --- ## Web 前端开发 启动开发服务器(需 C++ 后端同时运行): ```bash cd web && npm run dev ``` Vite 开发服务器会将 `/rpc` 和 `/static` 代理到 C++ 后端(`localhost:51206`)。 --- ## 代码生成脚本 修改以下源文件后需重新运行对应脚本: | 修改内容 | 需运行的脚本 | |----------|-------------| | `src/migrations/*.sql` | `node scripts/generate-migrations.js` | | `src/locales/*.json` | `node scripts/generate-embedded-locales.js` | ================================================ FILE: docs/zh/features/recording.md ================================================ # 视频录制 ## 使用方法 1. 将游戏窗口调整至需要的比例和分辨率 2. 点击浮窗中的"开始录制" 3. 录制完成后点击"停止录制" 录制文件默认保存至系统"视频"文件夹下的 `SpinningMomo` 目录(可在设置中自定义路径),格式为 MP4,分辨率与当前窗口尺寸一致。 ## 与外部录制工具的关系 内置录制定位为**轻量适配方案**,适合快速使用或不想切换工具的场景。如果有更高的录制质量、推流、多音轨等需求,仍建议使用 OBS 等专业工具并手动配置捕获区域。 ::: warning 性能提示 录制超高分辨率(如 8K)对 CPU 和磁盘写入速度要求较高,请确保硬件性能足够。 ::: ================================================ FILE: docs/zh/features/screenshot.md ================================================ # 超清截图 ## 游戏内置截图(推荐) 对于《无限暖暖》,**推荐优先使用游戏内置的"大喵相机"拍照**。游戏相机在拍摄时会暂停渲染,可避免运动模糊和时间性锯齿,同时也会保存游戏照片的元数据。 点击浮窗中的"游戏相册"可直接跳转到游戏相册目录,无需手动查找。 ## 程序内置截图 程序自带截图功能,直接捕获目标窗口的当前画面,文件保存至系统"视频"文件夹下的 `SpinningMomo` 目录(可在设置中自定义路径)。 ::: info 适用场景 - 游戏内置截图无法保存当前超高分辨率画面时 - 需要对其他游戏或窗口截图时 正常情况下进行《无限暖暖》游戏摄影时不需要用到此功能。 ::: ================================================ FILE: docs/zh/features/window.md ================================================ # 比例与分辨率 ## 选择目标窗口 ::: info 支持的游戏 程序默认选择《无限暖暖》作为目标窗口。同时兼容多数窗口化运行的其他游戏,如: - 《最终幻想14》 - 《鸣潮》 - 《崩坏:星穹铁道》(不完全适配自定义比例) - 《燕云十六声》(建议使用程序的截图功能) 如需调整其他窗口,可通过**右键单击悬浮窗或托盘图标**在菜单中选择窗口,**务必将游戏设置为窗口化运行**。 ::: ## 比例预设 | 比例 | 适用场景 | |:--|:--| | 32:9 | 超宽屏,全景构图 | | 21:9 | 宽屏,电影感构图 | | 16:9 | 标准横屏 | | 3:2 | 经典相机比例 | | 1:1 | 方形构图 | | 3:4 | 小红书推荐比例 | | 2:3 | 人像竖拍 | | 9:16 | 手机竖屏 / 短视频 | ## 分辨率预设 | 预设 | 等效基准 | 总像素数 | |:--|:--|:--| | 1080P | 1920×1080 | 约 207 万 | | 2K | 2560×1440 | 约 369 万 | | 4K | 3840×2160 | 约 830 万 | | 6K | 5760×3240 | 约 1870 万 | | 8K | 7680×4320 | 约 3320 万 | | 12K | 11520×6480 | 约 7460 万 | ::: tip 分辨率的计算逻辑 程序先按选定的分辨率预设确定总像素数,再按选定的比例重新分配宽高。 例如:**8K + 9:16** → 输出 **4320×7680**,总像素数与 8K 相近。 ::: ::: warning 性能说明 分辨率越高,对显存、内存和虚拟内存的消耗越大。RTX 3060 12G + 32G 内存的环境下,12K 分辨率会出现明显卡顿。如遇游戏崩溃,可通过任务管理器确认资源瓶颈,或适当增加虚拟内存。 ::: ## 辅助功能 **预览窗**:当游戏窗口超出屏幕时,提供类似 Photoshop 导航器的实时悬浮预览,支持滚轮缩放和拖拽移动。 **叠加层**:将超出屏幕的大窗口缩放渲染到全屏叠加层上,在超高分辨率下保持正常的鼠标交互。对 CPU 有额外开销,已明显卡顿时建议关闭。 **黑边模式**:在窗口底部铺全屏黑色背景,为非屏幕原生比例的窗口提供沉浸式体验。 ================================================ FILE: docs/zh/guide/getting-started.md ================================================ # 安装与运行 ::: info 版本与文档 当前仓库发布的新版仍在快速迭代,部分功能或体验可能不稳定。若你更希望使用**行为相对固定的版本**,可下载 [v0.7.7](https://github.com/ChanIok/SpinningMomo/releases/tag/v0.7.7),并阅读该版本对应的说明文档:[v0.7.7 文档专区](/v0/)。 ::: 本页面将带你完成初次配置,并拍出你的第一张超清竖构图。 ## 下载程序 | 下载源 | 链接 | 说明 | |:--|:--|:--| | **GitHub** | [点击下载](https://github.com/ChanIok/SpinningMomo/releases/latest) | 国内访问可能受限 | | **百度网盘** | [点击下载](https://pan.baidu.com/s/1UL9EJa2ogSZ4DcnGa2XcRQ?pwd=momo) | 提取码:`momo` | **版本类型说明:** 提供 **安装版(.exe)** 与 **便携版(.zip)**。**推荐大多数用户使用安装版**(含安装与卸载管理)。便携版为免安装绿色包,解压即可运行。 ### 系统要求 ::: warning 运行环境 - **操作系统**:Windows 10 1903 (Build 18362) 或更高版本(64 位) - **显卡驱动**:支持 DirectX 11,并保持驱动为最新版本 - **WebView2**:主界面依赖 [Microsoft WebView2 运行时](https://developer.microsoft.com/zh-cn/microsoft-edge/webview2/)(**≥ 123.0.2420.47**),现代 Windows 通常已内置 ::: ## 首次启动 运行程序后,可能会弹出以下系统提示: ::: warning Windows 安全提示 - **SmartScreen 提示**:点击 **更多信息** → **仍要运行**(开源软件无商业代码签名) - **UAC 提示**:点击 **是** 授予管理员权限(程序调整游戏窗口必须使用此权限) ::: ## 初始配置向导 首次启动后,程序会进入配置向导: **第 1 步**:选择界面语言和主题(深色/浅色/跟随系统) **第 2 步**:确认目标窗口标题 配置完成后,程序浮窗将自动出现在屏幕上。 ::: tip 按 **`` Ctrl + ` ``**(键盘左上角反引号键,数字 `1` 左边)可随时隐藏/显示浮窗。 ::: ## 游戏内前置设置 在开始拍摄前,进入《无限暖暖》确认以下设置: - **显示模式**:选择 **窗口模式** - **拍照 - 照片画质**:选择 **窗口分辨率** ## 拍出第一张超清竖构图 1. 打开大喵相机进入摄影模式,找好场景和角色位置 2. 在程序浮窗中,选择比例 **9:16**,分辨率选择 **8K**(电脑性能较弱可选 4K 或 6K) 3. 此时画面会扩展超出屏幕,**属正常现象** ::: tip 画面超出屏幕时 可在浮窗中开启**叠加层**或**预览窗**功能,在屏幕范围内实时预览完整画面。 ::: 4. 按空格键拍照 5. 完成拍摄后,在浮窗中点击 **重置** 恢复窗口到正常大小 ================================================ FILE: installer/Bundle.wxs ================================================ ================================================ FILE: installer/CleanupAppDataRoot.js ================================================ // 彻底卸载时递归删除 %LocalAppData%\SpinningMomo(含 webview2、缩略图等)。 // 路径由 Package.wxs 中 Type 51 CustomAction(SetRemoveAppDataDir)写入 REMOVEAPPDATADIR,再经 deferred 传入 CustomActionData。 // 注意:MSI 嵌入 JScript 的 Session 对象不支持 Session.Log,调用会报 1720 且中断脚本。 function RemoveAppDataRootDeferred() { try { var folder = Session.Property("CustomActionData"); if (!folder || folder === "") { return 1; } var fso = new ActiveXObject("Scripting.FileSystemObject"); if (fso.FolderExists(folder)) { fso.DeleteFolder(folder, true); } return 1; } catch (e) { return 1; } } ================================================ FILE: installer/DetectRunningSpinningMomo.js ================================================ function DetectRunningSpinningMomo() { try { var wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2"); var results = wmi.ExecQuery("SELECT ProcessId FROM Win32_Process WHERE Name='SpinningMomo.exe'"); var hasRunningProcess = false; var enumerator = new Enumerator(results); for (; !enumerator.atEnd(); enumerator.moveNext()) { hasRunningProcess = true; break; } Session.Property("SPINNINGMOMO_RUNNING") = hasRunningProcess ? "1" : ""; return 1; } catch (e) { Session.Log("DetectRunningSpinningMomo script error: " + e.message); return 1; } } ================================================ FILE: installer/License.rtf ================================================ {\rtf1\ansi\deff0{\fonttbl{\f0 Arial;}}\f0\fs20 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . } ================================================ FILE: installer/Package.en-us.wxl ================================================ ================================================ FILE: installer/Package.wxs ================================================ ================================================ FILE: installer/bundle/payloads/2052/thm.wxl ================================================ ================================================ FILE: installer/bundle/thm.wxl ================================================ ================================================ FILE: package.json ================================================ { "name": "spinning-momo", "private": true, "license": "GPL-3.0", "scripts": { "prepare": "husky", "build": "npm run build:cpp && npm run build:web && npm run build:dist", "build:cpp": "xmake config -m release && xmake build && xmake config -m debug", "build:cpp:ci": "xmake config -m release -y && xmake build -y", "build:ci": "npm run build:cpp:ci && npm run build:web && npm run build:dist", "build:web": "cd web && npm run build", "build:dist": "node scripts/prepare-dist.js", "build:portable": "node scripts/build-portable.js", "build:installer": "powershell -ExecutionPolicy Bypass -File scripts/build-msi.ps1", "build:checksums": "node scripts/generate-checksums.js", "release": "npm run build && npm run build:portable && npm run build:installer && npm run build:checksums", "release:version": "node scripts/release-version.js", "format:cpp": "node scripts/format-cpp.js", "format:web": "cd web && prettier --write ." }, "devDependencies": { "esbuild": "^0.25.12", "fast-glob": "^3.3.2", "husky": "^9.1.7", "lint-staged": "^16.2.4" }, "lint-staged": { "src/**/*.{cpp,ixx,h,hpp}": [ "node scripts/format-cpp.js --files" ], "web/**/*.{js,ts,vue,json,css,md}": [ "node scripts/format-web.js" ] } } ================================================ FILE: resources/app.manifest ================================================ true/PM PerMonitorV2 true UTF-8 SegmentHeap ================================================ FILE: resources/app.rc ================================================ #include #define APP_VERSION_NUM 2, 0, 8, 0 #define APP_VERSION_STR "2.0.8.0" #define PRODUCT_NAME "SpinningMomo" #define FILE_DESCRIPTION "SpinningMomo" #define COPYRIGHT_INFO "Copyright (c) 2024-2026 InfinityMomo" #define AUTHOR_NAME "InfinityMomo" #define INTERNAL_NAME "SpinningMomo" #define ORIGINAL_FILENAME "SpinningMomo.exe" #define IDI_ICON1 101 IDI_ICON1 ICON "icon.ico" // 版本信息资源 VS_VERSION_INFO VERSIONINFO FILEVERSION APP_VERSION_NUM PRODUCTVERSION APP_VERSION_NUM FILEFLAGSMASK VS_FFI_FILEFLAGSMASK #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG #else FILEFLAGS 0 #endif FILEOS VOS_NT_WINDOWS32 FILETYPE VFT_APP FILESUBTYPE VFT2_UNKNOWN BEGIN BLOCK "StringFileInfo" BEGIN BLOCK "040904E4" // English (United States) BEGIN VALUE "FileDescription", FILE_DESCRIPTION VALUE "FileVersion", APP_VERSION_STR VALUE "InternalName", INTERNAL_NAME VALUE "LegalCopyright", COPYRIGHT_INFO VALUE "OriginalFilename", ORIGINAL_FILENAME VALUE "ProductName", PRODUCT_NAME VALUE "ProductVersion", APP_VERSION_STR VALUE "Author", AUTHOR_NAME END BLOCK "080404B0" // Chinese (Simplified, PRC) BEGIN VALUE "FileDescription", FILE_DESCRIPTION VALUE "FileVersion", APP_VERSION_STR VALUE "InternalName", INTERNAL_NAME VALUE "LegalCopyright", COPYRIGHT_INFO VALUE "OriginalFilename", ORIGINAL_FILENAME VALUE "ProductName", PRODUCT_NAME VALUE "ProductVersion", APP_VERSION_STR VALUE "Author", AUTHOR_NAME END END BLOCK "VarFileInfo" BEGIN VALUE "Translation", 0x409, 1252, 0x804, 1200 // English (United States), Chinese (Simplified, PRC) END END ================================================ FILE: scripts/build-msi.ps1 ================================================ # Build MSI and Bundle installer locally # Prerequisites: # - .NET SDK 8.0+ # - WiX v5+: dotnet tool install --global wix --version 5.* # - WiX extensions: # wix extension add WixToolset.UI.wixext # wix extension add WixToolset.Util.wixext # wix extension add WixToolset.BootstrapperApplications.wixext param( [string]$Version = "", [switch]$MsiOnly # Only build MSI, skip Bundle ) $ErrorActionPreference = "Stop" $ProjectDir = Split-Path -Parent $PSScriptRoot Set-Location $ProjectDir # Extract version from version.json if not provided if ([string]::IsNullOrEmpty($Version)) { $versionInfo = Get-Content "version.json" -Raw | ConvertFrom-Json if ($null -eq $versionInfo.version -or $versionInfo.version -notmatch '^\d+\.\d+\.\d+$') { Write-Error "Could not extract version from version.json" exit 1 } $Version = $versionInfo.version } Write-Host "Building SpinningMomo v$Version MSI..." -ForegroundColor Cyan # Check if WiX is installed if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { Write-Error "WiX v5+ not found. Install with: dotnet tool install --global wix --version 5.*" exit 1 } # Build project if dist doesn't exist or is outdated if (-not (Test-Path "dist/SpinningMomo.exe")) { Write-Host "Building project..." -ForegroundColor Yellow npm run build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } # Build MSI (WiX v5+ uses Files element - no need for heat harvesting) Write-Host "Building MSI..." -ForegroundColor Yellow $distDir = Join-Path $ProjectDir "dist" $outputMsi = Join-Path $distDir "SpinningMomo-$Version-x64.msi" wix build ` -arch x64 ` -d ProductVersion=$Version ` -d ProjectDir=$ProjectDir ` -d DistDir=$distDir ` -ext WixToolset.UI.wixext ` -ext WixToolset.Util.wixext ` -culture en-US ` -loc installer/Package.en-us.wxl ` -out $outputMsi ` installer/Package.wxs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "Success! Created: $outputMsi" -ForegroundColor Green if ($MsiOnly) { exit 0 } # Build Bundle (.exe with modern UI) Write-Host "`nBuilding Bundle installer..." -ForegroundColor Yellow $outputExe = Join-Path $distDir "SpinningMomo-$Version-x64-Setup.exe" wix build ` -arch x64 ` -d ProductVersion=$Version ` -d ProjectDir=$ProjectDir ` -d MsiPath=$outputMsi ` -ext WixToolset.BootstrapperApplications.wixext ` -culture en-US ` -out $outputExe ` installer/Bundle.wxs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } Write-Host "`nSuccess! Created:" -ForegroundColor Green Write-Host " MSI: $outputMsi" -ForegroundColor Green Write-Host " Bundle: $outputExe" -ForegroundColor Green ================================================ FILE: scripts/build-portable.js ================================================ const path = require("path"); const fs = require("fs"); const { execSync } = require("child_process"); function getVersion() { const versionFile = fs.readFileSync(path.join(__dirname, "..", "version.json"), "utf8"); const versionInfo = JSON.parse(versionFile); if (typeof versionInfo.version !== "string" || !/^\d+\.\d+\.\d+$/.test(versionInfo.version)) { throw new Error("Could not extract version from version.json"); } return versionInfo.version; } function main() { const projectDir = path.join(__dirname, ".."); const distDir = path.join(projectDir, "dist"); // Verify dist directory exists with required files const exePath = path.join(distDir, "SpinningMomo.exe"); const resourcesDir = path.join(distDir, "resources"); const legalPath = path.join(distDir, "LEGAL.md"); const licensePath = path.join(distDir, "LICENSE"); if (!fs.existsSync(exePath)) { console.error("dist/SpinningMomo.exe not found. Run 'npm run build' first."); process.exit(1); } if (!fs.existsSync(resourcesDir)) { console.error("dist/resources not found. Run 'npm run build' first."); process.exit(1); } if (!fs.existsSync(legalPath)) { console.error("dist/LEGAL.md not found. Run 'npm run build' first."); process.exit(1); } if (!fs.existsSync(licensePath)) { console.error("dist/LICENSE not found. Run 'npm run build' first."); process.exit(1); } const version = getVersion(); const zipName = `SpinningMomo-${version}-x64-Portable.zip`; const zipPath = path.join(distDir, zipName); console.log(`Creating portable package: ${zipName}`); // Create portable marker file const portableMarker = path.join(distDir, "portable"); fs.writeFileSync(portableMarker, ""); // Remove existing ZIP if present if (fs.existsSync(zipPath)) { fs.unlinkSync(zipPath); } // Create ZIP using PowerShell (Windows native, no extra dependencies) const filesToZip = ["SpinningMomo.exe", "resources", "LEGAL.md", "LICENSE", "portable"] .map((f) => `"${path.join(distDir, f)}"`) .join(", "); execSync( `powershell -Command "Compress-Archive -Path ${filesToZip} -DestinationPath '${zipPath}' -Force"`, { stdio: "inherit" } ); // Clean up portable marker (only needed inside ZIP) fs.unlinkSync(portableMarker); console.log(`Done! Created: ${zipPath}`); } main(); ================================================ FILE: scripts/fetch-third-party.ps1 ================================================ $ErrorActionPreference = "Stop" Set-Location (Split-Path -Parent $PSScriptRoot) New-Item -ItemType Directory -Force "third_party" | Out-Null $dkmHeader = "third_party/dkm/include/dkm.hpp" if (Test-Path $dkmHeader) { Write-Host "DKM already exists, skip." } else { Write-Host "Cloning DKM..." git clone --depth 1 https://github.com/genbattle/dkm.git third_party/dkm } ================================================ FILE: scripts/format-cpp.js ================================================ const { execSync, spawnSync } = require("child_process"); const fs = require("fs"); const fg = require("fast-glob"); // 常见的 clang-format 路径(VS2022) const KNOWN_PATHS = [ "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\Llvm\\x64\\bin\\clang-format.exe", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\VC\\Tools\\Llvm\\x64\\bin\\clang-format.exe", "C:\\Program Files\\Microsoft Visual Studio\\2022\\Enterprise\\VC\\Tools\\Llvm\\x64\\bin\\clang-format.exe", "C:\\Program Files (x86)\\Microsoft Visual Studio\\2022\\BuildTools\\VC\\Tools\\Llvm\\x64\\bin\\clang-format.exe", ]; function findClangFormat() { // 1. 先检查 PATH try { const result = execSync("where clang-format", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); const firstPath = result.trim().split(/\r?\n/)[0]; if (firstPath && fs.existsSync(firstPath)) { return firstPath; } } catch { // where 命令失败,继续检查已知路径 } // 2. 检查已知路径 for (const p of KNOWN_PATHS) { if (fs.existsSync(p)) { return p; } } return null; } function main() { const clangFormat = findClangFormat(); if (!clangFormat) { console.log("⚠ clang-format not found, skipping."); process.exit(0); } // 获取要格式化的文件 const args = process.argv.slice(2); // 检查是否是 --files 模式(lint-staged 传递文件列表) let files; if (args[0] === "--files") { files = args.slice(1); } else if (args.length > 0) { files = args; } else { // 默认模式:在 Node 里自己展开 glob,避免 Windows shell 不展开通配符的问题 files = fg.sync(["src/**/*.cpp", "src/**/*.ixx", "src/**/*.h", "src/**/*.hpp"], { dot: false, onlyFiles: true, unique: true, }); } if (!files || files.length === 0) { process.exit(0); } const result = spawnSync(clangFormat, ["-i", ...files], { stdio: "inherit", shell: false, }); process.exit(result.status || 0); } main(); ================================================ FILE: scripts/format-web.js ================================================ const { spawnSync } = require("child_process"); const path = require("path"); function main() { const args = process.argv.slice(2); if (args.length === 0) { console.log("No files to format"); process.exit(0); } // 将绝对路径转换为相对于 web 目录的路径 const webDir = path.join(__dirname, "..", "web"); const relativeFiles = args.map((file) => path.relative(webDir, file)); // 使用 web 目录下的 prettier const prettierPath = path.join(webDir, "node_modules", ".bin", "prettier.cmd"); const result = spawnSync(prettierPath, ["--write", ...relativeFiles], { cwd: webDir, stdio: "inherit", shell: true, }); process.exit(result.status || 0); } main(); ================================================ FILE: scripts/generate-checksums.js ================================================ const path = require("path"); const fs = require("fs"); const crypto = require("crypto"); function calculateSHA256(filePath) { const fileBuffer = fs.readFileSync(filePath); const hashSum = crypto.createHash("sha256"); hashSum.update(fileBuffer); return hashSum.digest("hex"); } function main() { const distDir = path.join(__dirname, "..", "dist"); // Find release files (Setup.exe and Portable.zip) const files = fs.readdirSync(distDir).filter((f) => { return f.endsWith("-Setup.exe") || f.endsWith("-Portable.zip"); }); if (files.length === 0) { console.error("No release files found in dist/"); console.error("Run 'npm run build:portable' and 'npm run build:installer' first."); process.exit(1); } console.log("Generating SHA256 checksums..."); const checksums = files.map((file) => { const filePath = path.join(distDir, file); const hash = calculateSHA256(filePath); console.log(` ${hash} ${file}`); return `${hash} ${file}`; }); const outputPath = path.join(distDir, "SHA256SUMS.txt"); fs.writeFileSync(outputPath, checksums.join("\n") + "\n", "utf8"); console.log(`\nDone! Created: ${outputPath}`); } main(); ================================================ FILE: scripts/generate-embedded-locales.js ================================================ #!/usr/bin/env node // 本地化模块生成脚本 // 将 locales 目录下的 JSON 文件转换为 C++ 模块文件 const fs = require("fs"); const path = require("path"); // 项目根目录 const projectRoot = path.resolve(__dirname, ".."); // 输入和输出目录 const localesDir = path.join(projectRoot, "src", "locales"); const embeddedDir = path.join(projectRoot, "src", "core", "i18n", "embedded"); // 语言映射配置 const languageMappings = { "en-US": { moduleName: "Core.I18n.Embedded.EnUS", variableName: "en_us_json", comment: "English", }, "zh-CN": { moduleName: "Core.I18n.Embedded.ZhCN", variableName: "zh_cn_json", comment: "Chinese", }, }; // 将语言代码转换为文件名格式 (如 zh-CN -> zh_cn) function toFileNameFormat(langCode) { return langCode.toLowerCase().replace(/-/g, "_"); } // 将语言代码转换为模块名格式 (如 en-US -> EnUS) function toModuleNameFormat(langCode) { return langCode .split("-") .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join(""); } // 生成 C++ 模块文件内容 function generateCppModule( sourceFile, jsonContent, moduleName, variableName, languageComment ) { const fileSize = Buffer.byteLength(jsonContent, "utf8"); // 转义 JSON 内容中的特殊字符 const escapedJson = jsonContent.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `// Auto-generated embedded ${languageComment} locale module // DO NOT EDIT - This file contains embedded locale data // // Source: ${sourceFile} // Module: ${moduleName} // Variable: ${variableName} module; export module ${moduleName}; import std; export namespace EmbeddedLocales { // Embedded ${languageComment} JSON content as string_view // Size: ${fileSize} bytes constexpr std::string_view ${variableName} = R"EmbeddedJson(${jsonContent})EmbeddedJson"; } `; } // 处理单个语言文件 function processLanguageFile(fileName) { // 获取语言代码 (去除 .json 扩展名) const langCode = path.basename(fileName, ".json"); // 构建文件路径 const inputPath = path.join(localesDir, fileName); const outputPath = path.join( embeddedDir, `${toFileNameFormat(langCode)}.ixx` ); // 读取 JSON 文件内容 const jsonContent = fs.readFileSync(inputPath, "utf8"); // 获取相对于项目根目录的路径用于注释 const relativePath = path .relative(projectRoot, inputPath) .replace(/\\/g, "/"); // 获取映射配置或使用默认配置 const mapping = languageMappings[langCode] || { moduleName: `Core.I18n.Embedded.${toModuleNameFormat(langCode)}`, variableName: `${toFileNameFormat(langCode)}_json`, comment: langCode, }; // 生成 C++ 模块内容 const cppContent = generateCppModule( relativePath, jsonContent, mapping.moduleName, mapping.variableName, mapping.comment ); // 写入输出文件 fs.writeFileSync(outputPath, cppContent); console.log( `Generated embedded locale: ${fileName} -> ${path.basename( outputPath )} (${Buffer.byteLength(jsonContent, "utf8")} bytes)` ); } // 主函数 function main() { console.log("Generating embedded locale modules..."); // 确保输出目录存在 if (!fs.existsSync(embeddedDir)) { fs.mkdirSync(embeddedDir, { recursive: true }); } // 读取 locales 目录下的所有文件 const files = fs.readdirSync(localesDir); // 过滤出 JSON 文件 const jsonFiles = files.filter((file) => path.extname(file) === ".json"); // 处理每个 JSON 文件 jsonFiles.forEach(processLanguageFile); console.log("Successfully generated all embedded locale modules"); } // 执行主函数 main(); ================================================ FILE: scripts/generate-map-injection-cpp.js ================================================ const fs = require("fs"); const path = require("path"); const { pathToFileURL } = require("url"); const esbuild = require("esbuild"); function splitIntoChunks(content, chunkSize) { const chunks = []; for (let i = 0; i < content.length; i += chunkSize) { chunks.push(content.slice(i, i + chunkSize)); } return chunks; } function toCppModule(scriptContent) { const rawDelimiter = "SM_MAP_JS"; const chunkSize = 4 * 1024; const scriptChunks = splitIntoChunks(scriptContent, chunkSize); const literalExpression = scriptChunks .map((chunk) => `LR"${rawDelimiter}(${chunk})${rawDelimiter}"`) .join("\n "); return `// Auto-generated map injection script module // DO NOT EDIT - This file is generated by scripts/generate-map-injection-cpp.js module; export module Extensions.InfinityNikki.Generated.MapInjectionScript; import std; export namespace Extensions::InfinityNikki::Generated { constexpr std::wstring_view map_bridge_script = ${literalExpression}; } // namespace Extensions::InfinityNikki::Generated `; } async function main() { const projectRoot = path.resolve(__dirname, ".."); const sourceEntry = path.join( projectRoot, "web", "src", "features", "map", "injection", "source", "index.js" ); const generatedModulePath = path.join( projectRoot, "src", "extensions", "infinity_nikki", "generated", "map_injection_script.ixx" ); if (!fs.existsSync(sourceEntry)) { throw new Error(`Map injection source entry not found: ${sourceEntry}`); } const sourceModule = await import(pathToFileURL(sourceEntry).href); if (typeof sourceModule.buildMapBridgeScriptTemplate !== "function") { throw new Error("buildMapBridgeScriptTemplate() is missing in injection source entry."); } const bridgeTemplate = sourceModule.buildMapBridgeScriptTemplate(); const minified = esbuild.transformSync(bridgeTemplate, { loader: "js", minify: true, }); const scriptContent = minified.code.trim(); fs.mkdirSync(path.dirname(generatedModulePath), { recursive: true }); fs.writeFileSync(generatedModulePath, toCppModule(scriptContent), "utf8"); console.log( `Generated C++ map injection module: ${path.relative(projectRoot, generatedModulePath)}` ); } main().catch((error) => { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exit(1); }); ================================================ FILE: scripts/generate-migrations.js ================================================ #!/usr/bin/env node // SQL 迁移脚本生成器 // 将 src/migrations 目录下的 SQL 文件转换为 C++ 模块文件 // // 用法: // node scripts/generate-migrations.js // // 功能: // - 读取 src/migrations/*.sql 文件 // - 解析 SQL 文件中的版本号和描述(从注释中读取) // - 按分号分割 SQL 语句 // - 生成 C++ 模块文件到 src/core/upgrade/generated/ const fs = require("fs"); const path = require("path"); // ============================================================================ // 配置 // ============================================================================ // 项目根目录 const projectRoot = path.resolve(__dirname, ".."); // 输入和输出目录 const migrationsDir = path.join(projectRoot, "src", "migrations"); const generatedDir = path.join( projectRoot, "src", "core", "migration", "generated" ); // ============================================================================ // SQL 解析 // ============================================================================ /** * 从 SQL 文件名中提取版本号 * * 格式: 001_description.sql -> version: 1 * * @param {string} fileName - SQL 文件名 * @returns {number|null} 版本号 */ function extractVersionFromFilename(fileName) { const match = fileName.match(/^(\d+)_/); return match ? parseInt(match[1], 10) : null; } /** * 将 SQL 文件内容分割成独立的语句 * * 规则: * - 按分号分割 * - 忽略空语句 * - 保留触发器等复杂语句(BEGIN...END 内的分号不分割) * * @param {string} sqlContent - SQL 文件内容 * @returns {string[]} SQL 语句数组 */ function splitSqlStatements(sqlContent) { const statements = []; const currentStatement = []; let inBeginEnd = false; const lines = sqlContent.split("\n"); for (const line of lines) { const stripped = line.trim(); // 跳过纯注释行和空行 if (!stripped || stripped.startsWith("--")) { continue; } // 检测 BEGIN...END 块 if (/\bBEGIN\b/i.test(stripped)) { inBeginEnd = true; } currentStatement.push(line); // 在非 BEGIN...END 块中遇到分号,表示语句结束 if (line.includes(";") && !inBeginEnd) { // 提取分号之前的内容作为完整语句 const stmt = currentStatement .join("\n") .trim() .replace(/;+\s*$/, "") .trim(); if (stmt) { statements.push(stmt); } currentStatement.length = 0; // 清空数组 } // 检测 END 块结束 if (/\bEND\b/i.test(stripped)) { inBeginEnd = false; // END 语句后通常有分号,作为完整语句 if (line.includes(";")) { const stmt = currentStatement .join("\n") .trim() .replace(/;+\s*$/, "") .trim(); if (stmt) { statements.push(stmt); } currentStatement.length = 0; } } } // 处理最后可能剩余的语句 if (currentStatement.length > 0) { const stmt = currentStatement .join("\n") .trim() .replace(/;+\s*$/, "") .trim(); if (stmt) { statements.push(stmt); } } return statements; } // ============================================================================ // C++ 代码生成 // ============================================================================ /** * 生成 C++ 模块文件内容 * * @param {string} migrationFile - 迁移文件名 * @param {number} version - 版本号 * @param {string[]} sqlStatements - SQL 语句数组 * @returns {string} C++ 模块文件内容 */ function generateCppModule(migrationFile, version, sqlStatements) { // 模块名称格式: V001, V002, ... const moduleSuffix = `V${String(version).padStart(3, "0")}`; const moduleName = `Core.Migration.Schema.${moduleSuffix}`; const structName = moduleSuffix; // 结构体名称,如 V001 // 生成语句数组 const statementsCode = sqlStatements.map((stmt) => { // 使用自定义分隔符 let delimiter = "SQL"; // 确保 SQL 中不包含 )SQL" 这样的序列 while (stmt.includes(`)${delimiter}"`)) { delimiter += "X"; } return ` R"${delimiter}( ${stmt} )${delimiter}"`; }); const statementsArray = statementsCode.join(",\n"); return `// Auto-generated SQL schema module // DO NOT EDIT - This file is generated from src/migrations/${migrationFile} module; export module ${moduleName}; import std; export namespace Core::Migration::Schema { struct ${structName} { static constexpr std::array statements = { ${statementsArray} }; }; } // namespace Core::Migration::Schema `; } // ============================================================================ // 文件处理 // ============================================================================ /** * 处理单个 SQL 迁移文件 * * @param {string} sqlFile - SQL 文件路径 * @returns {boolean} 成功返回 true */ function processMigrationFile(sqlFile) { const fileName = path.basename(sqlFile); console.log(`Processing: ${fileName}`); try { // 从文件名提取版本号 const version = extractVersionFromFilename(fileName); if (version === null) { console.log( ` ⚠️ Warning: Cannot extract version from filename ${fileName}, skipping` ); return false; } // 读取 SQL 文件 const sqlContent = fs.readFileSync(sqlFile, "utf8"); // 分割 SQL 语句 const statements = splitSqlStatements(sqlContent); if (statements.length === 0) { console.log(` ⚠️ Warning: No SQL statements found in ${fileName}`); return false; } console.log(` Version: ${version}`); console.log(` Statements: ${statements.length}`); // 生成 C++ 代码 const cppContent = generateCppModule(fileName, version, statements); // 输出文件路径 const outputFile = path.join( generatedDir, `schema_${String(version).padStart(3, "0")}.ixx` ); fs.writeFileSync(outputFile, cppContent, "utf8"); const relativePath = path .relative(projectRoot, outputFile) .replace(/\\/g, "/"); console.log(` ✓ Generated: ${relativePath}`); return true; } catch (error) { console.log(` ✗ Error processing ${fileName}: ${error.message}`); console.error(error.stack); return false; } } /** * 生成索引模块,导出所有 Schema * * @param {number[]} processedVersions - 已处理的版本号数组 */ function generateIndexModule(processedVersions) { if (processedVersions.length === 0) { return; } processedVersions.sort((a, b) => a - b); // 生成 import 语句 const imports = processedVersions.map((ver) => { return `export import Core.Migration.Schema.V${String(ver).padStart( 3, "0" )};`; }); const importsCode = imports.join("\n"); const indexContent = `// Auto-generated schema index // DO NOT EDIT - This file imports all generated schema modules module; export module Core.Migration.Schema; // Import all schema modules ${importsCode} `; const indexFile = path.join(generatedDir, "schema.ixx"); fs.writeFileSync(indexFile, indexContent, "utf8"); const relativePath = path .relative(projectRoot, indexFile) .replace(/\\/g, "/"); console.log(`\n✓ Generated index: ${relativePath}`); } // ============================================================================ // 主函数 // ============================================================================ /** * 主函数 */ function main() { console.log("=".repeat(70)); console.log("SQL Migration Generator"); console.log("=".repeat(70)); console.log(); // 检查输入目录 if (!fs.existsSync(migrationsDir)) { console.log(`✗ Error: Migrations directory not found: ${migrationsDir}`); process.exit(1); } // 创建输出目录 if (!fs.existsSync(generatedDir)) { fs.mkdirSync(generatedDir, { recursive: true }); } const relativePath = path .relative(projectRoot, generatedDir) .replace(/\\/g, "/"); console.log(`Output directory: ${relativePath}`); console.log(); // 获取所有 SQL 文件 const allFiles = fs.readdirSync(migrationsDir); const sqlFiles = allFiles .filter((file) => path.extname(file) === ".sql") .map((file) => path.join(migrationsDir, file)) .sort(); if (sqlFiles.length === 0) { console.log(`✗ No SQL files found in ${migrationsDir}`); process.exit(1); } console.log(`Found ${sqlFiles.length} SQL file(s)`); console.log(); // 处理每个 SQL 文件 const processedVersions = []; let successCount = 0; for (const sqlFile of sqlFiles) { if (processMigrationFile(sqlFile)) { // 提取版本号 const fileName = path.basename(sqlFile); const versionMatch = fileName.match(/^(\d+)_/); if (versionMatch) { processedVersions.push(parseInt(versionMatch[1], 10)); } successCount++; } console.log(); } // 生成索引模块 if (processedVersions.length > 0) { generateIndexModule(processedVersions); } // 输出总结 console.log("=".repeat(70)); console.log( `✓ Successfully generated ${successCount}/${sqlFiles.length} schema module(s)` ); console.log("=".repeat(70)); process.exit(successCount === sqlFiles.length ? 0 : 1); } // 执行主函数 main(); ================================================ FILE: scripts/prepare-dist.js ================================================ const path = require("path"); const fs = require("fs"); function main() { const projectDir = path.join(__dirname, ".."); const distDir = path.join(projectDir, "dist"); const webDist = path.join(projectDir, "web", "dist"); const exePath = path.join(projectDir, "build", "windows", "x64", "release", "SpinningMomo.exe"); const legalPath = path.join(projectDir, "LEGAL.md"); const licensePath = path.join(projectDir, "LICENSE"); if (!fs.existsSync(webDist)) { console.error("web/dist not found. Run 'npm run build:web' first."); process.exit(1); } if (!fs.existsSync(exePath)) { console.error("SpinningMomo.exe not found. Run 'npm run build:cpp' first."); process.exit(1); } if (!fs.existsSync(legalPath)) { console.error("LEGAL.md not found."); process.exit(1); } if (!fs.existsSync(licensePath)) { console.error("LICENSE not found."); process.exit(1); } console.log(`Preparing ${distDir}...`); if (fs.existsSync(distDir)) { fs.rmSync(distDir, { recursive: true, force: true }); } fs.mkdirSync(distDir, { recursive: true }); fs.copyFileSync(exePath, path.join(distDir, "SpinningMomo.exe")); fs.copyFileSync(legalPath, path.join(distDir, "LEGAL.md")); fs.copyFileSync(licensePath, path.join(distDir, "LICENSE")); fs.cpSync(webDist, path.join(distDir, "resources", "web"), { recursive: true }); console.log("Done!"); } main(); ================================================ FILE: scripts/quick-cleanup-spinningmomo.ps1 ================================================ # powershell -NoProfile -ExecutionPolicy Bypass -File "./scripts/quick-cleanup-spinningmomo.ps1" Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Test-IsAdministrator { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Convert-GuidToPackedCode { param([Parameter(Mandatory = $true)][string]$GuidText) $guid = [Guid]$GuidText $n = $guid.ToString('N').ToUpperInvariant() $a = $n.Substring(0, 8) $b = $n.Substring(8, 4) $c = $n.Substring(12, 4) $d = $n.Substring(16, 4) $e = $n.Substring(20, 12) $reverseText = { param([string]$s) $chars = $s.ToCharArray() [array]::Reverse($chars) -join $chars } $swapNibblePairs = { param([string]$s) $chunks = for ($i = 0; $i -lt $s.Length; $i += 2) { $s.Substring($i, 2) } ($chunks | ForEach-Object { $_.Substring(1, 1) + $_.Substring(0, 1) }) -join '' } "$(& $reverseText $a)$(& $reverseText $b)$(& $reverseText $c)$(& $swapNibblePairs $d)$(& $swapNibblePairs $e)" } function Convert-PackedCodeToGuid { param([Parameter(Mandatory = $true)][string]$PackedCode) if ($PackedCode -notmatch '^[0-9A-Fa-f]{32}$') { throw "Invalid packed code: $PackedCode" } $p = $PackedCode.ToUpperInvariant() $a = $p.Substring(0, 8) $b = $p.Substring(8, 4) $c = $p.Substring(12, 4) $d = $p.Substring(16, 4) $e = $p.Substring(20, 12) $reverseText = { param([string]$s) $chars = $s.ToCharArray() [array]::Reverse($chars) -join $chars } $swapNibblePairs = { param([string]$s) $chunks = for ($i = 0; $i -lt $s.Length; $i += 2) { $s.Substring($i, 2) } ($chunks | ForEach-Object { $_.Substring(1, 1) + $_.Substring(0, 1) }) -join '' } $guidN = "$(& $reverseText $a)$(& $reverseText $b)$(& $reverseText $c)$(& $swapNibblePairs $d)$(& $swapNibblePairs $e)" return ([Guid]::ParseExact($guidN, 'N').ToString('D').ToUpperInvariant()) } function Remove-KeyIfExists { param([Parameter(Mandatory = $true)][string]$RegPath) if (Test-Path "Registry::$RegPath") { Remove-Item -Path "Registry::$RegPath" -Recurse -Force -ErrorAction SilentlyContinue Write-Host "Removed key: $RegPath" } } function Remove-PathIfExists { param([Parameter(Mandatory = $true)][string]$Path) if (Test-Path $Path) { Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue Write-Host "Removed path: $Path" } } if (-not (Test-IsAdministrator)) { throw "Please run this script in an elevated PowerShell (Run as Administrator)." } Write-Host "Quick cleanup started..." -ForegroundColor Cyan # SpinningMomo key component GUIDs (for occupancy detection) $componentGuids = @( '{CA8D282A-3752-4E74-9252-76AB6F280997}', # DesktopShortcut '{81DD2135-CFFF-4EAD-902C-D25BD1C5612B}', # DesktopShortcutRemove '{7808BA3C-C658-440F-976C-838362DD1FFF}', # StartMenuShortcut '{5E694D68-9DFD-4510-9EA1-698F8A09739D}', # StartMenuShortcutRemove '{7DF1D902-6FF4-43EF-B58A-837AE33B4494}' # CleanupUserData ) $componentPacked = @{} foreach ($g in $componentGuids) { $normalized = ([Guid]$g).ToString('D').ToUpperInvariant() $componentPacked[$normalized] = Convert-GuidToPackedCode $normalized } $userDataRoot = 'Registry::HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Installer\UserData' $detectedProductPacked = New-Object System.Collections.Generic.HashSet[string] # 1) Detect all product packed codes occupying our components. if (Test-Path $userDataRoot) { foreach ($sidRoot in (Get-ChildItem -Path $userDataRoot -ErrorAction SilentlyContinue)) { foreach ($packedComp in $componentPacked.Values) { $ck = Join-Path $sidRoot.PSPath "Components\$packedComp" if (-not (Test-Path $ck)) { continue } $item = Get-ItemProperty -Path $ck -ErrorAction SilentlyContinue if ($null -eq $item) { continue } foreach ($prop in $item.PSObject.Properties) { if ($prop.Name -in @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider')) { continue } if ($prop.Name -notmatch '^[0-9A-Fa-f]{32}$') { continue } if ($prop.Name -eq '00000000000000000000000000000000') { continue } $null = $detectedProductPacked.Add($prop.Name.ToUpperInvariant()) } } } } $detectedProductGuids = @() foreach ($pp in $detectedProductPacked) { try { $detectedProductGuids += "{$(Convert-PackedCodeToGuid $pp)}" } catch { # ignore malformed packed code } } Write-Host "Detected product codes:" if ($detectedProductGuids.Count -eq 0) { Write-Host " - (none)" -ForegroundColor Yellow } else { $detectedProductGuids | Sort-Object -Unique | ForEach-Object { Write-Host " - $_" } } # 2) Remove all matching product client values from Installer\UserData\*\Components\* if ((Test-Path $userDataRoot) -and $detectedProductPacked.Count -gt 0) { foreach ($sidRoot in (Get-ChildItem -Path $userDataRoot -ErrorAction SilentlyContinue)) { $componentsRoot = Join-Path $sidRoot.PSPath 'Components' if (-not (Test-Path $componentsRoot)) { continue } foreach ($ck in (Get-ChildItem -Path $componentsRoot -ErrorAction SilentlyContinue)) { $keyPath = $ck.PSPath $item = Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue if ($null -eq $item) { continue } $regPath = $keyPath -replace '^Microsoft\.PowerShell\.Core\\Registry::', '' foreach ($pp in $detectedProductPacked) { if ($item.PSObject.Properties.Name -contains $pp) { Remove-ItemProperty -Path "Registry::$regPath" -Name $pp -Force -ErrorAction SilentlyContinue Write-Host "Removed component client: $pp @ $regPath" } } } } } # 3) Remove uninstall entries by detected product codes + display name fallback. foreach ($pg in ($detectedProductGuids | Sort-Object -Unique)) { Remove-KeyIfExists "HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Uninstall\$pg" Remove-KeyIfExists "HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Uninstall\$pg" Remove-KeyIfExists "HKEY_LOCAL_MACHINE\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\$pg" } $uninstallRoots = @( 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' ) foreach ($root in $uninstallRoots) { if (-not (Test-Path $root)) { continue } foreach ($k in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { $p = Get-ItemProperty -Path $k.PSPath -ErrorAction SilentlyContinue if ($null -eq $p) { continue } $hasDisplayName = $p.PSObject.Properties.Name -contains 'DisplayName' if ($hasDisplayName -and $p.DisplayName -eq 'SpinningMomo') { Remove-Item -Path $k.PSPath -Recurse -Force -ErrorAction SilentlyContinue Write-Host "Removed uninstall entry by DisplayName: $($k.PSChildName)" } } } # 4) Remove app-specific registry key. Remove-KeyIfExists 'HKEY_CURRENT_USER\Software\SpinningMomo' # 5) Remove package cache folders related to detected product codes and bundle code entries. $packageCacheRoot = "$env:LOCALAPPDATA\Package Cache" if (Test-Path $packageCacheRoot) { foreach ($pg in ($detectedProductGuids | Sort-Object -Unique)) { $prefix = Join-Path $packageCacheRoot ($pg + 'v*') foreach ($m in (Get-ChildItem -Path $prefix -ErrorAction SilentlyContinue)) { Remove-PathIfExists $m.FullName } } foreach ($m in (Get-ChildItem -Path (Join-Path $packageCacheRoot '{*}') -ErrorAction SilentlyContinue)) { $setupExe = Join-Path $m.FullName 'SpinningMomo-*-Setup.exe' $hasSetup = Get-ChildItem -Path $setupExe -ErrorAction SilentlyContinue if ($hasSetup) { Remove-PathIfExists $m.FullName } } } # 6) Remove file leftovers (does not touch repo files like version.json). Remove-PathIfExists "$env:USERPROFILE\Desktop\SpinningMomo.lnk" Remove-PathIfExists "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\SpinningMomo\SpinningMomo.lnk" Remove-PathIfExists "$env:LOCALAPPDATA\Programs\SpinningMomo" Remove-PathIfExists "$env:LOCALAPPDATA\SpinningMomo" Write-Host "Quick cleanup finished." -ForegroundColor Green ================================================ FILE: scripts/release-version.js ================================================ // 运行此脚本更新版本号 // npm run release:version -- 2.0.1 const fs = require("fs"); const path = require("path"); function normalizeVersion(input) { const cleaned = input.startsWith("v") || input.startsWith("V") ? input.slice(1) : input; const isValid = /^\d+\.\d+\.\d+$/.test(cleaned); if (!isValid) { throw new Error("Invalid version. Use X.Y.Z (optionally prefixed with v)."); } return cleaned; } function toVersion4Parts(version) { const parts = version.split(".").map((p) => Number.parseInt(p, 10)); while (parts.length < 4) { parts.push(0); } return parts; } function updateVersionJson(filePath, version) { const updated = `${JSON.stringify({ version }, null, 2)}\n`; fs.writeFileSync(filePath, updated, "utf8"); } function updateAppRc(filePath, version4Parts) { const content = fs.readFileSync(filePath, "utf8"); const versionNum = version4Parts.join(", "); const versionStr = version4Parts.join("."); const hasVersionNum = /#define APP_VERSION_NUM .*/.test(content); const hasVersionStr = /#define APP_VERSION_STR ".*"/.test(content); if (!hasVersionNum || !hasVersionStr) { throw new Error("resources/app.rc format is unexpected. APP_VERSION_NUM / APP_VERSION_STR not found."); } const updated = content .replace(/#define APP_VERSION_NUM .*/, `#define APP_VERSION_NUM ${versionNum}`) .replace(/#define APP_VERSION_STR ".*"/, `#define APP_VERSION_STR "${versionStr}"`); fs.writeFileSync(filePath, updated, "utf8"); } function updateVersionModule(filePath, version4Parts) { const content = fs.readFileSync(filePath, "utf8"); const versionStr = version4Parts.join("."); const pattern = /export auto get_app_version\(\) -> std::string \{ return ".*"; \}/; if (!pattern.test(content)) { throw new Error("src/vendor/version.ixx format is unexpected. get_app_version() not found."); } const updated = content.replace( pattern, `export auto get_app_version() -> std::string { return "${versionStr}"; }` ); fs.writeFileSync(filePath, updated, "utf8"); } function updateVersionTxt(filePath, version) { fs.writeFileSync(filePath, `${version}\n`, "utf8"); } function main() { const rawVersion = process.argv[2]; if (!rawVersion) { console.error("Usage: npm run release:version -- "); console.error("Example: npm run release:version -- 2.1.0"); process.exit(1); } const projectRoot = path.join(__dirname, ".."); const versionJsonPath = path.join(projectRoot, "version.json"); const appRcPath = path.join(projectRoot, "resources", "app.rc"); const versionModulePath = path.join(projectRoot, "src", "vendor", "version.ixx"); const versionTxtPath = path.join(projectRoot, "docs", "public", "version.txt"); process.chdir(projectRoot); const version = normalizeVersion(rawVersion); const version4Parts = toVersion4Parts(version); const tagName = `v${version}`; updateVersionJson(versionJsonPath, version); updateAppRc(appRcPath, version4Parts); updateVersionModule(versionModulePath, version4Parts); updateVersionTxt(versionTxtPath, version); console.log(""); console.log(`Version files updated for ${tagName}`); console.log("Updated:"); console.log(`- version.json -> ${version}`); console.log(`- resources/app.rc -> ${version4Parts.join(".")}`); console.log(`- src/vendor/version.ixx -> ${version4Parts.join(".")}`); console.log(`- docs/public/version.txt -> ${version}`); console.log(""); console.log("Next:"); console.log("- Review the diff and commit it"); console.log(`- Create tag: git tag ${tagName}`); console.log(`- Push branch and tag: git push origin HEAD ${tagName}`); } try { main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exit(1); } ================================================ FILE: src/app.cpp ================================================ module; module App; import std; import Core.Initializer; import Core.RuntimeInfo; import Core.Shutdown; import Core.State; import UI.FloatingWindow.State; import Utils.Logger; import Vendor.Windows; Application::Application() = default; Application::~Application() { if (m_app_state) { Core::Shutdown::shutdown_application(*m_app_state); } } auto Application::Initialize(Vendor::Windows::HINSTANCE hInstance) -> bool { m_h_instance = hInstance; try { // 创建 AppState, 其构造函数会自动初始化所有子状态 m_app_state = std::make_unique(); m_app_state->floating_window->window.instance = m_h_instance; Core::RuntimeInfo::collect(*m_app_state); // 调用统一的初始化器 if (auto result = Core::Initializer::initialize_application(*m_app_state, m_h_instance); !result) { Logger().error("Failed to initialize application: {}", result.error()); return false; } return true; } catch (const std::exception& e) { Logger().error("Exception during initialization: {}", e.what()); return false; } } auto Application::Run() -> int { Vendor::Windows::MSG msg{}; // 消息驱动的事件循环: // - WM_APP_PROCESS_EVENTS: 处理异步事件队列 // - WM_TIMER: 处理通知动画更新(固定 60fps 帧率) // 没有任务时 GetMessage 会阻塞,零 CPU 占用 while (Vendor::Windows::GetWindowMessage(&msg, nullptr, 0, 0)) { if (msg.message == Vendor::Windows::kWM_QUIT) { return static_cast(msg.wParam); } Vendor::Windows::TranslateWindowMessage(&msg); Vendor::Windows::DispatchWindowMessageW(&msg); } return static_cast(msg.wParam); } ================================================ FILE: src/app.ixx ================================================ module; export module App; import std; import Vendor.Windows; import Core.Events; import Core.State; import UI.FloatingWindow; // 主应用程序类 export class Application { public: Application(); ~Application(); // 禁用拷贝和移动 Application(const Application&) = delete; auto operator=(const Application&) -> Application& = delete; Application(Application&&) = delete; auto operator=(Application&&) -> Application& = delete; // 现代C++风格的初始化 [[nodiscard]] auto Initialize(Vendor::Windows::HINSTANCE hInstance) -> bool; // 运行应用程序 [[nodiscard]] auto Run() -> int; private: // 应用状态 std::unique_ptr m_app_state; Vendor::Windows::HINSTANCE m_h_instance = nullptr; }; ================================================ FILE: src/core/async/async.cpp ================================================ module; #include module Core.Async; import std; import Core.Async.State; import Utils.Logger; namespace Core::Async { auto start(Core::Async::State::AsyncState& runtime, size_t thread_count) -> std::expected { // 检查是否已经运行 if (runtime.is_running.exchange(true)) { Logger().warn("AsyncRuntime already started"); return std::unexpected("AsyncRuntime already started"); } try { // 确定线程数 if (thread_count == 0) { thread_count = std::thread::hardware_concurrency(); if (thread_count == 0) thread_count = 2; // 备用值 } runtime.thread_count = thread_count; // 初始化io_context runtime.io_context = std::make_unique(); Logger().info("Starting AsyncRuntime with {} threads", thread_count); // 创建工作线程池 runtime.worker_threads.reserve(thread_count); for (size_t i = 0; i < thread_count; ++i) { runtime.worker_threads.emplace_back([&runtime, i]() { try { auto work = asio::make_work_guard(*runtime.io_context); runtime.io_context->run(); } catch (const std::exception& e) { Logger().error("AsyncRuntime worker thread {} error: {}", i, e.what()); } }); } Logger().info("AsyncRuntime started successfully"); return {}; } catch (const std::exception& e) { // 启动失败,恢复状态 runtime.is_running = false; runtime.io_context.reset(); runtime.worker_threads.clear(); auto error_msg = std::format("Failed to start AsyncRuntime: {}", e.what()); Logger().error(error_msg); return std::unexpected(error_msg); } } auto stop(Core::Async::State::AsyncState& runtime) -> void { if (!runtime.is_running.exchange(false)) { return; // 已经停止 } Logger().info("Stopping AsyncRuntime"); try { // 标记关闭请求 runtime.shutdown_requested = true; // 停止io_context if (runtime.io_context) { runtime.io_context->stop(); } // 等待所有工作线程结束 for (auto& worker : runtime.worker_threads) { if (worker.joinable()) { worker.join(); } } // 清理资源 runtime.worker_threads.clear(); runtime.io_context.reset(); runtime.shutdown_requested = false; Logger().info("AsyncRuntime stopped"); } catch (const std::exception& e) { Logger().error("Error during AsyncRuntime shutdown: {}", e.what()); } } auto is_running(const Core::Async::State::AsyncState& runtime) -> bool { return runtime.is_running.load(); } auto get_io_context(Core::Async::State::AsyncState& runtime) -> asio::io_context* { return runtime.io_context.get(); } } // namespace Core::Async ================================================ FILE: src/core/async/async.ixx ================================================ module; #include export module Core.Async; import std; import Core.Async.State; namespace Core::Async { // 启动异步运行时(包含初始化) export auto start(Core::Async::State::AsyncState& runtime, size_t thread_count = 0) -> std::expected; // 停止异步运行时(包含清理) export auto stop(Core::Async::State::AsyncState& runtime) -> void; // 检查运行时是否正在运行 export auto is_running(const Core::Async::State::AsyncState& runtime) -> bool; // 获取io_context用于提交任务 export auto get_io_context(Core::Async::State::AsyncState& runtime) -> asio::io_context*; } // namespace Core::Async ================================================ FILE: src/core/async/state.ixx ================================================ module; #include export module Core.Async.State; import std; export namespace Core::Async::State { struct AsyncState { // 核心asio状态 std::unique_ptr io_context; std::vector worker_threads; // 运行状态 std::atomic is_running{false}; std::atomic shutdown_requested{false}; // 配置 size_t thread_count = 0; // 0表示使用硬件并发数 }; } // namespace Core::Async::State ================================================ FILE: src/core/async/ui_awaitable.ixx ================================================ module; #include export module Core.Async.UiAwaitable; import std; namespace Core::Async { // 用于存储定时器 ID 到协程句柄的映射 inline std::unordered_map>& get_timer_handles() { static std::unordered_map> handles; return handles; } // 定时器回调函数 inline VOID CALLBACK ui_timer_proc(HWND, UINT, UINT_PTR id, DWORD) { auto& handles = get_timer_handles(); auto it = handles.find(id); if (it != handles.end()) { auto handle = it->second; handles.erase(it); KillTimer(nullptr, id); handle.resume(); // 恢复协程,在 UI 线程执行 } } // UI 线程延迟等待的 awaitable // 使用 Windows 原生定时器,完全事件驱动,无轮询 export struct ui_delay { std::chrono::milliseconds duration; // 如果延迟 <= 0,立即完成 bool await_ready() const noexcept { return duration.count() <= 0; } // 挂起协程,设置 Windows 定时器 void await_suspend(std::coroutine_handle<> h) const { UINT_PTR timer_id = SetTimer(nullptr, 0, static_cast(duration.count()), ui_timer_proc); if (timer_id == 0) { // SetTimer 失败,立即恢复协程 h.resume(); return; } // 记录映射关系 get_timer_handles()[timer_id] = h; } // 恢复时无返回值 void await_resume() const noexcept {} }; // UI 线程协程的返回类型 // 协程立即开始执行,完成后自动清理 export struct ui_task { struct promise_type { ui_task get_return_object() noexcept { return {}; } std::suspend_never initial_suspend() noexcept { return {}; } // 立即开始 std::suspend_never final_suspend() noexcept { return {}; } // 完成后不挂起 void return_void() noexcept {} void unhandled_exception() noexcept { // 可以在这里记录异常 } }; }; } // namespace Core::Async ================================================ FILE: src/core/commands/builtin.cpp ================================================ module; module Core.Commands; import std; import Core.State; import Core.Commands; import Features.Settings.State; import Features.Screenshot.UseCase; import Features.Recording.UseCase; import Features.ReplayBuffer.UseCase; import Features.ReplayBuffer.Types; import Features.ReplayBuffer.State; import Features.Letterbox.UseCase; import Features.Overlay.UseCase; import Features.Preview.UseCase; import Features.Letterbox.State; import Features.Recording.State; import Features.Overlay.State; import Features.Preview.State; import Features.WindowControl.UseCase; import UI.FloatingWindow; import UI.WebViewWindow; import Utils.Logger; import Utils.Path; import Utils.System; import Vendor.BuildConfig; import Vendor.Windows; namespace Core::Commands { // 注册所有内置命令 auto register_builtin_commands(Core::State::AppState& state, CommandRegistry& registry) -> void { Logger().info("Registering builtin commands..."); // === 应用层命令 === // 打开主界面(WebView2 或浏览器) register_command(registry, { .id = "app.main", .i18n_key = "menu.app_main", .is_toggle = false, .action = [&state]() { UI::WebViewWindow::activate_window(state); }, }); // 退出应用 register_command(registry, { .id = "app.exit", .i18n_key = "menu.app_exit", .is_toggle = false, .action = []() { Vendor::Windows::PostQuitMessage(0); }, }); // === 悬浮窗控制 === // 激活悬浮窗 register_command(registry, { .id = "app.float", .i18n_key = "menu.app_float", .is_toggle = false, .action = [&state]() { UI::FloatingWindow::toggle_visibility(state); }, .hotkey = HotkeyBinding{ .modifiers = 1, // MOD_CONTROL .key = 192, // VK_OEM_3 (`) .settings_path = "app.hotkey.floating_window", }, }); // === 截图功能 === // 截图 register_command(registry, { .id = "screenshot.capture", .i18n_key = "menu.screenshot_capture", .is_toggle = false, .action = [&state]() { Features::Screenshot::UseCase::capture(state); }, .hotkey = HotkeyBinding{ .modifiers = 0, // 无修饰键 .key = 44, // VK_SNAPSHOT (PrintScreen) .settings_path = "app.hotkey.screenshot", }, }); // 打开输出目录 register_command( registry, { .id = "output.open_folder", .i18n_key = "menu.output_open_folder", .is_toggle = false, .action = [&state]() { auto output_dir_result = Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path); if (!output_dir_result) { Logger().error("Failed to resolve output directory: {}", output_dir_result.error()); return; } auto open_result = Utils::System::open_directory(output_dir_result.value()); if (!open_result) { Logger().error("Failed to open output directory: {}", open_result.error()); } }, }); // 打开游戏相册目录 register_command( registry, { .id = "external_album.open_folder", .i18n_key = "menu.external_album_open_folder", .is_toggle = false, .action = [&state]() { std::filesystem::path folder_to_open; const auto& external_album_path = state.settings->raw.features.external_album_path; if (!external_album_path.empty()) { folder_to_open = external_album_path; } else { auto output_dir_result = Utils::Path::GetOutputDirectory(state.settings->raw.features.output_dir_path); if (!output_dir_result) { Logger().error("Failed to resolve fallback output directory: {}", output_dir_result.error()); return; } folder_to_open = output_dir_result.value(); } auto open_result = Utils::System::open_directory(folder_to_open); if (!open_result) { Logger().error("Failed to open external album directory: {}", open_result.error()); } }, }); // === 独立功能 === // 切换预览窗 register_command( registry, { .id = "preview.toggle", .i18n_key = "menu.preview_toggle", .is_toggle = true, .action = [&state]() { Features::Preview::UseCase::toggle_preview(state); UI::FloatingWindow::request_repaint(state); }, .get_state = [&state]() -> bool { return state.preview ? state.preview->running.load(std::memory_order_acquire) : false; }, }); // 切换叠加层 register_command(registry, { .id = "overlay.toggle", .i18n_key = "menu.overlay_toggle", .is_toggle = true, .action = [&state]() { Features::Overlay::UseCase::toggle_overlay(state); UI::FloatingWindow::request_repaint(state); }, .get_state = [&state]() -> bool { return state.overlay && state.overlay->enabled; }, }); // 切换黑边模式 register_command(registry, { .id = "letterbox.toggle", .i18n_key = "menu.letterbox_toggle", .is_toggle = true, .action = [&state]() { Features::Letterbox::UseCase::toggle_letterbox(state); UI::FloatingWindow::request_repaint(state); }, .get_state = [&state]() -> bool { return state.letterbox && state.letterbox->enabled; }, }); // 切换录制 register_command( registry, { .id = "recording.toggle", .i18n_key = "menu.recording_toggle", .is_toggle = true, .action = [&state]() { if (auto result = Features::Recording::UseCase::toggle_recording(state); !result) { Logger().error("Recording toggle failed: {}", result.error()); } UI::FloatingWindow::request_repaint(state); }, .get_state = [&state]() -> bool { return state.recording && state.recording->status == Features::Recording::Types::RecordingStatus::Recording; }, .hotkey = HotkeyBinding{ .modifiers = 0, // 无修饰键 .key = 0x77, // VK_F8 (F8) .settings_path = "app.hotkey.recording", }, }); // === 动态照片和即时回放 === if (Vendor::BuildConfig::is_debug_build()) { // 切换动态照片模式(仅运行时) register_command( registry, { .id = "motion_photo.toggle", .i18n_key = "menu.motion_photo_toggle", .is_toggle = true, .action = [&state]() { if (auto result = Features::ReplayBuffer::UseCase::toggle_motion_photo(state); !result) { Logger().error("Motion Photo toggle failed: {}", result.error()); } UI::FloatingWindow::request_repaint(state); }, .get_state = [&state]() -> bool { return state.replay_buffer && state.replay_buffer->motion_photo_enabled.load(std::memory_order_acquire); }, }); // 切换即时回放模式(仅运行时) register_command( registry, { .id = "replay_buffer.toggle", .i18n_key = "menu.replay_buffer_toggle", .is_toggle = true, .action = [&state]() { if (auto result = Features::ReplayBuffer::UseCase::toggle_replay_buffer(state); !result) { Logger().error("Instant Replay toggle failed: {}", result.error()); } UI::FloatingWindow::request_repaint(state); }, .get_state = [&state]() -> bool { return state.replay_buffer && state.replay_buffer->replay_enabled.load(std::memory_order_acquire); }, }); // 保存即时回放 register_command( registry, { .id = "replay_buffer.save", .i18n_key = "menu.replay_buffer_save", .is_toggle = false, .action = [&state]() { if (auto result = Features::ReplayBuffer::UseCase::save_replay(state); !result) { Logger().error("Save replay failed: {}", result.error()); } }, }); } else { Logger().info("Skipping experimental replay commands in release build"); } // === 窗口操作 === // 重置窗口变换 register_command( registry, { .id = "window.reset", .i18n_key = "menu.window_reset", .is_toggle = false, .action = [&state]() { Features::WindowControl::UseCase::reset_window_transform(state); }, }); Logger().info("Registered {} builtin commands", registry.descriptors.size()); } } // namespace Core::Commands ================================================ FILE: src/core/commands/registry.cpp ================================================ module; module Core.Commands; import std; import Core.State; import Core.Commands.State; import Features.Settings.State; import Utils.Logger; import ; namespace Core::Commands { static Core::Commands::State::CommandState* g_mouse_hotkey_state = nullptr; // 这个钩子故意不消费任何按键消息,只保留一个最小的全局键盘挂载。 LRESULT CALLBACK keyboard_keepalive_proc(int code, WPARAM wParam, LPARAM lParam) { return CallNextHookEx(nullptr, code, wParam, lParam); } auto install_keyboard_keepalive_hook(Core::State::AppState& state) -> void { if (!state.commands) { Logger().warn("Skip keyboard keepalive hook installation: command state is not ready"); return; } auto& cmd_state = *state.commands; if (cmd_state.keyboard_keepalive_hook) { return; } // 独立于 RegisterHotKey/设置刷新生命周期,避免热键重载时反复装卸这个常驻钩子。 auto hook = SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_keepalive_proc, GetModuleHandleW(nullptr), 0); if (!hook) { auto error = GetLastError(); Logger().warn("Failed to install keyboard keepalive hook, error code: {}", error); return; } cmd_state.keyboard_keepalive_hook = hook; Logger().info("Keyboard keepalive hook installed"); } auto uninstall_keyboard_keepalive_hook(Core::State::AppState& state) -> void { if (!state.commands) { return; } auto& cmd_state = *state.commands; if (!cmd_state.keyboard_keepalive_hook) { return; } UnhookWindowsHookEx(cmd_state.keyboard_keepalive_hook); cmd_state.keyboard_keepalive_hook = nullptr; Logger().info("Keyboard keepalive hook uninstalled"); } auto is_mouse_side_key(UINT key) -> bool { return key == VK_XBUTTON1 || key == VK_XBUTTON2; } auto make_mouse_combo(UINT modifiers, UINT key) -> std::uint32_t { constexpr UINT kSupportedModifiers = MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN; auto masked_modifiers = modifiers & kSupportedModifiers; return (static_cast(masked_modifiers & 0xFFFFu) << 16u) | static_cast(key & 0xFFFFu); } auto get_current_hotkey_modifiers() -> UINT { UINT modifiers = 0; if ((GetAsyncKeyState(VK_MENU) & 0x8000) != 0) { modifiers |= MOD_ALT; } if ((GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0) { modifiers |= MOD_CONTROL; } if ((GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0) { modifiers |= MOD_SHIFT; } if ((GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 || (GetAsyncKeyState(VK_RWIN) & 0x8000) != 0) { modifiers |= MOD_WIN; } return modifiers; } LRESULT CALLBACK mouse_hotkey_proc(int code, WPARAM wParam, LPARAM lParam) { if (code == HC_ACTION && g_mouse_hotkey_state && wParam == WM_XBUTTONDOWN) { auto* mouse_info = reinterpret_cast(lParam); if (mouse_info) { UINT key = 0; auto button = HIWORD(mouse_info->mouseData); if (button == XBUTTON1) { key = VK_XBUTTON1; } else if (button == XBUTTON2) { key = VK_XBUTTON2; } if (key != 0) { auto combo = make_mouse_combo(get_current_hotkey_modifiers(), key); auto it = g_mouse_hotkey_state->mouse_combo_to_hotkey_id.find(combo); if (it != g_mouse_hotkey_state->mouse_combo_to_hotkey_id.end()) { auto target_hwnd = g_mouse_hotkey_state->mouse_hotkey_target_hwnd; if (target_hwnd && IsWindow(target_hwnd)) { PostMessageW(target_hwnd, WM_HOTKEY, static_cast(it->second), 0); } } } } } return CallNextHookEx(nullptr, code, wParam, lParam); } auto install_mouse_hotkey_hook(Core::Commands::State::CommandState& cmd_state, HWND hwnd) -> bool { if (cmd_state.mouse_hotkey_hook) { cmd_state.mouse_hotkey_target_hwnd = hwnd; g_mouse_hotkey_state = &cmd_state; return true; } auto hook = SetWindowsHookExW(WH_MOUSE_LL, mouse_hotkey_proc, GetModuleHandleW(nullptr), 0); if (!hook) { auto error = GetLastError(); Logger().error("Failed to install mouse hotkey hook, error code: {}", error); return false; } cmd_state.mouse_hotkey_hook = hook; cmd_state.mouse_hotkey_target_hwnd = hwnd; g_mouse_hotkey_state = &cmd_state; Logger().info("Mouse hotkey hook installed"); return true; } auto uninstall_mouse_hotkey_hook(Core::Commands::State::CommandState& cmd_state) -> void { if (cmd_state.mouse_hotkey_hook) { UnhookWindowsHookEx(cmd_state.mouse_hotkey_hook); cmd_state.mouse_hotkey_hook = nullptr; Logger().info("Mouse hotkey hook uninstalled"); } cmd_state.mouse_hotkey_target_hwnd = nullptr; cmd_state.mouse_combo_to_hotkey_id.clear(); if (g_mouse_hotkey_state == &cmd_state) { g_mouse_hotkey_state = nullptr; } } auto register_command(CommandRegistry& registry, CommandDescriptor descriptor) -> void { const std::string id = descriptor.id; if (registry.descriptors.contains(id)) { Logger().warn("Command already registered: {}", id); return; } registry.descriptors.emplace(id, std::move(descriptor)); registry.registration_order.push_back(id); Logger().debug("Registered command: {}", id); } auto invoke_command(CommandRegistry& registry, const std::string& id) -> bool { auto it = registry.descriptors.find(id); if (it == registry.descriptors.end()) { Logger().warn("Command not found: {}", id); return false; } if (!it->second.action) { Logger().warn("Command has no action: {}", id); return false; } try { it->second.action(); Logger().debug("Invoked command: {}", id); return true; } catch (const std::exception& e) { Logger().error("Failed to invoke command {}: {}", id, e.what()); return false; } } auto get_command(const CommandRegistry& registry, const std::string& id) -> const CommandDescriptor* { auto it = registry.descriptors.find(id); if (it == registry.descriptors.end()) { return nullptr; } return &it->second; } auto get_all_commands(const CommandRegistry& registry) -> std::vector { std::vector result; result.reserve(registry.registration_order.size()); for (const auto& id : registry.registration_order) { auto it = registry.descriptors.find(id); if (it != registry.descriptors.end()) { result.push_back(it->second); } } return result; } // 从 settings 获取热键配置(根据 settings_path) auto get_hotkey_from_settings(const Core::State::AppState& state, const HotkeyBinding& binding) -> std::pair { const auto& settings = state.settings->raw; if (binding.settings_path == "app.hotkey.floating_window") { auto result = std::pair{settings.app.hotkey.floating_window.modifiers, settings.app.hotkey.floating_window.key}; Logger().debug("Hotkey config for '{}': modifiers={}, key={}", binding.settings_path, result.first, result.second); return result; } else if (binding.settings_path == "app.hotkey.screenshot") { auto result = std::pair{settings.app.hotkey.screenshot.modifiers, settings.app.hotkey.screenshot.key}; Logger().debug("Hotkey config for '{}': modifiers={}, key={}", binding.settings_path, result.first, result.second); return result; } else if (binding.settings_path == "app.hotkey.recording") { auto result = std::pair{settings.app.hotkey.recording.modifiers, settings.app.hotkey.recording.key}; Logger().debug("Hotkey config for '{}': modifiers={}, key={}", binding.settings_path, result.first, result.second); return result; } // 如果没有配置,使用默认值 Logger().debug("Using default hotkey for '{}': modifiers={}, key={}", binding.settings_path, binding.modifiers, binding.key); return {binding.modifiers, binding.key}; } auto register_all_hotkeys(Core::State::AppState& state, HWND hwnd) -> void { Logger().info("=== Starting hotkey registration ==="); if (!hwnd) { Logger().error("Cannot register hotkeys: HWND is null"); return; } Logger().debug("HWND for hotkey registration: {}", reinterpret_cast(hwnd)); auto& cmd_state = *state.commands; uninstall_mouse_hotkey_hook(cmd_state); cmd_state.hotkey_to_command.clear(); cmd_state.next_hotkey_id = 1; Logger().info("Total commands in registry: {}", cmd_state.registry.descriptors.size()); for (const auto& [id, descriptor] : cmd_state.registry.descriptors) { if (!descriptor.hotkey) { continue; } const auto& binding = *descriptor.hotkey; Logger().debug("Processing hotkey for command '{}', settings_path='{}'", id, binding.settings_path); auto [modifiers, key] = get_hotkey_from_settings(state, binding); if (key == 0) { Logger().warn("Hotkey key is 0 for command '{}', skipping registration", id); continue; } int hotkey_id = cmd_state.next_hotkey_id++; if (is_mouse_side_key(key)) { auto combo = make_mouse_combo(modifiers, key); if (cmd_state.mouse_combo_to_hotkey_id.contains(combo)) { Logger().error("Duplicate mouse hotkey for command '{}' (modifiers={}, key={}), skipping", id, modifiers & (MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN), key); continue; } cmd_state.hotkey_to_command[hotkey_id] = id; cmd_state.mouse_combo_to_hotkey_id[combo] = hotkey_id; Logger().info("Registered mouse hotkey {} for command '{}' (modifiers={}, key={})", hotkey_id, id, modifiers & (MOD_ALT | MOD_CONTROL | MOD_SHIFT | MOD_WIN), key); continue; } if (::RegisterHotKey(hwnd, hotkey_id, modifiers, key)) { cmd_state.hotkey_to_command[hotkey_id] = id; Logger().info("Successfully registered hotkey {} for command '{}' (modifiers={}, key={})", hotkey_id, id, modifiers, key); continue; } DWORD error = ::GetLastError(); Logger().error( "Failed to register hotkey for command '{}' (modifiers={}, key={}), error code: {}", id, modifiers, key, error); } if (!cmd_state.mouse_combo_to_hotkey_id.empty() && !install_mouse_hotkey_hook(cmd_state, hwnd)) { for (const auto& [_, mouse_hotkey_id] : cmd_state.mouse_combo_to_hotkey_id) { cmd_state.hotkey_to_command.erase(mouse_hotkey_id); } cmd_state.mouse_combo_to_hotkey_id.clear(); Logger().error("Mouse side-button hotkeys disabled because hook installation failed"); } Logger().info("=== Hotkey registration complete: {} hotkeys registered ===", cmd_state.hotkey_to_command.size()); } auto unregister_all_hotkeys(Core::State::AppState& state, HWND hwnd) -> void { auto& cmd_state = *state.commands; uninstall_mouse_hotkey_hook(cmd_state); if (hwnd) { for (const auto& [hotkey_id, _] : cmd_state.hotkey_to_command) { ::UnregisterHotKey(hwnd, hotkey_id); } } Logger().info("Unregistered {} hotkeys", cmd_state.hotkey_to_command.size()); cmd_state.hotkey_to_command.clear(); cmd_state.next_hotkey_id = 1; } auto handle_hotkey(Core::State::AppState& state, int hotkey_id) -> std::optional { Logger().debug("Received hotkey event, hotkey_id={}", hotkey_id); auto& cmd_state = *state.commands; auto it = cmd_state.hotkey_to_command.find(hotkey_id); if (it != cmd_state.hotkey_to_command.end()) { const auto& command_id = it->second; Logger().info("Hotkey {} mapped to command '{}', invoking...", hotkey_id, command_id); invoke_command(cmd_state.registry, command_id); return command_id; } Logger().warn("Hotkey {} not found in hotkey_to_command map (map size: {})", hotkey_id, cmd_state.hotkey_to_command.size()); return std::nullopt; } } // namespace Core::Commands ================================================ FILE: src/core/commands/registry.ixx ================================================ module; export module Core.Commands; import std; import Core.State; import Vendor.Windows; namespace Core::Commands { // 热键绑定 export struct HotkeyBinding { Vendor::Windows::UINT modifiers = 0; // MOD_CONTROL=1, MOD_ALT=2, MOD_SHIFT=4 Vendor::Windows::UINT key = 0; // 虚拟键码 (VK_*) std::string settings_path; // 设置文件中的路径,如 "app.hotkey.floating_window" }; // 命令描述符 export struct CommandDescriptor { std::string id; // 唯一标识,如 "screenshot.capture" std::string i18n_key; // i18n 键,如 "menu.screenshot_capture" bool is_toggle = false; // 是否为切换类型 std::function action; // 点击执行的动作 std::function get_state = nullptr; // toggle 类型:获取当前状态 std::optional hotkey; // 热键绑定(可选) }; // 运行时命令注册表 export struct CommandRegistry { std::unordered_map descriptors; std::vector registration_order; // 保持注册顺序 }; // === API === // 注册命令 export auto register_command(CommandRegistry& registry, CommandDescriptor descriptor) -> void; // 调用命令 export auto invoke_command(CommandRegistry& registry, const std::string& id) -> bool; // 获取单个命令描述符(零拷贝,只读) export auto get_command(const CommandRegistry& registry, const std::string& id) -> const CommandDescriptor*; // 获取所有命令描述符(按注册顺序) export auto get_all_commands(const CommandRegistry& registry) -> std::vector; // === RPC Types === // 用于 RPC 传输的命令描述符(不包含 function 字段) export struct CommandDescriptorData { std::string id; std::string i18n_key; bool is_toggle; }; export struct GetAllCommandsParams { // 空结构体,未来可扩展 }; export struct GetAllCommandsResult { std::vector commands; }; export struct InvokeCommandParams { std::string id; }; export struct InvokeCommandResult { bool success = false; std::string message; }; // 注册所有内置命令(需要在应用初始化时调用) export auto register_builtin_commands(Core::State::AppState& state, CommandRegistry& registry) -> void; // 安装常驻全局键盘钩子 export auto install_keyboard_keepalive_hook(Core::State::AppState& state) -> void; // 卸载常驻全局键盘钩子 export auto uninstall_keyboard_keepalive_hook(Core::State::AppState& state) -> void; // === 热键管理 === // 注册所有命令的热键 export auto register_all_hotkeys(Core::State::AppState& state, Vendor::Windows::HWND hwnd) -> void; // 注销所有热键 export auto unregister_all_hotkeys(Core::State::AppState& state, Vendor::Windows::HWND hwnd) -> void; // 处理热键消息,返回对应的命令ID(如果找到) export auto handle_hotkey(Core::State::AppState& state, int hotkey_id) -> std::optional; } // namespace Core::Commands ================================================ FILE: src/core/commands/state.ixx ================================================ module; export module Core.Commands.State; import std; import Core.Commands; import Vendor.Windows; namespace Core::Commands::State { export struct CommandState { CommandRegistry registry; // 常驻低级键盘钩子。 // 它不负责业务按键处理,只用于维持进程级全局输入挂载。 Vendor::Windows::HHOOK keyboard_keepalive_hook = nullptr; // 热键运行时状态 std::unordered_map hotkey_to_command; // hotkey_id -> command_id int next_hotkey_id = 1; // 下一个可用的热键ID // 鼠标侧键热键运行时状态 Vendor::Windows::HHOOK mouse_hotkey_hook = nullptr; Vendor::Windows::HWND mouse_hotkey_target_hwnd = nullptr; std::unordered_map mouse_combo_to_hotkey_id; // combo -> hotkey_id }; } // namespace Core::Commands::State ================================================ FILE: src/core/database/data_mapper.ixx ================================================ module; export module Core.Database.DataMapper; import std; import Core.Database.Types; import ; import ; namespace Core::Database::DataMapper { export enum class MappingErrorType { field_not_found, type_conversion_failed, validation_failed, null_value_for_required_field }; export struct MappingError { MappingErrorType type; std::string field_name; std::string details; auto to_string() const -> std::string { std::string type_str; switch (type) { case MappingErrorType::field_not_found: type_str = "Field not found"; break; case MappingErrorType::type_conversion_failed: type_str = "Type conversion failed"; break; case MappingErrorType::validation_failed: type_str = "Validation failed"; break; case MappingErrorType::null_value_for_required_field: type_str = "Null value for required field"; break; } return type_str + " for field '" + field_name + "': " + details; } }; export using MappingResult = std::vector; // 类型转换器 export template struct SqliteTypeConverter; // 基础类型特化 template <> struct SqliteTypeConverter { static auto from_column(const SQLite::Column& col) -> std::expected { if (col.isNull()) { return std::unexpected("Column is NULL"); } try { return col.getInt(); } catch (const SQLite::Exception& e) { return std::unexpected("SQLite error: " + std::string(e.what())); } } }; template <> struct SqliteTypeConverter { static auto from_column(const SQLite::Column& col) -> std::expected { if (col.isNull()) { return std::unexpected("Column is NULL"); } try { return col.getInt64(); } catch (const SQLite::Exception& e) { return std::unexpected("SQLite error: " + std::string(e.what())); } } }; template <> struct SqliteTypeConverter { static auto from_column(const SQLite::Column& col) -> std::expected { if (col.isNull()) { return std::unexpected("Column is NULL"); } try { return col.getDouble(); } catch (const SQLite::Exception& e) { return std::unexpected("SQLite error: " + std::string(e.what())); } } }; template <> struct SqliteTypeConverter { static auto from_column(const SQLite::Column& col) -> std::expected { if (col.isNull()) { return std::unexpected("Column is NULL"); } try { return col.getString(); } catch (const SQLite::Exception& e) { return std::unexpected("SQLite error: " + std::string(e.what())); } } }; // std::optional 特化 - 可以处理NULL值 template struct SqliteTypeConverter> { static auto from_column(const SQLite::Column& col) -> std::expected, std::string> { if (col.isNull()) { return std::optional{}; } auto result = SqliteTypeConverter::from_column(col); if (!result) { return std::unexpected(result.error()); } return std::optional{std::move(result.value())}; } }; // 提取字段值 export template auto extract_field_value(SQLite::Statement& stmt, const std::string& field_name) -> std::expected { try { // 尝试获取列 SQLite::Column col = stmt.getColumn(field_name.c_str()); // 使用类型转换器 auto result = SqliteTypeConverter::from_column(col); if (!result) { return std::unexpected(MappingError{.type = MappingErrorType::type_conversion_failed, .field_name = field_name, .details = result.error()}); } return result.value(); } catch (const SQLite::Exception& e) { // 字段不存在或其他SQLite错误 return std::unexpected(MappingError{.type = MappingErrorType::field_not_found, .field_name = field_name, .details = std::string(e.what())}); } } // 主要接口 - 对象构建器 export template auto from_statement(SQLite::Statement& query) -> std::expected { T object{}; std::vector errors; // 使用 rfl 遍历结构体的所有字段 rfl::to_view(object).apply([&](auto field) { const std::string field_name = std::string(field.name()); using FieldValueType = std::remove_cvref_t; // 直接提取字段值,使用字段名 auto result = extract_field_value(query, field_name); if (result) { *field.value() = std::move(result.value()); } else { errors.push_back(result.error()); } }); // 如果有错误,合并错误信息 if (!errors.empty()) { std::string error_message = "Found " + std::to_string(errors.size()) + " errors:\n"; for (size_t i = 0; i < errors.size(); ++i) { error_message += std::to_string(i + 1) + ") " + errors[i].to_string() + "\n"; } return std::unexpected(error_message); } return object; } } // namespace Core::Database::DataMapper ================================================ FILE: src/core/database/database.cpp ================================================ module; module Core.Database; import std; import Core.Database.DataMapper; import Core.Database.State; import Core.Database.Types; import Utils.Logger; import Utils.Path; import ; namespace Core::Database { // 获取当前线程的数据库连接。如果连接不存在,则创建它。 auto get_connection(const std::filesystem::path& db_path) -> SQLite::Database& { if (!thread_connection) { thread_connection = std::make_unique( db_path.string(), SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE); // 设置 WAL 模式以提高并发性能 thread_connection->exec("PRAGMA journal_mode=WAL;"); thread_connection->exec("PRAGMA synchronous=NORMAL;"); // SQLite 外键约束默认可能关闭,需要按连接显式启用。 thread_connection->exec("PRAGMA foreign_keys=ON;"); } return *thread_connection; } // 初始化数据库,现在只负责配置路径和进行初始连接测试 auto initialize(State::DatabaseState& state, const std::filesystem::path& db_path) -> std::expected { try { // 保存数据库路径 state.db_path = db_path; // 确保父目录存在 if (db_path.has_parent_path()) { std::filesystem::create_directories(db_path.parent_path()); } // 在主线程上尝试创建一个连接,以验证数据库路径和配置 get_connection(db_path); Logger().info("Database configured successfully: {}", db_path.string()); return {}; } catch (const SQLite::Exception& e) { Logger().error("Cannot open database: {} - Error: {}", db_path.string(), e.what()); return std::unexpected(std::string("Cannot open database: ") + e.what()); } catch (const std::exception& e) { Logger().error("Cannot open database: {} - Error: {}", db_path.string(), e.what()); return std::unexpected(std::string("Cannot open database: ") + e.what()); } } // 关闭当前线程的数据库连接 auto close(State::DatabaseState& state) -> void { if (thread_connection) { thread_connection.reset(); Logger().info("Database connection closed for the current thread: {}", state.db_path.string()); } } // 执行非查询操作 (INSERT, UPDATE, DELETE) auto execute(State::DatabaseState& state, const std::string& sql) -> std::expected { try { auto& connection = get_connection(state.db_path); connection.exec(sql); return {}; } catch (const SQLite::Exception& e) { Logger().error("Failed to execute statement: {} - Error: {}", sql, e.what()); return std::unexpected(std::string("Failed to execute statement: ") + e.what()); } } auto execute(State::DatabaseState& state, const std::string& sql, const std::vector& params) -> std::expected { try { auto& connection = get_connection(state.db_path); SQLite::Statement query(connection, sql); // 绑定参数 for (size_t i = 0; i < params.size(); ++i) { const auto& param = params[i]; int param_index = static_cast(i + 1); // SQLite 参数是 1-based 索引 std::visit( [&query, param_index](auto&& arg) { using T = std::decay_t; if constexpr (std::is_same_v) { query.bind(param_index); // 绑定 NULL } else if constexpr (std::is_same_v>) { query.bind(param_index, arg.data(), static_cast(arg.size())); // 绑定 BLOB } else { // 通过重载方法绑定 int64_t, double, std::string query.bind(param_index, arg); } }, param); } query.exec(); // 对不返回结果的语句使用 exec() return {}; } catch (const SQLite::Exception& e) { Logger().error("Failed to execute statement: {} - Error: {}", sql, e.what()); return std::unexpected(std::string("Failed to execute statement: ") + e.what()); } } } // namespace Core::Database ================================================ FILE: src/core/database/database.ixx ================================================ module; export module Core.Database; import std; import Core.Database.State; import Core.Database.Types; import Core.Database.DataMapper; import ; namespace Core::Database { // 线程局部存储,为每个线程维护一个独立的数据库连接 thread_local std::unique_ptr thread_connection; // 获取当前线程的数据库连接。如果连接不存在,则创建它。 auto get_connection(const std::filesystem::path& db_path) -> SQLite::Database&; // 初始化数据库连接 export auto initialize(State::DatabaseState& state, const std::filesystem::path& db_path) -> std::expected; // 关闭数据库连接,理论上非必要,thread_connection 会在 thread_exit 时自动关闭 export auto close(State::DatabaseState& state) -> void; // 执行非查询操作 (INSERT, UPDATE, DELETE) export auto execute(State::DatabaseState& state, const std::string& sql) -> std::expected; export auto execute(State::DatabaseState& state, const std::string& sql, const std::vector& params) -> std::expected; // 查询返回多个结果 (SELECT) export template auto query(State::DatabaseState& state, const std::string& sql, const std::vector& params = {}) -> std::expected, std::string> { try { auto& connection = get_connection(state.db_path); SQLite::Statement query(connection, sql); // 绑定参数 for (size_t i = 0; i < params.size(); ++i) { const auto& param = params[i]; int param_index = static_cast(i + 1); // SQLite 参数是 1-based 索引 std::visit( [&query, param_index](auto&& arg) { using T = std::decay_t; if constexpr (std::is_same_v) { query.bind(param_index); // 绑定 NULL } else if constexpr (std::is_same_v>) { query.bind(param_index, arg.data(), static_cast(arg.size())); // 绑定 BLOB } else { // 通过重载方法绑定 int64_t, double, std::string query.bind(param_index, arg); } }, param); } std::vector results; while (query.executeStep()) { auto mapped_object = DataMapper::from_statement(query); if (mapped_object) { results.push_back(std::move(*mapped_object)); } else { return std::unexpected("Failed to map row to object: " + mapped_object.error()); } } return results; } catch (const SQLite::Exception& e) { return std::unexpected("SQLite error: " + std::string(e.what())); } catch (const std::exception& e) { return std::unexpected("Generic error: " + std::string(e.what())); } } // 查询返回单个结果 (SELECT) export template auto query_single(State::DatabaseState& state, const std::string& sql, const std::vector& params = {}) -> std::expected, std::string> { auto results = query(state, sql, params); if (!results) { return std::unexpected(results.error()); } if (results->empty()) { return std::optional{}; } if (results->size() > 1) { // 或记录警告,具体取决于所需的严格性 return std::unexpected("Query for single result returned multiple rows."); } return std::move(results->front()); } // 查询返回单个标量值 export template auto query_scalar(State::DatabaseState& state, const std::string& sql, const std::vector& params = {}) -> std::expected, std::string> { try { auto& connection = get_connection(state.db_path); SQLite::Statement query(connection, sql); for (size_t i = 0; i < params.size(); ++i) { const auto& param = params[i]; int param_index = static_cast(i + 1); std::visit( [&query, param_index](auto&& arg) { using U = std::decay_t; if constexpr (std::is_same_v) { query.bind(param_index); } else if constexpr (std::is_same_v>) { query.bind(param_index, arg.data(), static_cast(arg.size())); } else { query.bind(param_index, arg); } }, param); } if (query.executeStep()) { SQLite::Column col = query.getColumn(0); if (col.isNull()) { return std::optional{}; } if constexpr (std::is_same_v) { return col.getInt(); } else if constexpr (std::is_same_v) { return col.getInt64(); } else if constexpr (std::is_same_v) { return col.getDouble(); } else if constexpr (std::is_same_v) { return col.getString(); } else { // 不支持的类型 static_assert(sizeof(T) == 0, "Unsupported type for query_scalar"); } } return std::optional{}; // 没有结果 } catch (const SQLite::Exception& e) { return std::unexpected("SQLite error: " + std::string(e.what())); } catch (const std::exception& e) { return std::unexpected("Generic error: " + std::string(e.what())); } } // 批量INSERT操作(自动分批处理) export template auto execute_batch_insert(State::DatabaseState& state, const std::string& insert_prefix, const std::string& values_placeholder, const std::vector& items, ParamExtractor param_extractor, size_t max_params_per_batch = 999) -> std::expected, std::string> { if (items.empty()) { return std::vector{}; } // 计算每个item需要的参数数量 auto sample_params = param_extractor(items[0]); size_t params_per_item = sample_params.size(); size_t max_items_per_batch = max_params_per_batch / params_per_item; if (max_items_per_batch == 0) { return std::unexpected("Single item exceeds maximum parameter limit"); } std::vector all_inserted_ids; all_inserted_ids.reserve(items.size()); return execute_transaction( state, [&](State::DatabaseState& db_state) -> std::expected, std::string> { for (size_t batch_start = 0; batch_start < items.size(); batch_start += max_items_per_batch) { size_t batch_end = std::min(batch_start + max_items_per_batch, items.size()); size_t batch_size = batch_end - batch_start; // 构建当前批次的SQL std::string batch_sql = insert_prefix; std::vector value_clauses; std::vector all_params; value_clauses.reserve(batch_size); all_params.reserve(batch_size * params_per_item); for (size_t i = batch_start; i < batch_end; ++i) { value_clauses.push_back(values_placeholder); auto item_params = param_extractor(items[i]); all_params.insert(all_params.end(), item_params.begin(), item_params.end()); } // 合并VALUES子句 for (size_t i = 0; i < value_clauses.size(); ++i) { if (i > 0) batch_sql += ", "; batch_sql += value_clauses[i]; } auto result = execute(db_state, batch_sql, all_params); if (!result) { return std::unexpected("Batch insert failed: " + result.error()); } // 获取插入的ID范围 auto last_id_result = query_scalar(db_state, "SELECT last_insert_rowid()"); if (!last_id_result || !last_id_result->has_value()) { return std::unexpected("Failed to get last insert ID"); } int64_t last_id = last_id_result->value(); int64_t first_id = last_id - static_cast(batch_size) + 1; // 添加当前批次的ID到结果中 for (int64_t id = first_id; id <= last_id; ++id) { all_inserted_ids.push_back(id); } } return all_inserted_ids; }); } // 事务管理 export template auto execute_transaction(State::DatabaseState& state, Func&& func) -> decltype(auto) { try { auto& connection = get_connection(state.db_path); SQLite::Transaction transaction(connection); // 执行用户提供的事务逻辑 auto result = func(state); if (!result) { // 如果函数返回错误,事务会在transaction析构时自动回滚 return result; } // 提交事务 transaction.commit(); return result; } catch (const SQLite::Exception& e) { // 异常发生时,transaction析构会自动回滚 using ReturnType = std::decay_t; return ReturnType{std::unexpected("Transaction failed: " + std::string(e.what()))}; } catch (const std::exception& e) { using ReturnType = std::decay_t; return ReturnType{std::unexpected("Transaction error: " + std::string(e.what()))}; } } } // namespace Core::Database ================================================ FILE: src/core/database/state.ixx ================================================ module; export module Core.Database.State; import std; export namespace Core::Database::State { struct DatabaseState { // 存储数据库文件路径 std::filesystem::path db_path; }; } // namespace Core::Database::State ================================================ FILE: src/core/database/types.ixx ================================================ module; export module Core.Database.Types; import std; namespace Core::Database::Types { // 代表数据库中的一个值,可以是NULL、整数、浮点数、字符串或二进制数据 export using DbValue = std::variant>; // 用于参数化查询的参数类型 export using DbParam = DbValue; } // namespace Core::Database::Types ================================================ FILE: src/core/dialog_service/dialog_service.cpp ================================================ module; #include module Core.DialogService; import std; import Core.DialogService.State; import Core.State; import Utils.Dialog; import Utils.Logger; namespace Core::DialogService { namespace Detail { auto run_worker_loop(Core::DialogService::State::DialogServiceState& service, std::stop_token stop_token) -> void { auto com_init = wil::CoInitializeEx(COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); while (!stop_token.stop_requested()) { std::function task; { std::unique_lock lock(service.queue_mutex); service.condition.wait(lock, [&service, &stop_token] { return stop_token.stop_requested() || service.shutdown_requested.load() || !service.task_queue.empty(); }); if ((stop_token.stop_requested() || service.shutdown_requested.load()) && service.task_queue.empty()) { break; } if (!service.task_queue.empty()) { task = std::move(service.task_queue.front()); service.task_queue.pop(); } } if (!task) { continue; } try { task(); } catch (const std::exception& e) { Logger().error("DialogService task execution error: {}", e.what()); } catch (...) { Logger().error("DialogService task execution unknown error"); } } } template using DialogResult = std::expected; template auto submit_dialog_task(Core::DialogService::State::DialogServiceState& service, std::function()> task) -> DialogResult { if (!service.is_running.load()) { return std::unexpected("DialogService is not running"); } if (service.shutdown_requested.load()) { return std::unexpected("DialogService is shutting down"); } auto promise = std::make_shared>>(); auto future = promise->get_future(); try { { std::lock_guard lock(service.queue_mutex); service.task_queue.push([task = std::move(task), promise]() mutable { try { promise->set_value(task()); } catch (const std::exception& e) { promise->set_value(std::unexpected(std::string("Dialog task failed: ") + e.what())); } catch (...) { promise->set_value(std::unexpected("Dialog task failed: unknown error")); } }); } service.condition.notify_one(); return future.get(); } catch (const std::exception& e) { return std::unexpected(std::string("Failed to submit dialog task: ") + e.what()); } } } // namespace Detail auto start(Core::DialogService::State::DialogServiceState& service) -> std::expected { if (service.is_running.exchange(true)) { Logger().warn("DialogService already started"); return std::unexpected("DialogService already started"); } try { service.shutdown_requested = false; service.worker_thread = std::jthread( [&service](std::stop_token stop_token) { Detail::run_worker_loop(service, stop_token); }); Logger().info("DialogService started successfully"); return {}; } catch (const std::exception& e) { service.is_running = false; service.shutdown_requested = false; return std::unexpected(std::string("Failed to start DialogService: ") + e.what()); } } auto stop(Core::DialogService::State::DialogServiceState& service) -> void { if (!service.is_running.exchange(false)) { return; } Logger().info("Stopping DialogService"); service.shutdown_requested = true; service.condition.notify_all(); if (service.worker_thread.joinable()) { service.worker_thread.request_stop(); service.worker_thread.join(); } { std::lock_guard lock(service.queue_mutex); std::queue> empty; service.task_queue.swap(empty); } service.shutdown_requested = false; Logger().info("DialogService stopped"); } auto open_file(Core::State::AppState& state, const Utils::Dialog::FileSelectorParams& params, HWND hwnd) -> std::expected { if (!state.dialog_service) { return std::unexpected("DialogService state is not initialized"); } return Detail::submit_dialog_task( *state.dialog_service, [params, hwnd]() { return Utils::Dialog::select_file(params, hwnd); }); } auto open_folder(Core::State::AppState& state, const Utils::Dialog::FolderSelectorParams& params, HWND hwnd) -> std::expected { if (!state.dialog_service) { return std::unexpected("DialogService state is not initialized"); } return Detail::submit_dialog_task( *state.dialog_service, [params, hwnd]() { return Utils::Dialog::select_folder(params, hwnd); }); } } // namespace Core::DialogService ================================================ FILE: src/core/dialog_service/dialog_service.ixx ================================================ module; export module Core.DialogService; import std; import Core.State; import Core.DialogService.State; import Utils.Dialog; import Vendor.Windows; namespace Core::DialogService { export auto start(Core::DialogService::State::DialogServiceState& service) -> std::expected; export auto stop(Core::DialogService::State::DialogServiceState& service) -> void; export auto open_file(Core::State::AppState& state, const Utils::Dialog::FileSelectorParams& params, Vendor::Windows::HWND hwnd = nullptr) -> std::expected; export auto open_folder(Core::State::AppState& state, const Utils::Dialog::FolderSelectorParams& params, Vendor::Windows::HWND hwnd = nullptr) -> std::expected; } // namespace Core::DialogService ================================================ FILE: src/core/dialog_service/state.ixx ================================================ module; export module Core.DialogService.State; import std; namespace Core::DialogService::State { export struct DialogServiceState { std::jthread worker_thread; std::queue> task_queue; std::mutex queue_mutex; std::condition_variable condition; std::atomic is_running{false}; std::atomic shutdown_requested{false}; }; } // namespace Core::DialogService::State ================================================ FILE: src/core/events/events.cpp ================================================ module; module Core.Events; import std; namespace Core::Events { auto process_events(State::EventsState& bus) -> void { std::queue> events_to_process; // 快速获取事件队列的副本,减少锁的持有时间 { std::lock_guard lock(bus.queue_mutex); if (bus.event_queue.empty()) { return; } events_to_process.swap(bus.event_queue); } // 处理所有事件 while (!events_to_process.empty()) { const auto& [type_index, event_data] = events_to_process.front(); // 查找并调用对应类型的处理器 if (auto it = bus.handlers.find(type_index); it != bus.handlers.end()) { for (const auto& handler : it->second) { try { handler(event_data); } catch (const std::exception& e) { // 异常处理暂时省略,避免循环依赖 } } } events_to_process.pop(); } } auto clear_events(State::EventsState& bus) -> void { std::lock_guard lock(bus.queue_mutex); bus.event_queue = {}; // 清空 } } // namespace Core::Events ================================================ FILE: src/core/events/events.ixx ================================================ module; export module Core.Events; import std; import Core.Events.State; import ; namespace Core::Events { // Custom message for UI thread wake-up to process async events export constexpr UINT kWM_APP_PROCESS_EVENTS = WM_APP + 1; // 同步发送事件 export template auto send(State::EventsState& bus, const T& event) -> void { auto key = std::type_index(typeid(T)); if (auto it = bus.handlers.find(key); it != bus.handlers.end()) { for (const auto& handler : it->second) { try { handler(std::any(event)); } catch (const std::exception& e) { // 异常处理暂时省略,避免循环依赖 } } } } // 异步投递事件 export template auto post(State::EventsState& bus, T event) -> void { { std::lock_guard lock(bus.queue_mutex); auto key = std::type_index(typeid(T)); bus.event_queue.emplace(key, std::any(std::move(event))); } // Wake up UI thread to process events if (bus.notify_hwnd) { ::PostMessageW(bus.notify_hwnd, kWM_APP_PROCESS_EVENTS, 0, 0); } } // 订阅事件 export template auto subscribe(State::EventsState& bus, std::function handler) -> void { if (handler) { auto key = std::type_index(typeid(T)); bus.handlers[key].emplace_back([handler = std::move(handler)](const std::any& data) { try { const T& event = std::any_cast(data); handler(event); } catch (const std::bad_any_cast& e) { // 类型转换错误,暂时忽略 } }); } } // 处理队列中的事件(在消息循环中调用) export auto process_events(State::EventsState& bus) -> void; // 清空事件队列 export auto clear_events(State::EventsState& bus) -> void; } // namespace Core::Events ================================================ FILE: src/core/events/handlers/feature_handlers.cpp ================================================ module; module Core.Events.Handlers.Feature; import std; import Core.Events; import Core.State; import UI.FloatingWindow; import UI.FloatingWindow.Events; import Features.Screenshot.UseCase; import Features.WindowControl.UseCase; import Features.Notifications; namespace Core::Events::Handlers { // 注册功能相关的事件处理器 // 注:大部分功能已迁移至命令系统(Core.Commands) // 此处仅保留通过热键/系统事件触发的处理器 auto register_feature_handlers(Core::State::AppState& app_state) -> void { using namespace Core::Events; // === 截图功能 === // 通过热键触发的截图事件 subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::CaptureEvent& event) { Features::Screenshot::UseCase::handle_capture_event(app_state, event); }); // === 窗口控制功能 === // 通过UI菜单选择触发的窗口调整事件 // 注:handle_xxx 会启动协程在 UI 线程执行,协程内部会在完成后请求重绘 subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::RatioChangeEvent& event) { Features::WindowControl::UseCase::handle_ratio_changed(app_state, event); }); subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::ResolutionChangeEvent& event) { Features::WindowControl::UseCase::handle_resolution_changed(app_state, event); }); subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::WindowSelectionEvent& event) { Features::WindowControl::UseCase::handle_window_selected(app_state, event); }); // 录制状态由后台线程切换完成后,触发悬浮窗重绘以更新 toggle 显示 subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::RecordingToggleEvent&) { UI::FloatingWindow::request_repaint(app_state); }); // === 通知功能 === // 跨线程安全的通知显示(由 WorkerPool 等工作线程 post 事件,UI 线程处理) subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::NotificationEvent& event) { Features::Notifications::show_notification(app_state, event.title, event.message); }); } } // namespace Core::Events::Handlers ================================================ FILE: src/core/events/handlers/feature_handlers.ixx ================================================ module; export module Core.Events.Handlers.Feature; import Core.State; namespace Core::Events::Handlers { export auto register_feature_handlers(Core::State::AppState& app_state) -> void; } ================================================ FILE: src/core/events/handlers/settings_handlers.cpp ================================================ module; module Core.Events.Handlers.Settings; import std; import Core.Events; import Core.RPC.NotificationHub; import Core.State; import Core.Commands; import Core.I18n; import Core.WebView; import Features.Gallery; import Features.Settings.Events; import Features.Settings.Types; import Extensions.InfinityNikki.PhotoService; import Extensions.InfinityNikki.TaskService; import UI.FloatingWindow; import UI.FloatingWindow.State; import UI.WebViewWindow; import Utils.Logger; namespace Core::Events::Handlers { auto has_hotkey_changes(const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { const auto& old_hotkey = old_settings.app.hotkey; const auto& new_hotkey = new_settings.app.hotkey; return old_hotkey.floating_window.modifiers != new_hotkey.floating_window.modifiers || old_hotkey.floating_window.key != new_hotkey.floating_window.key || old_hotkey.screenshot.modifiers != new_hotkey.screenshot.modifiers || old_hotkey.screenshot.key != new_hotkey.screenshot.key || old_hotkey.recording.modifiers != new_hotkey.recording.modifiers || old_hotkey.recording.key != new_hotkey.recording.key; } auto refresh_global_hotkeys(Core::State::AppState& state) -> void { if (!state.floating_window || !state.floating_window->window.hwnd) { Logger().warn("Skip hotkey refresh: floating window handle is not ready"); return; } auto hwnd = state.floating_window->window.hwnd; Core::Commands::unregister_all_hotkeys(state, hwnd); Core::Commands::register_all_hotkeys(state, hwnd); Logger().info("Global hotkeys refreshed from latest settings"); } auto has_webview_host_mode_changes(const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { return old_settings.ui.webview_window.enable_transparent_background != new_settings.ui.webview_window.enable_transparent_background; } auto has_webview_theme_mode_changes(const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { return old_settings.ui.web_theme.mode != new_settings.ui.web_theme.mode; } auto has_language_changes(const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { return old_settings.app.language.current != new_settings.app.language.current; } auto has_logger_level_changes(const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { return old_settings.app.logger.level != new_settings.app.logger.level; } auto has_infinity_nikki_hardlink_setting_changes( const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { const auto& old_config = old_settings.extensions.infinity_nikki; const auto& new_config = new_settings.extensions.infinity_nikki; return old_config.enable != new_config.enable || old_config.game_dir != new_config.game_dir || old_config.gallery_guide_seen != new_config.gallery_guide_seen || old_config.manage_screenshot_hardlinks != new_config.manage_screenshot_hardlinks; } auto should_start_infinity_nikki_hardlinks_initialization( const Features::Settings::Types::AppSettings& old_settings, const Features::Settings::Types::AppSettings& new_settings) -> bool { const auto& old_config = old_settings.extensions.infinity_nikki; const auto& new_config = new_settings.extensions.infinity_nikki; if (!new_config.enable || new_config.game_dir.empty() || !new_config.gallery_guide_seen || !new_config.manage_screenshot_hardlinks) { return false; } return (!old_config.enable && new_config.enable) || old_config.game_dir != new_config.game_dir || (!old_config.gallery_guide_seen && new_config.gallery_guide_seen) || (!old_config.manage_screenshot_hardlinks && new_config.manage_screenshot_hardlinks); } auto apply_runtime_language_from_settings(Core::State::AppState& state, const Features::Settings::Types::AppSettings& settings) -> void { if (!state.i18n) { Logger().warn("Skip runtime language sync: i18n state is not ready"); return; } const auto& locale = settings.app.language.current; if (auto result = Core::I18n::load_language_by_locale(*state.i18n, locale); !result) { Logger().warn("Failed to apply runtime language ('{}'): {}", locale, result.error()); return; } Logger().info("Runtime language switched to {}", locale); } auto apply_runtime_logger_level_from_settings( Core::State::AppState& state, const Features::Settings::Types::AppSettings& settings) -> void { const auto& level = settings.app.logger.level; if (auto result = Utils::Logging::set_level(level); !result) { Logger().warn("Failed to apply runtime logger level ('{}'): {}", level, result.error()); return; } Logger().debug("Runtime logger level switched to {}", level); } // 处理设置变更事件 auto handle_settings_changed(Core::State::AppState& state, const Features::Settings::Events::SettingsChangeEvent& event) -> void { try { Logger().info("Settings changed: {}", event.data.change_description); auto output_directory_changed = event.data.old_settings.features.output_dir_path != event.data.new_settings.features.output_dir_path; if (has_language_changes(event.data.old_settings, event.data.new_settings)) { apply_runtime_language_from_settings(state, event.data.new_settings); } if (has_logger_level_changes(event.data.old_settings, event.data.new_settings)) { apply_runtime_logger_level_from_settings(state, event.data.new_settings); } // 通知浮窗刷新UI以反映设置变更 UI::FloatingWindow::refresh_from_settings(state); if (!event.data.old_settings.app.onboarding.completed && event.data.new_settings.app.onboarding.completed) { Logger().info("Onboarding completed, showing floating window and closing webview"); Features::Gallery::ensure_output_directory_media_source( state, event.data.new_settings.features.output_dir_path); UI::FloatingWindow::show_window(state); auto _ = UI::WebViewWindow::close_window(state); } else if (output_directory_changed) { Features::Gallery::ensure_output_directory_media_source( state, event.data.new_settings.features.output_dir_path); } if (has_hotkey_changes(event.data.old_settings, event.data.new_settings)) { refresh_global_hotkeys(state); } if (has_infinity_nikki_hardlink_setting_changes(event.data.old_settings, event.data.new_settings)) { Extensions::InfinityNikki::PhotoService::refresh_from_settings(state); if (should_start_infinity_nikki_hardlinks_initialization(event.data.old_settings, event.data.new_settings)) { auto task_result = Extensions::InfinityNikki::TaskService::start_initialize_screenshot_hardlinks_task( state); if (!task_result) { Logger().warn("Failed to start Infinity Nikki screenshot hardlink task: {}", task_result.error()); } else { Logger().info("Infinity Nikki screenshot hardlink task started: {}", task_result.value()); } } } auto webview_host_mode_changed = has_webview_host_mode_changes(event.data.old_settings, event.data.new_settings); auto webview_theme_mode_changed = has_webview_theme_mode_changes(event.data.old_settings, event.data.new_settings); if (webview_host_mode_changed) { if (auto recreate_result = UI::WebViewWindow::recreate_webview_host(state); !recreate_result) { Logger().warn("Failed to recreate WebView host after settings change: {}", recreate_result.error()); } } else if (webview_theme_mode_changed) { Core::WebView::apply_background_mode_from_settings(state); } Core::RPC::NotificationHub::send_notification(state, "settings.changed"); Logger().debug("Settings change processing completed"); } catch (const std::exception& e) { Logger().error("Error handling settings change event: {}", e.what()); } } auto register_settings_handlers(Core::State::AppState& app_state) -> void { using namespace Core::Events; // 注册设置变更事件处理器 subscribe( *app_state.events, [&app_state](const Features::Settings::Events::SettingsChangeEvent& event) { handle_settings_changed(app_state, event); }); } } // namespace Core::Events::Handlers ================================================ FILE: src/core/events/handlers/settings_handlers.ixx ================================================ module; export module Core.Events.Handlers.Settings; import Core.State; namespace Core::Events::Handlers { export auto register_settings_handlers(Core::State::AppState& app_state) -> void; } ================================================ FILE: src/core/events/handlers/system_handlers.cpp ================================================ module; module Core.Events.Handlers.System; import std; import Core.Events; import Core.State; import Core.WebView; import Core.WebView.Events; import UI.FloatingWindow; import UI.FloatingWindow.Layout; import UI.FloatingWindow.D2DContext; import UI.FloatingWindow.State; import UI.FloatingWindow.Events; import UI.WebViewWindow; import Utils.Logger; import Vendor.Windows; namespace Core::Events::Handlers { // 从 app_state.ixx 迁移的 DPI 更新函数 auto update_render_dpi(Core::State::AppState& state, Vendor::Windows::UINT new_dpi, const Vendor::Windows::SIZE& window_size) -> void { state.floating_window->window.dpi = new_dpi; state.floating_window->d2d_context.needs_font_update = true; // 更新布局配置(基于新的DPI) UI::FloatingWindow::Layout::update_layout(state); // 更新窗口尺寸 if (state.floating_window->window.hwnd) { Vendor::Windows::RECT currentRect{}; Vendor::Windows::GetWindowRect(state.floating_window->window.hwnd, ¤tRect); Vendor::Windows::SetWindowPos( state.floating_window->window.hwnd, nullptr, currentRect.left, currentRect.top, window_size.cx, window_size.cy, Vendor::Windows::kSWP_NOZORDER | Vendor::Windows::kSWP_NOACTIVATE); // 如果Direct2D已初始化,调整渲染目标大小 if (state.floating_window->d2d_context.is_initialized) { UI::FloatingWindow::D2DContext::resize_d2d(state, window_size); } } } // 处理 hide 命令 auto handle_hide_event(Core::State::AppState& state) -> void { UI::FloatingWindow::hide_window(state); } // 处理退出事件 auto handle_exit_event(Core::State::AppState& state) -> void { Logger().info("Exit event received, posting quit message"); Vendor::Windows::PostQuitMessage(0); } // 处理 toggle_visibility 命令 auto handle_toggle_visibility_event(Core::State::AppState& state) -> void { UI::FloatingWindow::toggle_visibility(state); } auto register_system_handlers(Core::State::AppState& app_state) -> void { using namespace Core::Events; subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::HideEvent&) { handle_hide_event(app_state); }); subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::ExitEvent&) { handle_exit_event(app_state); }); subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::ToggleVisibilityEvent&) { handle_toggle_visibility_event(app_state); }); subscribe( *app_state.events, [&app_state](const UI::FloatingWindow::Events::DpiChangeEvent& event) { Logger().debug("DPI changed to: {}, window size: {}x{}", event.new_dpi, event.window_size.cx, event.window_size.cy); update_render_dpi(app_state, event.new_dpi, event.window_size); Logger().info("DPI update completed successfully"); }); subscribe( *app_state.events, [&app_state](const Core::WebView::Events::WebViewResponseEvent& event) { try { // 在UI线程上安全调用WebView API Core::WebView::post_message(app_state, event.response); } catch (const std::exception& e) { Logger().error("Error processing WebView response event: {}", e.what()); } }); } } // namespace Core::Events::Handlers ================================================ FILE: src/core/events/handlers/system_handlers.ixx ================================================ module; export module Core.Events.Handlers.System; import Core.State; namespace Core::Events::Handlers { export auto register_system_handlers(Core::State::AppState& app_state) -> void; } ================================================ FILE: src/core/events/registrar.cpp ================================================ module; module Core.Events.Registrar; import Core.State; import Core.Events.Handlers.Feature; import Core.Events.Handlers.Settings; import Core.Events.Handlers.System; namespace Core::Events { auto register_all_handlers(Core::State::AppState& app_state) -> void { Handlers::register_feature_handlers(app_state); Handlers::register_settings_handlers(app_state); Handlers::register_system_handlers(app_state); } } // namespace Core::Events ================================================ FILE: src/core/events/registrar.ixx ================================================ module; export module Core.Events.Registrar; import Core.State; namespace Core::Events { export auto register_all_handlers(Core::State::AppState& app_state) -> void; } // namespace Core::Events ================================================ FILE: src/core/events/state.ixx ================================================ module; #include export module Core.Events.State; import std; namespace Core::Events::State { export struct EventsState { std::unordered_map>> handlers; std::queue> event_queue; std::mutex queue_mutex; // Window handle for UI thread wake-up notifications HWND notify_hwnd = nullptr; }; } // namespace Core::Events::State ================================================ FILE: src/core/http_client/http_client.cpp ================================================ module; #include module Core.HttpClient; import std; import Core.State; import Core.HttpClient.State; import Core.HttpClient.Types; import Utils.Logger; import Utils.String; import Vendor.WinHttp; import ; namespace Core::HttpClient::Detail { using RequestOperation = Core::HttpClient::State::RequestOperation; // 延长异步操作生命周期,确保在底层 WinHTTP 回调结束前对象不被析构 auto acquire_keepalive(RequestOperation* operation) -> std::shared_ptr { if (operation == nullptr) { return {}; } std::lock_guard lock(operation->keepalive_mutex); return operation->keepalive; } // 清除保活引用,允许异步操作对象在后续流程中被正常析构 auto release_keepalive(RequestOperation& operation) -> void { std::lock_guard lock(operation.keepalive_mutex); operation.keepalive.reset(); } // 生成带有 Windows 系统错误码的详细报告信息 auto make_winhttp_error(std::string_view stage) -> std::string { return std::format("{} failed (error={})", stage, ::GetLastError()); } // 强制使超时定时器过期,以此来唤醒由于等待响应而挂起的协程 auto notify_waiter(RequestOperation& operation) -> void { if (operation.waiter_notified.exchange(true)) { return; } if (operation.completion_timer.has_value()) { operation.completion_timer->expires_at((std::chrono::steady_clock::time_point::min)()); } } auto close_connect_handle(RequestOperation& operation) -> void { if (operation.connect_handle != nullptr) { Vendor::WinHttp::WinHttpCloseHandle(operation.connect_handle); operation.connect_handle = nullptr; } } // 关闭请求句柄(幂等)。若回调已注册,句柄置空延迟到 HANDLE_CLOSING 回调中处理, // 避免在回调仍在运行时提前释放句柄导致悬空指针。 auto close_request_handle(RequestOperation& operation) -> void { if (operation.request_handle == nullptr) { return; } if (operation.close_requested.exchange(true)) { return; } Vendor::WinHttp::WinHttpCloseHandle(operation.request_handle); if (!operation.callback_registered) { operation.request_handle = nullptr; } } // 完成整个操作周期:保存结果、清理所有关联的 WinHTTP 句柄并唤醒等待的协程 auto complete_operation(std::shared_ptr operation, std::expected result) -> void { if (operation == nullptr) { return; } if (operation->completed.exchange(true)) { return; } operation->result = std::move(result); close_connect_handle(*operation); if (operation->request_handle != nullptr) { close_request_handle(*operation); } else { release_keepalive(*operation); } notify_waiter(*operation); } auto complete_with_error(std::shared_ptr operation, std::string message) -> void { complete_operation(std::move(operation), std::unexpected(std::move(message))); } // 将 UTF-8 编码的字符串转换为 WinHTTP 接口所需的宽字符串(UTF-16) auto to_wide_utf8(const std::string& value, std::string_view field_name) -> std::expected { auto wide = Utils::String::FromUtf8(value); if (!value.empty() && wide.empty()) { return std::unexpected(std::format("Invalid UTF-8 for {}", field_name)); } return wide; } // HTTP 方法缺省为 GET,并统一转为大写 auto normalize_method(std::string method) -> std::string { if (method.empty()) { method = "GET"; } std::ranges::transform(method, method.begin(), [](unsigned char ch) { return static_cast(std::toupper(ch)); }); return method; } auto trim_wstring(std::wstring_view value) -> std::wstring_view { auto is_space = [](wchar_t ch) { return std::iswspace(ch) != 0; }; auto begin = std::find_if_not(value.begin(), value.end(), is_space); if (begin == value.end()) { return {}; } auto end = std::find_if_not(value.rbegin(), value.rend(), is_space).base(); return std::wstring_view(begin, end); } // 逐行解析 WinHTTP 返回的原始响应头字符串,过滤状态行并拆分键值对 auto parse_raw_headers(std::wstring_view raw_headers) -> std::vector { std::vector headers; size_t cursor = 0; bool skipped_status_line = false; while (cursor < raw_headers.size()) { auto line_end = raw_headers.find(L"\r\n", cursor); if (line_end == std::wstring_view::npos) { line_end = raw_headers.size(); } auto line = raw_headers.substr(cursor, line_end - cursor); cursor = line_end + 2; if (line.empty()) { continue; } if (!skipped_status_line) { skipped_status_line = true; continue; } auto separator = line.find(L':'); if (separator == std::wstring_view::npos) { continue; } auto name = trim_wstring(line.substr(0, separator)); auto value = trim_wstring(line.substr(separator + 1)); if (name.empty()) { continue; } headers.push_back(Types::Header{ .name = Utils::String::ToUtf8(std::wstring(name)), .value = Utils::String::ToUtf8(std::wstring(value)), }); } return headers; } // 从响应头中查找 Content-Length 并解析为无符号整数;服务器不保证提供,失败时返回 nullopt auto find_content_length(const Types::Response& response) -> std::optional { for (const auto& header : response.headers) { if (Utils::String::ToLowerAscii(header.name) != "content-length") { continue; } try { return static_cast(std::stoull(Utils::String::TrimAscii(header.value))); } catch (...) { return std::nullopt; } } return std::nullopt; } // 向调用方发送当前下载进度快照;无回调或非文件下载模式时为空操作 auto emit_download_progress(RequestOperation& operation) -> void { if (!operation.download || !operation.download->progress_callback) { return; } operation.download->progress_callback(Types::DownloadProgress{ .downloaded_bytes = operation.download->downloaded_bytes, .total_bytes = operation.download->total_bytes, }); } // 刷新并关闭输出文件;非文件下载模式时为空操作 auto finalize_file_download(RequestOperation& operation) -> std::expected { if (!operation.download || !operation.download->output_file.has_value()) { return {}; } auto& dl = *operation.download; dl.output_file->flush(); if (!dl.output_file->good()) { return std::unexpected("Failed to flush output file: " + dl.output_path.string()); } dl.output_file->close(); if (dl.output_file->fail()) { return std::unexpected("Failed to close output file: " + dl.output_path.string()); } dl.output_file.reset(); return {}; } // 请求 URL 解析:将字符串分解为 host、path、port、scheme 等字段供后续 WinHTTP 调用使用 auto parse_request_url(RequestOperation& operation) -> std::expected { auto wide_url_result = to_wide_utf8(operation.request.url, "request.url"); if (!wide_url_result) { return std::unexpected(wide_url_result.error()); } operation.wide_url = std::move(wide_url_result.value()); Vendor::WinHttp::URL_COMPONENTS components{}; components.dwStructSize = sizeof(components); components.dwSchemeLength = static_cast(-1); components.dwHostNameLength = static_cast(-1); components.dwUrlPathLength = static_cast(-1); components.dwExtraInfoLength = static_cast(-1); if (!Vendor::WinHttp::WinHttpCrackUrl( operation.wide_url.c_str(), static_cast(operation.wide_url.size()), 0, &components)) { return std::unexpected(make_winhttp_error("WinHttpCrackUrl")); } if (components.dwHostNameLength == 0 || components.lpszHostName == nullptr) { return std::unexpected("Invalid URL host"); } operation.wide_host.assign(components.lpszHostName, components.dwHostNameLength); operation.wide_path.clear(); if (components.dwUrlPathLength > 0 && components.lpszUrlPath != nullptr) { operation.wide_path.append(components.lpszUrlPath, components.dwUrlPathLength); } if (components.dwExtraInfoLength > 0 && components.lpszExtraInfo != nullptr) { operation.wide_path.append(components.lpszExtraInfo, components.dwExtraInfoLength); } if (operation.wide_path.empty()) { operation.wide_path = L"/"; } operation.port = components.nPort; operation.secure = components.nScheme == Vendor::WinHttp::kINTERNET_SCHEME_HTTPS; return {}; } // 将请求头列表序列化为 WinHTTP 所需的宽字符串格式("Name: Value\r\n" 逐行拼接) auto build_request_headers(RequestOperation& operation) -> std::expected { operation.wide_headers.clear(); for (const auto& header : operation.request.headers) { if (header.name.empty()) { continue; } auto wide_name_result = to_wide_utf8(header.name, "request.headers.name"); if (!wide_name_result) { return std::unexpected(wide_name_result.error()); } auto wide_value_result = to_wide_utf8(header.value, "request.headers.value"); if (!wide_value_result) { return std::unexpected(wide_value_result.error()); } operation.wide_headers += wide_name_result.value(); operation.wide_headers += L": "; operation.wide_headers += wide_value_result.value(); operation.wide_headers += L"\r\n"; } return {}; } // 从 WinHTTP 句柄中读取 HTTP 状态码(如 200、404)并写入 response auto query_response_status(RequestOperation& operation) -> std::expected { Vendor::WinHttp::DWORD status_code = 0; Vendor::WinHttp::DWORD status_size = sizeof(status_code); if (!Vendor::WinHttp::WinHttpQueryHeaders( operation.request_handle, Vendor::WinHttp::kWINHTTP_QUERY_STATUS_CODE | Vendor::WinHttp::kWINHTTP_QUERY_FLAG_NUMBER, Vendor::WinHttp::kWINHTTP_HEADER_NAME_BY_INDEX, &status_code, &status_size, nullptr)) { return std::unexpected(make_winhttp_error("WinHttpQueryHeaders(status)")); } operation.response.status_code = static_cast(status_code); return {}; } // 查询并解析完整响应头列表,解析失败时静默跳过(状态码已单独提取) auto query_response_headers(RequestOperation& operation) -> void { Vendor::WinHttp::DWORD header_size = 0; (void)Vendor::WinHttp::WinHttpQueryHeaders( operation.request_handle, Vendor::WinHttp::kWINHTTP_QUERY_RAW_HEADERS_CRLF, Vendor::WinHttp::kWINHTTP_HEADER_NAME_BY_INDEX, nullptr, &header_size, nullptr); if (header_size == 0) { return; } std::wstring raw_headers(header_size / sizeof(wchar_t), L'\0'); if (!Vendor::WinHttp::WinHttpQueryHeaders(operation.request_handle, Vendor::WinHttp::kWINHTTP_QUERY_RAW_HEADERS_CRLF, Vendor::WinHttp::kWINHTTP_HEADER_NAME_BY_INDEX, raw_headers.data(), &header_size, nullptr)) { return; } if (!raw_headers.empty() && raw_headers.back() == L'\0') { raw_headers.pop_back(); } operation.response.headers = parse_raw_headers(raw_headers); } // 向 WinHTTP 查询当前可读字节数,触发后续 DATA_AVAILABLE 回调 auto request_more_data(std::shared_ptr operation) -> std::expected { if (!Vendor::WinHttp::WinHttpQueryDataAvailable(operation->request_handle, nullptr)) { return std::unexpected(make_winhttp_error("WinHttpQueryDataAvailable")); } return {}; } // 下载结束收尾:刷新关闭文件、发送最终进度通知、标记操作完成 auto complete_download(std::shared_ptr operation) -> void { auto finalize_result = finalize_file_download(*operation); if (!finalize_result) { complete_with_error(operation, finalize_result.error()); return; } emit_download_progress(*operation); complete_operation(operation, operation->response); } // 将回调逻辑派发到协程执行器线程,避免在 WinHTTP 系统线程上直接操作 operation 状态 template auto post_status_callback(std::shared_ptr operation, F&& fn) -> void { asio::post(operation->executor, [operation, fn = std::forward(fn)]() mutable { fn(); }); } // WinHTTP 核心异步回调函数:由系统底层触发,负责处理连接、收发数据等不同阶段的状态变更 auto CALLBACK winhttp_status_callback(Vendor::WinHttp::HINTERNET h_internet, Vendor::WinHttp::DWORD_PTR context, Vendor::WinHttp::DWORD internet_status, Vendor::WinHttp::LPVOID status_information, Vendor::WinHttp::DWORD status_information_length) -> void { auto* raw_operation = reinterpret_cast(context); auto operation = acquire_keepalive(raw_operation); if (!operation) { return; } switch (internet_status) { case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE: { post_status_callback(operation, [operation]() mutable { if (operation->completed.load()) { return; } // 请求发送完毕,开始等待并接收服务器的响应 if (!Vendor::WinHttp::WinHttpReceiveResponse(operation->request_handle, nullptr)) { complete_with_error(operation, make_winhttp_error("WinHttpReceiveResponse")); } }); break; } case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE: { post_status_callback(operation, [operation]() mutable { if (operation->completed.load()) { return; } // 响应头已可用,先提取 HTTP 状态码 (例如 200, 404) auto status_result = query_response_status(*operation); if (!status_result) { complete_with_error(operation, status_result.error()); return; } // 继续提取所有响应头并解析 query_response_headers(*operation); if (operation->download) { operation->download->total_bytes = find_content_length(operation->response); } // 尝试查询是否有可用的响应体数据 auto query_result = request_more_data(operation); if (!query_result) { complete_with_error(operation, query_result.error()); } }); break; } case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_DATA_AVAILABLE: { auto available_bytes = *reinterpret_cast(status_information); post_status_callback(operation, [operation, available_bytes]() mutable { if (operation->completed.load()) { return; } // 若可用数据为 0,说明响应体已经彻底接收完毕 if (available_bytes == 0) { complete_download(operation); return; } // 发起异步读取操作,将数据读入预分配的内部 buffer 中 auto bytes_to_read = static_cast( std::min(available_bytes, operation->read_buffer.size())); if (!Vendor::WinHttp::WinHttpReadData( operation->request_handle, operation->read_buffer.data(), bytes_to_read, nullptr)) { complete_with_error(operation, make_winhttp_error("WinHttpReadData")); } }); break; } case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_READ_COMPLETE: { auto bytes_read = static_cast(status_information_length); post_status_callback(operation, [operation, bytes_read]() mutable { if (operation->completed.load()) { return; } // 如果读取完成但读取到的字节为0,可能对端提前关闭,同样视为结束 if (bytes_read == 0) { complete_download(operation); return; } if (operation->download) { if (!operation->download->output_file.has_value()) { complete_with_error(operation, "Output file is not initialized: " + operation->download->output_path.string()); return; } operation->download->output_file->write(operation->read_buffer.data(), static_cast(bytes_read)); if (!operation->download->output_file->good()) { complete_with_error(operation, "Failed to write output file: " + operation->download->output_path.string()); return; } operation->download->downloaded_bytes += bytes_read; emit_download_progress(*operation); } else { // 把刚读到的数据追加到总的 response body 里 operation->response.body.append(operation->read_buffer.data(), bytes_read); } // 循环继续询问还有没有剩余的流数据 auto query_result = request_more_data(operation); if (!query_result) { complete_with_error(operation, query_result.error()); } }); break; } case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_REQUEST_ERROR: { auto async_result = *reinterpret_cast(status_information); post_status_callback(operation, [operation, async_result]() mutable { if (operation->completed.load()) { return; } complete_with_error(operation, std::format("WinHTTP async request error (api={}, error={})", async_result.dwResult, async_result.dwError)); }); break; } case Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HANDLE_CLOSING: { post_status_callback(operation, [operation, h_internet]() mutable { if (operation->request_handle == h_internet) { operation->request_handle = nullptr; operation->callback_registered = false; } // 只有确信 request_handle 被完全关闭且操作收尾后,才释放保活引用 if (operation->completed.load()) { release_keepalive(*operation); } }); break; } default: break; } } // 解析请求参数、创建且配置相应的 WinHTTP 连接和请求句柄,绑定异步回调后开始发送。 // 若此函数返回错误,说明请求完全未进入系统队列,调用方需自行释放 keepalive。 auto prepare_operation(State::HttpClientState& state, std::shared_ptr operation) -> std::expected { operation->request.method = normalize_method(operation->request.method); operation->request_body.assign(operation->request.body.begin(), operation->request.body.end()); auto method_result = to_wide_utf8(operation->request.method, "request.method"); if (!method_result) { return std::unexpected(method_result.error()); } operation->wide_method = std::move(method_result.value()); if (auto parse_result = parse_request_url(*operation); !parse_result) { return std::unexpected(parse_result.error()); } if (auto header_result = build_request_headers(*operation); !header_result) { return std::unexpected(header_result.error()); } // 第一步:创建一个到目标主机端口的连接 (Connect) operation->connect_handle = Vendor::WinHttp::WinHttpConnect( state.session.get(), operation->wide_host.c_str(), operation->port, 0); if (operation->connect_handle == nullptr) { return std::unexpected(make_winhttp_error("WinHttpConnect")); } // 第二步:利用上面建立的连接去初始化一个特定 URI 的请求句柄 (Request) Vendor::WinHttp::DWORD request_flags = operation->secure ? Vendor::WinHttp::kWINHTTP_FLAG_SECURE : 0; operation->request_handle = Vendor::WinHttp::WinHttpOpenRequest( operation->connect_handle, operation->wide_method.c_str(), operation->wide_path.c_str(), nullptr, Vendor::WinHttp::kWINHTTP_NO_REFERER, Vendor::WinHttp::kWINHTTP_DEFAULT_ACCEPT_TYPES, request_flags); if (operation->request_handle == nullptr) { close_connect_handle(*operation); return std::unexpected(make_winhttp_error("WinHttpOpenRequest")); } int connect_timeout = operation->request.connect_timeout_ms.value_or(state.connect_timeout_ms); int send_timeout = operation->request.send_timeout_ms.value_or(state.send_timeout_ms); int receive_timeout = operation->request.receive_timeout_ms.value_or(state.receive_timeout_ms); if (!Vendor::WinHttp::WinHttpSetTimeouts(operation->request_handle, state.resolve_timeout_ms, connect_timeout, send_timeout, receive_timeout)) { close_request_handle(*operation); close_connect_handle(*operation); return std::unexpected(make_winhttp_error("WinHttpSetTimeouts")); } // 绑定上下文:非常关键,将 operation 指针与该请求句柄关联,后续 WinHTTP // 回调才能拿到我们的操作上下文 Vendor::WinHttp::DWORD_PTR context = reinterpret_cast(operation.get()); if (!Vendor::WinHttp::WinHttpSetOption(operation->request_handle, Vendor::WinHttp::kWINHTTP_OPTION_CONTEXT_VALUE, &context, sizeof(context))) { close_request_handle(*operation); close_connect_handle(*operation); return std::unexpected(make_winhttp_error("WinHttpSetOption(context)")); } // 设置我们感兴趣的 WinHTTP 异步回调阶段,并挂载 winhttp_status_callback auto callback_flags = Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE | Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE | Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_DATA_AVAILABLE | Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_READ_COMPLETE | Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_REQUEST_ERROR | Vendor::WinHttp::kWINHTTP_CALLBACK_STATUS_HANDLE_CLOSING; auto callback_result = Vendor::WinHttp::WinHttpSetStatusCallback( operation->request_handle, winhttp_status_callback, callback_flags, 0); if (callback_result == Vendor::WinHttp::kWINHTTP_INVALID_STATUS_CALLBACK) { close_request_handle(*operation); close_connect_handle(*operation); return std::unexpected(make_winhttp_error("WinHttpSetStatusCallback")); } operation->callback_registered = true; const wchar_t* header_ptr = operation->wide_headers.empty() ? Vendor::WinHttp::kWINHTTP_NO_ADDITIONAL_HEADERS : operation->wide_headers.c_str(); auto header_len = operation->wide_headers.empty() ? 0 : static_cast(operation->wide_headers.size()); auto body_size = static_cast(operation->request_body.size()); void* request_data = body_size == 0 ? Vendor::WinHttp::kWINHTTP_NO_REQUEST_DATA : operation->request_body.data(); // 第三步:将请求头发往服务器,由于设定了 ASYNC 标志,此函数会立刻返回,后续流程交由系统回调处理 if (!Vendor::WinHttp::WinHttpSendRequest(operation->request_handle, header_ptr, header_len, request_data, body_size, body_size, 0)) { complete_with_error(operation, make_winhttp_error("WinHttpSendRequest")); } return {}; } // 通用 operation 执行:设保活、投递至 WinHTTP、挂起协程直至完成或中断。 // fetch 和 download_to_file 均通过此函数统一驱动请求生命周期。 auto execute_operation(State::HttpClientState& client_state, std::shared_ptr operation) -> asio::awaitable { { // 自引用保活:防止局部运行完后 shared_ptr 被回收导致在 WinHTTP 后台回调里出现空指针 std::lock_guard lock(operation->keepalive_mutex); operation->keepalive = operation; } // 投递进入 WinHTTP:如果这一步失败,说明完全没丢进系统队列,需直接移除保活引用并返回 if (auto prepare_result = prepare_operation(client_state, operation); !prepare_result) { release_keepalive(*operation); operation->result = std::unexpected(prepare_result.error()); co_return; } if (operation->completed.load()) { co_return; } // 让当前的协程(coroutine)在此挂起,直到底层完成全部网络通讯唤醒此 timer std::error_code wait_error; co_await operation->completion_timer->async_wait( asio::redirect_error(asio::use_awaitable, wait_error)); if (!operation->completed.load()) { complete_with_error(operation, "HTTP request interrupted before completion"); } } } // namespace Core::HttpClient::Detail namespace Core::HttpClient { // 初始化 HTTP 客户端全局状态,使用系统的自适应代理配置创建 WinHTTP 会话 (Session) auto initialize(Core::State::AppState& state) -> std::expected { if (!state.http_client) { return std::unexpected("HTTP client state is not initialized"); } if (state.http_client->is_initialized.load()) { return {}; } state.http_client->session = Vendor::WinHttp::UniqueHInternet{Vendor::WinHttp::WinHttpOpen( state.http_client->user_agent.c_str(), Vendor::WinHttp::kWINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, Vendor::WinHttp::kWINHTTP_NO_PROXY_NAME, Vendor::WinHttp::kWINHTTP_NO_PROXY_BYPASS, Vendor::WinHttp::kWINHTTP_FLAG_ASYNC)}; if (!state.http_client->session) { return std::unexpected("Failed to open WinHTTP async session"); } state.http_client->is_initialized = true; Logger().info("HTTP client initialized"); return {}; } // 关闭 HTTP 会话释放内核资源,后续请求将无法被挂起执行 auto shutdown(Core::State::AppState& state) -> void { if (!state.http_client) { return; } state.http_client->is_initialized = false; state.http_client->session = Vendor::WinHttp::UniqueHInternet{}; Logger().info("HTTP client shut down"); } // 核心异步 HTTP 请求:负责封装请求上下文,投递至 WinHTTP 进行处理并挂起当前协程直至完成 auto fetch(Core::State::AppState& state, const Core::HttpClient::Types::Request& request) -> asio::awaitable> { if (!state.http_client) { co_return std::unexpected("HTTP client state is not initialized"); } if (!state.http_client->is_initialized.load() || !state.http_client->session) { co_return std::unexpected("HTTP client is not initialized"); } if (request.url.empty()) { co_return std::unexpected("HTTP request URL is empty"); } auto executor = co_await asio::this_coro::executor; auto operation = std::make_shared(); operation->executor = executor; operation->completion_timer.emplace(executor); operation->completion_timer->expires_at((std::chrono::steady_clock::time_point::max)()); operation->request = request; co_await Detail::execute_operation(*state.http_client, operation); co_return operation->result; } // 便捷封装:执行 HTTP 抓取请求后,将接收到的响应体直接以二进制流写入本地文件中 auto download_to_file(Core::State::AppState& state, const Core::HttpClient::Types::Request& request, const std::filesystem::path& output_path, Core::HttpClient::Types::DownloadProgressCallback progress_callback) -> asio::awaitable> { if (!state.http_client) { co_return std::unexpected("HTTP client state is not initialized"); } if (!state.http_client->is_initialized.load() || !state.http_client->session) { co_return std::unexpected("HTTP client is not initialized"); } if (request.url.empty()) { co_return std::unexpected("HTTP request URL is empty"); } auto executor = co_await asio::this_coro::executor; auto operation = std::make_shared(); operation->executor = executor; operation->completion_timer.emplace(executor); operation->completion_timer->expires_at((std::chrono::steady_clock::time_point::max)()); operation->request = request; auto& dl = operation->download.emplace(); dl.output_path = output_path; dl.progress_callback = std::move(progress_callback); dl.output_file.emplace(output_path, std::ios::binary | std::ios::trunc); if (!dl.output_file->is_open()) { co_return std::unexpected("Failed to open output file: " + output_path.string()); } co_await Detail::execute_operation(*state.http_client, operation); if (!operation->result) { operation->download->output_file.reset(); co_return std::unexpected(operation->result.error()); } if (operation->response.status_code != 200) { co_return std::unexpected("HTTP error: " + std::to_string(operation->response.status_code)); } co_return std::expected{}; } } // namespace Core::HttpClient ================================================ FILE: src/core/http_client/http_client.ixx ================================================ module; #include export module Core.HttpClient; import std; import Core.State; import Core.HttpClient.Types; namespace Core::HttpClient { export auto initialize(Core::State::AppState& state) -> std::expected; export auto shutdown(Core::State::AppState& state) -> void; export auto fetch(Core::State::AppState& state, const Core::HttpClient::Types::Request& request) -> asio::awaitable>; export auto download_to_file( Core::State::AppState& state, const Core::HttpClient::Types::Request& request, const std::filesystem::path& output_path, Core::HttpClient::Types::DownloadProgressCallback progress_callback = nullptr) -> asio::awaitable>; } // namespace Core::HttpClient ================================================ FILE: src/core/http_client/state.ixx ================================================ module; export module Core.HttpClient.State; import std; import Core.HttpClient.Types; import Vendor.WinHttp; import ; export namespace Core::HttpClient::State { // 单次异步 HTTP 请求的完整运行时上下文。对象由协程侧创建,通过 keepalive 自引用延长生命周期, // 确保在 WinHTTP 后台回调结束前不被析构。 struct RequestOperation { // 自引用保活指针:在请求投递后置为自身,待操作彻底完成(包括句柄关闭回调)后清除。 std::shared_ptr keepalive; std::mutex keepalive_mutex; Core::HttpClient::Types::Request request; Core::HttpClient::Types::Response response; // 最终结果:初始为"未完成"错误态,由 complete_operation / complete_with_error 写入。 std::expected result = std::unexpected("Request is not completed"); // 协程执行器与完成通知定时器:timer 到期即唤醒挂起的协程。 asio::any_io_executor executor; std::optional completion_timer = std::nullopt; // WinHTTP 接口所需的 UTF-16 宽字符串,由 prepare_operation 在投递前填充。 std::wstring wide_url; std::wstring wide_method; std::wstring wide_host; std::wstring wide_path; std::wstring wide_headers; std::vector request_body; // 固定大小的读缓冲区,每次 WinHttpReadData 将数据写入此处。 std::array read_buffer{}; // 文件下载专用选项。有值表示当前请求为文件下载模式,响应体将流式写入文件而非内存。 struct DownloadOptions { std::filesystem::path output_path; std::optional output_file; std::uint64_t downloaded_bytes = 0; std::optional total_bytes; // 来自 Content-Length,服务器不保证提供 Core::HttpClient::Types::DownloadProgressCallback progress_callback; }; std::optional download; Vendor::WinHttp::HINTERNET connect_handle = nullptr; Vendor::WinHttp::HINTERNET request_handle = nullptr; Vendor::WinHttp::INTERNET_PORT port = 0; bool secure = false; bool callback_registered = false; // 已向 request_handle 注册状态回调 bool receive_started = false; std::atomic completed{false}; // 操作是否已进入完成状态(结果已写入) std::atomic waiter_notified{false}; // 协程唤醒通知是否已发出(防止重复触发) std::atomic close_requested{false}; // WinHttpCloseHandle 是否已调用(防止重复关闭) }; struct HttpClientState { Vendor::WinHttp::UniqueHInternet session; std::wstring user_agent = L"SpinningMomo/1.0"; int resolve_timeout_ms = 0; int connect_timeout_ms = 10'000; int send_timeout_ms = 30'000; int receive_timeout_ms = 30'000; std::atomic is_initialized{false}; }; } // namespace Core::HttpClient::State ================================================ FILE: src/core/http_client/types.ixx ================================================ module; export module Core.HttpClient.Types; import std; export namespace Core::HttpClient::Types { struct DownloadProgress { std::uint64_t downloaded_bytes = 0; std::optional total_bytes; }; using DownloadProgressCallback = std::function; struct Header { std::string name; std::string value; }; struct Request { std::string method = "GET"; std::string url; std::vector
headers; std::string body; std::optional connect_timeout_ms = std::nullopt; std::optional send_timeout_ms = std::nullopt; std::optional receive_timeout_ms = std::nullopt; }; struct Response { std::int32_t status_code = 0; std::string body; std::vector
headers; }; } // namespace Core::HttpClient::Types ================================================ FILE: src/core/http_server/http_server.cpp ================================================ module; #include module Core.HttpServer; import std; import Core.State; import Core.HttpServer.State; import Core.HttpServer.Routes; import Core.HttpServer.SseManager; import Utils.Logger; namespace Core::HttpServer { auto initialize(Core::State::AppState& state) -> std::expected { try { Logger().info("Initializing HTTP server on port {}", state.http_server->port); state.http_server->server_thread = std::jthread([&state]() { Logger().info("Starting HTTP server thread"); // 在线程中创建uWS::App实例,生命周期由线程管理 uWS::App app; Core::HttpServer::Routes::register_routes(state, app); // 仅监听本机回环地址,避免暴露到局域网 app.listen("127.0.0.1", state.http_server->port, [&state](auto* socket) { if (socket) { state.http_server->listen_socket = socket; state.http_server->is_running = true; Logger().info("HTTP server listening on 127.0.0.1:{}", state.http_server->port); } else { Logger().error("Failed to start HTTP server on 127.0.0.1:{}", state.http_server->port); state.http_server->is_running = false; } }); // 运行事件循环 if (state.http_server->is_running) { state.http_server->loop = uWS::Loop::get(); app.run(); } Logger().info("HTTP server thread finished"); }); return {}; } catch (const std::exception& e) { return std::unexpected(std::string("Failed to initialize HTTP server: ") + e.what()); } } auto shutdown(Core::State::AppState& state) -> void { if (!state.http_server || !state.http_server->is_running) { return; } Logger().info("Shutting down HTTP server"); auto active_sse = Core::HttpServer::SseManager::get_connection_count(state); Logger().info("Active SSE connections before shutdown: {}", active_sse); // 提前标记停止,避免 shutdown 过程中继续广播 SSE 事件 state.http_server->is_running = false; auto* loop = state.http_server->loop; auto* listen_socket = state.http_server->listen_socket; // 使用 defer 将关闭操作调度到事件循环线程 if (loop) { Logger().info("Scheduling SSE close and socket close"); loop->defer([&state, listen_socket]() { Core::HttpServer::SseManager::close_all_connections(state); if (listen_socket) { us_listen_socket_close(0, listen_socket); Logger().info("Listen socket closed"); } }); } else { Logger().warn("HTTP loop is null during shutdown; listen socket close was not scheduled"); } if (state.http_server->server_thread.joinable()) { state.http_server->server_thread.join(); } state.http_server->listen_socket = nullptr; state.http_server->loop = nullptr; auto remaining_sse = Core::HttpServer::SseManager::get_connection_count(state); Logger().info("Remaining SSE connections after shutdown: {}", remaining_sse); Logger().info("HTTP server shut down"); } auto get_sse_connection_count(const Core::State::AppState& state) -> size_t { return Core::HttpServer::SseManager::get_connection_count(state); } } // namespace Core::HttpServer ================================================ FILE: src/core/http_server/http_server.ixx ================================================ module; export module Core.HttpServer; import std; import Core.State; namespace Core::HttpServer { // 初始化HTTP服务器 export auto initialize(Core::State::AppState& state) -> std::expected; // 关闭服务器 export auto shutdown(Core::State::AppState& state) -> void; // 获取SSE连接数量 export auto get_sse_connection_count(const Core::State::AppState& state) -> size_t; } ================================================ FILE: src/core/http_server/routes.cpp ================================================ module; #include #include module Core.HttpServer.Routes; import std; import Core.State; import Core.HttpServer.State; import Core.HttpServer.SseManager; import Core.HttpServer.Static; import Core.Async; import Core.RPC; import Utils.Logger; import Vendor.BuildConfig; namespace Core::HttpServer::Routes { auto get_origin_header(auto* req) -> std::string { return std::string(req->getHeader("origin")); } auto is_local_origin_allowed(std::string_view origin, int port) -> bool { const auto localhost = std::format("http://localhost:{}", port); const auto loopback_v4 = std::format("http://127.0.0.1:{}", port); const auto loopback_v6 = std::format("http://[::1]:{}", port); return origin == localhost || origin == loopback_v4 || origin == loopback_v6; } auto is_origin_allowed(std::string_view origin, int port) -> bool { if (origin.empty()) { // 无 Origin 通常来自非浏览器本地请求。 return true; } // 开发模式放行所有 Origin,便于局域网/多设备联调。 if (Vendor::BuildConfig::is_debug_build()) { return true; } // 发布模式仅允许本机同端口来源。 return is_local_origin_allowed(origin, port); } auto write_cors_headers(auto* res, std::string_view origin) -> void { if (!origin.empty()) { res->writeHeader("Access-Control-Allow-Origin", origin); res->writeHeader("Vary", "Origin"); } res->writeHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res->writeHeader("Access-Control-Allow-Headers", "Content-Type"); } auto reject_forbidden(auto* res) -> void { res->writeStatus("403 Forbidden"); res->end("Forbidden"); } auto register_routes(Core::State::AppState& state, uWS::App& app) -> void { // 检查状态是否已初始化 if (!state.http_server) { Logger().error("HTTP server not initialized"); return; } // 注册RPC端点 app.post("/rpc", [&state](auto* res, auto* req) { auto origin = get_origin_header(req); if (!is_origin_allowed(origin, state.http_server->port)) { Logger().warn("Rejected RPC request due to disallowed origin: {}", origin.empty() ? "" : origin); reject_forbidden(res); return; } std::string buffer; res->onData([&state, buffer = std::move(buffer), origin = std::move(origin), res]( std::string_view data, bool last) mutable { buffer.append(data.data(), data.size()); if (last) { // 使用 cork 包裹整个异步操作,延长 res 的生命周期 res->cork([&state, buffer = std::move(buffer), origin = std::move(origin), res]() { // 获取事件循环 auto* loop = uWS::Loop::get(); // 在异步运行时中处理RPC请求 asio::co_spawn( *Core::Async::get_io_context(*state.async), [&state, buffer = std::move(buffer), origin = std::move(origin), res, loop]() -> asio::awaitable { try { // 处理RPC请求 auto response_json = co_await Core::RPC::process_request(state, buffer); // 在事件循环线程中发送响应 loop->defer([res, origin, response_json = std::move(response_json)]() { write_cors_headers(res, origin); res->writeHeader("Content-Type", "application/json"); res->writeStatus("200 OK"); res->end(response_json); }); } catch (const std::exception& e) { Logger().error("Error processing RPC request: {}", e.what()); std::string error_response = std::format(R"({{"error": "Internal server error: {}"}})", e.what()); loop->defer([res, origin, error_response = std::move(error_response)]() { write_cors_headers(res, origin); res->writeHeader("Content-Type", "application/json"); res->writeStatus("500 Internal Server Error"); res->end(error_response); }); } }, asio::detached); }); } }); // 连接中止时记录日志 res->onAborted([]() { Logger().debug("RPC request aborted"); }); }); // 注册SSE端点 app.get("/sse", [&state](auto* res, auto* req) { auto origin = get_origin_header(req); if (!is_origin_allowed(origin, state.http_server->port)) { Logger().warn("Rejected SSE request due to disallowed origin: {}", origin.empty() ? "" : origin); reject_forbidden(res); return; } Logger().info("New SSE connection request"); Core::HttpServer::SseManager::add_connection(state, res, std::move(origin)); }); // 配置CORS app.options("/*", [&state](auto* res, auto* req) { auto origin = get_origin_header(req); if (!is_origin_allowed(origin, state.http_server->port)) { reject_forbidden(res); return; } write_cors_headers(res, origin); res->writeStatus("204 No Content"); res->end(); }); // 静态文件服务(fallback路由) Core::HttpServer::Static::register_routes(state, app); } } // namespace Core::HttpServer::Routes ================================================ FILE: src/core/http_server/routes.ixx ================================================ module; #include export module Core.HttpServer.Routes; import std; import Core.State; namespace Core::HttpServer::Routes { // 注册所有路由 export auto register_routes(Core::State::AppState& state, uWS::App& app) -> void; } ================================================ FILE: src/core/http_server/sse_manager.cpp ================================================ module; #include module Core.HttpServer.SseManager; import std; import Core.State; import Core.HttpServer.State; import Core.HttpServer.Types; import Utils.Logger; namespace Core::HttpServer::SseManager { auto format_sse_message(const std::string& event_data) -> std::string { return std::format("data: {}\n\n", event_data); } auto add_connection(Core::State::AppState& state, uWS::HttpResponse* response, std::string allowed_origin) -> void { if (!state.http_server || !response) { Logger().error("Cannot add SSE connection: invalid state or response"); return; } auto& connections = state.http_server->sse_connections; auto& counter = state.http_server->client_counter; auto& mtx = state.http_server->sse_connections_mutex; auto connection = std::make_shared(); connection->response = response; connection->client_id = std::to_string(++counter); connection->connected_at = std::chrono::system_clock::now(); response->onAborted( [&state, client_id = connection->client_id]() { remove_connection(state, client_id); }); response->writeStatus("200 OK"); response->writeHeader("Content-Type", "text/event-stream"); response->writeHeader("Cache-Control", "no-cache"); response->writeHeader("Connection", "keep-alive"); if (!allowed_origin.empty()) { response->writeHeader("Access-Control-Allow-Origin", allowed_origin); response->writeHeader("Vary", "Origin"); } response->write(": connected\n\n"); size_t current_count = 0; { std::lock_guard lock(mtx); connections.push_back(connection); current_count = connections.size(); } Logger().info("New SSE connection established. client_id={}, total={}", connection->client_id, current_count); } auto remove_connection(Core::State::AppState& state, const std::string& client_id) -> void { if (!state.http_server) { return; } auto& connections = state.http_server->sse_connections; auto& mtx = state.http_server->sse_connections_mutex; std::lock_guard lock(mtx); auto old_size = connections.size(); auto it = std::remove_if(connections.begin(), connections.end(), [&client_id](const std::shared_ptr& conn) { if (conn && conn->client_id == client_id) { conn->is_closed = true; return true; } return false; }); connections.erase(it, connections.end()); if (connections.size() < old_size) { Logger().info("SSE connection removed. client_id={}, total={}", client_id, connections.size()); } } auto close_all_connections(Core::State::AppState& state) -> void { if (!state.http_server) { return; } auto& connections = state.http_server->sse_connections; auto& mtx = state.http_server->sse_connections_mutex; std::vector> snapshot; { std::lock_guard lock(mtx); snapshot.reserve(connections.size()); for (const auto& conn : connections) { if (!conn) { continue; } conn->is_closed = true; snapshot.push_back(conn); } connections.clear(); } size_t closed_count = 0; for (const auto& conn : snapshot) { if (!conn || !conn->response) { continue; } conn->response->end(); ++closed_count; } Logger().info("Closed {} SSE connections during shutdown", closed_count); } auto broadcast_event(Core::State::AppState& state, const std::string& event_data) -> void { if (!state.http_server || !state.http_server->is_running) { return; } auto* loop = state.http_server->loop; if (!loop) { return; } auto sse_message = format_sse_message(event_data); loop->defer([&state, sse_message = std::move(sse_message)]() { if (!state.http_server) { return; } auto& connections = state.http_server->sse_connections; auto& mtx = state.http_server->sse_connections_mutex; std::vector> snapshot; { std::lock_guard lock(mtx); snapshot.reserve(connections.size()); for (const auto& conn : connections) { if (conn && !conn->is_closed) { snapshot.push_back(conn); } } } if (snapshot.empty()) { return; } for (const auto& conn : snapshot) { if (!conn || !conn->response || conn->is_closed) { continue; } const auto ok = conn->response->write(sse_message); if (!ok) { Logger().warn("SSE write reported backpressure for client {}", conn->client_id); } } }); } auto get_connection_count(const Core::State::AppState& state) -> size_t { if (!state.http_server) { return 0; } auto& connections = state.http_server->sse_connections; auto& mtx = state.http_server->sse_connections_mutex; std::lock_guard lock(mtx); return connections.size(); } } // namespace Core::HttpServer::SseManager ================================================ FILE: src/core/http_server/sse_manager.ixx ================================================ module; #include export module Core.HttpServer.SseManager; import std; import Core.State; namespace Core::HttpServer::SseManager { // 添加 SSE 连接 export auto add_connection(Core::State::AppState& state, uWS::HttpResponse* response, std::string allowed_origin = "") -> void; // 移除 SSE 连接 export auto remove_connection(Core::State::AppState& state, const std::string& client_id) -> void; // 关闭所有 SSE 连接(应在 HTTP loop 线程调用) export auto close_all_connections(Core::State::AppState& state) -> void; // 广播事件到所有 SSE 客户端(线程安全,内部会切换到 HTTP loop 线程) export auto broadcast_event(Core::State::AppState& state, const std::string& event_data) -> void; // 获取 SSE 连接数量 export auto get_connection_count(const Core::State::AppState& state) -> size_t; } // namespace Core::HttpServer::SseManager ================================================ FILE: src/core/http_server/state.ixx ================================================ module; #include export module Core.HttpServer.State; import std; import Core.HttpServer.Types; export namespace Core::HttpServer::State { // HTTP服务器状态 struct HttpServerState { // 服务器核心 std::jthread server_thread{}; us_listen_socket_t* listen_socket{nullptr}; uWS::Loop* loop{nullptr}; // SSE连接管理 std::vector> sse_connections; std::atomic client_counter{0}; std::mutex sse_connections_mutex; std::atomic is_running{false}; // 服务器配置 int port{51206}; // 路径解析器注册表 Types::ResolverRegistry path_resolvers; }; } // namespace Core::HttpServer::State ================================================ FILE: src/core/http_server/static.cpp ================================================ module; #include #include module Core.HttpServer.Static; import std; import Core.State; import Core.HttpServer.Types; import Core.Async; import Utils.File; import Utils.File.Mime; import Utils.Path; import Utils.Logger; import Utils.Time; namespace Core::HttpServer::Static { auto register_path_resolver(Core::State::AppState& state, std::string prefix, Types::PathResolver resolver) -> void { if (!state.http_server) { Logger().error("HttpServer state not initialized, cannot register path resolver"); return; } auto& registry = state.http_server->path_resolvers; std::unique_lock lock(registry.write_mutex); auto current = registry.resolvers.load(); auto new_resolvers = std::make_shared>(*current); new_resolvers->push_back({std::move(prefix), std::move(resolver)}); registry.resolvers.store(new_resolvers); Logger().debug("Registered custom path resolver for: {}", prefix); } auto unregister_path_resolver(Core::State::AppState& state, std::string_view prefix) -> void { if (!state.http_server) { return; } auto& registry = state.http_server->path_resolvers; std::unique_lock lock(registry.write_mutex); auto current = registry.resolvers.load(); auto new_resolvers = std::make_shared>(*current); std::erase_if(*new_resolvers, [prefix](const auto& entry) { return entry.prefix == prefix; }); registry.resolvers.store(new_resolvers); Logger().debug("Unregistered path resolver for: {}", prefix); } auto try_custom_resolve(Core::State::AppState& state, std::string_view url_path) -> std::optional { if (!state.http_server) { return std::nullopt; } auto& registry = state.http_server->path_resolvers; auto resolvers = registry.resolvers.load(); for (const auto& entry : *resolvers) { if (url_path.starts_with(entry.prefix)) { auto result = entry.resolver(url_path); if (result.has_value()) { return result; } } } return std::nullopt; } auto is_safe_path(const std::filesystem::path& path, const std::filesystem::path& base_path) -> std::expected { try { std::filesystem::path normalized_path = path.lexically_normal(); std::filesystem::path normalized_base = base_path.lexically_normal(); std::string path_str = normalized_path.string(); std::string base_str = normalized_base.string(); // 检查路径是否以基础路径开头,或者路径等于基础路径 bool is_safe = path_str.rfind(base_str, 0) == 0 || normalized_path == normalized_base; return is_safe; } catch (const std::exception& e) { return std::unexpected("Failed to check path safety: " + std::string(e.what())); } } // 获取针对不同文件类型的缓存时间 auto get_cache_duration(const std::string& extension) -> std::chrono::seconds { // HTML文件:短缓存,便于开发调试 if (extension == ".html") return std::chrono::seconds{60}; // CSS/JS:中等缓存 if (extension == ".css" || extension == ".js") return std::chrono::seconds{300}; // 图片/字体:长缓存 if (extension == ".png" || extension == ".jpg" || extension == ".jpeg" || extension == ".svg" || extension == ".woff" || extension == ".woff2" || extension == ".webp") { return std::chrono::seconds{3600}; } // 默认:短缓存 return std::chrono::seconds{180}; } // 路径解析 auto resolve_file_path(const std::string& url_path) -> std::filesystem::path { auto web_root = Utils::Path::GetEmbeddedWebRootDirectory().value_or("."); auto clean_path = url_path == "/" ? "/index.html" : url_path; if (clean_path.ends_with("/")) clean_path += "index.html"; return web_root / clean_path.substr(1); // 移除开头的'/' } // 获取web根目录 auto get_web_root() -> std::filesystem::path { return Utils::Path::GetEmbeddedWebRootDirectory().value_or("."); } // ---- Range 请求: