From 69be8eb59a479fb671dfcd50cd1d6186a2080952 Mon Sep 17 00:00:00 2001 From: lei Date: Sun, 31 May 2026 00:01:54 +0800 Subject: [PATCH 1/2] update --- AGENTS.md | 39 +- CLAUDE.md | 57 + README.md | 165 +- apps/README.md | 44 +- apps/backend/src/server.ts | 482 -- apps/daemon/README.md | 34 + apps/daemon/package.json | 30 + apps/daemon/src/diagnostics.ts | 25 + apps/daemon/src/index.ts | 43 + apps/daemon/src/relay/client.ts | 119 + apps/daemon/src/relay/commandHandler.ts | 143 + apps/daemon/src/relay/queryHandler.ts | 245 + apps/daemon/src/relay/state.ts | 50 + apps/daemon/src/relay/taskSnapshots.ts | 168 + .../src/repositories}/ai-api-config.test.ts | 18 +- .../daemon/src/repositories}/config.ts | 72 +- .../daemon/src/repositories}/debug-events.ts | 0 .../legacy-storage-migration.test.ts | 126 + apps/daemon/src/repositories/migration.ts | 159 + .../src/repositories}/skills-storage.test.ts | 16 +- .../daemon/src/repositories}/skills.ts | 0 .../daemon/src/repositories}/state.ts | 1 + .../repositories}/workflow-storage.test.ts | 16 +- .../daemon/src/repositories}/workflow.ts | 2 +- .../repositories}/workfolders-storage.test.ts | 72 +- .../daemon/src/repositories}/workfolders.ts | 6 +- .../daemon/src/runtime}/claude.ts | 5 +- .../runtime}/langgraph-runtime/app-adapter.ts | 21 +- .../runtime}/langgraph-runtime/artifacts.ts | 0 .../runtime/langgraph-runtime/builder.test.ts | 120 + .../src/runtime}/langgraph-runtime/builder.ts | 28 +- .../src/runtime}/langgraph-runtime/dsl.ts | 0 .../langgraph-runtime/file-checkpointer.ts | 0 .../src/runtime}/langgraph-runtime/index.ts | 0 .../sdk-agent-adapter.test.ts | 46 +- .../langgraph-runtime/sdk-agent-adapter.ts | 11 +- .../langgraph-runtime}/workflow-dsl.test.ts | 2 +- .../src/runtime}/workflow-runtime.ts | 28 +- .../src/runtime}/worktree-config.test.ts | 2 +- .../daemon/src/runtime}/worktree.ts | 0 apps/daemon/src/services/settingsService.ts | 35 + apps/daemon/src/services/skillService.ts | 71 + apps/daemon/src/services/systemService.ts | 54 + apps/daemon/src/services/taskService.ts | 85 + apps/daemon/src/services/workflowService.ts | 173 + apps/daemon/src/services/workfolderService.ts | 43 + apps/daemon/tsconfig.json | 18 + apps/desktop/connector/src/index.ts | 524 -- apps/desktop/electron/api.ts | 438 -- apps/desktop/electron/main.ts | 272 - apps/desktop/electron/preload.cts | 49 - .../workflowController.ts | 399 -- .../workfoldersController.ts | 134 - .../local-server-controllers/wsController.ts | 51 - apps/desktop/renderer/src/App.css | 728 --- .../renderer/src/components/window-chrome.tsx | 5 - apps/desktop/renderer/src/lib/api-client.ts | 180 - .../renderer/src/stores/workflowStore.test.ts | 50 - apps/desktop/server.ts | 65 - apps/mobile/.gitignore | 45 - apps/mobile/.metadata | 33 - apps/mobile/README.md | 16 - apps/mobile/analysis_options.yaml | 28 - apps/mobile/android/.gitignore | 13 - apps/mobile/android/app/build.gradle | 44 - .../android/app/src/debug/AndroidManifest.xml | 7 - .../android/app/src/main/AndroidManifest.xml | 45 - .../dev_workflow_mobile/MainActivity.kt | 5 - .../res/drawable-v21/launch_background.xml | 12 - .../main/res/drawable/launch_background.xml | 12 - .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 544 -> 0 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 442 -> 0 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 721 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 1031 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 1443 -> 0 bytes .../app/src/main/res/values-night/styles.xml | 18 - .../app/src/main/res/values/styles.xml | 18 - .../app/src/profile/AndroidManifest.xml | 7 - apps/mobile/android/build.gradle | 18 - apps/mobile/android/gradle.properties | 3 - .../gradle/wrapper/gradle-wrapper.properties | 5 - apps/mobile/android/settings.gradle | 25 - apps/mobile/ios/.gitignore | 34 - .../mobile/ios/Flutter/AppFrameworkInfo.plist | 26 - apps/mobile/ios/Flutter/Debug.xcconfig | 2 - apps/mobile/ios/Flutter/Release.xcconfig | 2 - apps/mobile/ios/Podfile | 44 - apps/mobile/ios/Podfile.lock | 29 - .../ios/Runner.xcodeproj/project.pbxproj | 731 --- .../contents.xcworkspacedata | 7 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - .../xcshareddata/xcschemes/Runner.xcscheme | 98 - .../contents.xcworkspacedata | 10 - .../xcshareddata/IDEWorkspaceChecks.plist | 8 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - apps/mobile/ios/Runner/AppDelegate.swift | 13 - .../AppIcon.appiconset/Contents.json | 122 - .../Icon-App-1024x1024@1x.png | Bin 10932 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 295 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 450 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 282 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 462 -> 0 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 704 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 406 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 586 -> 0 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 862 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 862 -> 0 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 1674 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 762 -> 0 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 1226 -> 0 bytes .../Icon-App-83.5x83.5@2x.png | Bin 1418 -> 0 bytes .../LaunchImage.imageset/Contents.json | 23 - .../LaunchImage.imageset/LaunchImage.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 68 -> 0 bytes .../LaunchImage.imageset/README.md | 5 - .../Runner/Base.lproj/LaunchScreen.storyboard | 37 - .../ios/Runner/Base.lproj/Main.storyboard | 26 - apps/mobile/ios/Runner/Info.plist | 51 - .../ios/Runner/Runner-Bridging-Header.h | 1 - apps/mobile/ios/RunnerTests/RunnerTests.swift | 12 - apps/mobile/lib/main.dart | 2080 -------- apps/mobile/pubspec.lock | 543 -- apps/mobile/pubspec.yaml | 92 - apps/server/README.md | 20 + apps/server/package.json | 24 + apps/server/src/index.ts | 701 +++ apps/server/tsconfig.json | 18 + apps/{desktop/renderer => web}/index.html | 0 apps/{desktop/renderer => web}/package.json | 3 +- apps/{desktop/renderer => web}/src/App.tsx | 4 +- .../renderer => web}/src/StepDetail.tsx | 4 +- .../src/WorkflowEditor.test.tsx | 18 +- .../renderer => web}/src/WorkflowEditor.tsx | 48 +- .../src/components/SkillManager.tsx | 6 +- .../src/components/WorkflowDebugPanel.tsx | 2 +- .../src/components/back-button.tsx | 2 +- .../src/components/i18n-provider.tsx | 25 +- apps/web/src/components/language-toggle.tsx | 19 + .../src/components/theme-provider.tsx | 0 .../src/components/theme-toggle.tsx | 2 +- .../src/components/ui/badge.tsx | 0 .../src/components/ui/button.tsx | 2 +- .../src/components/ui/input.tsx | 2 +- .../src/components/ui/select.tsx | 2 +- .../src/components/ui/tooltip.tsx | 0 apps/{desktop/renderer => web}/src/index.css | 103 +- apps/web/src/lib/api-client.ts | 178 + .../renderer => web}/src/lib/debug-events.ts | 19 +- .../{desktop/renderer => web}/src/lib/i18n.ts | 22 - .../renderer => web}/src/lib/utils.ts | 0 apps/{desktop/renderer => web}/src/main.tsx | 0 .../renderer => web}/src/pages/HomePage.tsx | 47 +- .../src/pages/SettingsPage.tsx | 133 +- .../src/pages/SettingsPage.workflow.test.tsx | 18 +- .../renderer => web}/src/pages/TaskPage.tsx | 12 +- .../src/stores/configStore.ts | 68 +- .../src/stores/workflowEditorStore.test.ts | 0 .../src/stores/workflowEditorStore.ts | 0 apps/web/src/stores/workflowStore.test.ts | 107 + .../src/stores/workflowStore.ts | 106 +- .../renderer => web}/src/test/setup.ts | 0 .../renderer => web}/src/vite-env.d.ts | 1 - apps/{desktop/renderer => web}/vite.config.ts | 8 +- docs/app-package-dependency-analysis.zh.md | 172 + docs/workflows/default-claude-code.json | 2 +- docs/workflows/default-codex.json | 2 +- .../superpowers-development-workflow.json | 4 +- forge.config.js | 36 - package.json | 69 +- packages/protocol/README.md | 56 + packages/protocol/package.json | 23 + packages/protocol/src/index.test.ts | 106 + packages/protocol/src/index.ts | 382 ++ packages/protocol/tsconfig.json | 17 + pnpm-lock.yaml | 4464 +---------------- pnpm-workspace.yaml | 8 +- scripts/dev-desktop-instance.ts | 164 - tsconfig.preload.json | 14 - 181 files changed, 4335 insertions(+), 13051 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 apps/backend/src/server.ts create mode 100644 apps/daemon/README.md create mode 100644 apps/daemon/package.json create mode 100644 apps/daemon/src/diagnostics.ts create mode 100644 apps/daemon/src/index.ts create mode 100644 apps/daemon/src/relay/client.ts create mode 100644 apps/daemon/src/relay/commandHandler.ts create mode 100644 apps/daemon/src/relay/queryHandler.ts create mode 100644 apps/daemon/src/relay/state.ts create mode 100644 apps/daemon/src/relay/taskSnapshots.ts rename apps/{desktop/renderer/src/lib => daemon/src/repositories}/ai-api-config.test.ts (85%) rename {packages/core-models => apps/daemon/src/repositories}/config.ts (72%) rename {packages/core-models => apps/daemon/src/repositories}/debug-events.ts (100%) create mode 100644 apps/daemon/src/repositories/legacy-storage-migration.test.ts create mode 100644 apps/daemon/src/repositories/migration.ts rename apps/{desktop/renderer/src/lib => daemon/src/repositories}/skills-storage.test.ts (87%) rename {packages/core-models => apps/daemon/src/repositories}/skills.ts (100%) rename {packages/core-models => apps/daemon/src/repositories}/state.ts (99%) rename apps/{desktop/renderer/src/lib => daemon/src/repositories}/workflow-storage.test.ts (83%) rename {packages/core-models => apps/daemon/src/repositories}/workflow.ts (99%) rename apps/{desktop/renderer/src/lib => daemon/src/repositories}/workfolders-storage.test.ts (56%) rename {packages/core-models => apps/daemon/src/repositories}/workfolders.ts (98%) rename {packages/core-lib => apps/daemon/src/runtime}/claude.ts (96%) rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/app-adapter.ts (95%) rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/artifacts.ts (100%) create mode 100644 apps/daemon/src/runtime/langgraph-runtime/builder.test.ts rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/builder.ts (91%) rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/dsl.ts (100%) rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/file-checkpointer.ts (100%) rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/index.ts (100%) rename apps/{desktop/renderer/src/lib => daemon/src/runtime/langgraph-runtime}/sdk-agent-adapter.test.ts (75%) rename {packages/core-lib => apps/daemon/src/runtime}/langgraph-runtime/sdk-agent-adapter.ts (98%) rename apps/{desktop/renderer/src/lib => daemon/src/runtime/langgraph-runtime}/workflow-dsl.test.ts (96%) rename apps/{desktop/electron => daemon/src/runtime}/workflow-runtime.ts (97%) rename apps/{desktop/renderer/src/lib => daemon/src/runtime}/worktree-config.test.ts (92%) rename {packages/core-lib => apps/daemon/src/runtime}/worktree.ts (100%) create mode 100644 apps/daemon/src/services/settingsService.ts create mode 100644 apps/daemon/src/services/skillService.ts create mode 100644 apps/daemon/src/services/systemService.ts create mode 100644 apps/daemon/src/services/taskService.ts create mode 100644 apps/daemon/src/services/workflowService.ts create mode 100644 apps/daemon/src/services/workfolderService.ts create mode 100644 apps/daemon/tsconfig.json delete mode 100644 apps/desktop/connector/src/index.ts delete mode 100644 apps/desktop/electron/api.ts delete mode 100644 apps/desktop/electron/main.ts delete mode 100644 apps/desktop/electron/preload.cts delete mode 100644 apps/desktop/local-server-controllers/workflowController.ts delete mode 100644 apps/desktop/local-server-controllers/workfoldersController.ts delete mode 100644 apps/desktop/local-server-controllers/wsController.ts delete mode 100644 apps/desktop/renderer/src/App.css delete mode 100644 apps/desktop/renderer/src/components/window-chrome.tsx delete mode 100644 apps/desktop/renderer/src/lib/api-client.ts delete mode 100644 apps/desktop/renderer/src/stores/workflowStore.test.ts delete mode 100644 apps/desktop/server.ts delete mode 100644 apps/mobile/.gitignore delete mode 100644 apps/mobile/.metadata delete mode 100644 apps/mobile/README.md delete mode 100644 apps/mobile/analysis_options.yaml delete mode 100644 apps/mobile/android/.gitignore delete mode 100644 apps/mobile/android/app/build.gradle delete mode 100644 apps/mobile/android/app/src/debug/AndroidManifest.xml delete mode 100644 apps/mobile/android/app/src/main/AndroidManifest.xml delete mode 100644 apps/mobile/android/app/src/main/kotlin/com/example/dev_workflow_mobile/MainActivity.kt delete mode 100644 apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml delete mode 100644 apps/mobile/android/app/src/main/res/drawable/launch_background.xml delete mode 100644 apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png delete mode 100644 apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png delete mode 100644 apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 apps/mobile/android/app/src/main/res/values-night/styles.xml delete mode 100644 apps/mobile/android/app/src/main/res/values/styles.xml delete mode 100644 apps/mobile/android/app/src/profile/AndroidManifest.xml delete mode 100644 apps/mobile/android/build.gradle delete mode 100644 apps/mobile/android/gradle.properties delete mode 100644 apps/mobile/android/gradle/wrapper/gradle-wrapper.properties delete mode 100644 apps/mobile/android/settings.gradle delete mode 100644 apps/mobile/ios/.gitignore delete mode 100644 apps/mobile/ios/Flutter/AppFrameworkInfo.plist delete mode 100644 apps/mobile/ios/Flutter/Debug.xcconfig delete mode 100644 apps/mobile/ios/Flutter/Release.xcconfig delete mode 100644 apps/mobile/ios/Podfile delete mode 100644 apps/mobile/ios/Podfile.lock delete mode 100644 apps/mobile/ios/Runner.xcodeproj/project.pbxproj delete mode 100644 apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme delete mode 100644 apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata delete mode 100644 apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 apps/mobile/ios/Runner/AppDelegate.swift delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png delete mode 100644 apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md delete mode 100644 apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard delete mode 100644 apps/mobile/ios/Runner/Base.lproj/Main.storyboard delete mode 100644 apps/mobile/ios/Runner/Info.plist delete mode 100644 apps/mobile/ios/Runner/Runner-Bridging-Header.h delete mode 100644 apps/mobile/ios/RunnerTests/RunnerTests.swift delete mode 100644 apps/mobile/lib/main.dart delete mode 100644 apps/mobile/pubspec.lock delete mode 100644 apps/mobile/pubspec.yaml create mode 100644 apps/server/README.md create mode 100644 apps/server/package.json create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/tsconfig.json rename apps/{desktop/renderer => web}/index.html (100%) rename apps/{desktop/renderer => web}/package.json (92%) rename apps/{desktop/renderer => web}/src/App.tsx (82%) rename apps/{desktop/renderer => web}/src/StepDetail.tsx (99%) rename apps/{desktop/renderer => web}/src/WorkflowEditor.test.tsx (91%) rename apps/{desktop/renderer => web}/src/WorkflowEditor.tsx (97%) rename apps/{desktop/renderer => web}/src/components/SkillManager.tsx (94%) rename apps/{desktop/renderer => web}/src/components/WorkflowDebugPanel.tsx (99%) rename apps/{desktop/renderer => web}/src/components/back-button.tsx (70%) rename apps/{desktop/renderer => web}/src/components/i18n-provider.tsx (67%) create mode 100644 apps/web/src/components/language-toggle.tsx rename apps/{desktop/renderer => web}/src/components/theme-provider.tsx (100%) rename apps/{desktop/renderer => web}/src/components/theme-toggle.tsx (94%) rename apps/{desktop/renderer => web}/src/components/ui/badge.tsx (100%) rename apps/{desktop/renderer => web}/src/components/ui/button.tsx (89%) rename apps/{desktop/renderer => web}/src/components/ui/input.tsx (69%) rename apps/{desktop/renderer => web}/src/components/ui/select.tsx (82%) rename apps/{desktop/renderer => web}/src/components/ui/tooltip.tsx (100%) rename apps/{desktop/renderer => web}/src/index.css (66%) create mode 100644 apps/web/src/lib/api-client.ts rename apps/{desktop/renderer => web}/src/lib/debug-events.ts (71%) rename apps/{desktop/renderer => web}/src/lib/i18n.ts (95%) rename apps/{desktop/renderer => web}/src/lib/utils.ts (100%) rename apps/{desktop/renderer => web}/src/main.tsx (100%) rename apps/{desktop/renderer => web}/src/pages/HomePage.tsx (92%) rename apps/{desktop/renderer => web}/src/pages/SettingsPage.tsx (76%) rename apps/{desktop/renderer => web}/src/pages/SettingsPage.workflow.test.tsx (89%) rename apps/{desktop/renderer => web}/src/pages/TaskPage.tsx (99%) rename apps/{desktop/renderer => web}/src/stores/configStore.ts (60%) rename apps/{desktop/renderer => web}/src/stores/workflowEditorStore.test.ts (100%) rename apps/{desktop/renderer => web}/src/stores/workflowEditorStore.ts (100%) create mode 100644 apps/web/src/stores/workflowStore.test.ts rename apps/{desktop/renderer => web}/src/stores/workflowStore.ts (92%) rename apps/{desktop/renderer => web}/src/test/setup.ts (100%) rename apps/{desktop/renderer => web}/src/vite-env.d.ts (73%) rename apps/{desktop/renderer => web}/vite.config.ts (63%) create mode 100644 docs/app-package-dependency-analysis.zh.md delete mode 100644 forge.config.js create mode 100644 packages/protocol/README.md create mode 100644 packages/protocol/package.json create mode 100644 packages/protocol/src/index.test.ts create mode 100644 packages/protocol/src/index.ts create mode 100644 packages/protocol/tsconfig.json delete mode 100644 scripts/dev-desktop-instance.ts delete mode 100644 tsconfig.preload.json diff --git a/AGENTS.md b/AGENTS.md index 37a5e1d..56eb146 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,12 @@ ## Project Structure & Module Organization -This `pnpm` monorepo contains a desktop-first workflow app, relay backend, connector, and Flutter client. - -- `apps/desktop/electron/`: Electron main process and preload. -- `apps/desktop/renderer/src/`: React UI: `pages/`, `components/`, `stores/`, and `lib/`. -- `apps/desktop/local-server-controllers/`: Express/WebSocket controllers. -- `apps/desktop/connector/`: bridge for local state sync and remote commands. -- `apps/backend/`: cloud relay backend, default port `8787`. -- `apps/mobile/`: Flutter app, including `lib/` and `test/`. -- `packages/core-models/`, `packages/core-lib/`: shared models and runtime helpers. +This `pnpm` monorepo contains a web workflow app, local daemon, and stateless relay server. + +- `apps/web/src/`: React UI: `pages/`, `components/`, `stores/`, and `lib/`. +- `apps/daemon/`: local daemon for workflow/task data and outbound relay connection. +- `apps/server/`: stateless relay server, default port `8787`. +- `packages/protocol/`: shared daemon/server/web protocol contracts. - `docs/`, `references/`, `examples/`, and demo folders hold notes and experiments. ## Build, Test, and Development Commands @@ -18,25 +15,25 @@ This `pnpm` monorepo contains a desktop-first workflow app, relay backend, conne Use Node `25` from `.nvmrc`; run `pnpm` at the repo root. - `pnpm install`: install workspace dependencies. -- `pnpm dev:renderer`: start Vite on `127.0.0.1:5173`. -- `pnpm dev:desktop`: run renderer, local server, connector, and Electron. -- `pnpm dev:desktop-stack`: run server, backend, and connector. -- `pnpm dev:backend`: start the relay backend. -- `pnpm dev:server`: start the local workflow server on port `3000`. -- `pnpm dev:connector`: start the desktop connector. -- `pnpm build:desktop`: build `apps/desktop/renderer/dist`. -- `pnpm start:desktop`: open Electron with the existing renderer build. -- `cd apps/mobile && flutter pub get && flutter analyze`: install and check Flutter code. +- `pnpm dev:web`: start Vite on `127.0.0.1:5173`. +- `pnpm dev:all`: run web app, relay server, and daemon. +- `pnpm dev:stack`: run relay server and daemon. +- `pnpm dev:relay-server`: start the relay server. +- `pnpm dev:server`: alias for the stateless relay server. +- `pnpm dev:daemon`: start the local daemon; it connects outbound to the relay server. +- `pnpm build:web`: build `apps/web/dist`. +- `pnpm build:daemon`: check the daemon package. +- `pnpm build:server`: check the relay server package. `pnpm test` fails intentionally because no Node test suite is configured. ## Coding Style & Naming Conventions -Keep changes surgical and match nearby code. Use ES modules for Node and React. JavaScript and JSX use 2-space indentation, double quotes, and semicolons. Name React components with `PascalCase`, such as `HomePage.jsx`; use `camelCase` for variables and functions; keep Node filenames lowercase, such as `workflowController.mjs`. For Flutter, follow standard Dart formatting. +Keep changes surgical and match nearby code. Use ES modules for Node and React. JavaScript and JSX use 2-space indentation, double quotes, and semicolons. Name React components with `PascalCase`, such as `HomePage.jsx`; use `camelCase` for variables and functions; keep Node filenames lowercase, such as `workflowController.mjs`. ## Testing Guidelines -For desktop UI changes, run `pnpm build:desktop`; smoke-test with `pnpm dev:desktop` or `pnpm start:desktop` when behavior changes. For backend, connector, or server changes, manually verify the affected API or WebSocket path with the relevant `pnpm dev:*` command. For mobile changes, run `flutter analyze` under `apps/mobile`. If tests are added, place them near the feature or under `tests/` as `*.test.*`. +For web UI changes, run `pnpm build:web`; smoke-test with `pnpm dev:all` when behavior changes. For daemon or server changes, manually verify the affected API or WebSocket path with the relevant `pnpm dev:*` command. If tests are added, place them near the feature or under `tests/` as `*.test.*`. ## Commit & Pull Request Guidelines @@ -44,4 +41,4 @@ Use short, imperative commit subjects. History includes plain subjects like `upd ## Configuration Notes -Treat `apps/desktop/renderer/dist` as build output; edit source under `apps/desktop/renderer/src`. Keep mobile access and relay behavior desktop-first unless the task expands backend or mobile ownership. +Treat `apps/web/dist` as build output; edit source under `apps/web/src`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a89ca71 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- Install dependencies: `pnpm install` +- Start full local stack: `pnpm dev:all` +- Start web app only: `pnpm dev:web` +- Start relay server and daemon: `pnpm dev:stack` +- Start relay server only: `pnpm dev:relay-server` +- Start daemon only: `pnpm dev:daemon` +- Build web app: `pnpm build:web` +- Build daemon: `pnpm build:daemon` +- Build relay server: `pnpm build:server` + +## Testing Commands + +- Run web test suite: `pnpm test` +- Run web tests directly: `pnpm --dir apps/web test` +- Run a single test file: `pnpm --dir apps/web vitest run src/path/to/file.test.ts` +- Run tests matching a name: `pnpm --dir apps/web vitest run -t "test name"` +- Run protocol tests: `pnpm test:protocol` + +## Current Command Gaps + +- No root `lint` script is currently defined in `package.json`. + +## Architecture Overview + +This is a `pnpm` monorepo for a local AI workflow runner. + +Primary runtime path: + +1. The React web app in `apps/web` talks to the relay server over HTTP/WebSocket. +2. The relay server in `apps/server` stays stateless and forwards UI requests to a connected daemon. +3. The daemon in `apps/daemon` owns workflow/task data, JSON storage, local APIs, and workflow runtime execution. +4. Shared protocol contracts live in `packages/protocol`. + +## Key Module Responsibilities + +- `apps/web`: React UI for workflows, tasks, settings, and skills. +- `apps/daemon`: local source of truth for config, workflows, skills, workfolders, tasks, artifacts, runtime, and worktrees. +- `apps/server`: in-memory relay for daemon presence, task snapshots/events, and UI commands. +- `packages/protocol`: shared REST/WebSocket message contracts. + +## Data and Storage Notes + +- Daemon data is stored under `~/.dev-workflow` by default. +- The relay server stores only short-lived coordination state in memory. +- Workflow execution depends on the daemon staying online. + +## Practical Development Notes + +- For web behavior changes, run `pnpm build:web` and smoke-test with `pnpm dev:all`. +- For daemon/server behavior changes, verify the affected API or WebSocket path with the relevant `pnpm dev:*` command. +- Treat `apps/web/dist` as build output; edit web source under `apps/web/src`. diff --git a/README.md b/README.md index 9a47644..c25db93 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,45 @@ # Dev Workflow -Dev Workflow is an Electron desktop app for running AI-assisted development workflows on your own machine. +Dev Workflow is a local web app for running AI-assisted development workflows on your own machine. -The desktop app is the source of truth. It manages workflow definitions, selected project folders, task runs, AI agent execution, uploaded context, generated outputs, skills, and optional Git worktrees locally. The mobile app is still early and currently only exists as an optional remote companion through the relay backend. +The daemon is the source of truth. It stores workflow definitions, selected project folders, task runs, AI agent execution state, uploaded context, generated outputs, skills, and optional Git worktrees under `~/.dev-workflow` by default. The web app talks to the stateless relay server, and the relay server forwards requests to the local daemon. -## What The Desktop App Does +## What The App Does -The app is meant to turn repeatable development work into guided, inspectable workflow runs. Instead of manually prompting an AI agent step by step, you define a workflow made of agent steps, condition steps, and checkpoint steps. Then you start a task from the desktop UI, provide the task context, and let the app run each phase while keeping the state, logs, outputs, and decisions organized. +The app turns repeatable development work into guided, inspectable workflow runs. You define a workflow made of agent steps, condition steps, and checkpoint steps, then start a task from the web UI against a local project folder. The daemon runs each phase and keeps state, logs, outputs, and decisions organized. Typical use cases: -- Create a workflow for a recurring engineering process, such as planning, implementation, review, or documentation. +- Create workflows for planning, implementation, review, or documentation. - Start a task against a real local project folder. -- Provide structured context fields and pasted or uploaded screenshots/images. +- Provide structured context fields and uploaded screenshots/images. - Let Claude or Codex execute workflow phases from the local runtime. -- Review each phase in a graph-based run page that shows status, routes, checkpoints, outputs, logs, and debug details. -- Approve, reject, or send follow-up messages when a checkpoint needs human input. +- Review each phase in a graph-based run page. +- Approve, reject, or send follow-up messages at checkpoints. - Save generated outputs and phase artifacts under the task run. -- Use managed skills to control how agents perform specific workflow steps. -- Optionally create a Git worktree for a workflow run so code changes stay isolated. - -In short, the Electron app is a local control center for AI-assisted project work: it stores the workflow, starts the agents, tracks every phase, and keeps the run tied to your actual workspace. - -## Download - -Download the macOS installer from the latest GitHub release: - -https://github.com/hileix/dev-workflow/releases/latest/download/dev-workflow.dmg - -Open the DMG, drag Dev Workflow into Applications, then launch it from Applications. +- Use managed skills to guide specific workflow steps. +- Optionally create a Git worktree so code changes stay isolated. ## Architecture ```txt -Electron Desktop App - | - | local IPC - v -Desktop Runtime + Local Workflow Server - | - | local HTTP / WebSocket - v -Desktop Connector - | - | WebSocket - v -Relay Backend - | - | HTTP / WebSocket - v -Flutter Mobile App +Web App + | + | HTTP / WebSocket + v +Relay Server + | + | WebSocket + v +Daemon ``` -Mobile access is disabled by default and is not the main product surface yet. - ## Repository Layout -- `apps/desktop/electron`: Electron main process, preload bridge, API handlers, and workflow runtime. -- `apps/desktop/renderer`: React renderer for the desktop app. -- `apps/desktop/local-server-controllers`: local Express and WebSocket controllers. -- `apps/desktop/connector`: bridge between local desktop state and the relay backend. -- `apps/backend`: in-memory relay backend for mobile access. -- `apps/mobile`: Flutter mobile client. -- `packages/core-models`: workflow, config, state, skills, and workfolder storage. -- `packages/core-lib`: shared workflow runtime helpers, worktree helpers, and SDK adapters. +- `apps/web`: React web app. +- `apps/daemon`: local backend daemon that owns workflow/task data, JSON storage, workflow runtime, and outbound relay connection. +- `apps/server`: stateless in-memory relay server. +- `packages/protocol`: shared REST/WebSocket message contracts. - `docs`: design notes and implementation plans. - `references`: external source snapshots used for architecture research. @@ -71,116 +47,71 @@ Mobile access is disabled by default and is not the main product surface yet. - Node.js from `.nvmrc` - `pnpm` -- Flutter, only for mobile development Install dependencies: ```bash pnpm install -cd apps/mobile && flutter pub get ``` -## Desktop Development +## Development -Start the desktop app, local workflow server, and connector: +Start the full local stack: ```bash -pnpm dev:desktop +pnpm dev:all ``` -Build the renderer: +Start only the relay server and daemon: ```bash -pnpm build:desktop +pnpm dev:stack ``` -Run Electron with the existing renderer build: - -```bash -pnpm start:desktop -``` - -Useful focused commands: - -```bash -pnpm dev:renderer -pnpm dev:server -pnpm dev:connector -``` - -## Mobile Relay Development - -Start the local workflow server, backend, and connector: +Start individual processes: ```bash -pnpm dev:mobile-stack +pnpm dev:web +pnpm dev:daemon +pnpm dev:relay-server ``` -Start the Flutter app in another terminal: - -```bash -pnpm dev:mobile -``` - -Set the backend URL in the mobile app: - -- iOS simulator: `http://127.0.0.1:8787` -- Android emulator: `http://10.0.2.2:8787` -- Real device on the same LAN: `http://:8787` - Useful checks: ```txt -http://127.0.0.1:3000 +http://127.0.0.1:5173 http://127.0.0.1:8787/health http://127.0.0.1:8787/api/devices ``` -If the connector is online and mobile access is enabled, the device list should include `desktop-local`. - ## Runtime Data -Desktop data is stored under the platform app-data directory: - -- macOS: `~/Library/Application Support/dev-Workflow` -- Windows: `%APPDATA%/dev-Workflow` -- Linux: `$XDG_CONFIG_HOME/dev-Workflow` or `~/.config/dev-Workflow` +Daemon data is stored under `~/.dev-workflow` by default. Important subpaths: -- `config.json`: active workflow, mobile access, and AI backend settings. +- `config.json`: active workflow and AI backend settings. - `workflows/`: app-managed workflow DSL JSON files. - `skills/`: app-managed skills. - `tasks/`: task run state, uploads, phase logs, and output artifacts. -Legacy workflow JSON files from repo-root `workflows/` are migrated into the app-data workflow directory when possible. - ## Commands ```bash -pnpm dev:desktop # desktop UI + local server + connector -pnpm dev:desktop-stack # local server + backend + connector -pnpm dev:mobile-stack # local server + backend + connector -pnpm dev:all # desktop stack plus backend -pnpm dev:backend # relay backend only -pnpm build:desktop # build React renderer -pnpm start:desktop # start Electron from existing build -``` - -`pnpm test` is not configured and currently exits with an intentional error. - -For mobile checks: - -```bash -cd apps/mobile -flutter analyze -flutter run -d ios -flutter run -d android +pnpm dev:web # web app only +pnpm dev:stack # relay server + daemon +pnpm dev:all # web app + relay server + daemon +pnpm dev:daemon # daemon only; connects outbound to relay server +pnpm dev:relay-server # relay server only +pnpm build:web # build React web app +pnpm build:daemon # check daemon package +pnpm build:server # check relay server package +pnpm test # web tests +pnpm test:protocol # protocol tests ``` ## Current Limitations -- The backend stores relay state in memory. -- Mobile access has no production pairing or authentication layer yet. -- Workflow execution depends on the desktop machine staying online. -- Mobile supports remote task visibility and commands, not full workflow editing. +- The relay server stores coordination state in memory. +- There is no production authentication layer yet. +- Workflow execution depends on the daemon staying online. diff --git a/apps/README.md b/apps/README.md index 0612fdb..e66cf45 100644 --- a/apps/README.md +++ b/apps/README.md @@ -1,50 +1,36 @@ # Monorepo Apps -## Directory layout +## Directory Layout -- `apps/desktop/electron`: Electron main/preload/runtime -- `apps/desktop/renderer`: React renderer -- `apps/desktop/local-server-controllers`: existing local workflow HTTP/WebSocket controllers -- `apps/backend`: cloud relay backend -- `apps/desktop/connector`: local connector between local workflow server and cloud backend -- `apps/mobile`: Flutter mobile app -- `packages/core-models`: shared workflow/config/state models -- `packages/core-lib`: shared runtime helpers +- `apps/web`: React web app. +- `apps/daemon`: local backend daemon for workflow/task data, storage, runtime, and outbound relay connection. +- `apps/server`: stateless relay server. +- `packages/protocol`: shared daemon/server/web protocol contracts. -## Local development +## Local Development -### Existing desktop app +### Full Stack ```bash -pnpm dev:desktop +pnpm dev:all ``` -### Local workflow server +### Web App ```bash -pnpm dev:server +pnpm dev:web ``` -### Cloud backend +### Relay Server ```bash -pnpm dev:backend +pnpm dev:relay-server ``` -### Desktop connector +### Daemon ```bash -pnpm dev:connector +pnpm dev:daemon ``` -### Flutter mobile app - -```bash -pnpm dev:mobile -``` - -Set backend URL in the app to: - -```txt -http://:8787 -``` +The daemon does not expose the app API. It connects outbound to the relay server. diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts deleted file mode 100644 index ec8cccd..0000000 --- a/apps/backend/src/server.ts +++ /dev/null @@ -1,482 +0,0 @@ -import Fastify from "fastify"; -import cors from "@fastify/cors"; -import websocket from "@fastify/websocket"; -import { nanoid } from "nanoid"; - -const fastify = Fastify({ logger: true }); -const desktopConnections = new Map(); -const mobileSockets = new Set(); -const pendingDesktopRequests = new Map(); -const pendingCommands = new Map(); -const ALLOWED_COMMANDS = new Set([ - "approve", - "reject", - "message", - "sync_task", - "start_workflow", - "delete_task", -]); - -function now() { - return new Date().toISOString(); -} - -function sendJson(socket, payload) { - if (!socket || socket.readyState !== 1) return; - socket.send(JSON.stringify(payload)); -} - -function isDeviceExposed(connection) { - return connection?.meta?.mobileAccessEnabled === true; -} - -function publicDevice(connection) { - if (!connection || !isDeviceExposed(connection)) return null; - return { - deviceId: connection.deviceId, - name: connection.name || connection.deviceId, - status: "online", - lastSeenAt: connection.lastSeenAt || now(), - meta: connection.meta || {}, - }; -} - -function publicTask(task, fallback = {}) { - if (!task) return null; - const deviceId = String(task.deviceId || fallback.deviceId || "").trim(); - const taskId = String(task.taskId || fallback.taskId || "").trim(); - if (!deviceId || !taskId) return null; - return { - key: task.key || `${deviceId}:${taskId}`, - deviceId, - taskId, - workFolder: task.workFolder || "", - state: task.state || null, - messages: task.messages || {}, - outputArtifacts: task.outputArtifacts || {}, - interactions: task.interactions || {}, - updatedAt: task.updatedAt || task.state?.updated || now(), - }; -} - -function listDevices() { - return Array.from(desktopConnections.values()) - .map(publicDevice) - .filter(Boolean) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -function listHiddenDesktopConnections() { - return Array.from(desktopConnections.values()).filter((connection) => !isDeviceExposed(connection)); -} - -function getMobileAvailability() { - const devices = listDevices(); - if (devices.length > 0) { - return { status: "available" }; - } - - const hiddenConnections = listHiddenDesktopConnections(); - if (hiddenConnections.length > 0) { - return { - status: "desktop_mobile_access_disabled", - hiddenDesktopCount: hiddenConnections.length, - }; - } - - return { status: "no_desktop_online" }; -} - -function broadcastMobileAvailability() { - broadcastMobile({ - type: "availability", - availability: getMobileAvailability(), - }); -} - -function broadcastMobile(payload) { - for (const socket of mobileSockets) { - sendJson(socket, payload); - } -} - -function rejectPendingDesktopRequestsForDevice(deviceId) { - for (const [requestId, pending] of pendingDesktopRequests) { - if (pending.deviceId !== deviceId) continue; - clearTimeout(pending.timeout); - pendingDesktopRequests.delete(requestId); - pending.reject(new Error("device is offline")); - } -} - -function createDesktopRequest(deviceId, action, payload = {}, timeoutMs = 10000) { - const connection = desktopConnections.get(deviceId); - if (!connection || connection.socket.readyState !== 1 || !isDeviceExposed(connection)) { - throw new Error("device is offline"); - } - - const requestId = nanoid(); - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingDesktopRequests.delete(requestId); - reject(new Error("desktop request timed out")); - }, timeoutMs); - - pendingDesktopRequests.set(requestId, { - deviceId, - resolve, - reject, - timeout, - }); - - sendJson(connection.socket, { - type: "query.request", - requestId, - action, - payload, - }); - }); -} - -function resolveDesktopRequest(message) { - const pending = pendingDesktopRequests.get(message.requestId); - if (!pending) return; - clearTimeout(pending.timeout); - pendingDesktopRequests.delete(message.requestId); - if (message.ok === false) { - pending.reject(new Error(message.error || "desktop query failed")); - return; - } - pending.resolve(message.data || {}); -} - -async function listTasksForDevice(deviceId) { - const data = await createDesktopRequest(deviceId, "list_tasks"); - const tasks = Array.isArray(data.tasks) ? data.tasks : []; - return tasks - .map((task) => publicTask(task, { deviceId })) - .filter(Boolean); -} - -async function getTaskForDevice(deviceId, taskId) { - const data = await createDesktopRequest(deviceId, "get_task", { taskId }); - return publicTask(data.task, { deviceId, taskId }); -} - -async function listAllTasks() { - const devices = listDevices(); - const snapshots = await Promise.all( - devices.map(async (device) => { - try { - return await listTasksForDevice(device.deviceId); - } catch { - return []; - } - }) - ); - return snapshots.flat(); -} - -async function broadcastDeviceTasks(deviceId) { - const tasks = await listTasksForDevice(deviceId).catch(() => []); - for (const task of tasks) { - broadcastMobile({ type: "task.snapshot", task }); - } -} - -function createPendingCommand(deviceId, taskId, type, payload = {}) { - const command = { - id: nanoid(), - deviceId, - taskId, - type, - payload, - status: "pending", - createdAt: now(), - completedAt: null, - error: "", - }; - const timeout = setTimeout(() => { - pendingCommands.delete(command.id); - }, 30000); - pendingCommands.set(command.id, { command, timeout }); - return command; -} - -function resolvePendingCommand(commandId, status, error = "") { - const pending = pendingCommands.get(commandId); - if (!pending) { - return { - id: commandId, - status, - error, - completedAt: now(), - }; - } - clearTimeout(pending.timeout); - pendingCommands.delete(commandId); - pending.command.status = status; - pending.command.error = error; - pending.command.completedAt = now(); - return pending.command; -} - -function sendCommandToDesktop(deviceId, taskId, type, payload = {}) { - const connection = desktopConnections.get(deviceId); - if (!connection || connection.socket.readyState !== 1 || !isDeviceExposed(connection)) { - throw new Error("device is offline"); - } - const command = createPendingCommand(deviceId, taskId, type, payload); - sendJson(connection.socket, { - type: "command.request", - commandId: command.id, - taskId, - command: type, - payload, - }); - broadcastMobile({ type: "command.status", command }); - return command; -} - -await fastify.register(cors, { origin: true }); -await fastify.register(websocket); - -fastify.get("/health", async () => ({ ok: true })); - -fastify.get("/api/devices", async () => ({ - devices: listDevices(), - availability: getMobileAvailability(), -})); - -fastify.get("/api/tasks", async () => ({ - tasks: await listAllTasks(), -})); - -fastify.get("/api/tasks/:deviceId/:taskId", async (request, reply) => { - const { deviceId, taskId } = request.params; - try { - const task = await getTaskForDevice(deviceId, taskId); - if (!task) return reply.code(404).send({ error: "task not found" }); - return { task }; - } catch (error) { - const message = error instanceof Error ? error.message : "request failed"; - if (message === "device is offline") { - return reply.code(409).send({ error: message }); - } - return reply.code(502).send({ error: message }); - } -}); - -fastify.post("/api/tasks/:deviceId/:taskId/commands", async (request, reply) => { - const { deviceId, taskId } = request.params; - const { type, payload } = request.body || {}; - if (!ALLOWED_COMMANDS.has(type)) { - return reply.code(400).send({ error: "unsupported command" }); - } - try { - const command = sendCommandToDesktop(deviceId, taskId, type, payload || {}); - return { command }; - } catch (error) { - const message = error instanceof Error ? error.message : "request failed"; - if (message === "device is offline") { - return reply.code(409).send({ error: message }); - } - return reply.code(502).send({ error: message }); - } -}); - -fastify.get("/ws/desktop", { websocket: true }, (socket) => { - let deviceId = ""; - - socket.on("message", async (raw) => { - let message; - try { - message = JSON.parse(String(raw)); - } catch { - return; - } - - if (message.type === "device.hello") { - deviceId = String(message.deviceId || "").trim(); - if (!deviceId) return; - const previousConnection = desktopConnections.get(deviceId); - const connection = { - deviceId, - name: message.name || deviceId, - meta: message.meta || {}, - lastSeenAt: now(), - socket, - }; - desktopConnections.set(deviceId, connection); - const device = publicDevice(connection); - sendJson(socket, { type: "device.accepted", device }); - broadcastMobile({ type: "tasks.remove_device", deviceId }); - if (device) { - broadcastMobile({ type: "device.status", device }); - broadcastMobileAvailability(); - await broadcastDeviceTasks(deviceId); - return; - } - broadcastMobileAvailability(); - if (isDeviceExposed(previousConnection)) { - broadcastMobile({ - type: "device.status", - device: { - deviceId, - name: connection.name || deviceId, - status: "offline", - lastSeenAt: connection.lastSeenAt, - meta: connection.meta || {}, - }, - }); - } - return; - } - - if (!deviceId) return; - - if (message.type === "query.response") { - resolveDesktopRequest(message); - return; - } - - if (message.type === "task.snapshot") { - const task = publicTask(message.task, { - deviceId, - taskId: message.taskId, - }); - if (task) { - broadcastMobile({ type: "task.snapshot", task }); - } - return; - } - - if (message.type === "task.event") { - if (message.task) { - const task = publicTask(message.task, { - deviceId, - taskId: message.taskId, - }); - if (task) { - broadcastMobile({ - type: "task.event", - taskKey: task.key, - deviceId, - taskId: task.taskId, - event: message.event, - task, - }); - } - } - return; - } - - if (message.type === "task.removed") { - broadcastMobile({ - type: "tasks.remove_task", - taskKey: `${deviceId}:${message.taskId}`, - deviceId, - taskId: message.taskId, - }); - return; - } - - if (message.type === "command.result") { - const command = resolvePendingCommand( - message.commandId, - message.status || "ok", - message.error || "" - ); - broadcastMobile({ type: "command.status", command }); - } - }); - - socket.on("close", () => { - if (!deviceId) return; - const connection = desktopConnections.get(deviceId); - if (connection?.socket !== socket) return; - desktopConnections.delete(deviceId); - rejectPendingDesktopRequestsForDevice(deviceId); - broadcastMobile({ - type: "device.status", - device: { - deviceId, - name: connection.name || deviceId, - status: "offline", - lastSeenAt: now(), - meta: connection.meta || {}, - }, - }); - broadcastMobile({ type: "tasks.remove_device", deviceId }); - broadcastMobileAvailability(); - }); -}); - -fastify.get("/ws/mobile", { websocket: true }, (socket) => { - mobileSockets.add(socket); - Promise.resolve() - .then(async () => { - const availability = getMobileAvailability(); - if (availability.status === "desktop_mobile_access_disabled") { - fastify.log.info( - { hiddenDesktopCount: availability.hiddenDesktopCount }, - "mobile websocket connected but desktop mobile access is disabled" - ); - } - sendJson(socket, { - type: "bootstrap", - devices: listDevices(), - tasks: await listAllTasks(), - availability, - }); - }) - .catch(() => { - sendJson(socket, { - type: "bootstrap", - devices: listDevices(), - tasks: [], - availability: getMobileAvailability(), - }); - }); - - socket.on("message", (raw) => { - let message; - try { - message = JSON.parse(String(raw)); - } catch { - return; - } - - if (message.type === "command.create") { - const { deviceId: targetDeviceId, taskId, command, payload } = message; - if (!ALLOWED_COMMANDS.has(command)) { - sendJson(socket, { type: "command.error", error: "unsupported command" }); - return; - } - try { - const created = sendCommandToDesktop( - String(targetDeviceId || "").trim(), - String(taskId || "").trim(), - command, - payload || {} - ); - sendJson(socket, { type: "command.accepted", command: created }); - } catch (error) { - const messageText = error instanceof Error ? error.message : "request failed"; - sendJson(socket, { type: "command.error", error: messageText }); - } - } - }); - - socket.on("close", () => { - mobileSockets.delete(socket); - }); -}); - -const port = Number(process.env.BACKEND_PORT || process.env.PORT || 8787); -const host = process.env.BACKEND_HOST || "0.0.0.0"; - -fastify.listen({ port, host }).catch((error) => { - fastify.log.error(error); - process.exit(1); -}); diff --git a/apps/daemon/README.md b/apps/daemon/README.md new file mode 100644 index 0000000..84ceb75 --- /dev/null +++ b/apps/daemon/README.md @@ -0,0 +1,34 @@ +# Daemon + +Local backend daemon for workflow/task storage and execution. + +The daemon is the persistent source of truth for workflow data. It stores JSON files under `~/.dev-workflow` by default, runs workflow tasks, and owns the outbound relay connection. + +Current responsibilities: + +- connect outbound to the stateless relay server +- publish device/workflow/work folder metadata +- run workflow commands through the daemon runtime +- publish task snapshots and workflow events +- keep local daemon data in JSON-file storage + +Current source layout: + +- `src/services`: business orchestration +- `src/repositories`: JSON-file storage +- `src/runtime`: workflow runtime, worktree helpers, and agent adapters +- `src/relay`: outbound relay-server client, command/query handlers, and task snapshots + +Run: + +```sh +pnpm --filter @dev-workflow/daemon start +``` + +Optional diagnostics: + +```sh +DAEMON_DIAGNOSTICS_PORT=3099 pnpm --filter @dev-workflow/daemon start +``` + +This exposes only `GET /health` on `127.0.0.1` by default. It is diagnostics-only, not the app API. diff --git a/apps/daemon/package.json b/apps/daemon/package.json new file mode 100644 index 0000000..1d52bc6 --- /dev/null +++ b/apps/daemon/package.json @@ -0,0 +1,30 @@ +{ + "name": "@dev-workflow/daemon", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "build": "tsc -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.126", + "@dev-workflow/protocol": "workspace:*", + "@langchain/langgraph": "^1.3.0", + "@langchain/langgraph-checkpoint": "^1.0.2", + "@openai/codex": "^0.128.0", + "nanoid": "^5.1.6", + "openai": "^6.38.0", + "ws": "^8.20.0" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "@types/ws": "^8.18.1", + "tsx": "^4.21.0", + "typescript": "^6.0.3", + "vitest": "^4.1.6" + } +} diff --git a/apps/daemon/src/diagnostics.ts b/apps/daemon/src/diagnostics.ts new file mode 100644 index 0000000..1ca97e2 --- /dev/null +++ b/apps/daemon/src/diagnostics.ts @@ -0,0 +1,25 @@ +import { createServer } from "http"; + +export function startDiagnosticsServer(options: { port?: number; host?: string } = {}) { + const port = options.port || Number(process.env.DAEMON_DIAGNOSTICS_PORT || 0); + if (!port) return null; + + const host = options.host || process.env.DAEMON_DIAGNOSTICS_HOST || "127.0.0.1"; + const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); + + server.listen(port, host, () => { + const address = server.address(); + const actualPort = typeof address === "object" && address ? address.port : port; + console.log(`[daemon] diagnostics running at http://${host}:${actualPort}`); + }); + + return server; +} diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts new file mode 100644 index 0000000..86de77f --- /dev/null +++ b/apps/daemon/src/index.ts @@ -0,0 +1,43 @@ +import { fileURLToPath } from "url"; + +import { killAllChildren } from "./runtime/claude.ts"; +import { startDaemonConnection, stopDaemonConnection } from "./relay/client.ts"; +import { startDiagnosticsServer } from "./diagnostics.ts"; +import { migrateLegacyDesktopData } from "./repositories/migration.ts"; + +export { startDaemonConnection, stopDaemonConnection }; +export { startDiagnosticsServer }; + +let diagnosticsServer = null; + +export async function startDaemon() { + const migration = await migrateLegacyDesktopData(); + if (migration.migrated) { + console.log("[daemon] migrated legacy desktop data", migration.sources.filter((source) => source.migrated)); + } + startDaemonConnection(); + diagnosticsServer = startDiagnosticsServer(); +} + +export function stopDaemon() { + diagnosticsServer?.close(); + diagnosticsServer = null; + stopDaemonConnection(); + killAllChildren(); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + process.on("SIGINT", () => { + stopDaemon(); + process.exit(0); + }); + process.on("SIGTERM", () => { + stopDaemon(); + process.exit(0); + }); + startDaemon().catch((error) => { + console.error(error); + stopDaemon(); + process.exit(1); + }); +} diff --git a/apps/daemon/src/relay/client.ts b/apps/daemon/src/relay/client.ts new file mode 100644 index 0000000..5235383 --- /dev/null +++ b/apps/daemon/src/relay/client.ts @@ -0,0 +1,119 @@ +import WebSocket from "ws"; +import { PROTOCOL_VERSION, RELAY_DAEMON_MESSAGE_TYPES } from "@dev-workflow/protocol"; +import { bootstrapTasks, buildDeviceMeta } from "./taskSnapshots.ts"; +import { handleCommand } from "./commandHandler.ts"; +import { createQueryHandler } from "./queryHandler.ts"; +import { + clearRelayServerSocket, + deviceId, + deviceName, + getRelayServerSocket, + isRelayOpen, + log, + metaSyncIntervalMs, + reconnectDelayMs, + relayServerUrl, + sendToRelay, + setRelayServerSocket, +} from "./state.ts"; + +let lastMetaSignature = ""; +let reconnectTimer = null; +let metaSyncTimer = null; +let started = false; + +const handleQuery = createQueryHandler({ syncDeviceMeta }); + +async function syncDeviceMeta(force = false) { + if (!isRelayOpen()) return; + try { + const meta = await buildDeviceMeta(); + const signature = JSON.stringify(meta); + if (!force && signature === lastMetaSignature) return; + lastMetaSignature = signature; + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.daemonHello, + protocolVersion: PROTOCOL_VERSION, + deviceId, + name: deviceName, + meta, + }); + } catch (error) { + log(`failed to sync device meta: ${error.message}`); + } +} + +function connectRelayServer() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + const socket = new WebSocket(relayServerUrl); + setRelayServerSocket(socket); + + socket.on("open", async () => { + lastMetaSignature = ""; + await syncDeviceMeta(true); + await bootstrapTasks(); + }); + + socket.on("message", async (raw) => { + let message; + try { + message = JSON.parse(String(raw)); + } catch { + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.commandRequest) { + await handleCommand(message); + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.queryRequest) { + await handleQuery(message); + } + }); + + socket.on("close", () => { + if (getRelayServerSocket() !== socket) return; + lastMetaSignature = ""; + clearRelayServerSocket(socket); + if (started) { + reconnectTimer = setTimeout(connectRelayServer, reconnectDelayMs); + } + }); + + socket.on("error", (error) => { + log(`relay server ws error: ${error.message}`); + }); +} + +export function startDaemonConnection() { + if (started) return; + started = true; + connectRelayServer(); + metaSyncTimer = setInterval(() => { + syncDeviceMeta().catch((error) => { + log(`device meta sync interval failed: ${error.message}`); + }); + }, metaSyncIntervalMs); +} + +export function stopDaemonConnection() { + started = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (metaSyncTimer) { + clearInterval(metaSyncTimer); + metaSyncTimer = null; + } + const socket = getRelayServerSocket(); + if (socket && socket.readyState <= WebSocket.OPEN) { + socket.close(); + } + clearRelayServerSocket(socket); +} diff --git a/apps/daemon/src/relay/commandHandler.ts b/apps/daemon/src/relay/commandHandler.ts new file mode 100644 index 0000000..df0f225 --- /dev/null +++ b/apps/daemon/src/relay/commandHandler.ts @@ -0,0 +1,143 @@ +import { + COMMAND_TYPE, + CommandRequestSchema, + PROTOCOL_VERSION, + RELAY_DAEMON_MESSAGE_TYPES, +} from "@dev-workflow/protocol"; +import { activateWorkflow } from "../services/workflowService"; +import { removeTask } from "../services/taskService"; +import { + approveWorkflow, + pauseWorkflowPhase, + rejectWorkflow, + resumeWorkflowPhase, + retryWorkflowPhase, + sendWorkflowMessage, + startWorkflowSession, +} from "../runtime/workflow-runtime"; +import { createRelayTaskSender, normalizeImagePayload, pushTaskSnapshot } from "./taskSnapshots.ts"; +import { defaultWorkFolder, knownTasks, sendToRelay } from "./state.ts"; + +function runWorkflowCommand(taskId, sender, operation) { + Promise.resolve() + .then(operation) + .catch((error) => { + sender({ + type: "error", + taskId, + message: error.message || "workflow error", + }); + }); +} + +function sendCommandResult(commandId, status, error = "") { + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.commandResult, + protocolVersion: PROTOCOL_VERSION, + commandId, + status, + error, + }); +} + +export async function handleCommand(message) { + const parsedMessage = CommandRequestSchema.parse(message); + const { commandId, taskId, command } = parsedMessage; + const payload = (parsedMessage.payload || {}) as Record; + const taskInfo = knownTasks.get(taskId); + const workFolder = payload?.workFolder || taskInfo?.workFolder || defaultWorkFolder; + const runId = String(payload?.runId || taskInfo?.runId || "").trim(); + + const sendResult = (status, error = "") => sendCommandResult(commandId, status, error); + + if (command === COMMAND_TYPE.startWorkflow) { + if (!taskId || !workFolder) { + sendResult("error", "taskId and workFolder are required"); + return; + } + try { + const workflowFilename = String(payload?.workflowFilename || "").trim(); + const startRunId = String(payload?.runId || knownTasks.get(taskId)?.runId || "").trim() || `run-${Date.now()}`; + const imagePaths = await normalizeImagePayload(startRunId, payload?.images || []); + if (workflowFilename) { + await activateWorkflow(workflowFilename); + } + knownTasks.set(taskId, { workFolder, runId: startRunId }); + const sender = createRelayTaskSender(taskId, workFolder, startRunId); + runWorkflowCommand(taskId, sender, () => startWorkflowSession(taskId, workFolder, payload?.taskInputs || {}, imagePaths, startRunId, sender, { + worktreeName: payload?.worktreeName || "", + workflowFilename, + })); + sendResult("ok"); + } catch (error) { + sendResult("error", error.message); + } + return; + } + + if (command === COMMAND_TYPE.deleteTask) { + if (!taskId) { + sendResult("error", "taskId is required"); + return; + } + try { + await removeTask(taskId, runId, { removeWorktree: Boolean(payload?.removeWorktree) }); + knownTasks.delete(taskId); + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.taskRemoved, + protocolVersion: PROTOCOL_VERSION, + taskId, + runId, + }); + sendResult("ok"); + } catch (error) { + sendResult("error", error.message); + } + return; + } + + if (command === COMMAND_TYPE.syncTask) { + pushTaskSnapshot(taskId).then(() => sendResult("ok")).catch((error) => sendResult("error", error.message)); + return; + } + + const sender = createRelayTaskSender(taskId, workFolder, runId); + try { + if (command === COMMAND_TYPE.approve) { + runWorkflowCommand(taskId, sender, () => approveWorkflow(taskId, sender, runId)); + sendResult("ok"); + return; + } + if (command === COMMAND_TYPE.reject) { + runWorkflowCommand(taskId, sender, () => rejectWorkflow(taskId, payload?.rejectTo, payload?.reason || "", sender, runId)); + sendResult("ok"); + return; + } + if (command === COMMAND_TYPE.message) { + const imagePaths = await normalizeImagePayload(runId, payload?.images || []); + runWorkflowCommand(taskId, sender, () => sendWorkflowMessage(taskId, payload?.text || "", imagePaths, sender, runId)); + sendResult("ok"); + return; + } + if (command === COMMAND_TYPE.resumePhase) { + runWorkflowCommand(taskId, sender, () => resumeWorkflowPhase(taskId, payload?.phase, sender, runId)); + sendResult("ok"); + return; + } + if (command === COMMAND_TYPE.retryPhase) { + runWorkflowCommand(taskId, sender, () => retryWorkflowPhase(taskId, payload?.phase, sender, runId)); + sendResult("ok"); + return; + } + if (command === COMMAND_TYPE.pausePhase) { + runWorkflowCommand(taskId, sender, () => pauseWorkflowPhase(taskId, payload?.phase, sender, runId)); + sendResult("ok"); + return; + } + } catch (error) { + sendResult("error", error.message); + return; + } + + sendResult("error", "unsupported command"); +} diff --git a/apps/daemon/src/relay/queryHandler.ts b/apps/daemon/src/relay/queryHandler.ts new file mode 100644 index 0000000..617cbab --- /dev/null +++ b/apps/daemon/src/relay/queryHandler.ts @@ -0,0 +1,245 @@ +import { + DAEMON_QUERY_ACTIONS, + PROTOCOL_VERSION, + QueryRequestSchema, + RELAY_DAEMON_MESSAGE_TYPES, +} from "@dev-workflow/protocol"; +import { deleteAiApi, getWorkflowConfig, listAiApiProfiles, saveAiApi } from "../services/settingsService"; +import { + activateWorkflow, + createWorkflow, + createWorkflowDraft, + getWorkflowByFilename, + listWorkflows as listWorkflowDefinitions, + removeWorkflow, + setWorkflowVisible, + updateWorkflow, + updateWorkflowDraft, +} from "../services/workflowService"; +import { deleteSkill, generateSkill, importSkillsFromPaths, listSkills, saveSkill } from "../services/skillService"; +import { addWorkFolder, listWorkFolders, removeWorkFolder } from "../services/workfolderService"; +import { + getTaskOutputPath, + getTaskState, + openTaskOutput, + removeTask, + removeTaskWorktreeOnly, + saveTaskUploads as saveTaskUploadFiles, +} from "../services/taskService"; +import { openPath } from "../services/systemService"; +import { getTaskSnapshot, listTaskSnapshots } from "./taskSnapshots.ts"; +import { knownTasks, sendToRelay } from "./state.ts"; + +export function createQueryHandler({ syncDeviceMeta }) { + return async function handleQuery(message) { + const parsedMessage = QueryRequestSchema.parse(message); + const { requestId, action } = parsedMessage; + const payload = (parsedMessage.payload || {}) as Record; + const reply = (ok, data = {}, error = "") => { + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.queryResponse, + protocolVersion: PROTOCOL_VERSION, + requestId, + ok, + data, + error, + }); + }; + + try { + if (action === DAEMON_QUERY_ACTIONS.listTasks) { + reply(true, { tasks: await listTaskSnapshots() }); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.getTask) { + const taskId = String(payload?.taskId || "").trim(); + if (!taskId) { + reply(false, {}, "taskId is required"); + return; + } + const taskInfo = knownTasks.get(taskId); + try { + const task = await getTaskSnapshot(taskId, taskInfo?.workFolder || "", taskInfo?.runId || ""); + reply(true, { task }); + } catch (error) { + if (error.message === "task not found") { + reply(true, { task: null }); + return; + } + throw error; + } + return; + } + + if (action === DAEMON_QUERY_ACTIONS.getWorkflowConfig) { + reply(true, await getWorkflowConfig()); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.listAiApiProfiles) { + reply(true, await listAiApiProfiles()); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.saveAiApiProfile) { + reply(true, await saveAiApi(payload?.profile || {})); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.deleteAiApiProfile) { + reply(true, await deleteAiApi(payload?.id)); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.listWorkflows) { + reply(true, await listWorkflowDefinitions()); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.getWorkflow) { + reply(true, await getWorkflowByFilename(String(payload?.filename || ""))); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.setWorkflowVisible) { + reply(true, await setWorkflowVisible(String(payload?.filename || ""), payload?.visible)); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.createWorkflow) { + reply(true, payload?.draft + ? await createWorkflowDraft(payload?.workflow || {}) + : await createWorkflow(payload?.workflow || {})); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.updateWorkflow) { + reply(true, payload?.draft + ? await updateWorkflowDraft(String(payload?.filename || ""), payload?.workflow || {}) + : await updateWorkflow(String(payload?.filename || ""), payload?.workflow || {})); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.removeWorkflow) { + reply(true, await removeWorkflow(String(payload?.filename || ""))); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.activateWorkflow) { + reply(true, await activateWorkflow(String(payload?.filename || ""))); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.generateSkill) { + reply(true, await generateSkill(payload || {})); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.listSkills) { + reply(true, await listSkills()); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.saveSkill) { + reply(true, await saveSkill(payload?.skill || {})); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.deleteSkill) { + reply(true, await deleteSkill(String(payload?.slug || ""))); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.importSkills) { + reply(true, await importSkillsFromPaths(Array.isArray(payload?.paths) ? payload.paths : [])); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.listWorkfolders) { + reply(true, await listWorkFolders()); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.addWorkfolder) { + reply(true, await addWorkFolder(payload?.path)); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.removeWorkfolder) { + reply(true, await removeWorkFolder(payload?.path)); + await syncDeviceMeta(true); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.getTaskState) { + reply(true, await getTaskState(String(payload?.taskId || ""), String(payload?.runId || ""))); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.removeTask) { + const taskId = String(payload?.taskId || ""); + const runId = String(payload?.runId || ""); + await removeTask(taskId, runId, { removeWorktree: Boolean(payload?.removeWorktree) }); + knownTasks.delete(taskId); + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.taskRemoved, + protocolVersion: PROTOCOL_VERSION, + taskId, + runId, + }); + reply(true, { ok: true }); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.saveTaskUploads) { + reply(true, await saveTaskUploadFiles(String(payload?.runId || ""), payload?.files || [])); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.getTaskOutputPath) { + const path = await getTaskOutputPath( + String(payload?.taskId || ""), + String(payload?.runId || ""), + String(payload?.phaseId || ""), + String(payload?.outputKey || "") + ); + reply(true, { path }); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.openPath) { + reply(true, await openPath(payload?.path, payload?.editor || "code")); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.removeTaskWorktree) { + reply(true, await removeTaskWorktreeOnly(String(payload?.taskId || ""), String(payload?.runId || ""))); + return; + } + + if (action === DAEMON_QUERY_ACTIONS.openTaskOutput) { + reply(true, await openTaskOutput( + String(payload?.taskId || ""), + String(payload?.runId || ""), + String(payload?.phaseId || ""), + String(payload?.outputKey || ""), + String(payload?.editor || "code") + )); + return; + } + + reply(false, {}, "unsupported query"); + } catch (error) { + reply(false, {}, error.message); + } + }; +} diff --git a/apps/daemon/src/relay/state.ts b/apps/daemon/src/relay/state.ts new file mode 100644 index 0000000..7eb1aaa --- /dev/null +++ b/apps/daemon/src/relay/state.ts @@ -0,0 +1,50 @@ +import WebSocket from "ws"; +import { RELAY_WS_PATHS } from "@dev-workflow/protocol"; + +export const relayServerUrl = process.env.RELAY_SERVER_WS_URL + || process.env.CLOUD_BACKEND_WS_URL + || `ws://127.0.0.1:8787${RELAY_WS_PATHS.daemon}`; +export const deviceId = process.env.DEVICE_ID || "local-daemon"; +export const deviceName = process.env.DEVICE_NAME || "Local Daemon"; +export const defaultWorkFolder = process.env.DEFAULT_WORK_FOLDER || ""; +export const reconnectDelayMs = Number(process.env.DAEMON_RECONNECT_DELAY_MS || 1500); +export const metaSyncIntervalMs = Number(process.env.DAEMON_META_SYNC_INTERVAL_MS || 2000); + +export const knownTasks = new Map(); + +let relayServerSocket = null; + +export function log(message, extra = undefined) { + if (extra !== undefined) { + console.log(`[daemon] ${message}`, extra); + return; + } + console.log(`[daemon] ${message}`); +} + +export function getRelayServerSocket() { + return relayServerSocket; +} + +export function setRelayServerSocket(socket) { + relayServerSocket = socket; +} + +export function clearRelayServerSocket(socket = null) { + if (!socket || relayServerSocket === socket) { + relayServerSocket = null; + } +} + +export function isRelayOpen() { + return relayServerSocket?.readyState === WebSocket.OPEN; +} + +export function sendJson(socket, payload) { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify(payload)); +} + +export function sendToRelay(payload) { + sendJson(relayServerSocket, payload); +} diff --git a/apps/daemon/src/relay/taskSnapshots.ts b/apps/daemon/src/relay/taskSnapshots.ts new file mode 100644 index 0000000..6433829 --- /dev/null +++ b/apps/daemon/src/relay/taskSnapshots.ts @@ -0,0 +1,168 @@ +import { basename } from "path"; +import { PROTOCOL_VERSION, RELAY_DAEMON_MESSAGE_TYPES } from "@dev-workflow/protocol"; +import { getWorkflowConfig } from "../services/settingsService"; +import { listWorkflows as listWorkflowDefinitions } from "../services/workflowService"; +import { listWorkFolders } from "../services/workfolderService"; +import { getTaskState, saveTaskUploads as saveTaskUploadFiles } from "../services/taskService"; +import { + defaultWorkFolder, + deviceId, + isRelayOpen, + knownTasks, + log, + sendToRelay, +} from "./state.ts"; + +export async function buildDeviceMeta() { + const [workflowConfig, workflows, workFolders] = await Promise.all([ + getWorkflowConfig().catch(() => null), + listWorkflowDefinitions().then((data) => data.workflows || []).catch(() => []), + listWorkFolders().catch(() => []), + ]); + + const mappedWorkFolders = workFolders.map((folder) => ({ + name: folder.name, + path: folder.path, + })); + if (defaultWorkFolder && !mappedWorkFolders.some((folder) => folder.path === defaultWorkFolder)) { + mappedWorkFolders.unshift({ + name: basename(defaultWorkFolder) || "Default Work Folder", + path: defaultWorkFolder, + }); + } + + return { + workflowConfig, + workflows, + workFolders: mappedWorkFolders, + }; +} + +async function loadTaskState(taskId, runId = "") { + return getTaskState(taskId, runId); +} + +export async function saveTaskUploads(runId, images) { + if (!runId || !Array.isArray(images) || images.length === 0) return []; + const data = await saveTaskUploadFiles(runId, images); + return Array.isArray(data.paths) ? data.paths : []; +} + +export async function normalizeImagePayload(runId, images) { + if (!Array.isArray(images) || images.length === 0) return []; + const paths = []; + const uploads = []; + for (const image of images) { + if (typeof image === "string" && image.trim()) { + paths.push(image.trim()); + continue; + } + if (image && typeof image === "object" && image.name && image.data) { + uploads.push(image); + } + } + if (uploads.length === 0) return paths; + return [...paths, ...(await saveTaskUploads(runId, uploads))]; +} + +export function buildTaskSnapshot(taskId, data, fallbackWorkFolder = "", runId = "") { + const state = data?.state || null; + const workFolder = + state?.originalWorkFolder || + state?.workFolder || + fallbackWorkFolder || + knownTasks.get(taskId)?.workFolder || + ""; + return { + key: `${deviceId}:${taskId}`, + deviceId, + taskId, + runId: state?.runId || runId || "", + workFolder, + state, + messages: data?.messages || {}, + outputArtifacts: data?.outputArtifacts || {}, + interactions: data?.interactions || {}, + updatedAt: state?.updated || new Date().toISOString(), + }; +} + +export async function getTaskSnapshot(taskId, fallbackWorkFolder = "", runId = "") { + const data = await loadTaskState(taskId, runId); + return buildTaskSnapshot(taskId, data, fallbackWorkFolder, runId); +} + +export async function listTaskSnapshots() { + const folders = await listWorkFolders().catch(() => []); + const tasks = []; + for (const folder of folders) { + for (const task of folder.tasks || []) { + try { + const snapshot = await getTaskSnapshot(task.taskId, folder.path, task.runId || ""); + knownTasks.set(task.taskId, { workFolder: snapshot.workFolder, runId: snapshot.runId }); + tasks.push(snapshot); + } catch (error) { + log(`failed to load task ${task.taskId}: ${error.message}`); + } + } + } + return tasks.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || "")); +} + +export async function pushTaskSnapshot(taskId) { + if (!isRelayOpen()) return; + try { + const task = await getTaskSnapshot(taskId); + knownTasks.set(taskId, { workFolder: task.workFolder, runId: task.runId }); + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.taskSnapshot, + protocolVersion: PROTOCOL_VERSION, + taskId, + task, + }); + } catch (error) { + log(`failed to push snapshot for ${taskId}: ${error.message}`); + } +} + +export async function bootstrapTasks() { + const folders = await listWorkFolders().catch(() => []); + for (const folder of folders) { + for (const task of folder.tasks || []) { + knownTasks.set(task.taskId, { workFolder: folder.path, runId: task.runId || "" }); + await pushTaskSnapshot(task.taskId); + } + } +} + +function shouldPushTaskSnapshot(event) { + return event?.type === "state" + || event?.type === "phase_content" + || event?.type === "phase_artifact" + || event?.type === "user_message" + || event?.type === "phase_interaction"; +} + +export function createRelayTaskSender(taskId, fallbackWorkFolder = "", fallbackRunId = "") { + return (event = {}) => { + if (event.type === "state" && event.state) { + knownTasks.set(taskId, { + workFolder: event.state.originalWorkFolder || event.state.workFolder || fallbackWorkFolder, + runId: event.state.runId || fallbackRunId || "", + }); + } + + sendToRelay({ + type: RELAY_DAEMON_MESSAGE_TYPES.taskEvent, + protocolVersion: PROTOCOL_VERSION, + taskId, + event, + }); + + if (shouldPushTaskSnapshot(event)) { + pushTaskSnapshot(taskId).catch((error) => { + log(`failed to push snapshot for ${taskId}: ${error.message}`); + }); + } + }; +} diff --git a/apps/desktop/renderer/src/lib/ai-api-config.test.ts b/apps/daemon/src/repositories/ai-api-config.test.ts similarity index 85% rename from apps/desktop/renderer/src/lib/ai-api-config.test.ts rename to apps/daemon/src/repositories/ai-api-config.test.ts index 0915f6b..eae8a5d 100644 --- a/apps/desktop/renderer/src/lib/ai-api-config.test.ts +++ b/apps/daemon/src/repositories/ai-api-config.test.ts @@ -4,34 +4,34 @@ import { join } from "path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; let testHomeDir = ""; -let originalUserDataDir; +let originalDevWorkflowHome; let originalHome; let originalAppData; let originalXdgConfigHome; async function loadConfigModule() { vi.resetModules(); - return import("../../../../../packages/core-models/config"); + return import("./config"); } describe("AI API config", () => { beforeEach(async () => { - originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalDevWorkflowHome = process.env.DEV_WORKFLOW_HOME; originalHome = process.env.HOME; originalAppData = process.env.APPDATA; originalXdgConfigHome = process.env.XDG_CONFIG_HOME; testHomeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + delete process.env.DEV_WORKFLOW_HOME; process.env.HOME = testHomeDir; process.env.APPDATA = join(testHomeDir, "AppData", "Roaming"); process.env.XDG_CONFIG_HOME = join(testHomeDir, ".config"); }); afterEach(async () => { - if (originalUserDataDir === undefined) { - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + if (originalDevWorkflowHome === undefined) { + delete process.env.DEV_WORKFLOW_HOME; } else { - process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + process.env.DEV_WORKFLOW_HOME = originalDevWorkflowHome; } if (originalHome === undefined) { delete process.env.HOME; @@ -118,9 +118,9 @@ describe("AI API config", () => { }); test("uses one shared config file for user and system settings", async () => { - const { CONFIG_FILE, SYSTEM_CONFIG_FILE, getDefaultDesktopUserDataDir, getSystemDesktopUserDataDir } = await loadConfigModule(); + const { CONFIG_FILE, SYSTEM_CONFIG_FILE, getDefaultDaemonDataDir, getSystemDaemonDataDir } = await loadConfigModule(); - expect(getSystemDesktopUserDataDir()).toBe(getDefaultDesktopUserDataDir()); + expect(getSystemDaemonDataDir()).toBe(getDefaultDaemonDataDir()); expect(SYSTEM_CONFIG_FILE).toBe(CONFIG_FILE); }); }); diff --git a/packages/core-models/config.ts b/apps/daemon/src/repositories/config.ts similarity index 72% rename from packages/core-models/config.ts rename to apps/daemon/src/repositories/config.ts index b87a22c..58c7d4a 100644 --- a/packages/core-models/config.ts +++ b/apps/daemon/src/repositories/config.ts @@ -6,34 +6,24 @@ import os from "os"; let runtimeBaseDir = ""; let runtimeStorageDir = ""; -function getPlatformUserDataDir() { - switch (process.platform) { - case "darwin": - return join(os.homedir(), "Library", "Application Support", "dev-Workflow"); - case "win32": - return join(process.env.APPDATA || join(os.homedir(), "AppData", "Roaming"), "dev-Workflow"); - default: - return join( - process.env.XDG_CONFIG_HOME || join(os.homedir(), ".config"), - "dev-Workflow" - ); - } +export function getDaemonStorageDir() { + return process.env.DEV_WORKFLOW_HOME || join(os.homedir(), ".dev-workflow"); } -export function getSharedDesktopUserDataDir() { - return getPlatformUserDataDir(); +export function getSharedDaemonDataDir() { + return getDaemonStorageDir(); } -export function getDefaultDesktopUserDataDir() { - return getPlatformUserDataDir(); +export function getDefaultDaemonDataDir() { + return getDaemonStorageDir(); } -export function getSystemDesktopUserDataDir() { - return getDefaultDesktopUserDataDir(); +export function getSystemDaemonDataDir() { + return getDefaultDaemonDataDir(); } -export const SYSTEM_CONFIG_FILE = join(getSystemDesktopUserDataDir(), "config.json"); -export const CONFIG_FILE = join(getDefaultDesktopUserDataDir(), "config.json"); +export const SYSTEM_CONFIG_FILE = join(getSystemDaemonDataDir(), "config.json"); +export const CONFIG_FILE = join(getDefaultDaemonDataDir(), "config.json"); function parseJson(raw) { try { @@ -92,40 +82,6 @@ export async function saveConfig(config) { await writeFile(CONFIG_FILE, JSON.stringify(config || {}, null, 2)); } -export async function readMobileAccessEnabled() { - const config = await readConfig(); - return config.mobileAccessEnabled === true; -} - -export async function saveMobileAccessEnabled(enabled) { - const config = await readConfig(); - config.mobileAccessEnabled = enabled === true; - await saveConfig(config); - return config.mobileAccessEnabled; -} - -export function normalizeAiBackendOverride(value) { - const backend = String(value || "").trim(); - return backend === "codex" ? "codex" : "claude"; -} - -export function readAiBackendOverrideSync() { - const config = readConfigSync(); - return normalizeAiBackendOverride(config.aiBackendOverride); -} - -export async function readAiBackendOverride() { - const config = await readConfig(); - return normalizeAiBackendOverride(config.aiBackendOverride); -} - -export async function saveAiBackendOverride(backend) { - const config = await readConfig(); - config.aiBackendOverride = normalizeAiBackendOverride(backend); - await saveConfig(config); - return config.aiBackendOverride; -} - const DEFAULT_AI_API_BASE_URL = "https://api.openai.com/v1"; function slugifyConfigId(value, fallback = "ai-api") { @@ -227,7 +183,7 @@ export function setRuntimeStorageDir(storageDir) { } export function getStorageDir() { - return runtimeStorageDir || getDefaultDesktopUserDataDir(); + return runtimeStorageDir || getDefaultDaemonDataDir(); } export async function getBaseDir() { @@ -235,7 +191,7 @@ export async function getBaseDir() { } export function getSystemBaseDir() { - return join(getSystemDesktopUserDataDir(), "tasks"); + return join(getSystemDaemonDataDir(), "tasks"); } export function getSkillsDir() { @@ -243,7 +199,7 @@ export function getSkillsDir() { } export function getSystemSkillsDir() { - return join(getSystemDesktopUserDataDir(), "skills"); + return join(getSystemDaemonDataDir(), "skills"); } export function getSkillsDirs() { @@ -257,7 +213,7 @@ export function getWorkflowDir() { } export function getSystemWorkflowDir() { - return join(getSystemDesktopUserDataDir(), "workflows"); + return join(getSystemDaemonDataDir(), "workflows"); } export function getWorkflowDirs() { diff --git a/packages/core-models/debug-events.ts b/apps/daemon/src/repositories/debug-events.ts similarity index 100% rename from packages/core-models/debug-events.ts rename to apps/daemon/src/repositories/debug-events.ts diff --git a/apps/daemon/src/repositories/legacy-storage-migration.test.ts b/apps/daemon/src/repositories/legacy-storage-migration.test.ts new file mode 100644 index 0000000..1b710e2 --- /dev/null +++ b/apps/daemon/src/repositories/legacy-storage-migration.test.ts @@ -0,0 +1,126 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +let homeDir = ""; +let originalDevWorkflowHome; +let originalHome; +let originalAppData; +let originalXdgConfigHome; + +async function loadModules() { + vi.resetModules(); + const config = await import("./config"); + const migration = await import("./migration"); + return { config, migration }; +} + +async function writeJson(file, value) { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, JSON.stringify(value, null, 2)); +} + +async function writeText(file, value) { + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, value); +} + +describe("legacy storage migration", () => { + beforeEach(async () => { + originalDevWorkflowHome = process.env.DEV_WORKFLOW_HOME; + originalHome = process.env.HOME; + originalAppData = process.env.APPDATA; + originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); + delete process.env.DEV_WORKFLOW_HOME; + process.env.HOME = homeDir; + process.env.APPDATA = join(homeDir, "AppData", "Roaming"); + process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); + }); + + afterEach(async () => { + if (originalDevWorkflowHome === undefined) { + delete process.env.DEV_WORKFLOW_HOME; + } else { + process.env.DEV_WORKFLOW_HOME = originalDevWorkflowHome; + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + if (originalXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + } + if (homeDir) await rm(homeDir, { recursive: true, force: true }); + homeDir = ""; + }); + + test("migrates config, workfolders, skills, and workflows into daemon storage", async () => { + const { config, migration } = await loadModules(); + const legacyDir = migration.getLegacyDesktopDataDirs()[0]; + + await writeJson(join(legacyDir, "config.json"), { + mobileAccessEnabled: true, + activeWorkflow: "legacy.json", + aiApiProfiles: [{ + id: "deepseek", + name: "DeepSeek", + baseUrl: "https://api.deepseek.com", + apiKey: "sk-test", + model: "deepseek-chat", + }], + deletedWorkflowFiles: ["deleted.json"], + }); + await writeJson(join(legacyDir, "tasks", "workfolders.json"), [ + { name: "Legacy", path: "/tmp/legacy", tasks: [] }, + ]); + await writeJson(join(legacyDir, "workflows", "legacy.json"), { + id: "legacy", + name: "Legacy", + version: 1, + runtime: { engine: "langgraph", backend: "claude", workspaceAccess: "read", options: {} }, + steps: [{ id: "done", type: "end", label: "Done" }], + }); + await writeText(join(legacyDir, "skills", "review", "SKILL.md"), [ + "---", + "name: review", + "description: Review code", + "---", + "", + "Review the change.", + "", + ].join("\n")); + + const result = await migration.migrateLegacyDesktopData(); + + expect(result.migrated).toBe(true); + const migratedConfig = JSON.parse(await readFile(config.CONFIG_FILE, "utf-8")); + expect(migratedConfig).toMatchObject({ + activeWorkflow: "legacy.json", + deletedWorkflowFiles: ["deleted.json"], + }); + expect(migratedConfig.aiBackendOverride).toBeUndefined(); + expect(migratedConfig.mobileAccessEnabled).toBeUndefined(); + expect(migratedConfig.aiApiProfiles).toHaveLength(1); + expect(migratedConfig.aiApiProfiles[0].id).toBe("deepseek"); + + expect(JSON.parse(await readFile(config.getWorkfoldersFile(await config.getBaseDir()), "utf-8"))).toEqual([ + { name: "Legacy", path: "/tmp/legacy", tasks: [] }, + ]); + expect(await readFile(join(config.getWorkflowDir(), "legacy.json"), "utf-8")).toContain('"name": "Legacy"'); + expect(await readFile(join(config.getSkillsDir(), "review", "SKILL.md"), "utf-8")).toContain("Review the change."); + + await migration.migrateLegacyDesktopData(); + const secondConfig = JSON.parse(await readFile(config.CONFIG_FILE, "utf-8")); + expect(secondConfig.aiApiProfiles).toHaveLength(1); + }); +}); diff --git a/apps/daemon/src/repositories/migration.ts b/apps/daemon/src/repositories/migration.ts new file mode 100644 index 0000000..53cdb5d --- /dev/null +++ b/apps/daemon/src/repositories/migration.ts @@ -0,0 +1,159 @@ +import { basename, join, resolve } from "path"; +import { cp, mkdir, readdir, readFile, stat, writeFile } from "fs/promises"; +import os from "os"; +import { + CONFIG_FILE, + getBaseDir, + getDaemonStorageDir, + getSkillsDir, + getWorkflowDir, + getWorkfoldersFile, +} from "./config"; + +async function pathExists(path) { + return Boolean(await stat(path).catch(() => null)); +} + +async function readJson(path, fallback) { + try { + return JSON.parse(await readFile(path, "utf-8")); + } catch { + return fallback; + } +} + +async function writeJsonIfChanged(path, value) { + const next = JSON.stringify(value, null, 2); + const current = await readFile(path, "utf-8").catch(() => ""); + if (current.trim() === next.trim()) return false; + await mkdir(resolve(path, ".."), { recursive: true }); + await writeFile(path, `${next}\n`); + return true; +} + +function uniquePaths(paths) { + const seen = new Set(); + return paths.filter((path) => { + const key = resolve(path); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function getLegacyDesktopDataDirs() { + const names = ["dev-Workflow", "dev-workflow"]; + if (process.platform === "darwin") { + return uniquePaths(names.map((name) => join(os.homedir(), "Library", "Application Support", name))); + } + if (process.platform === "win32") { + const baseDir = process.env.APPDATA || join(os.homedir(), "AppData", "Roaming"); + return uniquePaths(names.map((name) => join(baseDir, name))); + } + const baseDir = process.env.XDG_CONFIG_HOME || join(os.homedir(), ".config"); + return uniquePaths(names.map((name) => join(baseDir, name))); +} + +function isSamePath(a, b) { + return resolve(a) === resolve(b); +} + +function mergeConfig(targetConfig, legacyConfig) { + const nextConfig = { ...targetConfig }; + if (!nextConfig.activeWorkflow && legacyConfig.activeWorkflow) { + nextConfig.activeWorkflow = legacyConfig.activeWorkflow; + } + + const profilesById = new Map(); + for (const profile of Array.isArray(legacyConfig.aiApiProfiles) ? legacyConfig.aiApiProfiles : []) { + if (profile?.id) profilesById.set(profile.id, profile); + } + for (const profile of Array.isArray(targetConfig.aiApiProfiles) ? targetConfig.aiApiProfiles : []) { + if (profile?.id) profilesById.set(profile.id, profile); + } + if (profilesById.size > 0) nextConfig.aiApiProfiles = Array.from(profilesById.values()); + + const deletedWorkflowFiles = new Set([ + ...(Array.isArray(legacyConfig.deletedWorkflowFiles) ? legacyConfig.deletedWorkflowFiles : []), + ...(Array.isArray(targetConfig.deletedWorkflowFiles) ? targetConfig.deletedWorkflowFiles : []), + ]); + if (deletedWorkflowFiles.size > 0) nextConfig.deletedWorkflowFiles = Array.from(deletedWorkflowFiles); + + delete nextConfig.mobileAccessEnabled; + return nextConfig; +} + +function mergeWorkfolders(targetFolders, legacyFolders) { + const foldersByPath = new Map(); + for (const folder of Array.isArray(legacyFolders) ? legacyFolders : []) { + if (folder?.path) foldersByPath.set(folder.path, folder); + } + for (const folder of Array.isArray(targetFolders) ? targetFolders : []) { + if (folder?.path) foldersByPath.set(folder.path, folder); + } + return Array.from(foldersByPath.values()); +} + +async function copyDirectoryEntries(sourceDir, targetDir) { + const entries = await readdir(sourceDir, { withFileTypes: true }).catch(() => []); + let copied = 0; + await mkdir(targetDir, { recursive: true }); + for (const entry of entries) { + const sourcePath = join(sourceDir, entry.name); + const targetPath = join(targetDir, entry.name); + if (await pathExists(targetPath)) continue; + await cp(sourcePath, targetPath, { recursive: entry.isDirectory() }); + copied += 1; + } + return copied; +} + +async function migrateConfig(legacyDir) { + const legacyConfigFile = join(legacyDir, "config.json"); + if (!(await pathExists(legacyConfigFile))) return false; + const legacyConfig = await readJson(legacyConfigFile, {}); + const targetConfig = await readJson(CONFIG_FILE, {}); + return writeJsonIfChanged(CONFIG_FILE, mergeConfig(targetConfig, legacyConfig)); +} + +async function migrateWorkfolders(legacyDir) { + const legacyFile = join(legacyDir, "tasks", "workfolders.json"); + if (!(await pathExists(legacyFile))) return false; + const targetFile = getWorkfoldersFile(await getBaseDir()); + const legacyFolders = await readJson(legacyFile, []); + const targetFolders = await readJson(targetFile, []); + return writeJsonIfChanged(targetFile, mergeWorkfolders(targetFolders, legacyFolders)); +} + +async function migrateWorkflows(legacyDir) { + return copyDirectoryEntries(join(legacyDir, "workflows"), getWorkflowDir()); +} + +async function migrateSkills(legacyDir) { + const copied = await copyDirectoryEntries(join(legacyDir, "skills"), getSkillsDir()); + return copied > 0; +} + +export async function migrateLegacyDesktopData() { + const targetDir = getDaemonStorageDir(); + const result = { + migrated: false, + sources: [], + }; + + for (const legacyDir of getLegacyDesktopDataDirs()) { + if (isSamePath(legacyDir, targetDir) || !(await pathExists(legacyDir))) continue; + const source = { + path: legacyDir, + config: await migrateConfig(legacyDir), + workfolders: await migrateWorkfolders(legacyDir), + workflows: (await migrateWorkflows(legacyDir)) > 0, + skills: await migrateSkills(legacyDir), + }; + source.migrated = source.config || source.workfolders || source.workflows || source.skills; + if (source.migrated) result.migrated = true; + result.sources.push(source); + } + + return result; +} diff --git a/apps/desktop/renderer/src/lib/skills-storage.test.ts b/apps/daemon/src/repositories/skills-storage.test.ts similarity index 87% rename from apps/desktop/renderer/src/lib/skills-storage.test.ts rename to apps/daemon/src/repositories/skills-storage.test.ts index 4c23b67..a15512d 100644 --- a/apps/desktop/renderer/src/lib/skills-storage.test.ts +++ b/apps/daemon/src/repositories/skills-storage.test.ts @@ -4,15 +4,15 @@ import { join } from "path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; let homeDir = ""; -let originalUserDataDir; +let originalDevWorkflowHome; let originalHome; let originalAppData; let originalXdgConfigHome; async function loadModules() { vi.resetModules(); - const config = await import("../../../../../packages/core-models/config"); - const skills = await import("../../../../../packages/core-models/skills"); + const config = await import("./config"); + const skills = await import("./skills"); return { config, skills }; } @@ -35,22 +35,22 @@ async function writeSkill(root, slug, name, description, body) { describe("managed skills storage", () => { beforeEach(async () => { - originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalDevWorkflowHome = process.env.DEV_WORKFLOW_HOME; originalHome = process.env.HOME; originalAppData = process.env.APPDATA; originalXdgConfigHome = process.env.XDG_CONFIG_HOME; homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + delete process.env.DEV_WORKFLOW_HOME; process.env.HOME = homeDir; process.env.APPDATA = join(homeDir, "AppData", "Roaming"); process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); }); afterEach(async () => { - if (originalUserDataDir === undefined) { - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + if (originalDevWorkflowHome === undefined) { + delete process.env.DEV_WORKFLOW_HOME; } else { - process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + process.env.DEV_WORKFLOW_HOME = originalDevWorkflowHome; } if (originalHome === undefined) { delete process.env.HOME; diff --git a/packages/core-models/skills.ts b/apps/daemon/src/repositories/skills.ts similarity index 100% rename from packages/core-models/skills.ts rename to apps/daemon/src/repositories/skills.ts diff --git a/packages/core-models/state.ts b/apps/daemon/src/repositories/state.ts similarity index 99% rename from packages/core-models/state.ts rename to apps/daemon/src/repositories/state.ts index 3a60c55..dedfb70 100644 --- a/packages/core-models/state.ts +++ b/apps/daemon/src/repositories/state.ts @@ -134,6 +134,7 @@ export function makeInitialState(taskId, workFolder, baseDir, taskInputs, option stepDecisions: {}, pendingMessages: {}, pendingImagePaths: {}, + revisionMessages: {}, logs: [], }; } diff --git a/apps/desktop/renderer/src/lib/workflow-storage.test.ts b/apps/daemon/src/repositories/workflow-storage.test.ts similarity index 83% rename from apps/desktop/renderer/src/lib/workflow-storage.test.ts rename to apps/daemon/src/repositories/workflow-storage.test.ts index b19a525..ce801d7 100644 --- a/apps/desktop/renderer/src/lib/workflow-storage.test.ts +++ b/apps/daemon/src/repositories/workflow-storage.test.ts @@ -4,15 +4,15 @@ import { join } from "path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; let homeDir = ""; -let originalUserDataDir; +let originalDevWorkflowHome; let originalHome; let originalAppData; let originalXdgConfigHome; async function loadModules() { vi.resetModules(); - const config = await import("../../../../../packages/core-models/config"); - const workflow = await import("../../../../../packages/core-models/workflow"); + const config = await import("./config"); + const workflow = await import("./workflow"); return { config, workflow }; } @@ -44,22 +44,22 @@ async function writeWorkflow(dir, filename, name) { describe("workflow storage", () => { beforeEach(async () => { - originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalDevWorkflowHome = process.env.DEV_WORKFLOW_HOME; originalHome = process.env.HOME; originalAppData = process.env.APPDATA; originalXdgConfigHome = process.env.XDG_CONFIG_HOME; homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + delete process.env.DEV_WORKFLOW_HOME; process.env.HOME = homeDir; process.env.APPDATA = join(homeDir, "AppData", "Roaming"); process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); }); afterEach(async () => { - if (originalUserDataDir === undefined) { - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + if (originalDevWorkflowHome === undefined) { + delete process.env.DEV_WORKFLOW_HOME; } else { - process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + process.env.DEV_WORKFLOW_HOME = originalDevWorkflowHome; } if (originalHome === undefined) { delete process.env.HOME; diff --git a/packages/core-models/workflow.ts b/apps/daemon/src/repositories/workflow.ts similarity index 99% rename from packages/core-models/workflow.ts rename to apps/daemon/src/repositories/workflow.ts index 6004f77..9b66714 100644 --- a/packages/core-models/workflow.ts +++ b/apps/daemon/src/repositories/workflow.ts @@ -1,7 +1,7 @@ import { join } from "path"; import { existsSync, readFileSync, readdirSync } from "fs"; import { readConfigSync, getWorkflowDir, getWorkflowDirs } from "./config"; -import { validateWorkflowDsl } from "../core-lib/langgraph-runtime/index"; +import { validateWorkflowDsl } from "../runtime/langgraph-runtime/index"; let WORKFLOW = null; let STEP_ORDER = []; diff --git a/apps/desktop/renderer/src/lib/workfolders-storage.test.ts b/apps/daemon/src/repositories/workfolders-storage.test.ts similarity index 56% rename from apps/desktop/renderer/src/lib/workfolders-storage.test.ts rename to apps/daemon/src/repositories/workfolders-storage.test.ts index e3e5582..b981e9d 100644 --- a/apps/desktop/renderer/src/lib/workfolders-storage.test.ts +++ b/apps/daemon/src/repositories/workfolders-storage.test.ts @@ -1,18 +1,18 @@ -import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { dirname, join } from "path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; let homeDir = ""; -let originalUserDataDir; +let originalDevWorkflowHome; let originalHome; let originalAppData; let originalXdgConfigHome; async function loadModules() { vi.resetModules(); - const config = await import("../../../../../packages/core-models/config"); - const workfolders = await import("../../../../../packages/core-models/workfolders"); + const config = await import("./config"); + const workfolders = await import("./workfolders"); return { config, workfolders }; } @@ -23,22 +23,22 @@ async function writeWorkfolders(file, folders) { describe("workfolders storage", () => { beforeEach(async () => { - originalUserDataDir = process.env.DEV_WORKFLOW_USER_DATA_DIR; + originalDevWorkflowHome = process.env.DEV_WORKFLOW_HOME; originalHome = process.env.HOME; originalAppData = process.env.APPDATA; originalXdgConfigHome = process.env.XDG_CONFIG_HOME; homeDir = await mkdtemp(join(tmpdir(), "dev-workflow-home-")); - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + delete process.env.DEV_WORKFLOW_HOME; process.env.HOME = homeDir; process.env.APPDATA = join(homeDir, "AppData", "Roaming"); process.env.XDG_CONFIG_HOME = join(homeDir, ".config"); }); afterEach(async () => { - if (originalUserDataDir === undefined) { - delete process.env.DEV_WORKFLOW_USER_DATA_DIR; + if (originalDevWorkflowHome === undefined) { + delete process.env.DEV_WORKFLOW_HOME; } else { - process.env.DEV_WORKFLOW_USER_DATA_DIR = originalUserDataDir; + process.env.DEV_WORKFLOW_HOME = originalDevWorkflowHome; } if (originalHome === undefined) { delete process.env.HOME; @@ -94,4 +94,58 @@ describe("workfolders storage", () => { { name: "User", path: "/tmp/user", tasks: [] }, ]); }); + + test("deletes stale task entries when the task state file is missing", async () => { + const { config, workfolders } = await loadModules(); + const userBaseDir = await config.getBaseDir(); + await writeWorkfolders(config.getWorkfoldersFile(userBaseDir), [ + { + name: "Project", + path: "/tmp/project", + tasks: [{ taskId: "task-1", runId: "run-1", status: "failed" }], + }, + ]); + + await workfolders.deleteTask("task-1", "run-1"); + + expect(JSON.parse(await readFile(config.getWorkfoldersFile(userBaseDir), "utf-8"))).toEqual([ + { + name: "Project", + path: "/tmp/project", + tasks: [], + }, + ]); + }); + + test("deletes task state and workfolder entry by run id", async () => { + const { config, workfolders } = await loadModules(); + const userBaseDir = await config.getBaseDir(); + const runDir = join(userBaseDir, "run-1"); + await mkdir(runDir, { recursive: true }); + await writeFile(join(runDir, "workflow-state.json"), JSON.stringify({ + taskId: "task-1", + runId: "run-1", + worktree: { + enabled: false, + }, + })); + await writeWorkfolders(config.getWorkfoldersFile(userBaseDir), [ + { + name: "Project", + path: "/tmp/project", + tasks: [{ taskId: "task-1", runId: "run-1", status: "in_progress" }], + }, + ]); + + await workfolders.deleteTask("task-1", "run-1", { removeWorktree: true }); + + expect(JSON.parse(await readFile(config.getWorkfoldersFile(userBaseDir), "utf-8"))).toEqual([ + { + name: "Project", + path: "/tmp/project", + tasks: [], + }, + ]); + await expect(readFile(join(runDir, "workflow-state.json"), "utf-8")).rejects.toThrow(); + }); }); diff --git a/packages/core-models/workfolders.ts b/apps/daemon/src/repositories/workfolders.ts similarity index 98% rename from packages/core-models/workfolders.ts rename to apps/daemon/src/repositories/workfolders.ts index ff53c43..04367b0 100644 --- a/packages/core-models/workfolders.ts +++ b/apps/daemon/src/repositories/workfolders.ts @@ -2,7 +2,7 @@ import { dirname, join } from "path"; import { readFile, writeFile, mkdir, rm } from "fs/promises"; import { getBaseDir, getSystemBaseDir, getWorkfoldersFile } from "./config"; import { assertSafeRunId, readState, getTaskRunId, writeState } from "./state"; -import { removeWorktree } from "../core-lib/worktree"; +import { removeWorktree } from "../runtime/worktree"; function getStoredTaskId(task) { return task?.taskId || ""; @@ -163,8 +163,8 @@ export async function deleteTask(taskId, runId = "", options = {}) { let state = null; try { state = await readState(taskId, requestedRunId); - } catch (error) { - if (requestedRunId) throw error; + } catch { + state = null; } if (shouldRemoveWorktree && state?.worktree?.enabled) { diff --git a/packages/core-lib/claude.ts b/apps/daemon/src/runtime/claude.ts similarity index 96% rename from packages/core-lib/claude.ts rename to apps/daemon/src/runtime/claude.ts index 324f9eb..4a78bfc 100644 --- a/packages/core-lib/claude.ts +++ b/apps/daemon/src/runtime/claude.ts @@ -1,7 +1,7 @@ import { join } from "path"; import { readFile } from "fs/promises"; -import { getWorkflow, interpolate } from "../core-models/workflow"; -import { readState, readPhaseMessages, taskDir } from "../core-models/state"; +import { getWorkflow, interpolate } from "../repositories/workflow"; +import { readState, readPhaseMessages, taskDir } from "../repositories/state"; export const AI_BACKENDS = { CLAUDE: "claude", @@ -52,6 +52,7 @@ export function stopActiveWorkflow(taskId, runId = "") { const wf = activeWorkflows.get(taskId); if (!wf) return; if (runId && wf.runId && wf.runId !== runId) return; + wf.send = null; if (wf.abortController) { try { wf.abortController.abort(); } catch {} wf.abortController = null; diff --git a/packages/core-lib/langgraph-runtime/app-adapter.ts b/apps/daemon/src/runtime/langgraph-runtime/app-adapter.ts similarity index 95% rename from packages/core-lib/langgraph-runtime/app-adapter.ts rename to apps/daemon/src/runtime/langgraph-runtime/app-adapter.ts index 440df85..4bcf53f 100644 --- a/packages/core-lib/langgraph-runtime/app-adapter.ts +++ b/apps/daemon/src/runtime/langgraph-runtime/app-adapter.ts @@ -1,8 +1,8 @@ import OpenAI from "openai"; import { mkdir, readFile, writeFile } from "fs/promises"; import { dirname, relative, resolve } from "path"; -import { readAiApiProfileSync, readAiApiProfilesSync } from "../../core-models/config"; -import { appendPhaseInteraction, appendToPhaseFile, readPhaseInteractions, readState, taskDir, updatePhaseStatus, writeState } from "../../core-models/state"; +import { readAiApiProfileSync, readAiApiProfilesSync } from "../../repositories/config"; +import { appendPhaseInteraction, appendToPhaseFile, readPhaseInteractions, readState, taskDir, updatePhaseStatus, writeState } from "../../repositories/state"; import { createSdkAgentAdapter } from "./sdk-agent-adapter"; import { createContentPreview, createContentSummary, createStepOutputMetadata, formatStepOutputForPrompt } from "./artifacts"; @@ -47,6 +47,10 @@ function renderTemplate(template, state) { function renderAiApiPrompt(step, state, inputSections = []) { const parts = []; + const pendingMessage = String(state.pendingMessages?.[step.id] || "").trim(); + if (pendingMessage) { + parts.push(["# User feedback", "", pendingMessage].join("\n")); + } if (step.instructions) parts.push(renderTemplate(step.instructions, state)); if (step.prompt) parts.push(renderTemplate(step.prompt, state)); if (inputSections.length > 0) parts.push(["# Declared inputs", "", inputSections.join("\n\n")].join("\n")); @@ -137,7 +141,7 @@ async function publishCheckpointOutput({ taskId, runId, step, state, rule }) { }; } -export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, taskRunDir, imagePaths = [], abortController, aiBackendOverride = "" }) { +export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, taskRunDir, imagePaths = [], abortController }) { async function markStepRunning(phase) { if (!phase) return; const state = await readState(taskId, runId); @@ -329,16 +333,13 @@ export function createAppSdkAgentAdapter({ taskId, runId, send, workFolder, task async runAgent({ step, agent, state, sessionId, sessionKey }) { const runtimeSessionKey = sessionKey || step.id; - const hasStepBackend = Boolean(String(step.backend || "").trim()); - const useBackendOverride = Boolean(aiBackendOverride && !hasStepBackend && agent.backend !== "ai_api"); - const runtimeAgent = useBackendOverride ? { ...agent, backend: aiBackendOverride, model: "" } : agent; - if (runtimeAgent.backend === "ai_api") return runAiApiStep({ step, state }); + if (agent.backend === "ai_api") return runAiApiStep({ step, state }); await markStepRunning(step.id); - await appendPhaseStart(step.id, runtimeAgent.backend); - send({ type: "backend_selected", phase: step.id, backend: runtimeAgent.backend, mode: useBackendOverride ? "app" : "workflow" }); + await appendPhaseStart(step.id, agent.backend); + send({ type: "backend_selected", phase: step.id, backend: agent.backend, mode: "workflow" }); const result = await adapter.runAgent({ step, - agent: runtimeAgent, + agent, state, sessionId, sessionKey: runtimeSessionKey, diff --git a/packages/core-lib/langgraph-runtime/artifacts.ts b/apps/daemon/src/runtime/langgraph-runtime/artifacts.ts similarity index 100% rename from packages/core-lib/langgraph-runtime/artifacts.ts rename to apps/daemon/src/runtime/langgraph-runtime/artifacts.ts diff --git a/apps/daemon/src/runtime/langgraph-runtime/builder.test.ts b/apps/daemon/src/runtime/langgraph-runtime/builder.test.ts new file mode 100644 index 0000000..20fe212 --- /dev/null +++ b/apps/daemon/src/runtime/langgraph-runtime/builder.test.ts @@ -0,0 +1,120 @@ +import { Command } from "@langchain/langgraph"; +import { describe, expect, test } from "vitest"; +import { buildWorkflowGraphFromDsl, createMemoryCheckpointer } from "./index"; + +function buildWorkflow() { + return { + id: "revision-workflow", + name: "Revision Workflow", + version: 1, + runtime: { + engine: "langgraph", + backend: "codex", + workspaceAccess: "write", + options: {}, + }, + steps: [ + { + id: "implement", + type: "agent", + label: "Implement", + next: "code_review", + prompt: "Implement {{task}}", + outputs: [{ key: "implementation", kind: "markdown", filename: "implementation.md" }], + }, + { + id: "code_review", + type: "agent", + label: "Code Review", + next: "risk_gate", + prompt: "Review implementation", + outputs: [{ key: "review", kind: "markdown", filename: "review.md" }], + }, + { + id: "risk_gate", + type: "condition", + label: "Risk Gate", + prompt: "Check risk", + passTo: "review", + failTo: "implement", + outputs: [{ key: "risk", kind: "markdown", filename: "risk.md" }], + }, + { + id: "review", + type: "checkpoint", + label: "Review", + question: "Approve?", + approve: "done", + rejectTo: "implement", + rejectTargets: ["implement"], + }, + { + id: "done", + type: "end", + label: "Done", + }, + ], + }; +} + +describe("buildWorkflowGraphFromDsl revision messages", () => { + test("keeps checkpoint rejection notes on AI steps before the checkpoint", async () => { + const seenPendingMessages = { + implement: [], + code_review: [], + risk_gate: [], + }; + let riskRunCount = 0; + const graph = buildWorkflowGraphFromDsl(buildWorkflow(), { + async runAgent({ step, state }) { + if (step.id === "implement") { + seenPendingMessages.implement.push(state.pendingMessages?.implement || ""); + return { + content: "implemented", + sessionId: state.sessionMap?.implement || "implement-session", + }; + } + if (step.id === "code_review") { + seenPendingMessages.code_review.push(state.pendingMessages?.code_review || ""); + return { + content: "reviewed", + sessionId: state.sessionMap?.code_review || "code-review-session", + }; + } + + riskRunCount += 1; + seenPendingMessages.risk_gate.push(state.pendingMessages?.risk_gate || ""); + return { + content: riskRunCount === 1 || riskRunCount === 3 + ? "{\"passed\": true}" + : "{\"passed\": false, \"reason\": \"missing verification\"}", + }; + }, + }, { checkpointer: createMemoryCheckpointer() }); + const config = { configurable: { thread_id: "revision-test" } }; + + await graph.invoke({ + taskId: "task-1", + runId: "run-1", + workFolder: "/repo", + taskDir: "/task", + currentStep: "implement", + overallStatus: "in_progress", + taskInputs: { task: "make it red" }, + }, config); + + await graph.invoke(new Command({ + resume: { + approved: false, + rejectTo: "implement", + notes: "make it green", + }, + }), config); + + expect(seenPendingMessages).toEqual({ + implement: ["", "make it green", "make it green"], + code_review: ["", "make it green", "make it green"], + risk_gate: ["", "make it green", "make it green"], + }); + }); +}); diff --git a/packages/core-lib/langgraph-runtime/builder.ts b/apps/daemon/src/runtime/langgraph-runtime/builder.ts similarity index 91% rename from packages/core-lib/langgraph-runtime/builder.ts rename to apps/daemon/src/runtime/langgraph-runtime/builder.ts index caaa1ae..272330f 100644 --- a/packages/core-lib/langgraph-runtime/builder.ts +++ b/apps/daemon/src/runtime/langgraph-runtime/builder.ts @@ -15,6 +15,7 @@ const WorkflowRuntimeState = Annotation.Root({ stepDecisions: Annotation({ default: () => ({}) }), pendingMessages: Annotation({ default: () => ({}) }), pendingImagePaths: Annotation({ default: () => ({}) }), + revisionMessages: Annotation({ default: () => ({}) }), logs: Annotation({ reducer: (left, right) => left.concat(right), default: () => [], @@ -41,6 +42,15 @@ function getNextStepId(step) { return step.next || ""; } +function getAutoStepIdsBefore(dsl, stepId) { + const ids = []; + for (const step of dsl.steps || []) { + if (step.id === stepId) break; + if (step.type === "agent" || step.type === "condition") ids.push(step.id); + } + return ids; +} + async function runAgentStep(step, dsl, adapters, state) { const agent = getRuntimeForStep(dsl, step); const sessionKey = step.id; @@ -59,9 +69,10 @@ async function runAgentStep(step, dsl, adapters, state) { const nextArtifacts = { ...(state.stepArtifacts || {}) }; if (hasStepOutputResult(result)) nextOutputs[step.id] = normalizeStepOutputMetadata(result); if (result?.artifactPath) nextArtifacts[step.id] = result.artifactPath; + const stepRevisionMessage = String(state.revisionMessages?.[step.id] || "").trim(); const nextPendingMessages = { ...(state.pendingMessages || {}) }; const nextPendingImagePaths = { ...(state.pendingImagePaths || {}) }; - nextPendingMessages[step.id] = ""; + nextPendingMessages[step.id] = stepRevisionMessage; nextPendingImagePaths[step.id] = []; return { @@ -74,6 +85,7 @@ async function runAgentStep(step, dsl, adapters, state) { stepArtifacts: nextArtifacts, pendingMessages: nextPendingMessages, pendingImagePaths: nextPendingImagePaths, + revisionMessages: state.revisionMessages || {}, logs: [`agent:${step.id}:${agent.backend}`], }, }; @@ -141,7 +153,7 @@ function createConditionNode(step, dsl, adapters) { }; } -function createCheckpointNode(step, adapters) { +function createCheckpointNode(step, dsl, adapters) { return async (state) => { const decision = interrupt({ stepId: step.id, @@ -161,7 +173,13 @@ function createCheckpointNode(step, adapters) { const rejectTo = (step.rejectTargets || []).includes(requestedRejectTo) ? requestedRejectTo : step.rejectTo; const pendingMessages = { ...(state.pendingMessages || {}) }; const pendingImagePaths = { ...(state.pendingImagePaths || {}) }; - if (!approved && decision?.notes) pendingMessages[rejectTo] = decision.notes; + const revisionMessages = { ...(state.revisionMessages || {}) }; + if (!approved && decision?.notes) { + for (const stepId of getAutoStepIdsBefore(dsl, step.id)) { + pendingMessages[stepId] = decision.notes; + revisionMessages[stepId] = decision.notes; + } + } const published = adapters.publishCheckpoint ? await adapters.publishCheckpoint({ step, @@ -185,6 +203,7 @@ function createCheckpointNode(step, adapters) { stepArtifacts: nextArtifacts, pendingMessages, pendingImagePaths, + revisionMessages, stepDecisions: { ...(state.stepDecisions || {}), [step.id]: { @@ -221,6 +240,7 @@ function createEndNode(step) { stepDecisions: state.stepDecisions || {}, pendingMessages: state.pendingMessages || {}, pendingImagePaths: state.pendingImagePaths || {}, + revisionMessages: state.revisionMessages || {}, logs: [`end:${step.id}`], }); } @@ -228,7 +248,7 @@ function createEndNode(step) { function createNode(step, dsl, adapters) { if (step.type === "agent") return createAgentNode(step, dsl, adapters); if (step.type === "condition") return createConditionNode(step, dsl, adapters); - if (step.type === "checkpoint") return createCheckpointNode(step, adapters); + if (step.type === "checkpoint") return createCheckpointNode(step, dsl, adapters); if (step.type === "end") return createEndNode(step); throw new Error(`unsupported step type ${step.type}`); } diff --git a/packages/core-lib/langgraph-runtime/dsl.ts b/apps/daemon/src/runtime/langgraph-runtime/dsl.ts similarity index 100% rename from packages/core-lib/langgraph-runtime/dsl.ts rename to apps/daemon/src/runtime/langgraph-runtime/dsl.ts diff --git a/packages/core-lib/langgraph-runtime/file-checkpointer.ts b/apps/daemon/src/runtime/langgraph-runtime/file-checkpointer.ts similarity index 100% rename from packages/core-lib/langgraph-runtime/file-checkpointer.ts rename to apps/daemon/src/runtime/langgraph-runtime/file-checkpointer.ts diff --git a/packages/core-lib/langgraph-runtime/index.ts b/apps/daemon/src/runtime/langgraph-runtime/index.ts similarity index 100% rename from packages/core-lib/langgraph-runtime/index.ts rename to apps/daemon/src/runtime/langgraph-runtime/index.ts diff --git a/apps/desktop/renderer/src/lib/sdk-agent-adapter.test.ts b/apps/daemon/src/runtime/langgraph-runtime/sdk-agent-adapter.test.ts similarity index 75% rename from apps/desktop/renderer/src/lib/sdk-agent-adapter.test.ts rename to apps/daemon/src/runtime/langgraph-runtime/sdk-agent-adapter.test.ts index d080ebd..a573aed 100644 --- a/apps/desktop/renderer/src/lib/sdk-agent-adapter.test.ts +++ b/apps/daemon/src/runtime/langgraph-runtime/sdk-agent-adapter.test.ts @@ -24,7 +24,7 @@ vi.mock("child_process", () => ({ }, })); -import { createSdkAgentAdapter } from "../../../../../packages/core-lib/langgraph-runtime"; +import { createSdkAgentAdapter } from "./index"; let taskDir = ""; @@ -155,4 +155,48 @@ describe("createSdkAgentAdapter output artifacts", () => { expect.objectContaining({ env: expect.any(Object), signal: expect.any(AbortSignal) }), ); }); + + test("resumes an existing Codex step session with only the pending user message", async () => { + const child = new EventEmitter(); + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + const stdinChunks = []; + child.stdin.on("data", (chunk) => stdinChunks.push(Buffer.from(chunk))); + mocks.spawn.mockReturnValue(child); + const adapter = createSdkAgentAdapter({ workFolder: "/project", taskDir }); + + const runPromise = adapter.runAgent(buildRun({ + agent: { + backend: "codex", + workspaceAccess: "write", + options: {}, + }, + state: { + taskId: "task-1", + runId: "run-1", + workFolder: "/project", + taskDir, + taskInputs: { task: "make it red" }, + stepOutputs: {}, + pendingMessages: { + review: "make it green", + }, + }, + sessionId: "codex-session", + })); + + await vi.waitFor(() => { + expect(mocks.spawn).toHaveBeenCalled(); + }); + child.stdout.write(`${JSON.stringify({ type: "thread.started", thread_id: "codex-session" })}\n`); + child.stdout.write(`${JSON.stringify({ type: "item.completed", item: { id: "msg-1", type: "agent_message", text: "Done" } })}\n`); + child.stdout.write(`${JSON.stringify({ type: "turn.completed", usage: null })}\n`); + child.stdout.end(); + child.emit("exit", 0, null); + + await runPromise; + + expect(Buffer.concat(stdinChunks).toString("utf-8")).toBe("make it green"); + }); }); diff --git a/packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts b/apps/daemon/src/runtime/langgraph-runtime/sdk-agent-adapter.ts similarity index 98% rename from packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts rename to apps/daemon/src/runtime/langgraph-runtime/sdk-agent-adapter.ts index d5ac6b8..ef76004 100644 --- a/packages/core-lib/langgraph-runtime/sdk-agent-adapter.ts +++ b/apps/daemon/src/runtime/langgraph-runtime/sdk-agent-adapter.ts @@ -468,10 +468,13 @@ export function createSdkAgentAdapter(options = {}) { ]; const abortController = options.abortController || new AbortController(); const workspaceWrite = canWriteWorkspace(step, agent); - const prompt = [ - buildWorkspaceAccessPrompt(workspaceWrite, workFolder, taskDir), - buildPrompt({ step, state }), - ].filter(Boolean).join("\n\n"); + const pendingMessage = String(state.pendingMessages?.[step.id] || "").trim(); + const prompt = sessionId && pendingMessage + ? pendingMessage + : [ + buildWorkspaceAccessPrompt(workspaceWrite, workFolder, taskDir), + buildPrompt({ step, state }), + ].filter(Boolean).join("\n\n"); let content = ""; let nextSessionId = sessionId || ""; diff --git a/apps/desktop/renderer/src/lib/workflow-dsl.test.ts b/apps/daemon/src/runtime/langgraph-runtime/workflow-dsl.test.ts similarity index 96% rename from apps/desktop/renderer/src/lib/workflow-dsl.test.ts rename to apps/daemon/src/runtime/langgraph-runtime/workflow-dsl.test.ts index 30329a9..0c4cd18 100644 --- a/apps/desktop/renderer/src/lib/workflow-dsl.test.ts +++ b/apps/daemon/src/runtime/langgraph-runtime/workflow-dsl.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { validateWorkflowDsl } from "../../../../../packages/core-lib/langgraph-runtime"; +import { validateWorkflowDsl } from "./index"; function buildWorkflow(overrides = {}) { return { diff --git a/apps/desktop/electron/workflow-runtime.ts b/apps/daemon/src/runtime/workflow-runtime.ts similarity index 97% rename from apps/desktop/electron/workflow-runtime.ts rename to apps/daemon/src/runtime/workflow-runtime.ts index d83f303..313dfb5 100644 --- a/apps/desktop/electron/workflow-runtime.ts +++ b/apps/daemon/src/runtime/workflow-runtime.ts @@ -2,8 +2,8 @@ import { Command, INTERRUPT, isInterrupted } from "@langchain/langgraph"; import OpenAI from "openai"; import { realpath, stat } from "fs/promises"; import { join, relative, resolve } from "path"; -import { getBaseDir, readAiApiProfileSync, readAiApiProfilesSync, readAiBackendOverrideSync } from "../../../packages/core-models/config"; -import { readManagedSkillContentSync } from "../../../packages/core-models/skills"; +import { getBaseDir, readAiApiProfileSync, readAiApiProfilesSync } from "../repositories/config"; +import { readManagedSkillContentSync } from "../repositories/skills"; import { assertSafeWorkflowFilename, getActiveWorkflowFile, @@ -14,7 +14,7 @@ import { getWorkflowStepType, isWorkflowAutoStep, readWorkflowFileSync, -} from "../../../packages/core-models/workflow"; +} from "../repositories/workflow"; import { appendPhaseInteraction, appendToPhaseFile, @@ -25,12 +25,12 @@ import { updatePhaseStatus, writeState, taskDir, -} from "../../../packages/core-models/state"; -import { WORKFLOW_DEBUG_EVENT_TYPES } from "../../../packages/core-models/debug-events"; -import { upsertTask } from "../../../packages/core-models/workfolders"; -import { activeWorkflows, getPhaseContent, normalizeAiBackend, normalizeWorktreeNamingProvider, readPhaseOutputArtifacts, stopActiveWorkflow, WORKTREE_NAMING_PROVIDERS } from "../../../packages/core-lib/claude"; -import { prepareWorktree, removeWorktree } from "../../../packages/core-lib/worktree"; -import { buildWorkflowGraphFromDsl, createAppSdkAgentAdapter, createSdkAgentAdapter, FileCheckpointSaver } from "../../../packages/core-lib/langgraph-runtime/index"; +} from "../repositories/state"; +import { WORKFLOW_DEBUG_EVENT_TYPES } from "../repositories/debug-events"; +import { upsertTask } from "../repositories/workfolders"; +import { activeWorkflows, getPhaseContent, normalizeAiBackend, normalizeWorktreeNamingProvider, readPhaseOutputArtifacts, stopActiveWorkflow, WORKTREE_NAMING_PROVIDERS } from "./claude"; +import { prepareWorktree, removeWorktree } from "./worktree"; +import { buildWorkflowGraphFromDsl, createAppSdkAgentAdapter, createSdkAgentAdapter, FileCheckpointSaver } from "./langgraph-runtime/index"; import { nanoid } from "nanoid"; const DEFAULT_WORKTREE_NAMING_SKILL = `Choose a concise Git branch name for this workflow run. @@ -199,6 +199,7 @@ function syncStateShape(langState, currentState) { stepDecisions: langState.stepDecisions || currentState.stepDecisions || {}, pendingMessages: langState.pendingMessages || currentState.pendingMessages || {}, pendingImagePaths: langState.pendingImagePaths || currentState.pendingImagePaths || {}, + revisionMessages: langState.revisionMessages || currentState.revisionMessages || {}, logs: langState.logs || currentState.logs || [], }; @@ -299,7 +300,7 @@ ${context}`; return isGenericWorktreeName(name) ? localFallback : name || localFallback; } - const backend = normalizeAiBackend(readAiBackendOverrideSync()); + const backend = normalizeAiBackend(workflow?.runtime?.backend); const adapter = createSdkAgentAdapter({ workFolder, taskDir: workFolder, @@ -354,7 +355,6 @@ async function emitExistingTaskFiles(taskId, state, send) { function createGraph(taskId, runId, wf, imagePaths = []) { const taskRunAbortController = new AbortController(); wf.abortController = taskRunAbortController; - const aiBackendOverride = readAiBackendOverrideSync(); const adapter = createAppSdkAgentAdapter({ taskId, runId, @@ -368,7 +368,6 @@ function createGraph(taskId, runId, wf, imagePaths = []) { taskRunDir: wf.taskRunDir, imagePaths, abortController: taskRunAbortController, - aiBackendOverride, }); return buildWorkflowGraphFromDsl(wf.workflow, adapter, { checkpointer: new FileCheckpointSaver(join(wf.taskRunDir, "checkpoints")) }); } @@ -608,6 +607,11 @@ export async function rejectWorkflow(taskId, rejectTo, reason = "", sender, runI await ensureCheckpointReady(taskId, stateRunId, state); const notes = String(reason || "").trim(); if (!notes) throw new Error("reject reason is required"); + state.revisionMessages = { + ...(state.revisionMessages || {}), + [rejectTo]: notes, + }; + await writeState(taskId, state); send({ type: "phase_rejected", phase: cur, rejectTo, requestedBy: "user", trigger: "checkpoint" }); await appendToPhaseFile(taskId, rejectTo, `\n\n---\n\n**You:** ${notes}\n\n`, stateRunId); const interaction = await appendPhaseInteraction(taskId, rejectTo, { diff --git a/apps/desktop/renderer/src/lib/worktree-config.test.ts b/apps/daemon/src/runtime/worktree-config.test.ts similarity index 92% rename from apps/desktop/renderer/src/lib/worktree-config.test.ts rename to apps/daemon/src/runtime/worktree-config.test.ts index 40a3e55..1eb6ac1 100644 --- a/apps/desktop/renderer/src/lib/worktree-config.test.ts +++ b/apps/daemon/src/runtime/worktree-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { normalizeWorktreeConfig } from "../../../../../packages/core-lib/worktree"; +import { normalizeWorktreeConfig } from "./worktree"; describe("normalizeWorktreeConfig", () => { test("uses built-in setup when custom setup is not enabled", () => { diff --git a/packages/core-lib/worktree.ts b/apps/daemon/src/runtime/worktree.ts similarity index 100% rename from packages/core-lib/worktree.ts rename to apps/daemon/src/runtime/worktree.ts diff --git a/apps/daemon/src/services/settingsService.ts b/apps/daemon/src/services/settingsService.ts new file mode 100644 index 0000000..68aa909 --- /dev/null +++ b/apps/daemon/src/services/settingsService.ts @@ -0,0 +1,35 @@ +import { + deleteAiApiProfile, + readAiApiProfilesForUi, + saveAiApiProfile, +} from "../repositories/config"; +import { getWorkflow, getWorkflowConfigShape } from "../repositories/workflow"; + +export async function getWorkflowConfig() { + const workflow = getWorkflow(); + const aiApiProfiles = await readAiApiProfilesForUi(); + if (!workflow) { + return { + ...getWorkflowConfigShape(null), + aiApiProfiles, + }; + } + return { + ...getWorkflowConfigShape(workflow), + aiApiProfiles, + }; +} + +export async function listAiApiProfiles() { + return { profiles: await readAiApiProfilesForUi() }; +} + +export async function saveAiApi(profile) { + const profiles = await saveAiApiProfile(profile); + return { profiles }; +} + +export async function deleteAiApi(id) { + const profiles = await deleteAiApiProfile(id); + return { profiles }; +} diff --git a/apps/daemon/src/services/skillService.ts b/apps/daemon/src/services/skillService.ts new file mode 100644 index 0000000..184a568 --- /dev/null +++ b/apps/daemon/src/services/skillService.ts @@ -0,0 +1,71 @@ +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { deleteManagedSkill, importManagedSkills, listManagedSkills, saveManagedSkill } from "../repositories/skills"; + +export async function generateSkill({ label, id, prompt, description }) { + if (!label) throw new Error("label is required"); + + const context = [ + `Phase label: ${label}`, + id ? `Phase ID: ${id}` : "", + prompt ? `Current prompt: ${prompt}` : "", + description ? `User description: ${description}` : "", + ].filter(Boolean).join("\n"); + + const metaPrompt = `You are generating a skill instruction for a workflow automation phase. The skill will guide an AI assistant (Claude) on how to execute this phase. + +Based on the following phase context, generate a concise, actionable skill instruction. The skill should describe what the AI should do, any constraints or best practices, and expected output format. + +${context} + +The output MUST start with YAML frontmatter containing name and description fields, followed by the skill body. Use this format: + +--- +name: +description: "" +--- + + + +Keep the skill body concise and actionable (under 500 words). No extra explanations outside the format above.`; + + let skill = ""; + for await (const message of query({ + prompt: metaPrompt, + options: { + cwd: process.cwd(), + permissionMode: "default", + maxTurns: 1, + }, + })) { + if (message.type === "result") { + if (message.subtype !== "success") { + throw new Error(message.errors?.join("; ") || message.result || "Failed to generate skill"); + } + skill = message.result?.trim() || ""; + } + } + + if (!skill) throw new Error("Failed to generate skill"); + return { skill }; +} + +export async function listSkills() { + return { skills: await listManagedSkills() }; +} + +export async function saveSkill(skill) { + await saveManagedSkill(skill); + return listSkills(); +} + +export async function deleteSkill(slug) { + await deleteManagedSkill(slug); + return listSkills(); +} + +export async function importSkillsFromPaths(filePaths = []) { + for (const filePath of filePaths) { + await importManagedSkills(filePath); + } + return { skills: await listManagedSkills() }; +} diff --git a/apps/daemon/src/services/systemService.ts b/apps/daemon/src/services/systemService.ts new file mode 100644 index 0000000..339e88c --- /dev/null +++ b/apps/daemon/src/services/systemService.ts @@ -0,0 +1,54 @@ +import { spawn } from "child_process"; + +function spawnDetached(command, args) { + return new Promise((resolve, reject) => { + const env = { ...process.env }; + delete env.NODE_OPTIONS; + const child = spawn(command, args, { + detached: true, + env, + stdio: "ignore", + }); + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +const EDITOR_OPENERS = { + code: { + command: "code", + args: (targetPath) => [targetPath], + darwinApp: "Visual Studio Code", + label: "VS Code", + }, + sublime: { + command: "subl", + args: (targetPath) => [targetPath], + darwinApp: "Sublime Text", + label: "Sublime Text", + }, + zed: { + command: "zed", + args: (targetPath) => [targetPath], + darwinApp: "Zed", + label: "Zed", + }, +}; + +export async function openPath(targetPath, editor = "code") { + if (!targetPath) throw new Error("path required"); + const opener = EDITOR_OPENERS[editor] || EDITOR_OPENERS.code; + try { + await spawnDetached(opener.command, opener.args(targetPath)); + return { ok: true }; + } catch (error) { + if (process.platform === "darwin") { + await spawnDetached("open", ["-a", opener.darwinApp, targetPath]); + return { ok: true }; + } + throw new Error(error?.message || `failed to open ${opener.label}`); + } +} diff --git a/apps/daemon/src/services/taskService.ts b/apps/daemon/src/services/taskService.ts new file mode 100644 index 0000000..7899ca3 --- /dev/null +++ b/apps/daemon/src/services/taskService.ts @@ -0,0 +1,85 @@ +import { basename, join, resolve } from "path"; +import { mkdir, readFile, stat, writeFile } from "fs/promises"; +import { getBaseDir } from "../repositories/config"; +import { assertSafeRunId, readPhaseInteractions, readState } from "../repositories/state"; +import { deleteTask, removeTaskWorktree } from "../repositories/workfolders"; +import { getPhaseContent, getPhaseOutputArtifactPath, readPhaseOutputArtifacts, stopActiveWorkflow } from "../runtime/claude"; +import { openPath } from "./systemService"; + +export async function getTaskState(taskId, runId = "") { + const state = await readState(taskId, runId); + const stateRunId = state.runId || runId; + const messages = {}; + const outputArtifacts = {}; + const interactions = {}; + for (const phase of state.phases) { + const content = await getPhaseContent(taskId, phase.id, stateRunId); + if (content) messages[phase.id] = content; + const phaseInteractions = await readPhaseInteractions(taskId, phase.id, stateRunId); + if (phaseInteractions.length > 0) interactions[phase.id] = phaseInteractions; + if (phase.status !== "pending") { + const phaseOutputArtifacts = await readPhaseOutputArtifacts(taskId, phase.id, stateRunId); + if (Object.keys(phaseOutputArtifacts).length > 0) { + outputArtifacts[phase.id] = phaseOutputArtifacts; + } + } + } + return { state, messages, outputArtifacts, interactions }; +} + +export async function getTaskOutputPath(taskId, runId = "", phaseId = "", outputKey = "") { + if (!taskId || !phaseId || !outputKey) throw new Error("document path required"); + const outputPath = await getPhaseOutputArtifactPath(taskId, phaseId, outputKey, runId); + if (!outputPath) throw new Error("document path not found"); + return outputPath; +} + +export async function openTaskOutput(taskId, runId = "", phaseId = "", outputKey = "", editor = "code") { + return openPath(await getTaskOutputPath(taskId, runId, phaseId, outputKey), editor); +} + +export async function removeTask(taskId, runId = "", options = {}) { + if (!taskId) throw new Error("taskId required"); + const safeRunId = assertSafeRunId(runId); + stopActiveWorkflow(taskId, safeRunId); + await deleteTask(taskId, safeRunId, { removeWorktree: Boolean(options?.removeWorktree) }); + return { ok: true }; +} + +export async function removeTaskWorktreeOnly(taskId, runId = "") { + if (!taskId) throw new Error("taskId required"); + const safeRunId = assertSafeRunId(runId); + stopActiveWorkflow(taskId, safeRunId); + return removeTaskWorktree(taskId, safeRunId); +} + +export async function saveTaskUploads(runId, filePaths) { + const safeRunId = assertSafeRunId(runId); + if (!safeRunId) throw new Error("runId required"); + if (!Array.isArray(filePaths)) return { paths: [] }; + + const baseDir = await getBaseDir(); + const uploadDir = join(baseDir, safeRunId, "uploads"); + await mkdir(uploadDir, { recursive: true }); + + const savedPaths = []; + for (const fileEntry of filePaths) { + if (typeof fileEntry === "string") { + const resolvedPath = resolve(fileEntry); + const fileStat = await stat(resolvedPath).catch(() => null); + if (!fileStat || !fileStat.isFile()) continue; + const filename = `${Date.now()}-${basename(resolvedPath)}`; + const target = join(uploadDir, filename); + await writeFile(target, await readFile(resolvedPath)); + savedPaths.push(target); + continue; + } + + if (!fileEntry || typeof fileEntry !== "object" || !fileEntry.name || !fileEntry.data) continue; + const target = join(uploadDir, `${Date.now()}-${basename(fileEntry.name)}`); + await writeFile(target, Buffer.from(fileEntry.data)); + savedPaths.push(target); + } + + return { paths: savedPaths }; +} diff --git a/apps/daemon/src/services/workflowService.ts b/apps/daemon/src/services/workflowService.ts new file mode 100644 index 0000000..cd0e4eb --- /dev/null +++ b/apps/daemon/src/services/workflowService.ts @@ -0,0 +1,173 @@ +import { join } from "path"; +import { mkdir, readFile, readdir, stat, unlink, writeFile } from "fs/promises"; +import { + readConfig, + saveConfig, + getWorkflowDir, + getWorkflowDirs, +} from "../repositories/config"; +import { + assertSafeWorkflowFilename, + getWorkflow, + getActiveWorkflowFile, + setActiveWorkflowFile, + deriveTaskInputFields, + getWorkflowConfigShape, + getWorkflowFilePath, + loadWorkflow, + unloadWorkflow, +} from "../repositories/workflow"; +import { validateWorkflowDsl } from "../runtime/langgraph-runtime/index"; + +async function ensureWorkflowDir() { + const workflowDir = getWorkflowDir(); + await mkdir(workflowDir, { recursive: true }); + return workflowDir; +} + +export async function listWorkflows() { + await ensureWorkflowDir(); + try { + const config = await readConfig(); + const deletedWorkflowFiles = new Set(Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []); + const files = Array.from(new Set((await Promise.all( + getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) + )).flat())).filter((file) => !deletedWorkflowFiles.has(file)).sort(); + const workflows = []; + for (const file of files) { + if (!file.endsWith(".json")) continue; + try { + const raw = JSON.parse(await readFile(getWorkflowFilePath(file), "utf-8")); + const workflowConfig = getWorkflowConfigShape(raw); + workflows.push({ + filename: file, + name: raw.name || file, + visible: raw.visible !== false, + phaseCount: workflowConfig.phaseOrder.length, + phaseOrder: workflowConfig.phaseOrder, + groups: workflowConfig.groups, + workflowConfig, + taskInputFields: deriveTaskInputFields(raw), + worktree: raw.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" }, + }); + } catch {} + } + return { workflows, activeWorkflow: getActiveWorkflowFile() }; + } catch { + return { workflows: [], activeWorkflow: getActiveWorkflowFile() }; + } +} + +export async function getWorkflowByFilename(filename) { + const safeFilename = assertSafeWorkflowFilename(filename); + const raw = await readFile(getWorkflowFilePath(safeFilename), "utf-8"); + return JSON.parse(raw); +} + +export async function setWorkflowVisible(filename, visible) { + const safeFilename = assertSafeWorkflowFilename(filename); + const workflowDir = await ensureWorkflowDir(); + const filepath = join(workflowDir, safeFilename); + const raw = JSON.parse(await readFile(getWorkflowFilePath(safeFilename), "utf-8")); + raw.visible = visible === true; + await writeFile(filepath, JSON.stringify(raw, null, 2)); + if (safeFilename === getActiveWorkflowFile()) loadWorkflow(filepath); + return { filename: safeFilename, visible: raw.visible }; +} + +export async function createWorkflow(workflow) { + return createWorkflowFile(workflow, false); +} + +export async function createWorkflowDraft(workflow) { + return createWorkflowFile(workflow, true); +} + +async function createWorkflowFile(workflow, isDraft) { + const data = isDraft ? workflow : validateWorkflowDsl(workflow); + if (!String(data.name || "").trim()) throw new Error("workflow name is required"); + const filename = data.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + ".json"; + const workflowDir = await ensureWorkflowDir(); + const filepath = join(workflowDir, filename); + const config = await readConfig(); + const deletedWorkflowFiles = Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []; + const existing = await stat(getWorkflowFilePath(filename)).catch(() => null); + if (existing && !deletedWorkflowFiles.includes(filename)) throw new Error("workflow with this name already exists"); + await writeFile(filepath, JSON.stringify(data, null, 2)); + config.deletedWorkflowFiles = deletedWorkflowFiles.filter((file) => file !== filename); + if (!isDraft && !getWorkflow()) { + loadWorkflow(filepath); + setActiveWorkflowFile(filename); + config.activeWorkflow = filename; + } + await saveConfig(config); + return { filename, name: data.name }; +} + +export async function updateWorkflow(filename, workflow) { + return updateWorkflowFile(filename, workflow, false); +} + +export async function updateWorkflowDraft(filename, workflow) { + return updateWorkflowFile(filename, workflow, true); +} + +async function updateWorkflowFile(filename, workflow, isDraft) { + const safeFilename = assertSafeWorkflowFilename(filename); + const data = isDraft ? workflow : validateWorkflowDsl(workflow); + if (!String(data.name || "").trim()) throw new Error("workflow name is required"); + const workflowDir = await ensureWorkflowDir(); + await writeFile(join(workflowDir, safeFilename), JSON.stringify(data, null, 2)); + const config = await readConfig(); + config.deletedWorkflowFiles = (config.deletedWorkflowFiles || []).filter((file) => file !== safeFilename); + await saveConfig(config); + if (!isDraft && safeFilename === getActiveWorkflowFile()) { + loadWorkflow(join(workflowDir, safeFilename)); + } + return { filename: safeFilename, name: data.name }; +} + +export async function removeWorkflow(filename) { + const safeFilename = assertSafeWorkflowFilename(filename); + const workflowDir = await ensureWorkflowDir(); + const files = (await readdir(workflowDir)).filter((file) => file.endsWith(".json")); + const userFileExists = files.includes(safeFilename); + const targetExists = await stat(getWorkflowFilePath(safeFilename)).catch(() => null); + const systemFileExists = await Promise.all( + getWorkflowDirs().slice(1).map((dir) => stat(join(dir, safeFilename)).catch(() => null)) + ).then((stats) => stats.some(Boolean)); + if (!targetExists) throw new Error("workflow not found"); + + if (userFileExists) { + await unlink(join(workflowDir, safeFilename)); + } + const config = await readConfig(); + if (!userFileExists || systemFileExists) { + config.deletedWorkflowFiles = Array.from(new Set([...(config.deletedWorkflowFiles || []), safeFilename])); + } + if (getActiveWorkflowFile() === safeFilename) { + const remaining = (await listWorkflows()).workflows.map((workflow) => workflow.filename).filter((file) => file !== safeFilename).sort(); + if (remaining.length > 0) { + const nextWorkflow = remaining[0]; + setActiveWorkflowFile(nextWorkflow); + loadWorkflow(getWorkflowFilePath(nextWorkflow)); + config.activeWorkflow = nextWorkflow; + } else { + unloadWorkflow(); + delete config.activeWorkflow; + } + } + await saveConfig(config); + return { ok: true }; +} + +export async function activateWorkflow(filename) { + const safeFilename = assertSafeWorkflowFilename(filename); + await ensureWorkflowDir(); + loadWorkflow(getWorkflowFilePath(safeFilename)); + setActiveWorkflowFile(safeFilename); + const config = await readConfig(); + config.activeWorkflow = safeFilename; + await saveConfig(config); + return { activeWorkflow: safeFilename }; +} diff --git a/apps/daemon/src/services/workfolderService.ts b/apps/daemon/src/services/workfolderService.ts new file mode 100644 index 0000000..e967af6 --- /dev/null +++ b/apps/daemon/src/services/workfolderService.ts @@ -0,0 +1,43 @@ +import { basename, resolve } from "path"; +import { stat } from "fs/promises"; +import { assertSafeRunId, getTaskRunId, readState } from "../repositories/state"; +import { readWorkfolders, removeWorkfolder, saveWorkfolders } from "../repositories/workfolders"; + +export async function listWorkFolders() { + const folders = await readWorkfolders(); + for (const folder of folders) { + if (!folder.tasks) continue; + for (const task of folder.tasks) { + try { + const runId = assertSafeRunId(task.runId || await getTaskRunId(task.taskId)); + if (!runId) throw new Error("task not found"); + const state = await readState(task.taskId, runId); + task.status = state.overallStatus || task.status; + task.phases = state.phases || []; + task.runId = state.runId || runId; + task.workflowConfig = state.workflowConfig || null; + } catch { + task.phases = []; + } + } + } + return folders; +} + +export async function addWorkFolder(folderPath) { + if (!folderPath) throw new Error("path required"); + const absPath = resolve(folderPath); + const fileStat = await stat(absPath).catch(() => null); + if (!fileStat) throw new Error("path does not exist"); + if (!fileStat.isDirectory()) throw new Error("not a directory"); + const folders = await readWorkfolders(); + if (folders.some((folder) => folder.path === absPath)) throw new Error("folder already added"); + folders.push({ name: basename(absPath), path: absPath, tasks: [] }); + await saveWorkfolders(folders); + return folders; +} + +export async function removeWorkFolder(folderPath) { + if (!folderPath) throw new Error("path required"); + return removeWorkfolder(folderPath); +} diff --git a/apps/daemon/tsconfig.json b/apps/daemon/tsconfig.json new file mode 100644 index 0000000..84dc08c --- /dev/null +++ b/apps/daemon/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "noCheck": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/desktop/connector/src/index.ts b/apps/desktop/connector/src/index.ts deleted file mode 100644 index baa3741..0000000 --- a/apps/desktop/connector/src/index.ts +++ /dev/null @@ -1,524 +0,0 @@ -import { basename } from "path"; -import WebSocket from "ws"; -import { readMobileAccessEnabled } from "../../../../packages/core-models/config"; - -const backendUrl = process.env.CLOUD_BACKEND_WS_URL || "ws://127.0.0.1:8787/ws/desktop"; -const localWsUrl = process.env.LOCAL_WORKFLOW_WS_URL || "ws://127.0.0.1:3000/ws"; -const localApiBase = process.env.LOCAL_WORKFLOW_API_BASE || "http://127.0.0.1:3000/api"; -const deviceId = process.env.DEVICE_ID || "desktop-local"; -const deviceName = process.env.DEVICE_NAME || "Desktop Local"; -const defaultWorkFolder = process.env.DEFAULT_WORK_FOLDER || ""; - -let backendSocket = null; -const localTaskSockets = new Map(); -const knownTasks = new Map(); -let lastMetaSignature = ""; - -function log(message, extra) { - if (extra !== undefined) { - console.log(`[connector] ${message}`, extra); - return; - } - console.log(`[connector] ${message}`); -} - -function sendJson(socket, payload) { - if (!socket || socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify(payload)); -} - -async function fetchJson(url, options) { - const response = await fetch(url, { - headers: { - "content-type": "application/json", - }, - ...options, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || `request failed: ${response.status}`); - } - return data; -} - -async function listWorkFolders() { - return fetchJson(`${localApiBase}/workfolders`); -} - -async function loadWorkflowConfig() { - return fetchJson(`${localApiBase}/workflow`); -} - -async function listWorkflows() { - const data = await fetchJson(`${localApiBase}/workflows`); - return data.workflows || []; -} - -async function activateWorkflow(filename) { - return fetchJson(`${localApiBase}/workflows/${filename}/activate`, { - method: "PUT", - }); -} - -async function isMobileAccessEnabled() { - return readMobileAccessEnabled().catch(() => false); -} - -async function buildDeviceMeta() { - const mobileAccessEnabled = await isMobileAccessEnabled(); - if (!mobileAccessEnabled) { - return { - localApiBase, - mobileAccessEnabled: false, - workflowConfig: null, - workflows: [], - workFolders: [], - }; - } - - const [workflowConfig, workflows, workFolders] = await Promise.all([ - loadWorkflowConfig().catch(() => null), - listWorkflows().catch(() => []), - listWorkFolders().catch(() => []), - ]); - - const mappedWorkFolders = workFolders.map((folder) => ({ - name: folder.name, - path: folder.path, - })); - if (defaultWorkFolder && !mappedWorkFolders.some((folder) => folder.path === defaultWorkFolder)) { - mappedWorkFolders.unshift({ - name: basename(defaultWorkFolder) || "Default Work Folder", - path: defaultWorkFolder, - }); - } - - return { - localApiBase, - mobileAccessEnabled: true, - workflowConfig, - workflows, - workFolders: mappedWorkFolders, - }; -} - -async function loadTaskState(taskId) { - return fetchJson(`${localApiBase}/tasks/${taskId}/state`); -} - -async function saveTaskUploads(runId, images) { - if (!runId || !Array.isArray(images) || images.length === 0) return []; - const form = new FormData(); - let count = 0; - for (const image of images) { - if (!image || typeof image !== "object" || !image.name || !image.data) continue; - form.append( - "images", - new Blob([Buffer.from(image.data)], { - type: image.type || "application/octet-stream", - }), - image.name - ); - count += 1; - } - if (count === 0) return []; - const response = await fetch(`${localApiBase}/tasks/${runId}/upload`, { - method: "POST", - body: form, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || `request failed: ${response.status}`); - } - return Array.isArray(data.paths) ? data.paths : []; -} - -async function normalizeImagePayload(runId, images) { - if (!Array.isArray(images) || images.length === 0) return []; - const paths = []; - const uploads = []; - for (const image of images) { - if (typeof image === "string" && image.trim()) { - paths.push(image.trim()); - continue; - } - if (image && typeof image === "object" && image.name && image.data) { - uploads.push(image); - } - } - if (uploads.length === 0) return paths; - return [...paths, ...(await saveTaskUploads(runId, uploads))]; -} - -function buildTaskSnapshot(taskId, data, fallbackWorkFolder = "", runId = "") { - const state = data?.state || null; - const workFolder = - state?.originalWorkFolder || - state?.workFolder || - fallbackWorkFolder || - knownTasks.get(taskId)?.workFolder || - ""; - return { - key: `${deviceId}:${taskId}`, - deviceId, - taskId, - runId: state?.runId || runId || "", - workFolder, - state, - messages: data?.messages || {}, - outputArtifacts: data?.outputArtifacts || {}, - interactions: data?.interactions || {}, - updatedAt: state?.updated || new Date().toISOString(), - }; -} - -async function getTaskSnapshot(taskId, fallbackWorkFolder = "", runId = "") { - const data = await loadTaskState(taskId); - return buildTaskSnapshot(taskId, data, fallbackWorkFolder, runId); -} - -async function listTaskSnapshots() { - if (!(await isMobileAccessEnabled())) return []; - const folders = await listWorkFolders().catch(() => []); - const tasks = []; - for (const folder of folders) { - for (const task of folder.tasks || []) { - try { - const snapshot = await getTaskSnapshot(task.taskId, folder.path, task.runId || ""); - knownTasks.set(task.taskId, { workFolder: snapshot.workFolder, runId: snapshot.runId }); - tasks.push(snapshot); - } catch (error) { - log(`failed to load task ${task.taskId}: ${error.message}`); - } - } - } - return tasks.sort((a, b) => (b.updatedAt || "").localeCompare(a.updatedAt || "")); -} - -async function pushTaskSnapshot(taskId) { - if (!(await isMobileAccessEnabled())) return; - if (!backendSocket || backendSocket.readyState !== WebSocket.OPEN) return; - try { - const task = await getTaskSnapshot(taskId); - knownTasks.set(taskId, { workFolder: task.workFolder, runId: task.runId }); - sendJson(backendSocket, { - type: "task.snapshot", - taskId, - task, - }); - } catch (error) { - log(`failed to push snapshot for ${taskId}: ${error.message}`); - } -} - -async function bootstrapTasks() { - if (!(await isMobileAccessEnabled())) return; - const folders = await listWorkFolders().catch(() => []); - for (const folder of folders) { - for (const task of folder.tasks || []) { - knownTasks.set(task.taskId, { workFolder: folder.path, runId: task.runId || "" }); - await pushTaskSnapshot(task.taskId); - } - } -} - -function attachLocalTask(taskId, workFolder, options = {}) { - const existing = localTaskSockets.get(taskId); - if (existing && existing.readyState === WebSocket.OPEN) return existing; - - const socket = new WebSocket(localWsUrl); - localTaskSockets.set(taskId, socket); - knownTasks.set(taskId, { workFolder, runId: options.runId || "" }); - - socket.on("open", () => { - sendJson(socket, { - type: "start", - taskId, - workFolder, - taskInputs: options.taskInputs || {}, - images: options.images || [], - runId: options.runId || "", - }); - }); - - socket.on("message", async (raw) => { - let message; - try { - message = JSON.parse(String(raw)); - } catch { - return; - } - - if (message.type === "state" && message.state) { - knownTasks.set(taskId, { - workFolder: message.state.originalWorkFolder || message.state.workFolder || workFolder, - runId: message.state.runId || options.runId || "", - }); - } - - if (!(await isMobileAccessEnabled())) return; - - sendJson(backendSocket, { - type: "task.event", - taskId, - event: message, - }); - - if ( - message.type === "state" || - message.type === "phase_content" || - message.type === "phase_artifact" || - message.type === "user_message" - ) { - await pushTaskSnapshot(taskId); - } - }); - - socket.on("close", () => { - localTaskSockets.delete(taskId); - }); - - socket.on("error", (error) => { - log(`local ws error for ${taskId}: ${error.message}`); - }); - - return socket; -} - -async function handleCommand(message) { - const { commandId, taskId, command, payload } = message; - const taskInfo = knownTasks.get(taskId); - const workFolder = payload?.workFolder || taskInfo?.workFolder || defaultWorkFolder; - const runId = String(payload?.runId || taskInfo?.runId || "").trim(); - - const sendResult = (status, error = "") => { - sendJson(backendSocket, { - type: "command.result", - commandId, - status, - error, - }); - }; - - if (!(await isMobileAccessEnabled())) { - sendResult("error", "mobile access is disabled"); - return; - } - - if (command === "start_workflow") { - if (!taskId || !workFolder) { - sendResult("error", "taskId and workFolder are required"); - return; - } - try { - const workflowFilename = String(payload?.workflowFilename || "").trim(); - const runId = String(payload?.runId || knownTasks.get(taskId)?.runId || "").trim() || `run-${Date.now()}`; - const imagePaths = await normalizeImagePayload(runId, payload?.images || []); - if (workflowFilename) { - await activateWorkflow(workflowFilename); - } - const socket = attachLocalTask(taskId, workFolder, { - taskInputs: payload?.taskInputs || {}, - images: imagePaths, - runId, - }); - if (socket.readyState === WebSocket.OPEN) { - sendResult("ok"); - return; - } - if (socket.readyState === WebSocket.CONNECTING) { - socket.once("open", () => sendResult("ok")); - return; - } - sendResult("error", "local workflow socket unavailable"); - } catch (error) { - sendResult("error", error.message); - } - return; - } - - if (command === "delete_task") { - if (!taskId) { - sendResult("error", "taskId is required"); - return; - } - try { - const query = runId ? `?runId=${encodeURIComponent(runId)}` : ""; - await fetchJson(`${localApiBase}/tasks/${encodeURIComponent(taskId)}${query}`, { - method: "DELETE", - }); - knownTasks.delete(taskId); - const existingSocket = localTaskSockets.get(taskId); - if (existingSocket && existingSocket.readyState <= WebSocket.OPEN) { - existingSocket.close(); - } - sendJson(backendSocket, { - type: "task.removed", - taskId, - }); - sendResult("ok"); - } catch (error) { - sendResult("error", error.message); - } - return; - } - - if (!workFolder) { - sendResult("error", "workFolder is required to attach task"); - return; - } - - const socket = attachLocalTask(taskId, workFolder, { runId }); - - const onOpen = async () => { - try { - if (command === "approve") { - sendJson(socket, { type: "approve", taskId, runId }); - } else if (command === "reject") { - sendJson(socket, { type: "reject", taskId, rejectTo: payload?.rejectTo, reason: payload?.reason || "", runId }); - } else if (command === "message") { - const imagePaths = await normalizeImagePayload(runId, payload?.images || []); - sendJson(socket, { - type: "message", - taskId, - text: payload?.text || "", - images: imagePaths, - runId, - }); - } else if (command === "sync_task") { - pushTaskSnapshot(taskId).then(() => sendResult("ok")).catch((error) => sendResult("error", error.message)); - return; - } else { - sendResult("error", "unsupported command"); - return; - } - sendResult("ok"); - } catch (error) { - sendResult("error", error.message); - } - }; - - if (socket.readyState === WebSocket.OPEN) { - await onOpen(); - return; - } - - if (socket.readyState === WebSocket.CONNECTING) { - socket.once("open", () => { - onOpen().catch((error) => sendResult("error", error.message)); - }); - return; - } - - sendResult("error", "local workflow socket unavailable"); -} - -async function handleQuery(message) { - const { requestId, action, payload } = message; - const reply = (ok, data = {}, error = "") => { - sendJson(backendSocket, { - type: "query.response", - requestId, - ok, - data, - error, - }); - }; - - try { - if (action === "list_tasks") { - reply(true, { tasks: await listTaskSnapshots() }); - return; - } - - if (action === "get_task") { - if (!(await isMobileAccessEnabled())) { - reply(true, { task: null }); - return; - } - const taskId = String(payload?.taskId || "").trim(); - if (!taskId) { - reply(false, {}, "taskId is required"); - return; - } - const taskInfo = knownTasks.get(taskId); - try { - const task = await getTaskSnapshot(taskId, taskInfo?.workFolder || ""); - reply(true, { task }); - } catch (error) { - if (error.message === "task not found") { - reply(true, { task: null }); - return; - } - throw error; - } - return; - } - - reply(false, {}, "unsupported query"); - } catch (error) { - reply(false, {}, error.message); - } -} - -async function syncDeviceMeta(force = false) { - if (!backendSocket || backendSocket.readyState !== WebSocket.OPEN) return; - try { - const meta = await buildDeviceMeta(); - const signature = JSON.stringify(meta); - if (!force && signature === lastMetaSignature) return; - lastMetaSignature = signature; - sendJson(backendSocket, { - type: "device.hello", - deviceId, - name: deviceName, - meta, - }); - } catch (error) { - log(`failed to sync device meta: ${error.message}`); - } -} - -function connectBackend() { - backendSocket = new WebSocket(backendUrl); - - backendSocket.on("open", async () => { - lastMetaSignature = ""; - await syncDeviceMeta(true); - await bootstrapTasks(); - }); - - backendSocket.on("message", async (raw) => { - let message; - try { - message = JSON.parse(String(raw)); - } catch { - return; - } - - if (message.type === "command.request") { - await handleCommand(message); - return; - } - - if (message.type === "query.request") { - await handleQuery(message); - } - }); - - backendSocket.on("close", () => { - lastMetaSignature = ""; - setTimeout(connectBackend, 1500); - }); - - backendSocket.on("error", (error) => { - log(`backend ws error: ${error.message}`); - }); -} - -connectBackend(); -setInterval(() => { - syncDeviceMeta().catch((error) => { - log(`device meta sync interval failed: ${error.message}`); - }); -}, 2000); diff --git a/apps/desktop/electron/api.ts b/apps/desktop/electron/api.ts deleted file mode 100644 index 6b63d83..0000000 --- a/apps/desktop/electron/api.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { basename, join, resolve } from "path"; -import { readFile, writeFile, readdir, stat, unlink, mkdir } from "fs/promises"; -import { dialog } from "electron"; -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { - readConfig, - saveConfig, - getBaseDir, - getWorkflowDir, - getWorkflowDirs, - readAiBackendOverride, - readAiApiProfilesForUi, - readMobileAccessEnabled, - saveAiBackendOverride, - saveAiApiProfile, - deleteAiApiProfile, - saveMobileAccessEnabled, -} from "../../../packages/core-models/config"; -import { - assertSafeWorkflowFilename, - getWorkflow, - getActiveWorkflowFile, - setActiveWorkflowFile, - deriveTaskInputFields, - getWorkflowConfigShape, - getWorkflowFilePath, - loadWorkflow, - unloadWorkflow, -} from "../../../packages/core-models/workflow"; -import { validateWorkflowDsl } from "../../../packages/core-lib/langgraph-runtime/index"; -import { readWorkfolders, saveWorkfolders, deleteTask, removeTaskWorktree, removeWorkfolder } from "../../../packages/core-models/workfolders"; -import { assertSafeRunId, readState, getTaskRunId, readPhaseInteractions } from "../../../packages/core-models/state"; -import { deleteManagedSkill, importManagedSkills, listManagedSkills, saveManagedSkill } from "../../../packages/core-models/skills"; -import { getPhaseContent, getPhaseOutputArtifactPath, readPhaseOutputArtifacts, stopActiveWorkflow } from "../../../packages/core-lib/claude"; - -function buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride = "") { - return { - ...getWorkflowConfigShape(null), - mobileAccessEnabled, - aiBackendOverride, - }; -} - -async function ensureWorkflowDir() { - const workflowDir = getWorkflowDir(); - await mkdir(workflowDir, { recursive: true }); - return workflowDir; -} - -export async function pickFolder(browserWindow) { - const result = await dialog.showOpenDialog(browserWindow, { - properties: ["openDirectory"], - }); - if (result.canceled || result.filePaths.length === 0) { - return { cancelled: true }; - } - return { cancelled: false, path: result.filePaths[0] }; -} - -export async function getWorkflowConfig() { - const workflow = getWorkflow(); - const mobileAccessEnabled = await readMobileAccessEnabled(); - const aiBackendOverride = await readAiBackendOverride(); - const aiApiProfiles = await readAiApiProfilesForUi(); - if (!workflow) { - return { - ...buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride), - aiApiProfiles, - }; - } - return { - ...getWorkflowConfigShape(workflow), - mobileAccessEnabled, - aiBackendOverride, - aiApiProfiles, - }; -} - -export async function setMobileAccessEnabled(enabled) { - await saveMobileAccessEnabled(enabled); - return getWorkflowConfig(); -} - -export async function setAiBackendOverride(backend) { - await saveAiBackendOverride(backend); - return getWorkflowConfig(); -} - -export async function listAiApiProfiles() { - return { profiles: await readAiApiProfilesForUi() }; -} - -export async function saveAiApi(profile) { - const profiles = await saveAiApiProfile(profile); - return { profiles }; -} - -export async function deleteAiApi(id) { - const profiles = await deleteAiApiProfile(id); - return { profiles }; -} - -export async function listWorkflows() { - await ensureWorkflowDir(); - try { - const config = await readConfig(); - const deletedWorkflowFiles = new Set(Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []); - const files = Array.from(new Set((await Promise.all( - getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) - )).flat())).filter((file) => !deletedWorkflowFiles.has(file)).sort(); - const workflows = []; - for (const file of files) { - if (!file.endsWith(".json")) continue; - try { - const raw = JSON.parse(await readFile(getWorkflowFilePath(file), "utf-8")); - const workflowConfig = getWorkflowConfigShape(raw); - workflows.push({ - filename: file, - name: raw.name || file, - visible: raw.visible !== false, - phaseCount: workflowConfig.phaseOrder.length, - phaseOrder: workflowConfig.phaseOrder, - groups: workflowConfig.groups, - workflowConfig, - taskInputFields: deriveTaskInputFields(raw), - worktree: raw.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" }, - }); - } catch {} - } - return { workflows, activeWorkflow: getActiveWorkflowFile() }; - } catch { - return { workflows: [], activeWorkflow: getActiveWorkflowFile() }; - } -} - -export async function getWorkflowByFilename(filename) { - const safeFilename = assertSafeWorkflowFilename(filename); - const raw = await readFile(getWorkflowFilePath(safeFilename), "utf-8"); - return JSON.parse(raw); -} - -export async function setWorkflowVisible(filename, visible) { - const safeFilename = assertSafeWorkflowFilename(filename); - const workflowDir = await ensureWorkflowDir(); - const filepath = join(workflowDir, safeFilename); - const raw = JSON.parse(await readFile(getWorkflowFilePath(safeFilename), "utf-8")); - raw.visible = visible === true; - await writeFile(filepath, JSON.stringify(raw, null, 2)); - if (safeFilename === getActiveWorkflowFile()) loadWorkflow(filepath); - return { filename: safeFilename, visible: raw.visible }; -} - -export async function createWorkflow(workflow) { - return createWorkflowFile(workflow, false); -} - -export async function createWorkflowDraft(workflow) { - return createWorkflowFile(workflow, true); -} - -async function createWorkflowFile(workflow, isDraft) { - const data = isDraft ? workflow : validateWorkflowDsl(workflow); - if (!String(data.name || "").trim()) throw new Error("workflow name is required"); - const filename = data.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + ".json"; - const workflowDir = await ensureWorkflowDir(); - const filepath = join(workflowDir, filename); - const config = await readConfig(); - const deletedWorkflowFiles = Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []; - const existing = await stat(getWorkflowFilePath(filename)).catch(() => null); - if (existing && !deletedWorkflowFiles.includes(filename)) throw new Error("workflow with this name already exists"); - await writeFile(filepath, JSON.stringify(data, null, 2)); - config.deletedWorkflowFiles = deletedWorkflowFiles.filter((file) => file !== filename); - if (!isDraft && !getWorkflow()) { - loadWorkflow(filepath); - setActiveWorkflowFile(filename); - config.activeWorkflow = filename; - } - await saveConfig(config); - return { filename, name: data.name }; -} - -export async function generateSkill({ label, id, prompt, description }) { - if (!label) throw new Error("label is required"); - - const context = [ - `Phase label: ${label}`, - id ? `Phase ID: ${id}` : "", - prompt ? `Current prompt: ${prompt}` : "", - description ? `User description: ${description}` : "", - ].filter(Boolean).join("\n"); - - const metaPrompt = `You are generating a skill instruction for a workflow automation phase. The skill will guide an AI assistant (Claude) on how to execute this phase. - -Based on the following phase context, generate a concise, actionable skill instruction. The skill should describe what the AI should do, any constraints or best practices, and expected output format. - -${context} - -The output MUST start with YAML frontmatter containing name and description fields, followed by the skill body. Use this format: - ---- -name: -description: "" ---- - - - -Keep the skill body concise and actionable (under 500 words). No extra explanations outside the format above.`; - - let skill = ""; - for await (const message of query({ - prompt: metaPrompt, - options: { - cwd: process.cwd(), - permissionMode: "default", - maxTurns: 1, - }, - })) { - if (message.type === "result") { - if (message.subtype !== "success") { - throw new Error(message.errors?.join("; ") || message.result || "Failed to generate skill"); - } - skill = message.result?.trim() || ""; - } - } - - if (!skill) throw new Error("Failed to generate skill"); - return { skill }; -} - -export async function listSkills() { - return { skills: await listManagedSkills() }; -} - -export async function saveSkill(skill) { - await saveManagedSkill(skill); - return listSkills(); -} - -export async function deleteSkill(slug) { - await deleteManagedSkill(slug); - return listSkills(); -} - -export async function importSkills(browserWindow) { - const result = await dialog.showOpenDialog(browserWindow, { - properties: ["openFile", "openDirectory", "multiSelections"], - filters: [{ name: "Skills", extensions: ["md"] }], - }); - if (result.canceled || result.filePaths.length === 0) { - return { cancelled: true, skills: await listManagedSkills() }; - } - - for (const filePath of result.filePaths) { - await importManagedSkills(filePath); - } - return { cancelled: false, skills: await listManagedSkills() }; -} - -export async function updateWorkflow(filename, workflow) { - return updateWorkflowFile(filename, workflow, false); -} - -export async function updateWorkflowDraft(filename, workflow) { - return updateWorkflowFile(filename, workflow, true); -} - -async function updateWorkflowFile(filename, workflow, isDraft) { - const safeFilename = assertSafeWorkflowFilename(filename); - const data = isDraft ? workflow : validateWorkflowDsl(workflow); - if (!String(data.name || "").trim()) throw new Error("workflow name is required"); - const workflowDir = await ensureWorkflowDir(); - await writeFile(join(workflowDir, safeFilename), JSON.stringify(data, null, 2)); - const config = await readConfig(); - config.deletedWorkflowFiles = (config.deletedWorkflowFiles || []).filter((file) => file !== safeFilename); - await saveConfig(config); - if (!isDraft && safeFilename === getActiveWorkflowFile()) { - loadWorkflow(join(workflowDir, safeFilename)); - } - return { filename: safeFilename, name: data.name }; -} - -export async function removeWorkflow(filename) { - const safeFilename = assertSafeWorkflowFilename(filename); - const workflowDir = await ensureWorkflowDir(); - const files = (await readdir(workflowDir)).filter((file) => file.endsWith(".json")); - const userFileExists = files.includes(safeFilename); - const targetExists = await stat(getWorkflowFilePath(safeFilename)).catch(() => null); - const systemFileExists = await Promise.all( - getWorkflowDirs().slice(1).map((dir) => stat(join(dir, safeFilename)).catch(() => null)) - ).then((stats) => stats.some(Boolean)); - if (!targetExists) throw new Error("workflow not found"); - - if (userFileExists) { - await unlink(join(workflowDir, safeFilename)); - } - const config = await readConfig(); - if (!userFileExists || systemFileExists) { - config.deletedWorkflowFiles = Array.from(new Set([...(config.deletedWorkflowFiles || []), safeFilename])); - } - if (getActiveWorkflowFile() === safeFilename) { - const remaining = (await listWorkflows()).workflows.map((workflow) => workflow.filename).filter((file) => file !== safeFilename).sort(); - if (remaining.length > 0) { - const nextWorkflow = remaining[0]; - setActiveWorkflowFile(nextWorkflow); - loadWorkflow(getWorkflowFilePath(nextWorkflow)); - config.activeWorkflow = nextWorkflow; - } else { - unloadWorkflow(); - delete config.activeWorkflow; - } - } - await saveConfig(config); - return { ok: true }; -} - -export async function activateWorkflow(filename) { - const safeFilename = assertSafeWorkflowFilename(filename); - await ensureWorkflowDir(); - loadWorkflow(getWorkflowFilePath(safeFilename)); - setActiveWorkflowFile(safeFilename); - const config = await readConfig(); - config.activeWorkflow = safeFilename; - await saveConfig(config); - return { activeWorkflow: safeFilename }; -} - -export async function listWorkFolders() { - const folders = await readWorkfolders(); - for (const folder of folders) { - if (!folder.tasks) continue; - for (const task of folder.tasks) { - try { - const runId = assertSafeRunId(task.runId || await getTaskRunId(task.taskId)); - if (!runId) throw new Error("task not found"); - const state = await readState(task.taskId, runId); - task.status = state.overallStatus || task.status; - task.phases = state.phases || []; - task.runId = state.runId || runId; - task.workflowConfig = state.workflowConfig || null; - } catch { - task.phases = []; - } - } - } - return folders; -} - -export async function addWorkFolder(folderPath) { - if (!folderPath) throw new Error("path required"); - const absPath = resolve(folderPath); - const fileStat = await stat(absPath).catch(() => null); - if (!fileStat) throw new Error("path does not exist"); - if (!fileStat.isDirectory()) throw new Error("not a directory"); - const folders = await readWorkfolders(); - if (folders.some((folder) => folder.path === absPath)) throw new Error("folder already added"); - folders.push({ name: basename(absPath), path: absPath, tasks: [] }); - await saveWorkfolders(folders); - return folders; -} - -export async function removeWorkFolder(folderPath) { - if (!folderPath) throw new Error("path required"); - return removeWorkfolder(folderPath); -} - -export async function getTaskState(taskId, runId = "") { - const state = await readState(taskId, runId); - const stateRunId = state.runId || runId; - const messages = {}; - const outputArtifacts = {}; - const interactions = {}; - for (const phase of state.phases) { - const content = await getPhaseContent(taskId, phase.id, stateRunId); - if (content) messages[phase.id] = content; - const phaseInteractions = await readPhaseInteractions(taskId, phase.id, stateRunId); - if (phaseInteractions.length > 0) interactions[phase.id] = phaseInteractions; - if (phase.status !== "pending") { - const phaseOutputArtifacts = await readPhaseOutputArtifacts(taskId, phase.id, stateRunId); - if (Object.keys(phaseOutputArtifacts).length > 0) { - outputArtifacts[phase.id] = phaseOutputArtifacts; - } - } - } - return { state, messages, outputArtifacts, interactions }; -} - -export async function getTaskOutputPath(taskId, runId = "", phaseId = "", outputKey = "") { - if (!taskId || !phaseId || !outputKey) throw new Error("document path required"); - const outputPath = await getPhaseOutputArtifactPath(taskId, phaseId, outputKey, runId); - if (!outputPath) throw new Error("document path not found"); - return outputPath; -} - -export async function removeTask(taskId, runId = "", options = {}) { - if (!taskId) throw new Error("taskId required"); - const safeRunId = assertSafeRunId(runId); - stopActiveWorkflow(taskId, safeRunId); - await deleteTask(taskId, safeRunId, { removeWorktree: Boolean(options?.removeWorktree) }); - return { ok: true }; -} - -export async function removeTaskWorktreeOnly(taskId, runId = "") { - if (!taskId) throw new Error("taskId required"); - const safeRunId = assertSafeRunId(runId); - stopActiveWorkflow(taskId, safeRunId); - return removeTaskWorktree(taskId, safeRunId); -} - -export async function saveTaskUploads(runId, filePaths) { - const safeRunId = assertSafeRunId(runId); - if (!safeRunId) throw new Error("runId required"); - if (!Array.isArray(filePaths)) return { paths: [] }; - - const baseDir = await getBaseDir(); - const uploadDir = join(baseDir, safeRunId, "uploads"); - await mkdir(uploadDir, { recursive: true }); - - const savedPaths = []; - for (const fileEntry of filePaths) { - if (typeof fileEntry === "string") { - const resolvedPath = resolve(fileEntry); - const fileStat = await stat(resolvedPath).catch(() => null); - if (!fileStat || !fileStat.isFile()) continue; - const filename = `${Date.now()}-${basename(resolvedPath)}`; - const target = join(uploadDir, filename); - await writeFile(target, await readFile(resolvedPath)); - savedPaths.push(target); - continue; - } - - if (!fileEntry || typeof fileEntry !== "object" || !fileEntry.name || !fileEntry.data) continue; - const target = join(uploadDir, `${Date.now()}-${basename(fileEntry.name)}`); - await writeFile(target, Buffer.from(fileEntry.data)); - savedPaths.push(target); - } - - return { paths: savedPaths }; -} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts deleted file mode 100644 index 704c3a6..0000000 --- a/apps/desktop/electron/main.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { spawn } from "child_process"; -import { app, BrowserWindow, ipcMain } from "electron"; -import { mkdirSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; -import { killAllChildren } from "../../../packages/core-lib/claude"; -import { getDefaultDesktopUserDataDir, setRuntimeBaseDir, setRuntimeStorageDir } from "../../../packages/core-models/config"; -import { - pickFolder, - getWorkflowConfig, - setMobileAccessEnabled, - setAiBackendOverride, - listAiApiProfiles, - saveAiApi, - deleteAiApi, - listWorkflows, - getWorkflowByFilename, - setWorkflowVisible, - createWorkflow, - createWorkflowDraft, - updateWorkflow, - updateWorkflowDraft, - generateSkill, - listSkills, - saveSkill, - deleteSkill, - importSkills, - removeWorkflow, - activateWorkflow, - listWorkFolders, - addWorkFolder, - removeWorkFolder, - getTaskState, - getTaskOutputPath, - removeTask, - removeTaskWorktreeOnly, - saveTaskUploads, -} from "./api"; -import { - startWorkflowSession, - approveWorkflow, - rejectWorkflow, - sendWorkflowMessage, - resumeWorkflowPhase, - retryWorkflowPhase, - pauseWorkflowPhase, - detachWorkflowSender, -} from "./workflow-runtime"; - -let mainWindow: BrowserWindow | null = null; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rendererDevServerUrl = process.env.ELECTRON_RENDERER_URL; -const isDev = Boolean(rendererDevServerUrl); -const userDataDir = getDefaultDesktopUserDataDir(); - -mkdirSync(userDataDir, { recursive: true }); -app.setPath("userData", userDataDir); - -const gotSingleInstanceLock = app.requestSingleInstanceLock(); - -if (!gotSingleInstanceLock) { - app.quit(); -} - -function spawnDetached(command, args) { - return new Promise((resolve, reject) => { - const env = { ...process.env }; - delete env.NODE_OPTIONS; - const child = spawn(command, args, { - detached: true, - env, - stdio: "ignore", - }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} - -const EDITOR_OPENERS = { - code: { - command: "code", - args: (targetPath) => [targetPath], - darwinApp: "Visual Studio Code", - label: "VS Code", - }, - sublime: { - command: "subl", - args: (targetPath) => [targetPath], - darwinApp: "Sublime Text", - label: "Sublime Text", - }, - zed: { - command: "zed", - args: (targetPath) => [targetPath], - darwinApp: "Zed", - label: "Zed", - }, -}; - -async function openInEditor(targetPath, editor = "code") { - if (!targetPath) throw new Error("path required"); - const opener = EDITOR_OPENERS[editor] || EDITOR_OPENERS.code; - try { - await spawnDetached(opener.command, opener.args(targetPath)); - return { ok: true }; - } catch (error) { - if (process.platform === "darwin") { - await spawnDetached("open", ["-a", opener.darwinApp, targetPath]); - return { ok: true }; - } - throw new Error(error?.message || `failed to open ${opener.label}`); - } -} - -async function waitForRenderer(url, attempts = 40, delayMs = 500) { - for (let i = 0; i < attempts; i++) { - try { - const response = await fetch(url, { method: "HEAD" }); - if (response.ok) return; - } catch {} - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - throw new Error(`Renderer dev server not ready: ${url}`); -} - -async function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 900, - minWidth: 960, - minHeight: 640, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 18, y: 8 }, - backgroundColor: "#f5f5f7", - webPreferences: { - preload: join(__dirname, "..", "electron-dist", "preload.cjs"), - contextIsolation: true, - nodeIntegration: false, - }, - }); - - mainWindow.webContents.on("before-input-event", (event, input) => { - const key = String(input.key || ""); - const isReloadShortcut = - input.type === "keyDown" && - ( - key === "F5" || - ( - key.toLowerCase() === "r" && - (input.meta || input.control) - ) - ); - - if (isReloadShortcut) { - event.preventDefault(); - } - }); - - if (isDev) { - await waitForRenderer(rendererDevServerUrl); - await mainWindow.loadURL(rendererDevServerUrl); - } else { - await mainWindow.loadFile(join(__dirname, "..", "renderer", "dist", "index.html")); - } - - mainWindow.on("closed", () => { - mainWindow = null; - }); -} - -function registerIpcHandlers() { - ipcMain.handle("app:pick-folder", (event) => pickFolder(BrowserWindow.fromWebContents(event.sender))); - ipcMain.handle("app:get-workflow-config", () => getWorkflowConfig()); - ipcMain.handle("app:set-mobile-access-enabled", (_event, enabled) => setMobileAccessEnabled(enabled)); - ipcMain.handle("app:set-ai-backend-override", (_event, backend) => setAiBackendOverride(backend)); - ipcMain.handle("app:list-ai-api-profiles", () => listAiApiProfiles()); - ipcMain.handle("app:save-ai-api-profile", (_event, profile) => saveAiApi(profile)); - ipcMain.handle("app:delete-ai-api-profile", (_event, id) => deleteAiApi(id)); - ipcMain.handle("app:list-workflows", () => listWorkflows()); - ipcMain.handle("app:get-workflow", (_event, filename) => getWorkflowByFilename(filename)); - ipcMain.handle("app:set-workflow-visible", (_event, filename, visible) => setWorkflowVisible(filename, visible)); - ipcMain.handle("app:create-workflow", (_event, workflow) => createWorkflow(workflow)); - ipcMain.handle("app:create-workflow-draft", (_event, workflow) => createWorkflowDraft(workflow)); - ipcMain.handle("app:update-workflow", (_event, filename, workflow) => updateWorkflow(filename, workflow)); - ipcMain.handle("app:update-workflow-draft", (_event, filename, workflow) => updateWorkflowDraft(filename, workflow)); - ipcMain.handle("app:generate-skill", (_event, payload) => generateSkill(payload)); - ipcMain.handle("app:list-skills", () => listSkills()); - ipcMain.handle("app:save-skill", (_event, skill) => saveSkill(skill)); - ipcMain.handle("app:delete-skill", (_event, slug) => deleteSkill(slug)); - ipcMain.handle("app:import-skills", (event) => importSkills(BrowserWindow.fromWebContents(event.sender))); - ipcMain.handle("app:remove-workflow", (_event, filename) => removeWorkflow(filename)); - ipcMain.handle("app:activate-workflow", (_event, filename) => activateWorkflow(filename)); - ipcMain.handle("app:list-workfolders", () => listWorkFolders()); - ipcMain.handle("app:add-workfolder", (_event, path) => addWorkFolder(path)); - ipcMain.handle("app:remove-workfolder", (_event, path) => removeWorkFolder(path)); - ipcMain.handle("app:get-task-state", (_event, taskId, runId) => getTaskState(taskId, runId)); - ipcMain.handle("app:open-in-code", (_event, targetPath, editor) => openInEditor(targetPath, editor)); - ipcMain.handle("app:open-task-output-in-code", async (_event, taskId, runId, phaseId, outputKey) => - openInEditor(await getTaskOutputPath(taskId, runId, phaseId, outputKey)) - ); - ipcMain.handle("app:remove-task", (_event, taskId, runId, options) => removeTask(taskId, runId, options)); - ipcMain.handle("app:remove-task-worktree", (_event, taskId, runId) => removeTaskWorktreeOnly(taskId, runId)); - ipcMain.handle("app:save-task-uploads", (_event, taskId, filePaths) => saveTaskUploads(taskId, filePaths)); - ipcMain.handle("app:start-workflow", (event, payload) => - startWorkflowSession( - payload.taskId, - payload.workFolder, - payload.taskInputs, - payload.images, - payload.runId, - (message) => event.sender.send("workflow:event", message), - { worktreeName: payload.worktreeName, workflowFilename: payload.workflowFilename }, - ) - ); - ipcMain.handle("app:approve-workflow", (event, taskId, runId) => - approveWorkflow(taskId, (message) => event.sender.send("workflow:event", message), runId) - ); - ipcMain.handle("app:reject-workflow", (event, taskId, rejectTo, reason, runId) => - rejectWorkflow(taskId, rejectTo, reason, (message) => event.sender.send("workflow:event", message), runId) - ); - ipcMain.handle("app:send-workflow-message", (event, taskId, text, images, runId) => - sendWorkflowMessage(taskId, text, images, (message) => event.sender.send("workflow:event", message), runId) - ); - ipcMain.handle("app:resume-workflow-phase", (event, taskId, phase, runId) => - resumeWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId) - ); - ipcMain.handle("app:retry-workflow-phase", (event, taskId, phase, runId) => - retryWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId) - ); - ipcMain.handle("app:pause-workflow-phase", (event, taskId, phase, runId) => - pauseWorkflowPhase(taskId, phase, (message) => event.sender.send("workflow:event", message), runId) - ); - ipcMain.on("workflow:detach", (_event, taskId, runId) => { - detachWorkflowSender(taskId, runId); - }); -} - -if (gotSingleInstanceLock) { - app.on("second-instance", () => { - if (!mainWindow) return; - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - }); - - app.whenReady().then(async () => { - const userDataDir = app.getPath("userData"); - setRuntimeStorageDir(userDataDir); - setRuntimeBaseDir(join(userDataDir, "tasks")); - registerIpcHandlers(); - try { - await createWindow(); - } catch (err) { - console.error(err); - app.quit(); - } - }); -} - -app.on("window-all-closed", () => { - if (process.platform !== "darwin") app.quit(); -}); - -app.on("activate", () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow(); -}); - -app.on("before-quit", () => { - killAllChildren(); -}); diff --git a/apps/desktop/electron/preload.cts b/apps/desktop/electron/preload.cts deleted file mode 100644 index d3ec7db..0000000 --- a/apps/desktop/electron/preload.cts +++ /dev/null @@ -1,49 +0,0 @@ -const { contextBridge, ipcRenderer } = require("electron"); - -function subscribeWorkflowEvents(callback) { - const handler = (_event, payload) => callback(payload); - ipcRenderer.on("workflow:event", handler); - return () => ipcRenderer.removeListener("workflow:event", handler); -} - -contextBridge.exposeInMainWorld("desktopApi", { - pickFolder: () => ipcRenderer.invoke("app:pick-folder"), - getWorkflowConfig: () => ipcRenderer.invoke("app:get-workflow-config"), - setMobileAccessEnabled: (enabled) => ipcRenderer.invoke("app:set-mobile-access-enabled", enabled), - setAiBackendOverride: (backend) => ipcRenderer.invoke("app:set-ai-backend-override", backend), - listAiApiProfiles: () => ipcRenderer.invoke("app:list-ai-api-profiles"), - saveAiApiProfile: (profile) => ipcRenderer.invoke("app:save-ai-api-profile", profile), - deleteAiApiProfile: (id) => ipcRenderer.invoke("app:delete-ai-api-profile", id), - listWorkflows: () => ipcRenderer.invoke("app:list-workflows"), - getWorkflow: (filename) => ipcRenderer.invoke("app:get-workflow", filename), - setWorkflowVisible: (filename, visible) => ipcRenderer.invoke("app:set-workflow-visible", filename, visible), - createWorkflow: (workflow) => ipcRenderer.invoke("app:create-workflow", workflow), - createWorkflowDraft: (workflow) => ipcRenderer.invoke("app:create-workflow-draft", workflow), - updateWorkflow: (filename, workflow) => ipcRenderer.invoke("app:update-workflow", filename, workflow), - updateWorkflowDraft: (filename, workflow) => ipcRenderer.invoke("app:update-workflow-draft", filename, workflow), - generateSkill: (payload) => ipcRenderer.invoke("app:generate-skill", payload), - listSkills: () => ipcRenderer.invoke("app:list-skills"), - saveSkill: (skill) => ipcRenderer.invoke("app:save-skill", skill), - deleteSkill: (slug) => ipcRenderer.invoke("app:delete-skill", slug), - importSkills: () => ipcRenderer.invoke("app:import-skills"), - removeWorkflow: (filename) => ipcRenderer.invoke("app:remove-workflow", filename), - activateWorkflow: (filename) => ipcRenderer.invoke("app:activate-workflow", filename), - listWorkFolders: () => ipcRenderer.invoke("app:list-workfolders"), - addWorkFolder: (path) => ipcRenderer.invoke("app:add-workfolder", path), - removeWorkFolder: (path) => ipcRenderer.invoke("app:remove-workfolder", path), - getTaskState: (taskId, runId) => ipcRenderer.invoke("app:get-task-state", taskId, runId), - openInCode: (targetPath, editor) => ipcRenderer.invoke("app:open-in-code", targetPath, editor), - openTaskOutputInCode: (taskId, runId, phaseId, outputKey) => ipcRenderer.invoke("app:open-task-output-in-code", taskId, runId, phaseId, outputKey), - removeTask: (taskId, runId, options) => ipcRenderer.invoke("app:remove-task", taskId, runId, options), - removeTaskWorktree: (taskId, runId) => ipcRenderer.invoke("app:remove-task-worktree", taskId, runId), - saveTaskUploads: (taskId, filePaths) => ipcRenderer.invoke("app:save-task-uploads", taskId, filePaths), - startWorkflow: (payload) => ipcRenderer.invoke("app:start-workflow", payload), - approveWorkflow: (taskId, runId) => ipcRenderer.invoke("app:approve-workflow", taskId, runId), - rejectWorkflow: (taskId, rejectTo, reason, runId) => ipcRenderer.invoke("app:reject-workflow", taskId, rejectTo, reason, runId), - sendWorkflowMessage: (taskId, text, images, runId) => ipcRenderer.invoke("app:send-workflow-message", taskId, text, images, runId), - resumeWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:resume-workflow-phase", taskId, phase, runId), - retryWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:retry-workflow-phase", taskId, phase, runId), - pauseWorkflowPhase: (taskId, phase, runId) => ipcRenderer.invoke("app:pause-workflow-phase", taskId, phase, runId), - onWorkflowEvent: subscribeWorkflowEvents, - detachWorkflow: (taskId, runId) => ipcRenderer.send("workflow:detach", taskId, runId), -}); diff --git a/apps/desktop/local-server-controllers/workflowController.ts b/apps/desktop/local-server-controllers/workflowController.ts deleted file mode 100644 index 067d777..0000000 --- a/apps/desktop/local-server-controllers/workflowController.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { Router } from "express"; -import { join } from "path"; -import { readFile, writeFile, readdir, stat, unlink, mkdir } from "fs/promises"; -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { - readConfig, - saveConfig, - getWorkflowDir, - getWorkflowDirs, - readAiBackendOverride, - readAiApiProfilesForUi, - readMobileAccessEnabled, - saveAiBackendOverride, - saveAiApiProfile, - deleteAiApiProfile, - saveMobileAccessEnabled, -} from "../../../packages/core-models/config"; -import { deleteManagedSkill, importManagedSkills, listManagedSkills, saveManagedSkill } from "../../../packages/core-models/skills"; -import { - assertSafeWorkflowFilename, - getWorkflow, getActiveWorkflowFile, setActiveWorkflowFile, - deriveTaskInputFields, getWorkflowConfigShape, getWorkflowFilePath, loadWorkflow, unloadWorkflow, -} from "../../../packages/core-models/workflow"; -import { validateWorkflowDsl } from "../../../packages/core-lib/langgraph-runtime/index"; - -const router = Router(); - -function buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride = "") { - return { - ...getWorkflowConfigShape(null), - mobileAccessEnabled, - aiBackendOverride, - }; -} - -async function ensureWorkflowDir() { - const workflowDir = getWorkflowDir(); - await mkdir(workflowDir, { recursive: true }); - return workflowDir; -} - -function getWorkflowFilename(workflow) { - const name = String(workflow?.name || "").trim(); - if (!name) throw new Error("workflow name is required"); - return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + ".json"; -} - -async function writeWorkflowFile(filename, workflow, isDraft) { - if (!String(workflow?.name || "").trim()) throw new Error("workflow name is required"); - const workflowDir = await ensureWorkflowDir(); - const data = isDraft ? workflow : validateWorkflowDsl(workflow); - await writeFile(join(workflowDir, filename), JSON.stringify(data, null, 2)); - const config = await readConfig(); - config.deletedWorkflowFiles = (config.deletedWorkflowFiles || []).filter((file) => file !== filename); - await saveConfig(config); - if (!isDraft && filename === getActiveWorkflowFile()) { - loadWorkflow(join(workflowDir, filename)); - } - return { filename, name: data.name }; -} - -// --- AI skill generation --- - -router.post("/generate-skill", async (req, res) => { - const { label, id, prompt, description } = req.body; - if (!label) return res.status(400).json({ error: "label is required" }); - - const context = [ - `Phase label: ${label}`, - id ? `Phase ID: ${id}` : "", - prompt ? `Current prompt: ${prompt}` : "", - description ? `User description: ${description}` : "", - ].filter(Boolean).join("\n"); - - const metaPrompt = `You are generating a skill instruction for a workflow automation phase. The skill will guide an AI assistant (Claude) on how to execute this phase. - -Based on the following phase context, generate a concise, actionable skill instruction. The skill should describe what the AI should do, any constraints or best practices, and expected output format. - -${context} - -The output MUST start with YAML frontmatter containing name and description fields, followed by the skill body. Use this format: - ---- -name: -description: "" ---- - - - -Keep the skill body concise and actionable (under 500 words). No extra explanations outside the format above.`; - - try { - let skill = ""; - for await (const message of query({ - prompt: metaPrompt, - options: { - cwd: process.cwd(), - permissionMode: "default", - maxTurns: 1, - }, - })) { - if (message.type === "result") { - if (message.subtype !== "success") { - return res.status(500).json({ error: message.errors?.join("; ") || message.result || "Failed to generate skill" }); - } - skill = message.result?.trim() || ""; - } - } - if (!skill) return res.status(500).json({ error: "Failed to generate skill" }); - res.json({ skill }); - } catch (err) { - res.status(500).json({ error: err.message || "Failed to generate skill" }); - } -}); - -// --- App-managed skills --- - -router.get("/skills", async (_req, res) => { - res.json({ skills: await listManagedSkills() }); -}); - -router.post("/skills", async (req, res) => { - try { - await saveManagedSkill(req.body); - res.json({ skills: await listManagedSkills() }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to save skill" }); - } -}); - -router.post("/skills/import", async (req, res) => { - try { - const paths = Array.isArray(req.body?.paths) ? req.body.paths : []; - for (const path of paths) { - await importManagedSkills(path); - } - res.json({ skills: await listManagedSkills() }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to import skills" }); - } -}); - -router.delete("/skills/:slug", async (req, res) => { - try { - await deleteManagedSkill(req.params.slug); - res.json({ skills: await listManagedSkills() }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to delete skill" }); - } -}); - -// --- Workflow metadata --- - -router.get("/workflow", async (req, res) => { - const WORKFLOW = getWorkflow(); - const mobileAccessEnabled = await readMobileAccessEnabled(); - const aiBackendOverride = await readAiBackendOverride(); - const aiApiProfiles = await readAiApiProfilesForUi(); - if (!WORKFLOW) { - return res.json({ - ...buildEmptyWorkflowConfig(mobileAccessEnabled, aiBackendOverride), - aiApiProfiles, - }); - } - - res.json({ - ...getWorkflowConfigShape(WORKFLOW), - mobileAccessEnabled, - aiBackendOverride, - aiApiProfiles, - }); -}); - -router.put("/settings/mobile-access", async (req, res) => { - try { - await saveMobileAccessEnabled(req.body?.enabled); - const WORKFLOW = getWorkflow(); - if (!WORKFLOW) return res.json(buildEmptyWorkflowConfig(await readMobileAccessEnabled(), await readAiBackendOverride())); - - res.json({ - ...getWorkflowConfigShape(WORKFLOW), - mobileAccessEnabled: await readMobileAccessEnabled(), - aiBackendOverride: await readAiBackendOverride(), - }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to update mobile access" }); - } -}); - -router.put("/settings/ai-backend", async (req, res) => { - try { - await saveAiBackendOverride(req.body?.backend); - const WORKFLOW = getWorkflow(); - if (!WORKFLOW) return res.json(buildEmptyWorkflowConfig(await readMobileAccessEnabled(), await readAiBackendOverride())); - - res.json({ - ...getWorkflowConfigShape(WORKFLOW), - mobileAccessEnabled: await readMobileAccessEnabled(), - aiBackendOverride: await readAiBackendOverride(), - }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to update AI backend" }); - } -}); - -router.get("/settings/ai-api-profiles", async (_req, res) => { - res.json({ profiles: await readAiApiProfilesForUi() }); -}); - -router.post("/settings/ai-api-profiles", async (req, res) => { - try { - res.json({ profiles: await saveAiApiProfile(req.body) }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to save AI API profile" }); - } -}); - -router.delete("/settings/ai-api-profiles/:id", async (req, res) => { - try { - res.json({ profiles: await deleteAiApiProfile(req.params.id) }); - } catch (err) { - res.status(400).json({ error: err.message || "failed to delete AI API profile" }); - } -}); - -// --- Workflow CRUD --- - -router.get("/workflows", async (req, res) => { - await ensureWorkflowDir(); - try { - const config = await readConfig(); - const deletedWorkflowFiles = new Set(Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []); - const files = Array.from(new Set((await Promise.all( - getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) - )).flat())).filter((file) => !deletedWorkflowFiles.has(file)).sort(); - const workflows = []; - for (const f of files) { - if (!f.endsWith(".json")) continue; - try { - const raw = JSON.parse(await readFile(getWorkflowFilePath(f), "utf-8")); - const workflowConfig = getWorkflowConfigShape(raw); - workflows.push({ - filename: f, - name: raw.name || f, - visible: raw.visible !== false, - phaseCount: workflowConfig.phaseOrder.length, - phaseOrder: workflowConfig.phaseOrder, - groups: workflowConfig.groups, - workflowConfig, - taskInputFields: deriveTaskInputFields(raw), - worktree: raw.worktree || { enabled: false, files: [], customFiles: [], removeOnComplete: false, useCustomSetupScript: false, setupScript: "" }, - }); - } catch {} - } - res.json({ workflows, activeWorkflow: getActiveWorkflowFile() }); - } catch { - res.json({ workflows: [], activeWorkflow: getActiveWorkflowFile() }); - } -}); - -router.get("/workflows/:filename", async (req, res) => { - try { - const filename = assertSafeWorkflowFilename(req.params.filename); - const raw = await readFile(getWorkflowFilePath(filename), "utf-8"); - res.json(JSON.parse(raw)); - } catch { - res.status(400).json({ error: "workflow not found" }); - } -}); - -router.put("/workflows/:filename/visibility", async (req, res) => { - try { - const filename = assertSafeWorkflowFilename(req.params.filename); - const workflowDir = await ensureWorkflowDir(); - const filepath = join(workflowDir, filename); - const workflow = JSON.parse(await readFile(getWorkflowFilePath(filename), "utf-8")); - workflow.visible = req.body?.visible === true; - await writeFile(filepath, JSON.stringify(workflow, null, 2)); - if (filename === getActiveWorkflowFile()) loadWorkflow(filepath); - res.json({ filename, visible: workflow.visible }); - } catch { - res.status(400).json({ error: "workflow not found" }); - } -}); - -router.post("/workflows", async (req, res) => { - const isDraft = req.query.draft === "1"; - let workflow; - try { - workflow = isDraft ? req.body : validateWorkflowDsl(req.body); - } catch (err) { - return res.status(400).json({ error: err.message || "invalid workflow DSL" }); - } - let filename; - try { - filename = getWorkflowFilename(workflow); - } catch (err) { - return res.status(400).json({ error: err.message || "invalid workflow DSL" }); - } - const workflowDir = await ensureWorkflowDir(); - const filepath = join(workflowDir, filename); - const config = await readConfig(); - const deletedWorkflowFiles = Array.isArray(config.deletedWorkflowFiles) ? config.deletedWorkflowFiles : []; - try { - await stat(getWorkflowFilePath(filename)); - if (!deletedWorkflowFiles.includes(filename)) { - return res.status(409).json({ error: "workflow with this name already exists" }); - } - } catch {} - await writeFile(filepath, JSON.stringify(workflow, null, 2)); - config.deletedWorkflowFiles = deletedWorkflowFiles.filter((file) => file !== filename); - if (!isDraft && !getWorkflow()) { - loadWorkflow(filepath); - setActiveWorkflowFile(filename); - config.activeWorkflow = filename; - } - await saveConfig(config); - res.json({ filename, name: workflow.name }); -}); - -router.put("/workflows/:filename", async (req, res) => { - let filename; - try { - filename = assertSafeWorkflowFilename(req.params.filename); - } catch { - return res.status(400).json({ error: "invalid filename" }); - } - const isDraft = req.query.draft === "1"; - let workflow; - try { - workflow = isDraft ? req.body : validateWorkflowDsl(req.body); - } catch (err) { - return res.status(400).json({ error: err.message || "invalid workflow DSL" }); - } - try { - res.json(await writeWorkflowFile(filename, workflow, isDraft)); - } catch (err) { - res.status(400).json({ error: err.message || "invalid workflow DSL" }); - } -}); - -router.delete("/workflows/:filename", async (req, res) => { - try { - const filename = assertSafeWorkflowFilename(req.params.filename); - const workflowDir = await ensureWorkflowDir(); - const files = (await readdir(workflowDir)).filter((file) => file.endsWith(".json")); - const userFileExists = files.includes(filename); - const targetExists = await stat(getWorkflowFilePath(filename)).catch(() => null); - const systemFileExists = await Promise.all( - getWorkflowDirs().slice(1).map((dir) => stat(join(dir, filename)).catch(() => null)) - ).then((stats) => stats.some(Boolean)); - if (!targetExists) return res.status(404).json({ error: "workflow not found" }); - - if (userFileExists) { - await unlink(join(workflowDir, filename)); - } - const config = await readConfig(); - if (!userFileExists || systemFileExists) { - config.deletedWorkflowFiles = Array.from(new Set([...(config.deletedWorkflowFiles || []), filename])); - } - if (getActiveWorkflowFile() === filename) { - const remaining = Array.from(new Set((await Promise.all( - getWorkflowDirs().map((dir) => readdir(dir).catch(() => [])) - )).flat())) - .filter((file) => file.endsWith(".json") && file !== filename && !(config.deletedWorkflowFiles || []).includes(file)) - .sort(); - if (remaining.length > 0) { - const nextWorkflow = remaining[0]; - setActiveWorkflowFile(nextWorkflow); - loadWorkflow(getWorkflowFilePath(nextWorkflow)); - config.activeWorkflow = nextWorkflow; - } else { - unloadWorkflow(); - delete config.activeWorkflow; - } - } - await saveConfig(config); - res.json({ ok: true }); - } catch { - res.status(404).json({ error: "workflow not found" }); - } -}); - -router.put("/workflows/:filename/activate", async (req, res) => { - try { - const filename = assertSafeWorkflowFilename(req.params.filename); - await ensureWorkflowDir(); - loadWorkflow(getWorkflowFilePath(filename)); - setActiveWorkflowFile(filename); - const config = await readConfig(); - config.activeWorkflow = filename; - await saveConfig(config); - res.json({ activeWorkflow: filename }); - } catch (err) { - res.status(400).json({ error: `failed to load workflow: ${err.message}` }); - } -}); - -export default router; diff --git a/apps/desktop/local-server-controllers/workfoldersController.ts b/apps/desktop/local-server-controllers/workfoldersController.ts deleted file mode 100644 index 3ac82c1..0000000 --- a/apps/desktop/local-server-controllers/workfoldersController.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Router } from "express"; -import { basename, resolve, join } from "path"; -import { stat, mkdir } from "fs/promises"; -import { spawn } from "child_process"; -import multer from "multer"; -import { readWorkfolders, saveWorkfolders, deleteTask, removeWorkfolder } from "../../../packages/core-models/workfolders"; -import { getBaseDir } from "../../../packages/core-models/config"; -import { assertSafeRunId, readState, getTaskRunId, readPhaseInteractions } from "../../../packages/core-models/state"; -import { getPhaseContent, readPhaseOutputArtifacts, stopActiveWorkflow } from "../../../packages/core-lib/claude"; - -const upload = multer({ storage: multer.diskStorage({ - async destination(req, _file, cb) { - try { - const baseDir = await getBaseDir(); - const runId = assertSafeRunId(req.params.runId || ""); - if (!runId) return cb(new Error("runId required")); - const dir = join(baseDir, runId, "uploads"); - await mkdir(dir, { recursive: true }); - cb(null, dir); - } catch (error) { - cb(error); - } - }, - filename(_req, file, cb) { - cb(null, `${Date.now()}-${file.originalname}`); - }, -}) }); - -const router = Router(); - -router.get("/workfolders", async (req, res) => { - const folders = await readWorkfolders(); - for (const folder of folders) { - if (!folder.tasks) continue; - for (const task of folder.tasks) { - try { - const runId = assertSafeRunId(task.runId || await getTaskRunId(task.taskId)); - if (!runId) throw new Error("task not found"); - const state = await readState(task.taskId, runId); - task.status = state.overallStatus || task.status; - task.phases = state.phases || []; - task.runId = state.runId || runId; - task.workflowConfig = state.workflowConfig || null; - } catch { - task.phases = []; - } - } - } - res.json(folders); -}); - -router.post("/workfolders", async (req, res) => { - const { path: folderPath } = req.body; - if (!folderPath) return res.status(400).json({ error: "path required" }); - const absPath = resolve(folderPath); - try { - const s = await stat(absPath); - if (!s.isDirectory()) return res.status(400).json({ error: "not a directory" }); - } catch { - return res.status(400).json({ error: "path does not exist" }); - } - const folders = await readWorkfolders(); - if (folders.some((f) => f.path === absPath)) return res.status(400).json({ error: "folder already added" }); - folders.push({ name: basename(absPath), path: absPath, tasks: [] }); - await saveWorkfolders(folders); - res.json(folders); -}); - -router.delete("/workfolders", async (req, res) => { - const { path: folderPath } = req.body; - if (!folderPath) return res.status(400).json({ error: "path required" }); - res.json(await removeWorkfolder(folderPath)); -}); - -router.get("/tasks/:taskId/state", async (req, res) => { - const { taskId } = req.params; - const runId = String(req.query.runId || ""); - try { - const state = await readState(taskId, runId); - const stateRunId = state.runId || runId; - const messages = {}; - const outputArtifacts = {}; - const interactions = {}; - for (const p of state.phases) { - const content = await getPhaseContent(taskId, p.id, stateRunId); - if (content) messages[p.id] = content; - const phaseInteractions = await readPhaseInteractions(taskId, p.id, stateRunId); - if (phaseInteractions.length > 0) interactions[p.id] = phaseInteractions; - if (p.status !== "pending") { - const phaseOutputArtifacts = await readPhaseOutputArtifacts(taskId, p.id, stateRunId); - if (Object.keys(phaseOutputArtifacts).length > 0) { - outputArtifacts[p.id] = phaseOutputArtifacts; - } - } - } - res.json({ state, messages, outputArtifacts, interactions }); - } catch { - res.status(404).json({ error: "task not found" }); - } -}); - -router.delete("/tasks/:taskId", async (req, res) => { - const { taskId } = req.params; - const runId = String(req.query.runId || ""); - const removeWorktree = req.query.removeWorktree === "1" || req.query.removeWorktree === "true"; - if (!taskId) return res.status(400).json({ error: "taskId required" }); - try { - const safeRunId = assertSafeRunId(runId); - stopActiveWorkflow(taskId, safeRunId); - await deleteTask(taskId, safeRunId, { removeWorktree }); - res.json({ ok: true }); - } catch { - res.status(404).json({ error: "task not found" }); - } -}); - -router.post("/pick-folder", (req, res) => { - const script = 'osascript -e \'tell application "System Events" to activate\' -e \'POSIX path of (choose folder with prompt "Select a work folder")\''; - const child = spawn("sh", ["-c", script], { stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - child.stdout.on("data", (d) => { stdout += d.toString(); }); - child.on("close", (code) => { - const selected = stdout.trim().replace(/\/$/, ""); - if (code !== 0 || !selected) return res.json({ cancelled: true }); - res.json({ cancelled: false, path: selected }); - }); -}); - -router.post("/tasks/:runId/upload", upload.array("images", 10), (req, res) => { - const paths = (req.files || []).map((f) => f.path); - res.json({ paths }); -}); - -export default router; diff --git a/apps/desktop/local-server-controllers/wsController.ts b/apps/desktop/local-server-controllers/wsController.ts deleted file mode 100644 index cc92ea7..0000000 --- a/apps/desktop/local-server-controllers/wsController.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { WebSocketServer } from "ws"; -import { wsSend } from "../../../packages/core-lib/claude"; -import { - approveWorkflow, - pauseWorkflowPhase, - rejectWorkflow, - resumeWorkflowPhase, - retryWorkflowPhase, - sendWorkflowMessage, - startWorkflowSession, -} from "../electron/workflow-runtime"; - -export function setupWebSocket(server) { - const wss = new WebSocketServer({ server, path: "/ws" }); - - wss.on("connection", (ws) => { - const send = (payload) => wsSend(ws, payload); - - ws.on("message", async (raw) => { - let msg; - try { msg = JSON.parse(raw); } catch { return; } - - try { - if (msg.type === "start") { - await startWorkflowSession(msg.taskId, msg.workFolder, msg.taskInputs, msg.images, msg.runId, send, { - worktreeName: msg.worktreeName, - workflowFilename: msg.workflowFilename, - }); - } else if (msg.type === "approve") { - await approveWorkflow(msg.taskId, send, msg.runId); - } else if (msg.type === "reject") { - await rejectWorkflow(msg.taskId, msg.rejectTo, msg.reason, send, msg.runId); - } else if (msg.type === "message") { - await sendWorkflowMessage(msg.taskId, msg.text, msg.images, send, msg.runId); - } else if (msg.type === "resume_phase") { - await resumeWorkflowPhase(msg.taskId, msg.phase, send, msg.runId); - } else if (msg.type === "retry_phase") { - await retryWorkflowPhase(msg.taskId, msg.phase, send, msg.runId); - } else if (msg.type === "pause_phase") { - await pauseWorkflowPhase(msg.taskId, msg.phase, send, msg.runId); - } - } catch (err) { - send({ type: "error", taskId: msg.taskId, runId: msg.runId || "", message: err.message || "workflow error" }); - } - }); - - ws.on("close", () => { - // The active task id is not tracked per socket here; aborting is handled by explicit detach/delete. - }); - }); -} diff --git a/apps/desktop/renderer/src/App.css b/apps/desktop/renderer/src/App.css deleted file mode 100644 index 484df43..0000000 --- a/apps/desktop/renderer/src/App.css +++ /dev/null @@ -1,728 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #1a1a2e; - color: #e0e0e0; - height: 100vh; -} - -#root { - height: 100vh; -} - -/* Layout */ -.app-layout { - display: flex; - flex-direction: column; - height: 100vh; -} - -.app-header { - padding: 16px 24px; - border-bottom: 1px solid #2a2a4a; - background: #16162b; -} - -.app-header h1 { - font-size: 18px; - font-weight: 600; - color: #fff; -} - -.header-form { - display: flex; - align-items: center; - gap: 16px; -} - -.header-form input { - flex: 1; - max-width: 400px; - padding: 10px 16px; - border: 1px solid #3a3a6a; - border-radius: 8px; - background: #2a2a4a; - color: #e0e0e0; - font-size: 14px; - outline: none; -} - -.header-form input:focus { - border-color: #6a6aaa; -} - -.header-form button { - padding: 10px 24px; - background: #4a4a8a; - color: #fff; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; -} - -.header-form button:hover:not(:disabled) { - background: #5a5a9a; -} - -.header-form button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.header-active { - display: flex; - align-items: center; - gap: 16px; -} - -.header-streaming { - font-size: 13px; - padding: 4px 12px; - background: #2d3a4a; - color: #6a9fcf; - border-radius: 12px; - animation: pulse 1.5s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -.app-body { - flex: 1; - display: flex; - min-height: 0; -} - -/* Step List (Left sidebar) */ -.step-list { - width: 240px; - border-right: 1px solid #2a2a4a; - background: #16162b; - padding: 16px 0; - flex-shrink: 0; - overflow-y: auto; -} - -.step-list-title { - font-size: 13px; - font-weight: 600; - color: #888; - text-transform: uppercase; - letter-spacing: 0.5px; - padding: 0 16px 12px; -} - -.step-items { - list-style: none; -} - -.step-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - cursor: pointer; - font-size: 14px; - color: #888; - transition: background 0.15s; -} - -.step-item:hover { - background: #1e1e3a; -} - -.step-selected { - background: #2a2a4a; - color: #fff; -} - -.step-current { - color: #e0e0e0; -} - -.step-checkbox { - width: 20px; - height: 20px; - border: 2px solid #3a3a6a; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - font-weight: 700; - flex-shrink: 0; -} - -.step-checkbox.checked { - background: #2d4a3e; - border-color: #6fcf97; - color: #6fcf97; -} - -.step-checkbox.awaiting { - background: #4a3a2d; - border-color: #f2c94c; - color: #f2c94c; -} - -.step-label { - flex: 1; - display: flex; - align-items: center; - gap: 4px; -} - -.step-manual-icon { - font-size: 12px; -} - -.step-badge { - font-size: 11px; - padding: 2px 8px; - border-radius: 8px; - flex-shrink: 0; -} - -.badge-active { - background: #2d3a4a; - color: #6a9fcf; -} - -.badge-awaiting { - background: #4a3a2d; - color: #f2c94c; -} - -/* Step Detail (Right panel) */ -.step-detail { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -} - -.step-detail-empty { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #666; - font-size: 15px; -} - -.step-detail-header { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 24px; - border-bottom: 1px solid #2a2a4a; -} - -.step-detail-header h2 { - font-size: 16px; - font-weight: 600; - color: #fff; -} - -.streaming-badge { - font-size: 12px; - padding: 3px 10px; - background: #2d3a4a; - color: #6a9fcf; - border-radius: 10px; - animation: pulse 1.5s ease-in-out infinite; -} - -.awaiting-badge { - font-size: 12px; - padding: 3px 10px; - background: #4a3a2d; - color: #f2c94c; - border-radius: 10px; -} - -.ai-response-collapse { - margin: 0 -24px; - border: none; - border-bottom: 1px solid #2a2a4a; - border-radius: 0; - overflow: hidden; - position: sticky; - top: 0; - z-index: 10; -} - -.ai-response-collapse summary { - padding: 10px 16px; - background: #1e1e3a; - color: #888; - font-size: 13px; - font-weight: 600; - cursor: pointer; - user-select: none; -} - -.ai-response-collapse summary:hover { - color: #bbb; - background: #252545; -} - -.ai-response-collapse .ai-response-body { - padding: 16px; - border-top: 1px solid #2a2a4a; - background: #16162b; - max-height: 400px; - overflow-y: auto; -} - -.step-detail-content { - flex: 1; - overflow-y: auto; - padding: 0 24px 24px; - line-height: 1.6; - font-size: 14px; -} - -.step-detail-content:not(:has(.ai-response-collapse)) { - padding-top: 24px; -} - -.step-detail-content h1, -.step-detail-content h2, -.step-detail-content h3 { - color: #fff; - margin: 16px 0 8px; -} - -.step-detail-content h1 { font-size: 20px; } -.step-detail-content h2 { font-size: 17px; } -.step-detail-content h3 { font-size: 15px; } - -.step-detail-content p { - margin: 8px 0; -} - -.step-detail-content ul, -.step-detail-content ol { - margin: 8px 0; - padding-left: 24px; -} - -.step-detail-content li { - margin: 4px 0; -} - -.step-detail-content code { - background: #2a2a4a; - padding: 2px 6px; - border-radius: 4px; - font-family: "SF Mono", "Fira Code", monospace; - font-size: 13px; -} - -.step-detail-content pre { - background: #2a2a4a; - padding: 12px 16px; - border-radius: 8px; - overflow-x: auto; - margin: 12px 0; -} - -.step-detail-content pre code { - background: none; - padding: 0; -} - -.step-detail-content strong { - color: #fff; -} - -.step-detail-content a { - color: #6a9fcf; -} - -.step-detail-content table { - border-collapse: collapse; - width: 100%; - margin: 12px 0; -} - -.step-detail-content th, -.step-detail-content td { - border: 1px solid #3a3a6a; - padding: 8px 12px; - text-align: left; -} - -.step-detail-content th { - background: #2a2a4a; - color: #fff; -} - -.step-detail-content hr { - border: none; - border-top: 1px solid #2a2a4a; - margin: 16px 0; -} - -.step-detail-placeholder { - color: #666; - font-style: italic; -} - -.cursor { - display: inline-block; - width: 8px; - height: 16px; - background: #888; - margin-left: 2px; - animation: blink 0.8s step-end infinite; - vertical-align: text-bottom; -} - -@keyframes blink { - 50% { opacity: 0; } -} - -.reject-hint { - padding: 10px 24px; - background: #4a3a2d; - color: #f2c94c; - font-size: 13px; - text-align: center; -} - -.step-detail-input-area { - border-top: 1px solid #2a2a4a; - background: #1a1a2e; -} - -.step-detail-input-area .step-detail-actions { - border-top: none; - padding-top: 0; -} - -.step-detail-input { - display: flex; - gap: 12px; - padding: 16px 24px; - background: #1a1a2e; -} - -.step-detail-input input { - flex: 1; - padding: 12px 16px; - border: 1px solid #3a3a6a; - border-radius: 8px; - background: #2a2a4a; - color: #e0e0e0; - font-size: 14px; - outline: none; -} - -.step-detail-input input:focus { - border-color: #6a6aaa; -} - -.step-detail-input input:disabled { - opacity: 0.5; -} - -.step-detail-input button { - padding: 12px 24px; - background: #4a4a8a; - color: #fff; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; -} - -.step-detail-input button:hover:not(:disabled) { - background: #5a5a9a; -} - -.step-detail-input button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -/* Action buttons (approve/reject bar) */ -.step-detail-actions { - display: flex; - justify-content: center; - gap: 12px; - padding: 12px 24px; - border-top: 1px solid #2a2a4a; - background: #1a1a2e; -} - -.approve-btn { - padding: 10px 24px; - background: #2d6a3e; - color: #fff; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; -} - -.approve-btn:hover { - background: #3a8a50; -} - -.reject-btn { - padding: 10px 24px; - background: #6a2d2d; - color: #fff; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; -} - -.reject-btn:hover { - background: #8a3a3a; -} - -/* Home page */ -.home-body { - flex: 1; - padding: 24px; - overflow-y: auto; - max-width: 800px; - margin: 0 auto; - width: 100%; -} - -.folder-section { - margin-bottom: 24px; -} - -.folder-section-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.folder-section-header h2 { - font-size: 16px; - font-weight: 600; - color: #fff; -} - -.add-folder-btn { - padding: 8px 16px; - background: #3a3a6a; - color: #fff; - border: none; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -.add-folder-btn:hover { - background: #4a4a8a; -} - -.folder-empty { - color: #666; - font-size: 14px; - padding: 24px; - text-align: center; - border: 1px dashed #3a3a6a; - border-radius: 8px; -} - -.folder-list { - list-style: none; -} - -.folder-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border: 1px solid #2a2a4a; - border-radius: 8px; - margin-bottom: 8px; - cursor: pointer; - transition: background 0.15s, border-color 0.15s; -} - -.folder-item:hover { - background: #1e1e3a; -} - -.folder-selected { - background: #2a2a4a; - border-color: #6a6aaa; -} - -.folder-info { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - -.folder-name { - font-size: 14px; - font-weight: 600; - color: #fff; -} - -.folder-path { - font-size: 12px; - color: #888; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.folder-remove { - background: none; - border: none; - color: #666; - font-size: 16px; - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; -} - -.folder-remove:hover { - color: #e55; - background: #3a2a2a; -} - -/* Task status */ -.task-status { - font-size: 12px; - padding: 2px 8px; - border-radius: 8px; -} - -.task-status-in_progress { - background: #2d3a4a; - color: #6a9fcf; -} - -.task-status-awaiting_input { - background: #4a3a2d; - color: #f2c94c; -} - -.task-status-paused { - background: #4a3a2d; - color: #f2c94c; -} - -.task-status-completed { - background: #2d4a3e; - color: #6fcf97; -} - -/* Start section */ -.start-section { - margin-top: 8px; -} - -.start-form { - display: flex; - gap: 12px; -} - -.start-form input { - flex: 1; - padding: 12px 16px; - border: 1px solid #3a3a6a; - border-radius: 8px; - background: #2a2a4a; - color: #e0e0e0; - font-size: 14px; - outline: none; -} - -.start-form input:focus { - border-color: #6a6aaa; -} - -.start-form button { - padding: 12px 24px; - background: #4a4a8a; - color: #fff; - border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; -} - -.start-form button:hover:not(:disabled) { - background: #5a5a9a; -} - -.start-form button:disabled { - opacity: 0.4; - cursor: not-allowed; -} - - -.user-message { - display: flex; - flex-direction: column; - align-items: flex-end; - margin: 12px 0; -} - -.user-message-label { - font-size: 11px; - font-weight: 600; - color: #888; - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 4px; -} - -.user-message-text { - background: #3a3a6a; - padding: 10px 16px; - border-radius: 12px; - border-bottom-right-radius: 4px; - font-size: 14px; - max-width: 70%; - word-wrap: break-word; -} - -/* Scrollbar */ -.step-detail-content::-webkit-scrollbar, -.step-list::-webkit-scrollbar { - width: 6px; -} - -.step-detail-content::-webkit-scrollbar-track, -.step-list::-webkit-scrollbar-track { - background: transparent; -} - -.step-detail-content::-webkit-scrollbar-thumb, -.step-list::-webkit-scrollbar-thumb { - background: #3a3a6a; - border-radius: 3px; -} diff --git a/apps/desktop/renderer/src/components/window-chrome.tsx b/apps/desktop/renderer/src/components/window-chrome.tsx deleted file mode 100644 index d9d17f2..0000000 --- a/apps/desktop/renderer/src/components/window-chrome.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export function WindowChrome() { - return ( -
- ); -} diff --git a/apps/desktop/renderer/src/lib/api-client.ts b/apps/desktop/renderer/src/lib/api-client.ts deleted file mode 100644 index 8207bce..0000000 --- a/apps/desktop/renderer/src/lib/api-client.ts +++ /dev/null @@ -1,180 +0,0 @@ -function jsonHeaders(headers = {}) { - return { - "content-type": "application/json", - ...headers, - }; -} - -function normalizeBaseUrl() { - const base = window.__DEV_WORKFLOW_API_BASE__ || ""; - return String(base).replace(/\/$/, ""); -} - -function createWebApi() { - const baseUrl = normalizeBaseUrl(); - let workflowSocket = null; - let workflowHandler = null; - let activeTaskId = null; - let activeRunId = ""; - let activeWorkFolder = ""; - - async function request(path, options = {}) { - const response = await fetch(`${baseUrl}${path}`, { - ...options, - headers: jsonHeaders(options.headers), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || `request failed: ${response.status}`); - } - return data; - } - - function ensureSocket() { - if (workflowSocket && workflowSocket.readyState <= 1) return workflowSocket; - const target = new URL(`${baseUrl}/ws`, window.location.origin); - target.protocol = target.protocol === "https:" ? "wss:" : "ws:"; - workflowSocket = new WebSocket(target); - workflowSocket.addEventListener("message", (event) => { - if (!workflowHandler) return; - try { - workflowHandler(JSON.parse(event.data)); - } catch {} - }); - return workflowSocket; - } - - function sendSocket(payload) { - const socket = ensureSocket(); - if (socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(payload)); - return; - } - socket.addEventListener("open", () => { - socket.send(JSON.stringify(payload)); - }, { once: true }); - } - - return { - pickFolder: async () => request("/api/pick-folder", { method: "POST" }), - getWorkflowConfig: async () => request("/api/workflow"), - setMobileAccessEnabled: async (enabled) => request("/api/settings/mobile-access", { method: "PUT", body: JSON.stringify({ enabled }) }), - setAiBackendOverride: async (backend) => request("/api/settings/ai-backend", { method: "PUT", body: JSON.stringify({ backend }) }), - listAiApiProfiles: async () => request("/api/settings/ai-api-profiles"), - saveAiApiProfile: async (profile) => request("/api/settings/ai-api-profiles", { method: "POST", body: JSON.stringify(profile) }), - deleteAiApiProfile: async (id) => request(`/api/settings/ai-api-profiles/${encodeURIComponent(id)}`, { method: "DELETE" }), - listWorkflows: async () => request("/api/workflows"), - getWorkflow: async (filename) => request(`/api/workflows/${filename}`), - setWorkflowVisible: async (filename, visible) => request(`/api/workflows/${filename}/visibility`, { method: "PUT", body: JSON.stringify({ visible }) }), - createWorkflow: async (workflow) => request("/api/workflows", { method: "POST", body: JSON.stringify(workflow) }), - createWorkflowDraft: async (workflow) => request("/api/workflows?draft=1", { method: "POST", body: JSON.stringify(workflow) }), - updateWorkflow: async (filename, workflow) => request(`/api/workflows/${filename}`, { method: "PUT", body: JSON.stringify(workflow) }), - updateWorkflowDraft: async (filename, workflow) => request(`/api/workflows/${filename}?draft=1`, { method: "PUT", body: JSON.stringify(workflow) }), - generateSkill: async (payload) => request("/api/generate-skill", { method: "POST", body: JSON.stringify(payload) }), - listSkills: async () => request("/api/skills"), - saveSkill: async (skill) => request("/api/skills", { method: "POST", body: JSON.stringify(skill) }), - deleteSkill: async (slug) => request(`/api/skills/${slug}`, { method: "DELETE" }), - importSkills: async () => request("/api/skills/import", { method: "POST", body: JSON.stringify({ paths: [] }) }).catch(() => ({ - cancelled: true, - skills: [], - })), - removeWorkflow: async (filename) => request(`/api/workflows/${filename}`, { method: "DELETE" }), - activateWorkflow: async (filename) => request(`/api/workflows/${filename}/activate`, { method: "PUT" }), - listWorkFolders: async () => request("/api/workfolders"), - addWorkFolder: async (path) => request("/api/workfolders", { method: "POST", body: JSON.stringify({ path }) }), - removeWorkFolder: async (path) => request("/api/workfolders", { method: "DELETE", body: JSON.stringify({ path }) }), - getTaskState: async (taskId, runId) => { - const query = runId ? `?runId=${encodeURIComponent(runId)}` : ""; - return request(`/api/tasks/${encodeURIComponent(taskId)}/state${query}`); - }, - openInCode: async () => { - throw new Error("Open in VS Code is only available in the desktop app"); - }, - openTaskOutputInCode: async () => { - throw new Error("Open in VS Code is only available in the desktop app"); - }, - removeTask: async (taskId, runId, options = {}) => { - const params = new URLSearchParams(); - if (runId) params.set("runId", runId); - if (options?.removeWorktree) params.set("removeWorktree", "1"); - const query = params.toString() ? `?${params.toString()}` : ""; - return request(`/api/tasks/${encodeURIComponent(taskId)}${query}`, { method: "DELETE" }); - }, - removeTaskWorktree: async () => { - throw new Error("Removing a Git worktree is only available in the desktop app"); - }, - saveTaskUploads: async (taskId, filePaths) => { - if (!Array.isArray(filePaths) || filePaths.length === 0) return { paths: [] }; - const form = new FormData(); - for (const entry of filePaths) { - if (!entry || typeof entry !== "object" || !entry.name || !entry.data) continue; - form.append("images", new File([new Uint8Array(entry.data)], entry.name)); - } - const response = await fetch(`${baseUrl}/api/tasks/${encodeURIComponent(taskId)}/upload`, { - method: "POST", - body: form, - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(data.error || "upload failed"); - return data; - }, - startWorkflow: async (payload) => { - activeTaskId = payload.taskId; - activeRunId = payload.runId || ""; - activeWorkFolder = payload.workFolder; - sendSocket({ - type: "start", - taskId: payload.taskId, - workFolder: payload.workFolder, - taskInputs: payload.taskInputs, - images: payload.images, - runId: payload.runId, - worktreeName: payload.worktreeName, - workflowFilename: payload.workflowFilename, - }); - }, - approveWorkflow: async (taskId, runId) => { - sendSocket({ type: "approve", taskId, runId }); - }, - rejectWorkflow: async (taskId, rejectTo, reason, runId) => { - sendSocket({ type: "reject", taskId, rejectTo, reason, runId }); - }, - sendWorkflowMessage: async (taskId, text, images, runId) => { - sendSocket({ type: "message", taskId, text, images, runId }); - }, - resumeWorkflowPhase: async (taskId, phase, runId) => { - sendSocket({ type: "resume_phase", taskId, phase, runId }); - }, - retryWorkflowPhase: async (taskId, phase, runId) => { - sendSocket({ type: "retry_phase", taskId, phase, runId }); - }, - pauseWorkflowPhase: async (taskId, phase, runId) => { - sendSocket({ type: "pause_phase", taskId, phase, runId }); - }, - onWorkflowEvent(callback) { - workflowHandler = callback; - ensureSocket(); - return () => { - workflowHandler = null; - }; - }, - detachWorkflow(taskId, runId) { - if (activeTaskId === taskId && (!runId || activeRunId === runId)) { - activeTaskId = null; - activeRunId = ""; - activeWorkFolder = ""; - } - }, - }; -} - -function requireDesktopApi() { - if (!window.desktopApi) { - return createWebApi(); - } - return window.desktopApi; -} - -export function getAppApi() { - return requireDesktopApi(); -} diff --git a/apps/desktop/renderer/src/stores/workflowStore.test.ts b/apps/desktop/renderer/src/stores/workflowStore.test.ts deleted file mode 100644 index 0dc4793..0000000 --- a/apps/desktop/renderer/src/stores/workflowStore.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { getPhasesWithRunningPhase, getSelectedPhaseFromWorkflowState } from "./workflowStore"; - -describe("getSelectedPhaseFromWorkflowState", () => { - test("prefers the active phase over a stale currentPhase", () => { - expect(getSelectedPhaseFromWorkflowState({ - currentPhase: "implement", - phases: [ - { id: "implement", status: "completed" }, - { id: "code_review", status: "in_progress" }, - ], - })).toBe("code_review"); - }); - - test("shows the latest started phase when the run is completed", () => { - expect(getSelectedPhaseFromWorkflowState({ - currentPhase: "completed", - phases: [ - { id: "implement", status: "completed" }, - { id: "code_review", status: "completed" }, - ], - })).toBe("code_review"); - }); - - test("falls back to currentPhase when there is no active phase", () => { - expect(getSelectedPhaseFromWorkflowState({ - currentPhase: "code_review", - phases: [ - { id: "implement", status: "completed" }, - { id: "code_review", status: "pending" }, - ], - })).toBe("code_review"); - }); - - test("resets later phases when an earlier phase starts running again", () => { - expect(getPhasesWithRunningPhase([ - { id: "implement", status: "completed" }, - { id: "code_review", status: "completed" }, - { id: "risk_gate", status: "completed" }, - { id: "review", status: "awaiting_input" }, - { id: "commit", status: "pending" }, - ], "implement", "now")).toEqual([ - { id: "implement", status: "in_progress", updated: "now" }, - { id: "code_review", status: "pending", updated: "now" }, - { id: "risk_gate", status: "pending", updated: "now" }, - { id: "review", status: "pending", updated: "now" }, - { id: "commit", status: "pending", updated: undefined }, - ]); - }); -}); diff --git a/apps/desktop/server.ts b/apps/desktop/server.ts deleted file mode 100644 index 3caf773..0000000 --- a/apps/desktop/server.ts +++ /dev/null @@ -1,65 +0,0 @@ -import express from "express"; -import cors from "cors"; -import { createServer } from "http"; -import { fileURLToPath } from "url"; -import { dirname, join } from "path"; - -import "../../packages/core-models/workflow"; -import workfoldersController from "./local-server-controllers/workfoldersController"; -import workflowController from "./local-server-controllers/workflowController"; -import { setupWebSocket } from "./local-server-controllers/wsController"; -import { activeWorkflows } from "../../packages/core-lib/claude"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const app = express(); -const PORT = process.env.LOCAL_SERVER_PORT || process.env.PORT || 3000; - -app.use(cors()); -app.use(express.json()); -app.use(express.static(join(__dirname, "renderer", "dist"))); - -app.use("/api", workfoldersController); -app.use("/api", workflowController); - -// SPA fallback -app.get("*", (req, res) => { - res.sendFile(join(__dirname, "renderer", "dist", "index.html")); -}); - -function killAllChildren() { - for (const [, wf] of activeWorkflows) { - if (wf.abortController) { - try { wf.abortController.abort(); } catch {} - wf.abortController = null; - } - } -} - -export function startServer(port = PORT, host) { - const server = createServer(app); - setupWebSocket(server); - - return new Promise((resolve, reject) => { - const listenArgs = host ? [port, host] : [port]; - const onError = (err) => reject(err); - server.once("error", onError); - server.listen(...listenArgs, () => { - server.off("error", onError); - const address = server.address(); - const actualPort = typeof address === "object" && address ? address.port : port; - console.log(`Server running at http://localhost:${actualPort}`); - resolve({ app, server, port: actualPort }); - }); - }); -} - -export { killAllChildren }; - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - process.on("SIGINT", () => { killAllChildren(); process.exit(0); }); - process.on("SIGTERM", () => { killAllChildren(); process.exit(0); }); - startServer().catch((err) => { - console.error(err); - process.exit(1); - }); -} diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore deleted file mode 100644 index 79c113f..0000000 --- a/apps/mobile/.gitignore +++ /dev/null @@ -1,45 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/apps/mobile/.metadata b/apps/mobile/.metadata deleted file mode 100644 index baf647d..0000000 --- a/apps/mobile/.metadata +++ /dev/null @@ -1,33 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1" - channel: "[user-branch]" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1 - base_revision: 30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1 - - platform: android - create_revision: 30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1 - base_revision: 30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1 - - platform: ios - create_revision: 30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1 - base_revision: 30bc9bdc8b437dc29d8b7ef7691c85d31c3336f1 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/mobile/README.md b/apps/mobile/README.md deleted file mode 100644 index 3d27d39..0000000 --- a/apps/mobile/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# dev_workflow_mobile - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml deleted file mode 100644 index 0d29021..0000000 --- a/apps/mobile/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore deleted file mode 100644 index 55afd91..0000000 --- a/apps/mobile/android/.gitignore +++ /dev/null @@ -1,13 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle deleted file mode 100644 index 5508d2a..0000000 --- a/apps/mobile/android/app/build.gradle +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id "com.android.application" - id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id "dev.flutter.flutter-gradle-plugin" -} - -android { - namespace = "com.example.dev_workflow_mobile" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_1_8 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.dev_workflow_mobile" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug - } - } -} - -flutter { - source = "../.." -} diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/apps/mobile/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index ff85b69..0000000 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/mobile/android/app/src/main/kotlin/com/example/dev_workflow_mobile/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/example/dev_workflow_mobile/MainActivity.kt deleted file mode 100644 index bed3c0a..0000000 --- a/apps/mobile/android/app/src/main/kotlin/com/example/dev_workflow_mobile/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.dev_workflow_mobile - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity: FlutterActivity() diff --git a/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f..0000000 --- a/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/apps/mobile/android/app/src/main/res/drawable/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f..0000000 --- a/apps/mobile/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4b7b0906d62b1847e87f15cdcacf6a4f29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b79bb8a35cc66c3c1fd44f5a5526c1b78be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d34e7a88e3f88bea192c3a370d44689c3c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372eebdb28e45604e46eeda8dd24651419bc0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` diff --git a/apps/mobile/android/app/src/main/res/values-night/styles.xml b/apps/mobile/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be..0000000 --- a/apps/mobile/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88..0000000 --- a/apps/mobile/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/apps/mobile/android/app/src/profile/AndroidManifest.xml b/apps/mobile/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/apps/mobile/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/apps/mobile/android/build.gradle b/apps/mobile/android/build.gradle deleted file mode 100644 index d2ffbff..0000000 --- a/apps/mobile/android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -rootProject.buildDir = "../build" -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean", Delete) { - delete rootProject.buildDir -} diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties deleted file mode 100644 index 2597170..0000000 --- a/apps/mobile/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -android.enableJetifier=true diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 7bb2df6..0000000 --- a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip diff --git a/apps/mobile/android/settings.gradle b/apps/mobile/android/settings.gradle deleted file mode 100644 index b9e43bd..0000000 --- a/apps/mobile/android/settings.gradle +++ /dev/null @@ -1,25 +0,0 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - }() - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false -} - -include ":app" diff --git a/apps/mobile/ios/.gitignore b/apps/mobile/ios/.gitignore deleted file mode 100644 index 7a7f987..0000000 --- a/apps/mobile/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/apps/mobile/ios/Flutter/AppFrameworkInfo.plist b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 7c56964..0000000 --- a/apps/mobile/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 12.0 - - diff --git a/apps/mobile/ios/Flutter/Debug.xcconfig b/apps/mobile/ios/Flutter/Debug.xcconfig deleted file mode 100644 index ec97fc6..0000000 --- a/apps/mobile/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Flutter/Release.xcconfig b/apps/mobile/ios/Flutter/Release.xcconfig deleted file mode 100644 index c4855bf..0000000 --- a/apps/mobile/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile deleted file mode 100644 index d97f17e..0000000 --- a/apps/mobile/ios/Podfile +++ /dev/null @@ -1,44 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '12.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock deleted file mode 100644 index 86dfca1..0000000 --- a/apps/mobile/ios/Podfile.lock +++ /dev/null @@ -1,29 +0,0 @@ -PODS: - - Flutter (1.0.0) - - image_picker_ios (0.0.1): - - Flutter - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - -DEPENDENCIES: - - Flutter (from `Flutter`) - - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - -EXTERNAL SOURCES: - Flutter: - :path: Flutter - image_picker_ios: - :path: ".symlinks/plugins/image_picker_ios/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - -SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a - shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 - -PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 - -COCOAPODS: 1.16.2 diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index e3cfbc1..0000000 --- a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,731 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 0D473BD4244D5F43C6EE0826 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B043B25044BA192CE3B6C450 /* Pods_Runner.framework */; }; - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 6A141F16ADB03D841C5BD697 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6FB8A510ADD853C484BE5A47 /* Pods_RunnerTests.framework */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 03AA8A62E1540E3C7C9B809C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 0DFFD608252D71B4878728BD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 3EF0BC6DABAA381A8637E183 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 674A8E27D4F00CC206964845 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 6FB8A510ADD853C484BE5A47 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A8CD8CAD4FFBB966596DE126 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - B043B25044BA192CE3B6C450 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - E66AB5D427F70DDA39F70A26 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 38A6DA023DA5EC548BE4CD5A /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 6A141F16ADB03D841C5BD697 /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 0D473BD4244D5F43C6EE0826 /* Pods_Runner.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - B17114302E621D667C67D3D2 /* Pods */, - B41532826DB0983428290AC7 /* Frameworks */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; - B17114302E621D667C67D3D2 /* Pods */ = { - isa = PBXGroup; - children = ( - E66AB5D427F70DDA39F70A26 /* Pods-Runner.debug.xcconfig */, - 3EF0BC6DABAA381A8637E183 /* Pods-Runner.release.xcconfig */, - 0DFFD608252D71B4878728BD /* Pods-Runner.profile.xcconfig */, - A8CD8CAD4FFBB966596DE126 /* Pods-RunnerTests.debug.xcconfig */, - 674A8E27D4F00CC206964845 /* Pods-RunnerTests.release.xcconfig */, - 03AA8A62E1540E3C7C9B809C /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - B41532826DB0983428290AC7 /* Frameworks */ = { - isa = PBXGroup; - children = ( - B043B25044BA192CE3B6C450 /* Pods_Runner.framework */, - 6FB8A510ADD853C484BE5A47 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - A76F0943CA153E053781CF0C /* [CP] Check Pods Manifest.lock */, - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - 38A6DA023DA5EC548BE4CD5A /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - FC4D99B083A757940A247375 /* [CP] Check Pods Manifest.lock */, - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 3855099CD8227A69ECF1217A /* [CP] Embed Pods Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3855099CD8227A69ECF1217A /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; - A76F0943CA153E053781CF0C /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - FC4D99B083A757940A247375 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = Q83FXUCHNL; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.devWorkflowMobile; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = A8CD8CAD4FFBB966596DE126 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.devWorkflowMobile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 674A8E27D4F00CC206964845 /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.devWorkflowMobile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 03AA8A62E1540E3C7C9B809C /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.devWorkflowMobile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = Q83FXUCHNL; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.devWorkflowMobile; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = Q83FXUCHNL; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.devWorkflowMobile; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 8e3ca5d..0000000 --- a/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 21a3cc1..0000000 --- a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/apps/mobile/ios/Runner/AppDelegate.swift b/apps/mobile/ios/Runner/AppDelegate.swift deleted file mode 100644 index 6266644..0000000 --- a/apps/mobile/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4725e9b0ddb1deab583e5b5102493aa332..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10932 zcmeHN2~<R zh`|8`A_PQ1nSu(UMFx?8j8PC!!VDphaL#`F42fd#7Vlc`zIE4n%Y~eiz4y1j|NDpi z?<@|pSJ-HM`qifhf@m%MamgwK83`XpBA<+azdF#2QsT{X@z0A9Bq>~TVErigKH1~P zRX-!h-f0NJ4Mh++{D}J+K>~~rq}d%o%+4dogzXp7RxX4C>Km5XEI|PAFDmo;DFm6G zzjVoB`@qW98Yl0Kvc-9w09^PrsobmG*Eju^=3f?0o-t$U)TL1B3;sZ^!++3&bGZ!o-*6w?;oOhf z=A+Qb$scV5!RbG+&2S}BQ6YH!FKb0``VVX~T$dzzeSZ$&9=X$3)_7Z{SspSYJ!lGE z7yig_41zpQ)%5dr4ff0rh$@ky3-JLRk&DK)NEIHecf9c*?Z1bUB4%pZjQ7hD!A0r-@NF(^WKdr(LXj|=UE7?gBYGgGQV zidf2`ZT@pzXf7}!NH4q(0IMcxsUGDih(0{kRSez&z?CFA0RVXsVFw3^u=^KMtt95q z43q$b*6#uQDLoiCAF_{RFc{!H^moH_cmll#Fc^KXi{9GDl{>%+3qyfOE5;Zq|6#Hb zp^#1G+z^AXfRKaa9HK;%b3Ux~U@q?xg<2DXP%6k!3E)PA<#4$ui8eDy5|9hA5&{?v z(-;*1%(1~-NTQ`Is1_MGdQ{+i*ccd96ab$R$T3=% zw_KuNF@vI!A>>Y_2pl9L{9h1-C6H8<)J4gKI6{WzGBi<@u3P6hNsXG=bRq5c+z;Gc3VUCe;LIIFDmQAGy+=mRyF++u=drBWV8-^>0yE9N&*05XHZpPlE zxu@?8(ZNy7rm?|<+UNe0Vs6&o?l`Pt>P&WaL~M&#Eh%`rg@Mbb)J&@DA-wheQ>hRV z<(XhigZAT z>=M;URcdCaiO3d^?H<^EiEMDV+7HsTiOhoaMX%P65E<(5xMPJKxf!0u>U~uVqnPN7T!X!o@_gs3Ct1 zlZ_$5QXP4{Aj645wG_SNT&6m|O6~Tsl$q?nK*)(`{J4b=(yb^nOATtF1_aS978$x3 zx>Q@s4i3~IT*+l{@dx~Hst21fR*+5}S1@cf>&8*uLw-0^zK(+OpW?cS-YG1QBZ5q! zgTAgivzoF#`cSz&HL>Ti!!v#?36I1*l^mkrx7Y|K6L#n!-~5=d3;K<;Zqi|gpNUn_ z_^GaQDEQ*jfzh;`j&KXb66fWEk1K7vxQIMQ_#Wu_%3 z4Oeb7FJ`8I>Px;^S?)}2+4D_83gHEq>8qSQY0PVP?o)zAv3K~;R$fnwTmI-=ZLK`= zTm+0h*e+Yfr(IlH3i7gUclNH^!MU>id$Jw>O?2i0Cila#v|twub21@e{S2v}8Z13( zNDrTXZVgris|qYm<0NU(tAPouG!QF4ZNpZPkX~{tVf8xY690JqY1NVdiTtW+NqyRP zZ&;T0ikb8V{wxmFhlLTQ&?OP7 z;(z*<+?J2~z*6asSe7h`$8~Se(@t(#%?BGLVs$p``;CyvcT?7Y!{tIPva$LxCQ&4W z6v#F*);|RXvI%qnoOY&i4S*EL&h%hP3O zLsrFZhv&Hu5tF$Lx!8(hs&?!Kx5&L(fdu}UI5d*wn~A`nPUhG&Rv z2#ixiJdhSF-K2tpVL=)5UkXRuPAFrEW}7mW=uAmtVQ&pGE-&az6@#-(Te^n*lrH^m@X-ftVcwO_#7{WI)5v(?>uC9GG{lcGXYJ~Q8q zbMFl7;t+kV;|;KkBW2!P_o%Czhw&Q(nXlxK9ak&6r5t_KH8#1Mr-*0}2h8R9XNkr zto5-b7P_auqTJb(TJlmJ9xreA=6d=d)CVbYP-r4$hDn5|TIhB>SReMfh&OVLkMk-T zYf%$taLF0OqYF?V{+6Xkn>iX@TuqQ?&cN6UjC9YF&%q{Ut3zv{U2)~$>-3;Dp)*(? zg*$mu8^i=-e#acaj*T$pNowo{xiGEk$%DusaQiS!KjJH96XZ-hXv+jk%ard#fu=@Q z$AM)YWvE^{%tDfK%nD49=PI|wYu}lYVbB#a7wtN^Nml@CE@{Gv7+jo{_V?I*jkdLD zJE|jfdrmVbkfS>rN*+`#l%ZUi5_bMS<>=MBDNlpiSb_tAF|Zy`K7kcp@|d?yaTmB^ zo?(vg;B$vxS|SszusORgDg-*Uitzdi{dUV+glA~R8V(?`3GZIl^egW{a919!j#>f` znL1o_^-b`}xnU0+~KIFLQ)$Q6#ym%)(GYC`^XM*{g zv3AM5$+TtDRs%`2TyR^$(hqE7Y1b&`Jd6dS6B#hDVbJlUXcG3y*439D8MrK!2D~6gn>UD4Imctb z+IvAt0iaW73Iq$K?4}H`7wq6YkTMm`tcktXgK0lKPmh=>h+l}Y+pDtvHnG>uqBA)l zAH6BV4F}v$(o$8Gfo*PB>IuaY1*^*`OTx4|hM8jZ?B6HY;F6p4{`OcZZ(us-RVwDx zUzJrCQlp@mz1ZFiSZ*$yX3c_#h9J;yBE$2g%xjmGF4ca z&yL`nGVs!Zxsh^j6i%$a*I3ZD2SoNT`{D%mU=LKaEwbN(_J5%i-6Va?@*>=3(dQy` zOv%$_9lcy9+(t>qohkuU4r_P=R^6ME+wFu&LA9tw9RA?azGhjrVJKy&8=*qZT5Dr8g--d+S8zAyJ$1HlW3Olryt`yE zFIph~Z6oF&o64rw{>lgZISC6p^CBer9C5G6yq%?8tC+)7*d+ib^?fU!JRFxynRLEZ zj;?PwtS}Ao#9whV@KEmwQgM0TVP{hs>dg(1*DiMUOKHdQGIqa0`yZnHk9mtbPfoLx zo;^V6pKUJ!5#n`w2D&381#5#_t}AlTGEgDz$^;u;-vxDN?^#5!zN9ngytY@oTv!nc zp1Xn8uR$1Z;7vY`-<*?DfPHB;x|GUi_fI9@I9SVRv1)qETbNU_8{5U|(>Du84qP#7 z*l9Y$SgA&wGbj>R1YeT9vYjZuC@|{rajTL0f%N@>3$DFU=`lSPl=Iv;EjuGjBa$Gw zHD-;%YOE@<-!7-Mn`0WuO3oWuL6tB2cpPw~Nvuj|KM@))ixuDK`9;jGMe2d)7gHin zS<>k@!x;!TJEc#HdL#RF(`|4W+H88d4V%zlh(7#{q2d0OQX9*FW^`^_<3r$kabWAB z$9BONo5}*(%kx zOXi-yM_cmB3>inPpI~)duvZykJ@^^aWzQ=eQ&STUa}2uT@lV&WoRzkUoE`rR0)`=l zFT%f|LA9fCw>`enm$p7W^E@U7RNBtsh{_-7vVz3DtB*y#*~(L9+x9*wn8VjWw|Q~q zKFsj1Yl>;}%MG3=PY`$g$_mnyhuV&~O~u~)968$0b2!Jkd;2MtAP#ZDYw9hmK_+M$ zb3pxyYC&|CuAbtiG8HZjj?MZJBFbt`ryf+c1dXFuC z0*ZQhBzNBd*}s6K_G}(|Z_9NDV162#y%WSNe|FTDDhx)K!c(mMJh@h87@8(^YdK$&d*^WQe8Z53 z(|@MRJ$Lk-&ii74MPIs80WsOFZ(NX23oR-?As+*aq6b?~62@fSVmM-_*cb1RzZ)`5$agEiL`-E9s7{GM2?(KNPgK1(+c*|-FKoy}X(D_b#etO|YR z(BGZ)0Ntfv-7R4GHoXp?l5g#*={S1{u-QzxCGng*oWr~@X-5f~RA14b8~B+pLKvr4 zfgL|7I>jlak9>D4=(i(cqYf7#318!OSR=^`xxvI!bBlS??`xxWeg?+|>MxaIdH1U~#1tHu zB{QMR?EGRmQ_l4p6YXJ{o(hh-7Tdm>TAX380TZZZyVkqHNzjUn*_|cb?T? zt;d2s-?B#Mc>T-gvBmQZx(y_cfkXZO~{N zT6rP7SD6g~n9QJ)8F*8uHxTLCAZ{l1Y&?6v)BOJZ)=R-pY=Y=&1}jE7fQ>USS}xP#exo57uND0i*rEk@$;nLvRB@u~s^dwRf?G?_enN@$t* zbL%JO=rV(3Ju8#GqUpeE3l_Wu1lN9Y{D4uaUe`g>zlj$1ER$6S6@{m1!~V|bYkhZA z%CvrDRTkHuajMU8;&RZ&itnC~iYLW4DVkP<$}>#&(`UO>!n)Po;Mt(SY8Yb`AS9lt znbX^i?Oe9r_o=?})IHKHoQGKXsps_SE{hwrg?6dMI|^+$CeC&z@*LuF+P`7LfZ*yr+KN8B4{Nzv<`A(wyR@!|gw{zB6Ha ziwPAYh)oJ(nlqSknu(8g9N&1hu0$vFK$W#mp%>X~AU1ay+EKWcFdif{% z#4!4aoVVJ;ULmkQf!ke2}3hqxLK>eq|-d7Ly7-J9zMpT`?dxo6HdfJA|t)?qPEVBDv z{y_b?4^|YA4%WW0VZd8C(ZgQzRI5(I^)=Ub`Y#MHc@nv0w-DaJAqsbEHDWG8Ia6ju zo-iyr*sq((gEwCC&^TYBWt4_@|81?=B-?#P6NMff(*^re zYqvDuO`K@`mjm_Jd;mW_tP`3$cS?R$jR1ZN09$YO%_iBqh5ftzSpMQQtxKFU=FYmP zeY^jph+g<4>YO;U^O>-NFLn~-RqlHvnZl2yd2A{Yc1G@Ga$d+Q&(f^tnPf+Z7serIU};17+2DU_f4Z z@GaPFut27d?!YiD+QP@)T=77cR9~MK@bd~pY%X(h%L={{OIb8IQmf-!xmZkm8A0Ga zQSWONI17_ru5wpHg3jI@i9D+_Y|pCqVuHJNdHUauTD=R$JcD2K_liQisqG$(sm=k9;L* z!L?*4B~ql7uioSX$zWJ?;q-SWXRFhz2Jt4%fOHA=Bwf|RzhwqdXGr78y$J)LR7&3T zE1WWz*>GPWKZ0%|@%6=fyx)5rzUpI;bCj>3RKzNG_1w$fIFCZ&UR0(7S?g}`&Pg$M zf`SLsz8wK82Vyj7;RyKmY{a8G{2BHG%w!^T|Njr!h9TO2LaP^_f22Q1=l$QiU84ao zHe_#{S6;qrC6w~7{y(hs-?-j?lbOfgH^E=XcSgnwW*eEz{_Z<_xN#0001NP)t-s|Ns9~ z#rXRE|M&d=0au&!`~QyF`q}dRnBDt}*!qXo`c{v z{Djr|@Adh0(D_%#_&mM$D6{kE_x{oE{l@J5@%H*?%=t~i_`ufYOPkAEn!pfkr2$fs z652Tz0001XNklqeeKN4RM4i{jKqmiC$?+xN>3Apn^ z0QfuZLym_5b<*QdmkHjHlj811{If)dl(Z2K0A+ekGtrFJb?g|wt#k#pV-#A~bK=OT ts8>{%cPtyC${m|1#B1A6#u!Q;umknL1chzTM$P~L002ovPDHLkV1lTfnu!1a diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452e458972bab9d994556c8305db4c827017..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d933e1120817fe9182483a228007b18ab6ae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b0099ca80c806f8fe495613e8d6c69460d76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe730945a01f64a61e2235dbe3f45b08f7729182..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463a9bc882b461c96aadf492d1729e49e725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec303439225b78712f49115768196d8d76f6790..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea27c705180eb716271f41b582e76dcbd90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1674 zcmV;526g#~P){YQnis^a@{&-nmRmq)<&%Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b zE>q9${Lb$D&h7k=+4=!ek^n+`0zq>LL1O?lVyea53S5x`Nqqo2YyeuIrQrJj9XjOp z{;T5qbj3}&1vg1VK~#9!?b~^C5-}JC@Pyrv-6dSEqJqT}#j9#dJ@GzT@B8}x zU&J@bBI>f6w6en+CeI)3^kC*U?}X%OD8$Fd$H&LV$H&LV$H&LV#|K5~mLYf|VqzOc zkc7qL~0sOYuM{tG`rYEDV{DWY`Z8&)kW*hc2VkBuY+^Yx&92j&StN}Wp=LD zxoGxXw6f&8sB^u})h@b@z0RBeD`K7RMR9deyL(ZJu#39Z>rT)^>v}Khq8U-IbIvT> z?4pV9qGj=2)TNH3d)=De<+^w;>S7m_eFKTvzeaBeir45xY!^m!FmxnljbSS_3o=g( z->^wC9%qkR{kbGnW8MfFew_o9h3(r55Is`L$8KI@d+*%{=Nx+FXJ98L0PjFIu;rGnnfY zn1R5Qnp<{Jq0M1vX=X&F8gtLmcWv$1*M@4ZfF^9``()#hGTeKeP`1!iED ztNE(TN}M5}3Bbc*d=FIv`DNv&@|C6yYj{sSqUj5oo$#*0$7pu|Dd2TLI>t5%I zIa4Dvr(iayb+5x=j*Vum9&irk)xV1`t509lnPO0%skL8_1c#Xbamh(2@f?4yUI zhhuT5<#8RJhGz4%b$`PJwKPAudsm|at?u;*hGgnA zU1;9gnxVBC)wA(BsB`AW54N{|qmikJR*%x0c`{LGsSfa|NK61pYH(r-UQ4_JXd!Rsz)=k zL{GMc5{h138)fF5CzHEDM>+FqY)$pdN3}Ml+riTgJOLN0F*Vh?{9ESR{SVVg>*>=# zix;VJHPtvFFCRY$Ks*F;VX~%*r9F)W`PmPE9F!(&s#x07n2<}?S{(ygpXgX-&B&OM zONY&BRQ(#%0%jeQs?oJ4P!p*R98>qCy5p8w>_gpuh39NcOlp)(wOoz0sY-Qz55eB~ z7OC-fKBaD1sE3$l-6QgBJO!n?QOTza`!S_YK z_v-lm^7{VO^8Q@M_^8F)09Ki6%=s?2_5eupee(w1FB%aqSweusQ-T+CH0Xt{` zFjMvW{@C&TB)k25()nh~_yJ9coBRL(0oO@HK~z}7?bm5j;y@69;bvlHb2tf!$ReA~x{22wTq550 z?f?Hnw(;m3ip30;QzdV~7pi!wyMYhDtXW#cO7T>|f=bdFhu+F!zMZ2UFj;GUKX7tI z;hv3{q~!*pMj75WP_c}>6)IWvg5_yyg<9Op()eD1hWC19M@?_9_MHec{Z8n3FaF{8 z;u`Mw0ly(uE>*CgQYv{be6ab2LWhlaH1^iLIM{olnag$78^Fd}%dR7;JECQ+hmk|o z!u2&!3MqPfP5ChDSkFSH8F2WVOEf0(E_M(JL17G}Y+fg0_IuW%WQ zG(mG&u?|->YSdk0;8rc{yw2@2Z&GA}z{Wb91Ooz9VhA{b2DYE7RmG zjL}?eq#iX%3#k;JWMx_{^2nNax`xPhByFiDX+a7uTGU|otOvIAUy|dEKkXOm-`aWS z27pUzD{a)Ct<6p{{3)+lq@i`t@%>-wT4r?*S}k)58e09WZYP0{{R3FC5Sl00039P)t-s|Ns9~ z#rP?<_5oL$Q^olD{r_0T`27C={r>*`|Nj71npVa5OTzc(_WfbW_({R{p56NV{r*M2 z_xt?)2V0#0NsfV0u>{42ctGP(8vQj-Btk1n|O0ZD=YLwd&R{Ko41Gr9H= zY@z@@bOAMB5Ltl$E>bJJ{>JP30ZxkmI%?eW{k`b?Wy<&gOo;dS`~CR$Vwb@XWtR|N zi~t=w02?-0&j0TD{>bb6sNwsK*!p?V`RMQUl(*DVjk-9Cx+-z1KXab|Ka2oXhX5f% z`$|e!000AhNklrxs)5QTeTVRiEmz~MKK1WAjCw(c-JK6eox;2O)?`? zTG`AHia671e^vgmp!llKp|=5sVHk#C7=~epA~VAf-~%aPC=%Qw01h8mnSZ|p?hz91 z7p83F3%LVu9;S$tSI$C^%^yud1dfTM_6p2|+5Ejp$bd`GDvbR|xit>i!ZD&F>@CJrPmu*UjD&?DfZs=$@e3FQA(vNiU+$A*%a} z?`XcG2jDxJ_ZQ#Md`H{4Lpf6QBDp81_KWZ6Tk#yCy1)32zO#3<7>b`eT7UyYH1eGz z;O(rH$=QR*L%%ZcBpc=eGua?N55nD^K(8<#gl2+pN_j~b2MHs4#mcLmv%DkspS-3< zpI1F=^9siI0s-;IN_IrA;5xm~3?3!StX}pUv0vkxMaqm+zxrg7X7(I&*N~&dEd0kD z-FRV|g=|QuUsuh>-xCI}vD2imzYIOIdcCVV=$Bz@*u0+Bs<|L^)32nN*=wu3n%Ynw z@1|eLG>!8ruU1pFXUfb`j>(=Gy~?Rn4QJ-c3%3T|(Frd!bI`9u&zAnyFYTqlG#&J7 zAkD(jpw|oZLNiA>;>hgp1KX7-wxC~31II47gc zHcehD6Uxlf%+M^^uN5Wc*G%^;>D5qT{>=uxUhX%WJu^Z*(_Wq9y}npFO{Hhb>s6<9 zNi0pHXWFaVZnb)1+RS&F)xOv6&aeILcI)`k#0YE+?e)5&#r7J#c`3Z7x!LpTc01dx zrdC3{Z;joZ^KN&))zB_i)I9fWedoN>Zl-6_Iz+^G&*ak2jpF07*qoM6N<$f;w%0(f|Me diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf12aa4d28f374bb26596605a46dcbb3e7c8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2..0000000 --- a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725..0000000 --- a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c..0000000 --- a/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/mobile/ios/Runner/Base.lproj/Main.storyboard b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c2851..0000000 --- a/apps/mobile/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist deleted file mode 100644 index b62c115..0000000 --- a/apps/mobile/ios/Runner/Info.plist +++ /dev/null @@ -1,51 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Dev Workflow Mobile - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - dev_workflow_mobile - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - NSPhotoLibraryUsageDescription - Select images to send to your desktop workflow. - - diff --git a/apps/mobile/ios/Runner/Runner-Bridging-Header.h b/apps/mobile/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a5..0000000 --- a/apps/mobile/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/apps/mobile/ios/RunnerTests/RunnerTests.swift b/apps/mobile/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b..0000000 --- a/apps/mobile/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart deleted file mode 100644 index 86bc61b..0000000 --- a/apps/mobile/lib/main.dart +++ /dev/null @@ -1,2080 +0,0 @@ -import "dart:async"; -import "dart:convert"; -import "dart:typed_data"; - -import "package:dio/dio.dart"; -import "package:flutter/cupertino.dart"; -import "package:flutter_localizations/flutter_localizations.dart"; -import "package:image_picker/image_picker.dart"; -import "package:shared_preferences/shared_preferences.dart"; -import "package:web_socket_channel/web_socket_channel.dart"; - -void main() { - runApp(const DevWorkflowApp()); -} - -class DevWorkflowApp extends StatefulWidget { - const DevWorkflowApp({super.key}); - - @override - State createState() => _DevWorkflowAppState(); -} - -class _DevWorkflowAppState extends State { - static const _languageCodeKey = "languageCode"; - - Locale _locale = const Locale("en"); - - @override - void initState() { - super.initState(); - _loadLocale(); - } - - Future _loadLocale() async { - final prefs = await SharedPreferences.getInstance(); - final languageCode = prefs.getString(_languageCodeKey); - if (!mounted || languageCode == null || languageCode.isEmpty) return; - setState(() { - _locale = Locale(languageCode); - }); - } - - Future _setLocale(Locale locale) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_languageCodeKey, locale.languageCode); - if (!mounted) return; - setState(() { - _locale = locale; - }); - } - - @override - Widget build(BuildContext context) { - final strings = AppStrings(_locale); - - return CupertinoApp( - title: strings.appTitle, - locale: _locale, - supportedLocales: AppStrings.supportedLocales, - localizationsDelegates: const [ - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - ], - theme: const CupertinoThemeData( - brightness: Brightness.light, - primaryColor: Color(0xFF0F766E), - scaffoldBackgroundColor: Color(0xFFF3F4F6), - barBackgroundColor: Color(0xF2F3F4F6), - ), - home: AppTabScaffold( - strings: strings, - locale: _locale, - onLocaleChanged: _setLocale, - ), - ); - } -} - -class AppTabScaffold extends StatelessWidget { - const AppTabScaffold({ - super.key, - required this.strings, - required this.locale, - required this.onLocaleChanged, - }); - - final AppStrings strings; - final Locale locale; - final Future Function(Locale locale) onLocaleChanged; - - @override - Widget build(BuildContext context) { - return CupertinoTabScaffold( - tabBar: CupertinoTabBar( - activeColor: const Color(0xFF0F766E), - items: [ - BottomNavigationBarItem( - icon: const Icon(CupertinoIcons.square_list), - label: strings.tasks, - ), - BottomNavigationBarItem( - icon: const Icon(CupertinoIcons.gear), - label: strings.settings, - ), - ], - ), - tabBuilder: (context, index) { - if (index == 1) { - return _SettingsPage( - strings: strings, - locale: locale, - onLocaleChanged: onLocaleChanged, - ); - } - return WorkflowHomePage(strings: strings, locale: locale); - }, - ); - } -} - -class WorkflowHomePage extends StatefulWidget { - const WorkflowHomePage({ - super.key, - required this.strings, - required this.locale, - }); - - final AppStrings strings; - final Locale locale; - - @override - State createState() => _WorkflowHomePageState(); -} - -class _SelectedImage { - const _SelectedImage({ - required this.name, - required this.bytes, - }); - - final String name; - final Uint8List bytes; - - Map toPayload() { - return { - "name": name, - "data": bytes.toList(), - }; - } -} - -Future> _pickSelectedImages(ImagePicker picker) async { - final files = await picker.pickMultiImage(); - if (files.isEmpty) return const []; - return Future.wait( - files.map((file) async => _SelectedImage( - name: file.name, - bytes: await file.readAsBytes(), - )), - ); -} - -class _WorkflowHomePageState extends State { - final Dio _dio = Dio(); - final ImagePicker _imagePicker = ImagePicker(); - final TextEditingController _baseUrlController = - TextEditingController(text: "http://127.0.0.1:8787"); - final TextEditingController _messageController = TextEditingController(); - - WebSocketChannel? _channel; - OverlayEntry? _toastEntry; - List _devices = const []; - List _tasks = const []; - List<_SelectedImage> _messageImages = const []; - Map? _selectedTask; - bool _connecting = false; - bool _startingWorkflow = false; - String _status = ConnectionStatus.disconnected; - String _availabilityStatus = "no_desktop_online"; - - AppStrings get strings => widget.strings; - bool get _hasAvailableDevice => - _devices.whereType>().any(_isDeviceAvailable); - String get _availableConnectionStatus => _hasAvailableDevice - ? ConnectionStatus.connected - : ConnectionStatus.disconnected; - String get _displayStatus => - _status == ConnectionStatus.connected && !_hasAvailableDevice - ? ConnectionStatus.disconnected - : _status; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _connect(silentUnavailable: true); - } - }); - } - - @override - void dispose() { - _toastEntry?.remove(); - _channel?.sink.close(); - _baseUrlController.dispose(); - _messageController.dispose(); - super.dispose(); - } - - Uri get _wsUri { - final base = Uri.parse(_baseUrlController.text.trim()); - final scheme = base.scheme == "https" ? "wss" : "ws"; - return base.replace(scheme: scheme, path: "/ws/mobile"); - } - - bool _isDeviceAvailable(Map device) { - if (device["status"]?.toString() != "online") return false; - final meta = (device["meta"] as Map?)?.cast() ?? const {}; - return meta["mobileAccessEnabled"] == true; - } - - String _availabilityMessage(String status) { - switch (status) { - case "desktop_mobile_access_disabled": - return strings.mobileAccessDisabled; - case "no_desktop_online": - return strings.noDesktopOnline; - default: - return ""; - } - } - - Future _connect({bool silentUnavailable = false}) async { - setState(() { - _connecting = true; - _status = ConnectionStatus.connecting; - }); - - try { - final response = - await _dio.get("${_baseUrlController.text.trim()}/api/tasks"); - List devices = const []; - String availabilityStatus = "no_desktop_online"; - try { - final deviceResponse = - await _dio.get("${_baseUrlController.text.trim()}/api/devices"); - devices = (deviceResponse.data["devices"] as List?) ?? const []; - availabilityStatus = - deviceResponse.data["availability"]?["status"]?.toString() ?? - "no_desktop_online"; - } catch (error) { - devices = const []; - } - final tasks = (response.data["tasks"] as List?) ?? const []; - _channel?.sink.close(); - final channel = WebSocketChannel.connect(_wsUri); - channel.stream.listen(_handleSocketMessage, onDone: () { - if (!mounted) return; - setState(() { - _channel = null; - _devices = const []; - _tasks = const []; - _selectedTask = null; - _status = ConnectionStatus.disconnected; - }); - }, onError: (_) { - if (!mounted) return; - setState(() { - _channel = null; - _devices = const []; - _tasks = const []; - _selectedTask = null; - _status = ConnectionStatus.error; - }); - }); - - if (!mounted) return; - setState(() { - _channel = channel; - _devices = devices.cast(); - _tasks = tasks.cast(); - _selectedTask = _pickSelectedTask(_selectedTask, tasks.cast()); - _availabilityStatus = availabilityStatus; - _connecting = false; - _status = _availableConnectionStatus; - }); - if (!silentUnavailable && availabilityStatus != "available") { - final message = _availabilityMessage(availabilityStatus); - if (message.isNotEmpty) { - _showToast(message); - } - } - } catch (error) { - if (!mounted) return; - setState(() { - _connecting = false; - _status = ConnectionStatus.failed; - }); - _showToast("${strings.failedToConnectPrefix}$error"); - } - } - - Future _disconnect() async { - await _channel?.sink.close(); - if (!mounted) return; - setState(() { - _channel = null; - _devices = const []; - _tasks = const []; - _selectedTask = null; - _connecting = false; - _status = ConnectionStatus.disconnected; - }); - } - - void _handleSocketMessage(dynamic raw) { - final payload = jsonDecode(raw as String) as Map; - - if (!mounted) return; - setState(() { - if (payload["type"] == "bootstrap") { - _devices = (payload["devices"] as List?) ?? const []; - _tasks = (payload["tasks"] as List?) ?? const []; - _selectedTask = _pickSelectedTask(_selectedTask, _tasks); - _availabilityStatus = payload["availability"]?["status"]?.toString() ?? - _availabilityStatus; - } else if (payload["type"] == "availability") { - _availabilityStatus = payload["availability"]?["status"]?.toString() ?? - _availabilityStatus; - } else if (payload["type"] == "device.status") { - final device = payload["device"] as Map; - _devices = _upsertByKey(_devices, device, "deviceId"); - } else if (payload["type"] == "task.snapshot") { - final task = payload["task"] as Map; - _tasks = _upsertByKey(_tasks, task, "key"); - _selectedTask = _refreshSelected(_selectedTask, task); - } else if (payload["type"] == "task.event") { - final task = payload["task"] as Map?; - if (task == null) return; - _tasks = _upsertByKey(_tasks, task, "key"); - _selectedTask = _refreshSelected(_selectedTask, task); - } else if (payload["type"] == "tasks.remove_device") { - final deviceId = payload["deviceId"]?.toString() ?? ""; - _tasks = _tasks - .whereType>() - .where((task) => task["deviceId"]?.toString() != deviceId) - .toList(); - _selectedTask = _pickSelectedTask(_selectedTask, _tasks); - } else if (payload["type"] == "tasks.remove_task") { - final taskKey = payload["taskKey"]?.toString() ?? ""; - final removingSelected = _selectedTask?["key"]?.toString() == taskKey; - _tasks = _tasks - .whereType>() - .where((task) => task["key"]?.toString() != taskKey) - .toList(); - _selectedTask = _pickSelectedTask(_selectedTask, _tasks); - if (removingSelected) { - _messageController.clear(); - _messageImages = const []; - } - } else if (payload["type"] == "command.status") { - final command = payload["command"] as Map; - if (command["status"] == "error") { - _showToast(command["error"]?.toString() ?? strings.commandFailed); - } - } else if (payload["type"] == "command.error") { - _showToast(payload["error"]?.toString() ?? strings.commandFailed); - } - _status = _channel != null - ? _availableConnectionStatus - : ConnectionStatus.disconnected; - }); - } - - List _upsertByKey( - List source, Map item, String key) { - final target = List.from(source); - final index = target.indexWhere((entry) => entry[key] == item[key]); - if (index >= 0) { - target[index] = item; - } else { - target.insert(0, item); - } - return target; - } - - Map? _refreshSelected( - Map? selected, Map next) { - if (selected == null) return null; - return selected["key"] == next["key"] ? next : selected; - } - - Map? _pickSelectedTask( - Map? selected, List tasks) { - if (tasks.isEmpty) return null; - if (selected == null) return tasks.first as Map; - final matched = tasks.whereType>().firstWhere( - (task) => task["key"] == selected["key"], - orElse: () => tasks.first as Map, - ); - return matched; - } - - Map _deviceMeta(String deviceId) { - final device = _devices - .whereType>() - .cast?>() - .firstWhere( - (entry) => entry?["deviceId"]?.toString() == deviceId, - orElse: () => null, - ); - return (device?["meta"] as Map?)?.cast() ?? const {}; - } - - List _rejectTargetsForTask(Map? task) { - if (task == null) return const []; - final deviceId = task["deviceId"]?.toString() ?? ""; - final currentPhase = - (task["state"] as Map?)?["currentPhase"]?.toString() ?? ""; - if (deviceId.isEmpty || currentPhase.isEmpty) return const []; - final workflowConfig = (_deviceMeta(deviceId)["workflowConfig"] as Map?) - ?.cast() ?? - const {}; - final rejectTargets = - (workflowConfig["rejectTargets"] as Map?)?.cast() ?? - const {}; - final targets = (rejectTargets[currentPhase] as List?) ?? const []; - return targets.map((target) => target.toString()).toList(); - } - - Future _sendCommand(String command, - {Map? payload}) async { - final task = _selectedTask; - if (task == null) return; - - try { - await _dio.post( - "${_baseUrlController.text.trim()}/api/tasks/${task["deviceId"]}/${task["taskId"]}/commands", - data: { - "type": command, - "payload": payload ?? const {}, - }, - ); - } catch (error) { - _showToast("${strings.commandFailedPrefix}$error"); - } - } - - Future _addMessageImages() async { - final images = await _pickSelectedImages(_imagePicker); - if (!mounted || images.isEmpty) return; - setState(() { - _messageImages = [..._messageImages, ...images]; - }); - } - - void _removeMessageImage(int index) { - setState(() { - _messageImages = [ - for (var i = 0; i < _messageImages.length; i++) - if (i != index) _messageImages[i], - ]; - }); - } - - Future _confirmDeleteTask() async { - final task = _selectedTask; - if (task == null) return; - final confirmed = await showCupertinoDialog( - context: context, - builder: (context) { - return CupertinoAlertDialog( - title: Text(strings.deleteTask), - content: Text( - strings.deleteTaskConfirm(task["taskId"]?.toString() ?? "-"), - ), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.of(context).pop(false), - child: Text(strings.cancel), - ), - CupertinoDialogAction( - isDestructiveAction: true, - onPressed: () => Navigator.of(context).pop(true), - child: Text(strings.delete), - ), - ], - ); - }, - ); - if (confirmed != true) return; - await _sendCommand("delete_task"); - } - - Future _showRejectSheet() async { - final task = _selectedTask; - final targets = _rejectTargetsForTask(task); - if (task == null || targets.isEmpty) { - _showToast(strings.noRejectTargets); - return; - } - - final rejectTo = await showCupertinoModalPopup( - context: context, - builder: (context) { - return CupertinoActionSheet( - title: Text(strings.rejectTo), - actions: targets - .map((target) => CupertinoActionSheetAction( - onPressed: () => Navigator.of(context).pop(target), - child: Text(target), - )) - .toList(), - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.of(context).pop(), - child: Text(strings.cancel), - ), - ); - }, - ); - - if (rejectTo == null || rejectTo.isEmpty) return; - await _sendCommand("reject", payload: {"rejectTo": rejectTo}); - } - - Future _startWorkflow({ - required String deviceId, - required String taskId, - required String runId, - required String workFolder, - required String workflowFilename, - required Map taskInputs, - required List> images, - }) async { - try { - await _dio.post( - "${_baseUrlController.text.trim()}/api/tasks/$deviceId/$taskId/commands", - data: { - "type": "start_workflow", - "payload": { - "runId": runId, - "workFolder": workFolder, - "workflowFilename": workflowFilename, - "taskInputs": taskInputs, - "images": images, - }, - }, - ); - _showToast(strings.workflowStarted); - } catch (error) { - _showToast("${strings.startFailedPrefix}$error"); - } - } - - Future _showStartWorkflowSheet() async { - final onlineDevices = _devices - .whereType>() - .where((device) => device["status"] == "online") - .toList(); - - if (onlineDevices.isEmpty) { - _showToast(strings.noOnlineDeviceAvailable); - return; - } - - final meta = - (onlineDevices.first["meta"] as Map?)?.cast() ?? - const {}; - final mobileAccessEnabled = meta["mobileAccessEnabled"] == true; - final workflows = ((meta["workflows"] as List?) ?? const []) - .whereType() - .map((entry) => Map.from(entry)) - .toList(); - final workFolders = ((meta["workFolders"] as List?) ?? const []) - .whereType() - .map((entry) => Map.from(entry)) - .toList(); - - if (workflows.isEmpty || workFolders.isEmpty) { - _showToast(mobileAccessEnabled - ? strings.deviceMetadataIncomplete - : strings.mobileAccessDisabled); - return; - } - - final result = await showCupertinoModalPopup<_StartWorkflowResult>( - context: context, - builder: (context) { - return _StartWorkflowSheet( - strings: strings, - workflows: workflows, - workFolders: workFolders, - ); - }, - ); - - if (result == null) return; - - setState(() { - _startingWorkflow = true; - }); - - try { - await _startWorkflow( - deviceId: onlineDevices.first["deviceId"]?.toString() ?? "", - taskId: result.taskId, - runId: result.runId, - workFolder: result.workFolder, - workflowFilename: result.workflowFilename, - taskInputs: result.taskInputs, - images: result.images.map((image) => image.toPayload()).toList(), - ); - } finally { - if (mounted) { - setState(() { - _startingWorkflow = false; - }); - } - } - } - - Future _syncSelectedTask() async { - await _sendCommand("sync_task"); - } - - Future _showConnectionSheet() async { - await showCupertinoModalPopup( - context: context, - builder: (context) { - return _ConnectionSheet( - strings: strings, - locale: widget.locale, - baseUrlController: _baseUrlController, - status: _displayStatus, - availabilityMessage: _availabilityMessage(_availabilityStatus), - connecting: _connecting, - onDisconnect: _disconnect, - onConnect: () => _connect(), - ); - }, - ); - } - - void _showToast(String message) { - final overlay = Overlay.maybeOf(context, rootOverlay: true); - if (overlay == null) return; - - _toastEntry?.remove(); - final entry = OverlayEntry( - builder: (context) { - final bottom = MediaQuery.of(context).padding.bottom + 32; - return Positioned( - left: 24, - right: 24, - bottom: bottom, - child: IgnorePointer( - child: Center( - child: DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xE61F2937), - borderRadius: BorderRadius.circular(18), - ), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Text( - message, - textAlign: TextAlign.center, - style: const TextStyle( - color: CupertinoColors.white, - fontSize: 14, - ), - ), - ), - ), - ), - ), - ); - }, - ); - - overlay.insert(entry); - _toastEntry = entry; - Future.delayed(const Duration(seconds: 2), () { - if (_toastEntry == entry) { - _toastEntry?.remove(); - _toastEntry = null; - } - }); - } - - @override - Widget build(BuildContext context) { - final selectedTask = _selectedTask; - final taskState = selectedTask?["state"] as Map?; - final currentPhase = taskState?["currentPhase"]?.toString() ?? "-"; - final overallStatus = taskState?["overallStatus"]?.toString() ?? "-"; - final rejectTargets = _rejectTargetsForTask(selectedTask); - final messages = - (selectedTask?["messages"] as Map?)?.cast() ?? - const {}; - - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - border: null, - middle: Text(strings.appTitle), - trailing: GestureDetector( - onTap: _showConnectionSheet, - child: _StatusBadge(status: _displayStatus, strings: strings), - ), - ), - child: SafeArea( - bottom: false, - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Row( - children: [ - Expanded(child: _SectionHeader(strings.tasks)), - CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - color: const Color(0xFFDCEFEA), - borderRadius: BorderRadius.circular(999), - onPressed: (_connecting || - _startingWorkflow || - !_hasAvailableDevice) - ? null - : _showStartWorkflowSheet, - child: Text( - _startingWorkflow - ? strings.startingWorkflow - : strings.startWorkflow, - style: const TextStyle( - color: Color(0xFF0F766E), - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - if (_tasks.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Text( - strings.noTasksYet, - style: const TextStyle( - color: Color(0xFF6B7280), - fontSize: 16, - ), - ), - ), - ) - else - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - sliver: SliverList( - delegate: SliverChildListDelegate([ - ..._tasks.whereType>().map((task) { - final selected = _selectedTask?["key"] == task["key"]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _TaskCard( - task: task, - selected: selected, - onTap: () { - setState(() { - _selectedTask = task; - }); - }, - ), - ); - }), - if (selectedTask != null) ...[ - const SizedBox(height: 8), - _SectionHeader(strings.details), - const SizedBox(height: 12), - _SectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - selectedTask["taskId"]?.toString() ?? "-", - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w700, - color: Color(0xFF111827), - ), - ), - const SizedBox(height: 14), - _DetailRow( - label: strings.status, value: overallStatus), - _DetailRow( - label: strings.phase, value: currentPhase), - _DetailRow( - label: strings.device, - value: - selectedTask["deviceId"]?.toString() ?? "-", - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: CupertinoButton.filled( - padding: const EdgeInsets.symmetric( - vertical: 12, - ), - borderRadius: BorderRadius.circular(12), - onPressed: () => _sendCommand("approve"), - child: Text(strings.approve), - ), - ), - const SizedBox(width: 10), - Expanded( - child: CupertinoButton( - padding: const EdgeInsets.symmetric( - vertical: 12, - ), - borderRadius: BorderRadius.circular(12), - color: const Color(0xFFFDE8E8), - onPressed: rejectTargets.isEmpty - ? null - : _showRejectSheet, - child: Text( - strings.reject, - style: const TextStyle( - color: Color(0xFFB91C1C), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: CupertinoButton( - color: const Color(0xFFE5E7EB), - borderRadius: BorderRadius.circular(12), - onPressed: _syncSelectedTask, - child: Text( - strings.refresh, - style: const TextStyle( - color: Color(0xFF111827), - ), - ), - ), - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: CupertinoButton( - color: const Color(0xFFFDE8E8), - borderRadius: BorderRadius.circular(12), - onPressed: _confirmDeleteTask, - child: Text( - strings.deleteTask, - style: const TextStyle( - color: Color(0xFFB91C1C), - ), - ), - ), - ), - const SizedBox(height: 16), - _FieldLabel(strings.message), - const SizedBox(height: 10), - CupertinoTextField( - controller: _messageController, - minLines: 3, - maxLines: 5, - padding: const EdgeInsets.all(14), - placeholder: strings.messagePlaceholder, - decoration: _inputDecoration(), - ), - const SizedBox(height: 10), - Row( - children: [ - CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - color: const Color(0xFFE5E7EB), - borderRadius: BorderRadius.circular(12), - onPressed: _addMessageImages, - child: Text( - strings.attachImages, - style: const TextStyle( - color: Color(0xFF111827), - ), - ), - ), - ], - ), - if (_messageImages.isNotEmpty) ...[ - const SizedBox(height: 10), - _SelectedImageStrip( - images: _messageImages, - onRemove: _removeMessageImage, - ), - ], - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: CupertinoButton( - color: const Color(0xFFDCEFEA), - borderRadius: BorderRadius.circular(12), - onPressed: () { - final text = _messageController.text.trim(); - if (text.isEmpty && _messageImages.isEmpty) { - return; - } - _sendCommand("message", payload: { - "text": text, - "images": _messageImages - .map((image) => image.toPayload()) - .toList(), - }); - _messageController.clear(); - setState(() { - _messageImages = const []; - }); - }, - child: Text( - strings.send, - style: const TextStyle( - color: Color(0xFF0F766E), - fontWeight: FontWeight.w600, - ), - ), - ), - ), - if (messages.isNotEmpty) ...[ - const SizedBox(height: 16), - ...messages.entries.map((entry) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(14), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - entry.key, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF111827), - ), - ), - const SizedBox(height: 8), - Text( - entry.value?.toString() ?? "", - style: const TextStyle( - color: Color(0xFF374151), - fontSize: 14, - height: 1.4, - ), - ), - ], - ), - ), - ), - ); - }), - ], - ], - ), - ), - ], - ]), - ), - ), - ], - ), - ), - ); - } -} - -class _TaskCard extends StatelessWidget { - const _TaskCard({ - required this.task, - required this.selected, - required this.onTap, - }); - - final Map task; - final bool selected; - final VoidCallback onTap; - - Color _statusColor(String status) { - switch (status) { - case "completed": - return const Color(0xFF15803D); - case "paused": - return const Color(0xFFD97706); - case "awaiting_input": - return const Color(0xFFD97706); - case "in_progress": - return const Color(0xFF2563EB); - default: - return const Color(0xFF475569); - } - } - - @override - Widget build(BuildContext context) { - final state = task["state"] as Map?; - final status = state?["overallStatus"]?.toString() ?? "unknown"; - final currentPhase = state?["currentPhase"]?.toString() ?? "-"; - final accent = _statusColor(status); - - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - decoration: BoxDecoration( - color: CupertinoColors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: selected ? accent : const Color(0xFFE5E7EB), - width: selected ? 2 : 1, - ), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - task["taskId"]?.toString() ?? "-", - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: Color(0xFF111827), - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - DecoratedBox( - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - child: Text( - status, - style: TextStyle( - color: accent, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - currentPhase, - style: const TextStyle( - color: Color(0xFF0F766E), - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Text( - task["deviceId"]?.toString() ?? "-", - style: const TextStyle( - color: Color(0xFF6B7280), - fontSize: 13, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _SelectedImageStrip extends StatelessWidget { - const _SelectedImageStrip({ - required this.images, - required this.onRemove, - }); - - final List<_SelectedImage> images; - final void Function(int index) onRemove; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 76, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: images.length, - separatorBuilder: (_, __) => const SizedBox(width: 10), - itemBuilder: (context, index) { - final image = images[index]; - return Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.memory( - image.bytes, - width: 76, - height: 76, - fit: BoxFit.cover, - ), - ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => onRemove(index), - child: DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xCC111827), - borderRadius: BorderRadius.circular(999), - ), - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon( - CupertinoIcons.clear_thick, - size: 10, - color: CupertinoColors.white, - ), - ), - ), - ), - ), - ], - ); - }, - ), - ); - } -} - -class _StartWorkflowResult { - const _StartWorkflowResult({ - required this.taskId, - required this.runId, - required this.workFolder, - required this.workflowFilename, - required this.taskInputs, - required this.images, - }); - - final String taskId; - final String runId; - final String workFolder; - final String workflowFilename; - final Map taskInputs; - final List<_SelectedImage> images; -} - -class _StartWorkflowSheet extends StatefulWidget { - const _StartWorkflowSheet({ - required this.strings, - required this.workflows, - required this.workFolders, - }); - - final AppStrings strings; - final List> workflows; - final List> workFolders; - - @override - State<_StartWorkflowSheet> createState() => _StartWorkflowSheetState(); -} - -class _StartWorkflowSheetState extends State<_StartWorkflowSheet> { - final ImagePicker _imagePicker = ImagePicker(); - late String _selectedWorkflow; - late String _selectedFolder; - final Map _controllers = {}; - List<_SelectedImage> _images = const []; - - AppStrings get strings => widget.strings; - - @override - void initState() { - super.initState(); - _selectedWorkflow = widget.workflows.first["filename"]?.toString() ?? ""; - _selectedFolder = widget.workFolders.first["path"]?.toString() ?? ""; - _syncPromptControllers(); - } - - @override - void dispose() { - for (final controller in _controllers.values) { - controller.dispose(); - } - super.dispose(); - } - - Future _pickWorkflow() async { - final value = await _showPicker( - title: strings.workflow, - options: widget.workflows, - currentValue: _selectedWorkflow, - valueForOption: (option) => option["filename"]?.toString() ?? "", - labelForOption: (option) => option["name"]?.toString() ?? "-", - ); - if (value == null) return; - setState(() { - _selectedWorkflow = value; - _syncPromptControllers(); - }); - } - - Future _pickFolder() async { - final value = await _showPicker( - title: strings.workFolder, - options: widget.workFolders, - currentValue: _selectedFolder, - valueForOption: (option) => option["path"]?.toString() ?? "", - labelForOption: (option) => option["name"]?.toString() ?? "-", - ); - if (value == null) return; - setState(() { - _selectedFolder = value; - }); - } - - Future _showPicker({ - required String title, - required List> options, - required String currentValue, - required String Function(Map) valueForOption, - required String Function(Map) labelForOption, - }) async { - var selected = currentValue; - final initialIndex = options.indexWhere( - (option) => valueForOption(option) == currentValue, - ); - final controller = FixedExtentScrollController( - initialItem: initialIndex >= 0 ? initialIndex : 0, - ); - - return showCupertinoModalPopup( - context: context, - builder: (context) { - return Container( - height: 300, - color: CupertinoColors.systemBackground.resolveFrom(context), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () => Navigator.of(context).pop(), - child: Text(strings.cancel), - ), - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF111827), - ), - ), - CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () => Navigator.of(context).pop(selected), - child: Text(strings.done), - ), - ], - ), - ), - const ColoredBox( - color: Color(0xFFE5E7EB), - child: SizedBox(height: 1, width: double.infinity), - ), - Expanded( - child: CupertinoPicker( - scrollController: controller, - itemExtent: 36, - onSelectedItemChanged: (index) { - selected = valueForOption(options[index]); - }, - children: options - .map((option) => Center( - child: Text(labelForOption(option)), - )) - .toList(), - ), - ), - ], - ), - ); - }, - ); - } - - List> get _displayTaskInputFields { - final selected = widget.workflows.cast?>().firstWhere( - (workflow) => workflow?["filename"]?.toString() == _selectedWorkflow, - orElse: () => widget.workflows.first, - ); - return ((selected?["taskInputFields"] as List?) ?? const []) - .whereType() - .map((entry) => Map.from(entry)) - .toList(); - } - - void _syncPromptControllers() { - final nextKeys = _displayTaskInputFields - .map((field) => field["key"]?.toString() ?? "") - .where((key) => key.isNotEmpty) - .toSet(); - - final staleKeys = _controllers.keys - .where((key) => !nextKeys.contains(key)) - .toList(growable: false); - for (final key in staleKeys) { - _controllers.remove(key)?.dispose(); - } - - for (final key in nextKeys) { - _controllers.putIfAbsent(key, TextEditingController.new); - } - } - - void _submit() { - final taskInputs = {}; - for (final field in _displayTaskInputFields) { - final key = field["key"]?.toString() ?? ""; - taskInputs[key] = _controllers[key]?.text.trim() ?? ""; - } - final hasMissingRequiredField = _displayTaskInputFields.any((field) { - final key = field["key"]?.toString() ?? ""; - final required = field["required"] != false; - if (!required) return false; - return (taskInputs[key] ?? "").isEmpty; - }); - if (hasMissingRequiredField) return; - - final firstKey = _displayTaskInputFields.isNotEmpty - ? _displayTaskInputFields.first["key"]?.toString() ?? "" - : ""; - final taskId = firstKey.isNotEmpty && - (taskInputs[firstKey]?.trim().isNotEmpty ?? false) - ? taskInputs[firstKey]!.trim() - : "task-${DateTime.now().millisecondsSinceEpoch}"; - final runId = "run-${DateTime.now().millisecondsSinceEpoch}"; - - Navigator.of(context).pop( - _StartWorkflowResult( - taskId: taskId, - runId: runId, - workFolder: _selectedFolder, - workflowFilename: _selectedWorkflow, - taskInputs: taskInputs, - images: _images, - ), - ); - } - - Future _addImages() async { - final images = await _pickSelectedImages(_imagePicker); - if (!mounted || images.isEmpty) return; - setState(() { - _images = [..._images, ...images]; - }); - } - - void _removeImage(int index) { - setState(() { - _images = [ - for (var i = 0; i < _images.length; i++) - if (i != index) _images[i], - ]; - }); - } - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - final canSubmit = _selectedWorkflow.isNotEmpty && - _selectedFolder.isNotEmpty && - _controllers.values - .every((controller) => controller.text.trim().isNotEmpty); - - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: Align( - alignment: Alignment.bottomCenter, - child: DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFF9FAFB), - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 12, 18, 18), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 44, - height: 5, - decoration: BoxDecoration( - color: const Color(0xFFD1D5DB), - borderRadius: BorderRadius.circular(999), - ), - ), - ), - const SizedBox(height: 16), - Text( - strings.startWorkflow, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: Color(0xFF111827), - ), - ), - const SizedBox(height: 16), - _PickerField( - label: strings.workflow, - value: _selectedLabel( - widget.workflows, - _selectedWorkflow, - (item) => item["filename"]?.toString() ?? "", - (item) => item["name"]?.toString() ?? "-", - ), - onTap: _pickWorkflow, - ), - const SizedBox(height: 12), - _PickerField( - label: strings.workFolder, - value: _selectedLabel( - widget.workFolders, - _selectedFolder, - (item) => item["path"]?.toString() ?? "", - (item) => item["name"]?.toString() ?? "-", - ), - onTap: _pickFolder, - ), - const SizedBox(height: 12), - ..._displayTaskInputFields.map((field) { - final key = field["key"]?.toString() ?? ""; - final label = field["label"]?.toString() ?? key; - final placeholder = - field["placeholder"]?.toString() ?? ""; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FieldLabel(label), - const SizedBox(height: 10), - CupertinoTextField( - controller: _controllers[key], - minLines: 2, - maxLines: 4, - padding: const EdgeInsets.all(14), - placeholder: placeholder, - decoration: _inputDecoration(), - onChanged: (_) { - setState(() {}); - }, - ), - ], - ), - ); - }), - Row( - children: [ - CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - color: const Color(0xFFE5E7EB), - borderRadius: BorderRadius.circular(12), - onPressed: _addImages, - child: Text( - strings.attachImages, - style: const TextStyle( - color: Color(0xFF111827), - ), - ), - ), - ], - ), - if (_images.isNotEmpty) ...[ - const SizedBox(height: 10), - _SelectedImageStrip( - images: _images, - onRemove: _removeImage, - ), - ], - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: CupertinoButton.filled( - onPressed: canSubmit ? _submit : null, - borderRadius: BorderRadius.circular(14), - child: Text(strings.start), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } - - String _selectedLabel( - List> items, - String value, - String Function(Map) valueForItem, - String Function(Map) labelForItem, - ) { - final matched = items.where((item) => valueForItem(item) == value); - if (matched.isEmpty) return "-"; - return labelForItem(matched.first); - } -} - -class _PickerField extends StatelessWidget { - const _PickerField({ - required this.label, - required this.value, - required this.onTap, - }); - - final String label; - final String value; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: DecoratedBox( - decoration: _inputDecoration(), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FieldLabel(label), - const SizedBox(height: 6), - Text( - value, - style: const TextStyle( - color: Color(0xFF111827), - fontSize: 17, - ), - ), - ], - ), - ), - const Icon( - CupertinoIcons.chevron_up_chevron_down, - size: 18, - color: Color(0xFF6B7280), - ), - ], - ), - ), - ), - ); - } -} - -class _ConnectionSheet extends StatelessWidget { - const _ConnectionSheet({ - required this.strings, - required this.locale, - required this.baseUrlController, - required this.status, - required this.availabilityMessage, - required this.connecting, - required this.onDisconnect, - required this.onConnect, - }); - - final AppStrings strings; - final Locale locale; - final TextEditingController baseUrlController; - final String status; - final String availabilityMessage; - final bool connecting; - final Future Function() onDisconnect; - final Future Function() onConnect; - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - final isConnected = status == ConnectionStatus.connected; - - return SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: Align( - alignment: Alignment.bottomCenter, - child: DecoratedBox( - decoration: const BoxDecoration( - color: Color(0xFFF9FAFB), - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 12, 18, 18), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 44, - height: 5, - decoration: BoxDecoration( - color: const Color(0xFFD1D5DB), - borderRadius: BorderRadius.circular(999), - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: Text( - strings.connection, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: Color(0xFF111827), - ), - ), - ), - ], - ), - const SizedBox(height: 8), - _FieldLabel(strings.connectionStatus(status)), - if (availabilityMessage.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - availabilityMessage, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF6B7280), - ), - ), - ], - const SizedBox(height: 16), - _FieldLabel(strings.backendUrl), - const SizedBox(height: 10), - CupertinoTextField( - controller: baseUrlController, - enabled: !isConnected, - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 14, - ), - decoration: _inputDecoration(), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: CupertinoButton.filled( - onPressed: connecting - ? null - : (isConnected ? onDisconnect : onConnect), - borderRadius: BorderRadius.circular(14), - child: Text( - connecting - ? strings.connecting - : (isConnected - ? strings.disconnect - : strings.connect), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _SettingsPage extends StatelessWidget { - const _SettingsPage({ - required this.strings, - required this.locale, - required this.onLocaleChanged, - }); - - final AppStrings strings; - final Locale locale; - final Future Function(Locale locale) onLocaleChanged; - - @override - Widget build(BuildContext context) { - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(strings.settings), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FieldLabel(strings.language), - const SizedBox(height: 12), - _LanguageOption( - label: strings.english, - selected: locale.languageCode == "en", - onTap: () async { - await onLocaleChanged(const Locale("en")); - }, - ), - const SizedBox(height: 10), - _LanguageOption( - label: strings.chinese, - selected: locale.languageCode == "zh", - onTap: () async { - await onLocaleChanged(const Locale("zh")); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ); - } -} - -class _SectionCard extends StatelessWidget { - const _SectionCard({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: CupertinoColors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: child, - ), - ); - } -} - -class _FieldLabel extends StatelessWidget { - const _FieldLabel(this.text); - - final String text; - - @override - Widget build(BuildContext context) { - return Text( - text, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Color(0xFF6B7280), - ), - ); - } -} - -class _SectionHeader extends StatelessWidget { - const _SectionHeader(this.text); - - final String text; - - @override - Widget build(BuildContext context) { - return Text( - text, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: Color(0xFF111827), - ), - ); - } -} - -class _StatusBadge extends StatelessWidget { - const _StatusBadge({ - required this.status, - required this.strings, - }); - - final String status; - final AppStrings strings; - - Color get _color { - switch (status) { - case ConnectionStatus.connected: - return const Color(0xFF15803D); - case ConnectionStatus.connecting: - return const Color(0xFFD97706); - default: - return const Color(0xFF6B7280); - } - } - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: _color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Text( - strings.connectionStatus(status), - style: TextStyle( - color: _color, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ), - ); - } -} - -class _DetailRow extends StatelessWidget { - const _DetailRow({ - required this.label, - required this.value, - }); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row( - children: [ - SizedBox( - width: 56, - child: Text( - label, - style: const TextStyle( - color: Color(0xFF6B7280), - fontSize: 14, - ), - ), - ), - Expanded( - child: Text( - value, - style: const TextStyle( - color: Color(0xFF111827), - fontSize: 15, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ); - } -} - -class _LanguageOption extends StatelessWidget { - const _LanguageOption({ - required this.label, - required this.selected, - required this.onTap, - }); - - final String label; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: DecoratedBox( - decoration: BoxDecoration( - color: selected ? const Color(0xFFDCEFEA) : const Color(0xFFF9FAFB), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: selected ? const Color(0xFF0F766E) : const Color(0xFFD1D5DB), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - child: Row( - children: [ - Expanded( - child: Text( - label, - style: const TextStyle( - color: Color(0xFF111827), - fontSize: 17, - ), - ), - ), - if (selected) - const Icon( - CupertinoIcons.check_mark, - color: Color(0xFF0F766E), - size: 18, - ), - ], - ), - ), - ), - ); - } -} - -class ConnectionStatus { - static const disconnected = "disconnected"; - static const connecting = "connecting"; - static const connected = "connected"; - static const failed = "failed"; - static const error = "error"; -} - -class AppStrings { - AppStrings(this.locale); - - final Locale locale; - - static const supportedLocales = [ - Locale("en"), - Locale("zh"), - ]; - - bool get isZh => locale.languageCode == "zh"; - - String get appTitle => isZh ? "开发工作流" : "Dev Workflow"; - String get tasks => isZh ? "任务" : "Tasks"; - String get add => isZh ? "添加" : "Add"; - String get adding => isZh ? "添加中..." : "Adding..."; - String get noTasksYet => isZh ? "还没有任务" : "No tasks yet"; - String get details => isZh ? "详情" : "Details"; - String get status => isZh ? "状态" : "Status"; - String get phase => isZh ? "阶段" : "Phase"; - String get device => isZh ? "设备" : "Device"; - String get delete => isZh ? "删除" : "Delete"; - String get deleteTask => isZh ? "删除任务" : "Delete Task"; - String deleteTaskConfirm(String name) => - isZh ? "确定要删除任务 $name 吗?" : "Are you sure you want to delete task $name?"; - String get approve => isZh ? "通过" : "Approve"; - String get reject => isZh ? "拒绝" : "Reject"; - String get rejectTo => isZh ? "选择回退阶段" : "Choose reject target"; - String get noRejectTargets => - isZh ? "当前阶段没有可回退目标" : "No reject targets for this phase"; - String get refresh => isZh ? "刷新" : "Refresh"; - String get attachImages => isZh ? "添加图片" : "Add Images"; - String get message => isZh ? "消息" : "Message"; - String get messagePlaceholder => - isZh ? "给这个任务发送备注" : "Send a note to this task"; - String get send => isZh ? "发送" : "Send"; - String get commandFailed => isZh ? "命令失败" : "Command failed"; - String get commandFailedPrefix => isZh ? "命令失败: " : "Command failed: "; - String get failedToConnectPrefix => isZh ? "连接失败: " : "Failed to connect: "; - String get workflowStarted => isZh ? "工作流已启动" : "Workflow started"; - String get startingWorkflow => isZh ? "启动中..." : "Starting..."; - String get startFailedPrefix => isZh ? "启动失败: " : "Start failed: "; - String get noOnlineDeviceAvailable => - isZh ? "没有可用的在线设备" : "No online device available"; - String get noDesktopOnline => - isZh ? "当前没有在线的桌面端" : "No desktop is currently online"; - String get deviceMetadataIncomplete => - isZh ? "设备元数据不完整" : "Device metadata is incomplete"; - String get mobileAccessDisabled => - isZh ? "桌面端未开启手机访问" : "Mobile access is disabled on the desktop"; - String get startWorkflow => isZh ? "开始工作流" : "Start Workflow"; - String get workflow => isZh ? "工作流" : "Workflow"; - String get workFolder => isZh ? "工作目录" : "Work Folder"; - String get start => isZh ? "开始" : "Start"; - String get cancel => isZh ? "取消" : "Cancel"; - String get done => isZh ? "完成" : "Done"; - String get instanceId => isZh ? "实例 ID" : "Instance ID"; - String get enterIdentifier => isZh ? "输入标识" : "Enter an identifier"; - String get connection => isZh ? "连接" : "Connection"; - String get backendUrl => isZh ? "后端地址" : "Backend URL"; - String get connect => isZh ? "连接" : "Connect"; - String get disconnect => isZh ? "断开连接" : "Disconnect"; - String get connecting => isZh ? "连接中..." : "Connecting..."; - String get settings => isZh ? "设置" : "Settings"; - String get language => isZh ? "语言" : "Language"; - String get english => isZh ? "英文" : "English"; - String get chinese => isZh ? "中文" : "Chinese"; - - String connectionStatus(String status) { - switch (status) { - case ConnectionStatus.connected: - return isZh ? "已连接" : "Connected"; - case ConnectionStatus.connecting: - return isZh ? "连接中" : "Connecting"; - case ConnectionStatus.failed: - return isZh ? "连接失败" : "Connect failed"; - case ConnectionStatus.error: - return isZh ? "连接错误" : "Connection error"; - default: - return isZh ? "未连接" : "Disconnected"; - } - } - - String currentLanguage(String languageCode) { - final label = languageCode == "zh" ? chinese : english; - return isZh ? "当前语言: $label" : "Current language: $label"; - } -} - -BoxDecoration _inputDecoration() { - return BoxDecoration( - color: const Color(0xFFF9FAFB), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: const Color(0xFFD1D5DB), - ), - ); -} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock deleted file mode 100644 index bfaf36c..0000000 --- a/apps/mobile/pubspec.lock +++ /dev/null @@ -1,543 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.11.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.3.0" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.19.0" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.3.4+2" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.8" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.9.2" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.2" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.3.1" - ffi: - dependency: transitive - description: - name: ffi - sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.3" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.0.1" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.3+2" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.4+3" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.6.2" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.3+4" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.0.0" - flutter_localizations: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "6382ce712ff69b0f719640ce957559dde459e55ecd433c767e06d139ddf16cab" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.29" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.6.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.1.2" - image_picker: - dependency: "direct main" - description: - name: image_picker - sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.0" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - sha256: e83b2b05141469c5e19d77e1dfa11096b6b1567d09065b2265d7c6904560050c - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.8.13" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.0" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.8.13" - image_picker_linux: - dependency: transitive - description: - name: image_picker_linux - sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - image_picker_macos: - dependency: transitive - description: - name: image_picker_macos - sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - sha256: "9f143b0dba3e459553209e20cc425c9801af48e6dfa4f01a0fcf927be3f41665" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.11.0" - image_picker_windows: - dependency: transitive - description: - name: image_picker_windows - sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - intl: - dependency: transitive - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.19.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" - url: "https://pub.flutter-io.cn" - source: hosted - version: "10.0.7" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.8" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.1" - lints: - dependency: transitive - description: - name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.1.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.12.16+1" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.15.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.0" - path: - dependency: transitive - description: - name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.9.0" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.8" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.5.3" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.11" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.5.4" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.1" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.10.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.12.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.2" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.3.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.7.3" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.4" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b - url: "https://pub.flutter-io.cn" - source: hosted - version: "14.3.0" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.3" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.0" -sdks: - dart: ">=3.6.2 <4.0.0" - flutter: ">=3.27.0" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml deleted file mode 100644 index 4c9b241..0000000 --- a/apps/mobile/pubspec.yaml +++ /dev/null @@ -1,92 +0,0 @@ -name: dev_workflow_mobile -description: "Remote mobile client for the dev workflow backend." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 - -environment: - sdk: ^3.6.2 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - cupertino_icons: ^1.0.8 - dio: ^5.9.0 - image_picker: ^1.1.2 - shared_preferences: ^2.5.3 - web_socket_channel: ^3.0.3 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^5.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..26bf0d2 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,20 @@ +# Server + +Stateless relay server for the daemon-server-web architecture. + +The server keeps only short-lived coordination state in memory: + +- connected daemon sockets +- connected web UI sockets +- pending daemon query requests +- pending command status + +Persistent workflow, task, artifact, settings, and runtime data stays daemon-owned. + +Run: + +```sh +pnpm --filter @dev-workflow/server start +``` + +The server listens on `RELAY_SERVER_PORT`, `BACKEND_PORT`, `PORT`, or `8787`. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..80ed969 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,24 @@ +{ + "name": "@dev-workflow/server", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@dev-workflow/protocol": "workspace:*", + "@fastify/cors": "^11.1.0", + "@fastify/websocket": "^11.2.0", + "fastify": "^5.6.1", + "nanoid": "^5.1.6" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..48bb284 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,701 @@ +import Fastify from "fastify"; +import cors from "@fastify/cors"; +import websocket from "@fastify/websocket"; +import { nanoid } from "nanoid"; +import { + COMMAND_TYPE, + COMMAND_TYPES, + DAEMON_QUERY_ACTIONS, + PROTOCOL_VERSION, + RELAY_API_ROUTES, + RELAY_DAEMON_MESSAGE_TYPES, + RELAY_UI_EVENT_TYPES, + RELAY_WS_PATHS, +} from "@dev-workflow/protocol"; + +const fastify = Fastify({ + logger: true, + bodyLimit: Number(process.env.RELAY_SERVER_BODY_LIMIT || 25 * 1024 * 1024), +}); +const daemonConnections = new Map(); +const uiSockets = new Set(); +const pendingDaemonRequests = new Map(); +const pendingCommands = new Map(); +const ALLOWED_COMMANDS = new Set(COMMAND_TYPES); + +function now() { + return new Date().toISOString(); +} + +function sendJson(socket, payload) { + if (!socket || socket.readyState !== 1) return; + socket.send(JSON.stringify(payload)); +} + +function publicDevice(connection) { + if (!connection) return null; + return { + deviceId: connection.deviceId, + name: connection.name || connection.deviceId, + status: "online", + lastSeenAt: connection.lastSeenAt || now(), + meta: connection.meta || {}, + }; +} + +function publicTask(task, fallback = {}) { + if (!task) return null; + const deviceId = String(task.deviceId || fallback.deviceId || "").trim(); + const taskId = String(task.taskId || fallback.taskId || "").trim(); + if (!deviceId || !taskId) return null; + return { + key: task.key || `${deviceId}:${taskId}`, + deviceId, + taskId, + workFolder: task.workFolder || "", + state: task.state || null, + messages: task.messages || {}, + outputArtifacts: task.outputArtifacts || {}, + interactions: task.interactions || {}, + updatedAt: task.updatedAt || task.state?.updated || now(), + }; +} + +function listDevices() { + return Array.from(daemonConnections.values()) + .map(publicDevice) + .filter(Boolean) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function broadcastUi(payload) { + for (const socket of uiSockets) { + sendJson(socket, payload); + } +} + +function getDefaultDaemonConnection() { + const preferredDeviceId = String(process.env.DEVICE_ID || process.env.DEFAULT_DEVICE_ID || "").trim(); + if (preferredDeviceId && daemonConnections.has(preferredDeviceId)) { + return daemonConnections.get(preferredDeviceId); + } + return Array.from(daemonConnections.values())[0] || null; +} + +function getRequestDeviceId(request) { + return String(request?.query?.deviceId || request?.headers?.["x-device-id"] || "").trim(); +} + +function rejectPendingDaemonRequestsForDevice(deviceId) { + for (const [requestId, pending] of pendingDaemonRequests) { + if (pending.deviceId !== deviceId) continue; + clearTimeout(pending.timeout); + pendingDaemonRequests.delete(requestId); + pending.reject(new Error("device is offline")); + } +} + +function createDaemonConnectionRequest(deviceId, action, payload = {}, timeoutMs = 10000) { + const connection = deviceId ? daemonConnections.get(deviceId) : getDefaultDaemonConnection(); + if (!connection || connection.socket.readyState !== 1) { + throw new Error("device is offline"); + } + + const requestId = nanoid(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingDaemonRequests.delete(requestId); + reject(new Error("daemon request timed out")); + }, timeoutMs); + + pendingDaemonRequests.set(requestId, { + deviceId: connection.deviceId, + resolve, + reject, + timeout, + }); + + sendJson(connection.socket, { + type: RELAY_DAEMON_MESSAGE_TYPES.queryRequest, + protocolVersion: PROTOCOL_VERSION, + requestId, + action, + payload, + }); + }); +} + +function resolveDaemonConnectionRequest(message) { + const pending = pendingDaemonRequests.get(message.requestId); + if (!pending) return; + clearTimeout(pending.timeout); + pendingDaemonRequests.delete(message.requestId); + if (message.ok === false) { + pending.reject(new Error(message.error || "daemon query failed")); + return; + } + pending.resolve(message.data || {}); +} + +function createDaemonRequest(action, payload = {}, request = null, timeoutMs = 10000) { + return createDaemonConnectionRequest(getRequestDeviceId(request), action, payload, timeoutMs); +} + +function sendQueryError(reply, error) { + const message = error instanceof Error ? error.message : "request failed"; + if (message === "device is offline") { + return reply.code(409).send({ error: message }); + } + return reply.code(502).send({ error: message }); +} + +async function listTasksForDevice(deviceId) { + const data = await createDaemonConnectionRequest(deviceId, DAEMON_QUERY_ACTIONS.listTasks); + const tasks = Array.isArray(data.tasks) ? data.tasks : []; + return tasks + .map((task) => publicTask(task, { deviceId })) + .filter(Boolean); +} + +async function getTaskForDevice(deviceId, taskId) { + const data = await createDaemonConnectionRequest(deviceId, DAEMON_QUERY_ACTIONS.getTask, { taskId }); + return publicTask(data.task, { deviceId, taskId }); +} + +async function listAllTasks() { + const devices = listDevices(); + const snapshots = await Promise.all( + devices.map(async (device) => { + try { + return await listTasksForDevice(device.deviceId); + } catch { + return []; + } + }) + ); + return snapshots.flat(); +} + +async function broadcastDeviceTasks(deviceId) { + const tasks = await listTasksForDevice(deviceId).catch(() => []); + for (const task of tasks) { + broadcastUi({ type: RELAY_UI_EVENT_TYPES.taskSnapshot, task }); + } +} + +function createPendingCommand(deviceId, taskId, type, payload = {}) { + const command = { + id: nanoid(), + deviceId, + taskId, + type, + payload, + status: "pending", + createdAt: now(), + completedAt: null, + error: "", + }; + let resolveCompletion; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const timeout = setTimeout(() => { + pendingCommands.delete(command.id); + command.status = "error"; + command.error = "command timed out"; + command.completedAt = now(); + resolveCompletion(command); + }, 30000); + pendingCommands.set(command.id, { command, timeout, completion, resolveCompletion }); + return command; +} + +function resolvePendingCommand(commandId, status, error = "") { + const pending = pendingCommands.get(commandId); + if (!pending) { + return { + id: commandId, + status, + error, + completedAt: now(), + }; + } + clearTimeout(pending.timeout); + pendingCommands.delete(commandId); + pending.command.status = status; + pending.command.error = error; + pending.command.completedAt = now(); + pending.resolveCompletion(pending.command); + return pending.command; +} + +async function waitForCommandResult(command) { + if (!command || command.status !== "pending") return command; + const pending = pendingCommands.get(command.id); + if (!pending) return command; + return pending.completion; +} + +function sendCommandToDaemon(deviceId, taskId, type, payload = {}) { + const connection = daemonConnections.get(deviceId); + if (!connection || connection.socket.readyState !== 1) { + throw new Error("device is offline"); + } + const command = createPendingCommand(deviceId, taskId, type, payload); + sendJson(connection.socket, { + type: RELAY_DAEMON_MESSAGE_TYPES.commandRequest, + protocolVersion: PROTOCOL_VERSION, + commandId: command.id, + taskId, + command: type, + payload, + }); + broadcastUi({ type: RELAY_UI_EVENT_TYPES.commandStatus, command }); + return command; +} + +function sendCommandToDefaultDaemon(taskId, type, payload = {}, request = null) { + const requestedDeviceId = getRequestDeviceId(request); + const connection = requestedDeviceId ? daemonConnections.get(requestedDeviceId) : getDefaultDaemonConnection(); + if (!connection) throw new Error("device is offline"); + return sendCommandToDaemon(connection.deviceId, taskId, type, payload); +} + +await fastify.register(cors, { origin: true }); +await fastify.register(websocket); + +fastify.get(RELAY_API_ROUTES.health, async () => ({ ok: true })); + +fastify.get(RELAY_API_ROUTES.devices, async () => ({ + devices: listDevices(), +})); + +fastify.get(RELAY_API_ROUTES.tasks, async () => ({ + tasks: await listAllTasks(), +})); + +fastify.get(RELAY_API_ROUTES.workflow, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.getWorkflowConfig, {}, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.aiApiProfiles, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.listAiApiProfiles, {}, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.aiApiProfiles, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.saveAiApiProfile, { profile: request.body || {} }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.delete(RELAY_API_ROUTES.aiApiProfile, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.deleteAiApiProfile, { id: request.params.id }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.workflows, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.listWorkflows, {}, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.workflowByFilename, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.getWorkflow, { filename: request.params.filename }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.put(RELAY_API_ROUTES.workflowVisibility, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.setWorkflowVisible, { + filename: request.params.filename, + visible: request.body?.visible, + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.workflows, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.createWorkflow, { + workflow: request.body || {}, + draft: request.query?.draft === "1", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.put(RELAY_API_ROUTES.workflowByFilename, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.updateWorkflow, { + filename: request.params.filename, + workflow: request.body || {}, + draft: request.query?.draft === "1", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.delete(RELAY_API_ROUTES.workflowByFilename, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.removeWorkflow, { filename: request.params.filename }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.put(RELAY_API_ROUTES.workflowActivate, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.activateWorkflow, { filename: request.params.filename }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.generateSkill, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.generateSkill, request.body || {}, request, 30000); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.skills, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.listSkills, {}, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.skills, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.saveSkill, { skill: request.body || {} }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.delete(RELAY_API_ROUTES.skillBySlug, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.deleteSkill, { slug: request.params.slug }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.skillsImport, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.importSkills, { + paths: Array.isArray(request.body?.paths) ? request.body.paths : [], + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.workfolders, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.listWorkfolders, {}, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.workfolders, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.addWorkfolder, { path: request.body?.path }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.delete(RELAY_API_ROUTES.workfolders, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.removeWorkfolder, { path: request.body?.path }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.taskState, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.getTaskState, { + taskId: request.params.taskId, + runId: request.query?.runId || "", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.delete(RELAY_API_ROUTES.taskById, async (request, reply) => { + try { + const taskId = String(request.params.taskId || "").trim(); + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.removeTask, { + taskId, + runId: request.query?.runId || "", + removeWorktree: request.query?.removeWorktree === "1" || request.query?.removeWorktree === "true", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.taskUpload, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.saveTaskUploads, { + runId: request.params.runId, + files: request.body?.files || [], + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.openPath, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.openPath, { + path: request.body?.path, + editor: request.body?.editor || "code", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.delete(RELAY_API_ROUTES.taskWorktree, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.removeTaskWorktree, { + taskId: request.params.taskId, + runId: request.query?.runId || "", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.taskOpenOutput, async (request, reply) => { + try { + return await createDaemonRequest(DAEMON_QUERY_ACTIONS.openTaskOutput, { + taskId: request.params.taskId, + runId: request.body?.runId || "", + phaseId: request.body?.phaseId || "", + outputKey: request.body?.outputKey || "", + editor: request.body?.editor || "code", + }, request); + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.post(RELAY_API_ROUTES.taskCommands, async (request, reply) => { + const taskId = String(request.params.taskId || "").trim(); + const { type, payload } = request.body || {}; + if (!ALLOWED_COMMANDS.has(type)) { + return reply.code(400).send({ error: "unsupported command" }); + } + try { + const command = sendCommandToDefaultDaemon(taskId, type, payload || {}, request); + return { command }; + } catch (error) { + return sendQueryError(reply, error); + } +}); + +fastify.get(RELAY_API_ROUTES.deviceTask, async (request, reply) => { + const { deviceId, taskId } = request.params; + try { + const task = await getTaskForDevice(deviceId, taskId); + if (!task) return reply.code(404).send({ error: "task not found" }); + return { task }; + } catch (error) { + const message = error instanceof Error ? error.message : "request failed"; + if (message === "device is offline") { + return reply.code(409).send({ error: message }); + } + return reply.code(502).send({ error: message }); + } +}); + +fastify.post(RELAY_API_ROUTES.deviceTaskCommands, async (request, reply) => { + const { deviceId, taskId } = request.params; + const { type, payload } = request.body || {}; + if (!ALLOWED_COMMANDS.has(type)) { + return reply.code(400).send({ error: "unsupported command" }); + } + try { + const command = sendCommandToDaemon(deviceId, taskId, type, payload || {}); + return { command }; + } catch (error) { + const message = error instanceof Error ? error.message : "request failed"; + if (message === "device is offline") { + return reply.code(409).send({ error: message }); + } + return reply.code(502).send({ error: message }); + } +}); + +fastify.get(RELAY_WS_PATHS.daemon, { websocket: true }, (socket) => { + let deviceId = ""; + + socket.on("message", async (raw) => { + let message; + try { + message = JSON.parse(String(raw)); + } catch { + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.daemonHello) { + deviceId = String(message.deviceId || "").trim(); + if (!deviceId) return; + const connection = { + deviceId, + name: message.name || deviceId, + meta: message.meta || {}, + lastSeenAt: now(), + socket, + }; + daemonConnections.set(deviceId, connection); + const device = publicDevice(connection); + sendJson(socket, { type: RELAY_DAEMON_MESSAGE_TYPES.daemonAccepted, protocolVersion: PROTOCOL_VERSION, device }); + broadcastUi({ type: RELAY_UI_EVENT_TYPES.tasksRemoveDevice, deviceId }); + broadcastUi({ type: RELAY_UI_EVENT_TYPES.deviceStatus, device }); + await broadcastDeviceTasks(deviceId); + return; + } + + if (!deviceId) return; + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.queryResponse) { + resolveDaemonConnectionRequest(message); + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.taskSnapshot) { + const task = publicTask(message.task, { + deviceId, + taskId: message.taskId, + }); + if (task) { + broadcastUi({ type: RELAY_UI_EVENT_TYPES.taskSnapshot, task }); + } + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.taskEvent) { + broadcastUi({ + type: RELAY_UI_EVENT_TYPES.taskEvent, + deviceId, + taskId: message.taskId, + event: message.event, + }); + const task = publicTask(message.task, { + deviceId, + taskId: message.taskId, + }); + if (task) { + broadcastUi({ + type: RELAY_UI_EVENT_TYPES.taskEvent, + taskKey: task.key, + deviceId, + taskId: task.taskId, + event: message.event, + task, + }); + } + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.taskRemoved) { + broadcastUi({ + type: RELAY_UI_EVENT_TYPES.tasksRemoveTask, + taskKey: `${deviceId}:${message.taskId}`, + deviceId, + taskId: message.taskId, + runId: message.runId || "", + }); + return; + } + + if (message.type === RELAY_DAEMON_MESSAGE_TYPES.commandResult) { + const command = resolvePendingCommand( + message.commandId, + message.status || "ok", + message.error || "" + ); + broadcastUi({ type: RELAY_UI_EVENT_TYPES.commandStatus, command }); + } + }); + + socket.on("close", () => { + if (!deviceId) return; + const connection = daemonConnections.get(deviceId); + if (connection?.socket !== socket) return; + daemonConnections.delete(deviceId); + rejectPendingDaemonRequestsForDevice(deviceId); + broadcastUi({ + type: RELAY_UI_EVENT_TYPES.deviceStatus, + device: { + deviceId, + name: connection.name || deviceId, + status: "offline", + lastSeenAt: now(), + meta: connection.meta || {}, + }, + }); + broadcastUi({ type: RELAY_UI_EVENT_TYPES.tasksRemoveDevice, deviceId }); + }); +}); + +fastify.get(RELAY_WS_PATHS.ui, { websocket: true }, (socket) => { + uiSockets.add(socket); + Promise.resolve() + .then(async () => { + sendJson(socket, { + type: RELAY_UI_EVENT_TYPES.bootstrap, + devices: listDevices(), + tasks: await listAllTasks(), + }); + }) + .catch(() => { + sendJson(socket, { + type: RELAY_UI_EVENT_TYPES.bootstrap, + devices: listDevices(), + tasks: [], + }); + }); + + socket.on("close", () => { + uiSockets.delete(socket); + }); +}); + +const port = Number(process.env.RELAY_SERVER_PORT || process.env.BACKEND_PORT || process.env.PORT || 8787); +const host = process.env.RELAY_SERVER_HOST || process.env.BACKEND_HOST || "0.0.0.0"; + +fastify.listen({ port, host }).catch((error) => { + fastify.log.error(error); + process.exit(1); +}); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..84dc08c --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "noCheck": true, + "strict": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/desktop/renderer/index.html b/apps/web/index.html similarity index 100% rename from apps/desktop/renderer/index.html rename to apps/web/index.html diff --git a/apps/desktop/renderer/package.json b/apps/web/package.json similarity index 92% rename from apps/desktop/renderer/package.json rename to apps/web/package.json index efc429f..d18e02a 100644 --- a/apps/desktop/renderer/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "dev-workflow-renderer", + "name": "@dev-workflow/web", "private": true, "type": "module", "packageManager": "pnpm@10.33.2", @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "@dev-workflow/protocol": "workspace:*", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.4", diff --git a/apps/desktop/renderer/src/App.tsx b/apps/web/src/App.tsx similarity index 82% rename from apps/desktop/renderer/src/App.tsx rename to apps/web/src/App.tsx index 18714a2..fc1abd9 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/web/src/App.tsx @@ -15,7 +15,7 @@ export default function App() { }, []); return ( -
+
} /> } /> @@ -25,7 +25,7 @@ export default function App() { {toast && ( -
+
{toast}
)} diff --git a/apps/desktop/renderer/src/StepDetail.tsx b/apps/web/src/StepDetail.tsx similarity index 99% rename from apps/desktop/renderer/src/StepDetail.tsx rename to apps/web/src/StepDetail.tsx index 216f711..7b8fd37 100644 --- a/apps/desktop/renderer/src/StepDetail.tsx +++ b/apps/web/src/StepDetail.tsx @@ -7,7 +7,7 @@ import { Button } from "./components/ui/button"; import { useWorkflowStore } from "./stores/workflowStore"; import { getAppApi } from "./lib/api-client"; -const desktopApi = getAppApi(); +const appApi = getAppApi(); function formatTime(value) { if (!value) return ""; @@ -393,7 +393,7 @@ export default function StepDetail({ phase, content, artifact, interactions = [] name: img.file.name, data: Array.from(new Uint8Array(await img.file.arrayBuffer())), }))); - const data = await desktopApi.saveTaskUploads(runId, payload); + const data = await appApi.saveTaskUploads(runId, payload); uploadedPaths = data.paths || []; } diff --git a/apps/desktop/renderer/src/WorkflowEditor.test.tsx b/apps/web/src/WorkflowEditor.test.tsx similarity index 91% rename from apps/desktop/renderer/src/WorkflowEditor.test.tsx rename to apps/web/src/WorkflowEditor.test.tsx index 14d1d44..8b3b602 100644 --- a/apps/desktop/renderer/src/WorkflowEditor.test.tsx +++ b/apps/web/src/WorkflowEditor.test.tsx @@ -3,11 +3,11 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - desktopApi: {}, + appApi: {}, })); vi.mock("./lib/api-client", () => ({ - getAppApi: () => mocks.desktopApi, + getAppApi: () => mocks.appApi, })); vi.mock("@xyflow/react", async () => { @@ -69,8 +69,8 @@ const workflow = { ], }; -function setupDesktopApi() { - Object.assign(mocks.desktopApi, { +function setupAppApi() { + Object.assign(mocks.appApi, { getWorkflow: vi.fn().mockResolvedValue(workflow), createWorkflow: vi.fn(), createWorkflowDraft: vi.fn(), @@ -94,11 +94,11 @@ describe("WorkflowEditor", () => { beforeEach(() => { sessionStorage.clear(); - for (const key of Object.keys(mocks.desktopApi)) { - delete mocks.desktopApi[key]; + for (const key of Object.keys(mocks.appApi)) { + delete mocks.appApi[key]; } useWorkflowEditorStore.getState().resetEditor(); - setupDesktopApi(); + setupAppApi(); }); test("renders while an existing workflow is loading and then hydrates the draft", async () => { @@ -108,7 +108,7 @@ describe("WorkflowEditor", () => { expect(screen.getByTestId("workflow-canvas")).toBeInTheDocument(); await waitFor(() => { - expect(mocks.desktopApi.getWorkflow).toHaveBeenCalledWith("default-codex.json"); + expect(mocks.appApi.getWorkflow).toHaveBeenCalledWith("default-codex.json"); expect(useWorkflowEditorStore.getState().currentFilename).toBe("default-codex.json"); }); expect(screen.getByDisplayValue("Default Codex")).toBeInTheDocument(); @@ -150,6 +150,6 @@ describe("WorkflowEditor", () => { await user.click(screen.getByRole("button", { name: "Save" })); expect(await screen.findByText("Setup script is required when custom setup is enabled.")).toBeInTheDocument(); - expect(mocks.desktopApi.updateWorkflow).not.toHaveBeenCalled(); + expect(mocks.appApi.updateWorkflow).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/renderer/src/WorkflowEditor.tsx b/apps/web/src/WorkflowEditor.tsx similarity index 97% rename from apps/desktop/renderer/src/WorkflowEditor.tsx rename to apps/web/src/WorkflowEditor.tsx index a084b3c..66fdfb3 100644 --- a/apps/desktop/renderer/src/WorkflowEditor.tsx +++ b/apps/web/src/WorkflowEditor.tsx @@ -19,14 +19,13 @@ import { Input } from "./components/ui/input"; import { Select } from "./components/ui/select"; import { Badge } from "./components/ui/badge"; import { Tooltip } from "./components/ui/tooltip"; -import { WindowChrome } from "./components/window-chrome"; import { cn } from "./lib/utils"; import { getAppApi } from "./lib/api-client"; import { useConfigStore } from "./stores/configStore"; import { getWorkflowEditorKey, useWorkflowEditorStore } from "./stores/workflowEditorStore"; import { useWorkflowStore } from "./stores/workflowStore"; -const desktopApi = getAppApi(); +const appApi = getAppApi(); const COMMON_WORKTREE_FILES = [ ".env", @@ -674,7 +673,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) { return; } - desktopApi.getWorkflow(filename) + appApi.getWorkflow(filename) .then((data) => { const next = normalizeWorkflow(data); initializeEditor({ @@ -1181,15 +1180,15 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) { let savedFilename = currentFilename; if (isNew) { const created = draft - ? await desktopApi.createWorkflowDraft(dsl) - : await desktopApi.createWorkflow(dsl); + ? await appApi.createWorkflowDraft(dsl) + : await appApi.createWorkflow(dsl); savedFilename = created.filename; setCurrentFilename(created.filename); } else { if (draft) { - await desktopApi.updateWorkflowDraft(currentFilename, dsl); + await appApi.updateWorkflowDraft(currentFilename, dsl); } else { - await desktopApi.updateWorkflow(currentFilename, dsl); + await appApi.updateWorkflow(currentFilename, dsl); } } setDirty(false); @@ -1209,8 +1208,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) { return (
- -
+
dirty ? setShowBackConfirm(true) : closeEditor()} label={t("editor.back")} className="-ml-2" /> - -
{error && {error}} - - -
-
+
{showWorkflowSetup && (
setShowWorkflowSetup(false)}>
event.stopPropagation()}> @@ -1254,7 +1252,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
-
+
{(workflow.worktree.namingProvider || "ai_api") === "ai_api" && ( @@ -1377,7 +1375,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) { )} -
+

Node Library

-
+

Canvas Routes

Drag from a node handle to another node to create a route.

@@ -1431,7 +1429,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) { )}
-
+

Workflow Canvas

@@ -1476,10 +1474,10 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) { {selected && (
event.stopPropagation()} > -
+

Selected Node

{selected.label || selected.id}

@@ -1855,7 +1853,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
-
+

Default Runtime

@@ -1903,7 +1901,7 @@ export default function WorkflowEditor({ filename, onClose, onSaved }) {
-
+

JSON DSL Preview

                   {dslPreview}
diff --git a/apps/desktop/renderer/src/components/SkillManager.tsx b/apps/web/src/components/SkillManager.tsx
similarity index 94%
rename from apps/desktop/renderer/src/components/SkillManager.tsx
rename to apps/web/src/components/SkillManager.tsx
index fadf1c1..620f6a8 100644
--- a/apps/desktop/renderer/src/components/SkillManager.tsx
+++ b/apps/web/src/components/SkillManager.tsx
@@ -74,7 +74,7 @@ export default function SkillManager({ skills, onSave, onDelete, onImport }) {
           {t("skillManager.noSkills")}
         
) : ( -
    +
      {skills.map((skill) => (
    • event.stopPropagation()} >
      @@ -141,7 +141,7 @@ export default function SkillManager({ skills, onSave, onDelete, onImport }) { value={draft.body} onChange={(event) => setDraft((prev) => ({ ...prev, body: event.target.value }))} placeholder={t("skillManager.instructionsPlaceholder")} - className="flex min-h-[420px] w-full resize-y rounded-xl border border-input bg-card/70 px-4 py-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring" + className="flex min-h-[420px] w-full resize-y rounded-md border border-input bg-card px-4 py-3 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring" rows={18} /> {error && {error}} diff --git a/apps/desktop/renderer/src/components/WorkflowDebugPanel.tsx b/apps/web/src/components/WorkflowDebugPanel.tsx similarity index 99% rename from apps/desktop/renderer/src/components/WorkflowDebugPanel.tsx rename to apps/web/src/components/WorkflowDebugPanel.tsx index 10a6b47..b1d3d34 100644 --- a/apps/desktop/renderer/src/components/WorkflowDebugPanel.tsx +++ b/apps/web/src/components/WorkflowDebugPanel.tsx @@ -297,7 +297,7 @@ export default function WorkflowDebugPanel({
      setIsOpen(false)} />
      event.stopPropagation()} >
      diff --git a/apps/desktop/renderer/src/components/back-button.tsx b/apps/web/src/components/back-button.tsx similarity index 70% rename from apps/desktop/renderer/src/components/back-button.tsx rename to apps/web/src/components/back-button.tsx index 337517a..9e9152f 100644 --- a/apps/desktop/renderer/src/components/back-button.tsx +++ b/apps/web/src/components/back-button.tsx @@ -2,7 +2,7 @@ import { ChevronLeft } from "lucide-react"; import { Link } from "react-router-dom"; import { cn } from "../lib/utils"; -const backButtonClassName = "no-drag inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-[13px] font-medium text-muted-foreground/90 transition-colors hover:bg-accent/55 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; +const backButtonClassName = "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-[13px] font-medium text-muted-foreground/90 transition-colors hover:bg-accent/55 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; export function BackButton({ to, onClick, label, className }) { if (to) { diff --git a/apps/desktop/renderer/src/components/i18n-provider.tsx b/apps/web/src/components/i18n-provider.tsx similarity index 67% rename from apps/desktop/renderer/src/components/i18n-provider.tsx rename to apps/web/src/components/i18n-provider.tsx index 6e8f2ee..e98508a 100644 --- a/apps/desktop/renderer/src/components/i18n-provider.tsx +++ b/apps/web/src/components/i18n-provider.tsx @@ -3,6 +3,7 @@ import { messages } from "../lib/i18n"; const DEFAULT_LOCALE = "en"; const CHINESE_LOCALE = "zh-CN"; +const LOCALE_STORAGE_KEY = "dev-workflow-locale"; function normalizeLocale(value) { const locale = String(value || "").toLowerCase(); @@ -14,6 +15,11 @@ function formatMessage(template, values = {}) { } function getInitialLocale() { + try { + const storedLocale = window.localStorage.getItem(LOCALE_STORAGE_KEY); + if (storedLocale) return normalizeLocale(storedLocale); + } catch {} + try { return normalizeLocale(navigator.language); } catch { @@ -24,6 +30,8 @@ function getInitialLocale() { const I18nContext = createContext({ locale: DEFAULT_LOCALE, t: (key) => key, + setLocale: () => {}, + toggleLocale: () => {}, }); export function I18nProvider({ children }) { @@ -35,6 +43,9 @@ export function I18nProvider({ children }) { useEffect(() => { function handleLanguageChange() { + try { + if (window.localStorage.getItem(LOCALE_STORAGE_KEY)) return; + } catch {} setLocaleState(normalizeLocale(navigator.language)); } @@ -48,8 +59,20 @@ export function I18nProvider({ children }) { return formatMessage(currentMessages[key] ?? fallbackMessages[key] ?? key, values); } + function setLocale(value) { + const nextLocale = normalizeLocale(value); + try { + window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale); + } catch {} + setLocaleState(nextLocale); + } + + function toggleLocale() { + setLocale(locale === CHINESE_LOCALE ? DEFAULT_LOCALE : CHINESE_LOCALE); + } + return ( - + {children} ); diff --git a/apps/web/src/components/language-toggle.tsx b/apps/web/src/components/language-toggle.tsx new file mode 100644 index 0000000..1c925e5 --- /dev/null +++ b/apps/web/src/components/language-toggle.tsx @@ -0,0 +1,19 @@ +import { Languages } from "lucide-react"; +import { useI18n } from "./i18n-provider"; + +export function LanguageToggle() { + const { locale, t, toggleLocale } = useI18n(); + const nextLocale = locale === "zh-CN" ? "English" : "中文"; + + return ( + + ); +} diff --git a/apps/desktop/renderer/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx similarity index 100% rename from apps/desktop/renderer/src/components/theme-provider.tsx rename to apps/web/src/components/theme-provider.tsx diff --git a/apps/desktop/renderer/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx similarity index 94% rename from apps/desktop/renderer/src/components/theme-toggle.tsx rename to apps/web/src/components/theme-toggle.tsx index 2bed7bf..fc33f97 100644 --- a/apps/desktop/renderer/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -34,7 +34,7 @@ export function ThemeToggle() {