diff --git a/.fvmrc b/.fvmrc index ade640d..43e7102 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1 +1 @@ -{"flutter": "3.44.6"} \ No newline at end of file +{"flutter": "3.47.2"} diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml new file mode 100644 index 0000000..4f64c05 --- /dev/null +++ b/.github/workflows/android-release.yml @@ -0,0 +1,56 @@ +name: Android ARM64 release + +on: + workflow_dispatch: + inputs: + release_tag: + description: Existing release tag to update + required: true + default: v1.2.6 + +permissions: + contents: write + +env: + FLUTTER_VERSION: "3.47.2" + +jobs: + android: + name: Build Android ARM64 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + - name: Bootstrap + shell: bash + run: | + flutter pub get + (cd packages/video_player_win/example && flutter pub get) + - name: Analyze + run: flutter analyze + - name: Tests + shell: bash + run: bash tool/test_all.sh + - name: Build AAB + working-directory: apps/flutter_forge + run: flutter build appbundle --release + - name: Build ARM64 APK + working-directory: apps/flutter_forge + run: flutter build apk --release --target-platform android-arm64 --split-per-abi + - name: Stage artifacts + shell: bash + run: | + mkdir -p release-assets + cp "$GITHUB_WORKSPACE/apps/flutter_forge/build/app/outputs/bundle/release/app-release.aab" release-assets/flutter_forge-android-arm64.aab + cp "$GITHUB_WORKSPACE/apps/flutter_forge/build/app/outputs/apk/release/app-arm64-v8a-release.apk" release-assets/flutter_forge-android-arm64.apk + - uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.release_tag }} + name: Flutter Forge ${{ inputs.release_tag }} Android ARM64 + prerelease: true + files: release-assets/* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf65db..43f744f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,22 +10,23 @@ jobs: quality-gate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.44.6" + flutter-version: "3.47.2" channel: stable - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: - node-version: "20.20.2" + node-version: "24" - name: Bootstrap run: | flutter pub get + (cd packages/video_player_win/example && flutter pub get) git config core.hooksPath .githooks - name: Agent doc generation + drift check @@ -35,8 +36,9 @@ jobs: AI_ANALYSIS_SCHEMA.json \ AI_PROJECT_CONTEXT.md \ REFACTOR_PLAN.md \ - 'lib/**/AI_ANALYSIS.md' \ - 'lib/AI_MODULE_INDEX.md' \ + AI_ANALYSIS.md \ + 'apps/flutter_forge/lib/**/AI_ANALYSIS.md' \ + 'apps/flutter_forge/lib/AI_MODULE_INDEX.md' \ 'packages/**/AI_ANALYSIS.md' - name: Dart format diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b669c9..3ec7ce7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,15 +9,15 @@ permissions: contents: write env: - FLUTTER_VERSION: "3.44.6" - NODE_VERSION: "20.20.2" + FLUTTER_VERSION: "3.47.2" + NODE_VERSION: "24" jobs: build: name: Build ${{ matrix.os }} installer runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false matrix: include: - os: macos-latest @@ -26,25 +26,35 @@ jobs: - os: windows-latest platform: windows artifact: flutter_forge-windows-x64.zip + - os: ubuntu-latest + platform: android + artifact: flutter_forge-android-arm64 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ env.NODE_VERSION }} - name: Bootstrap - run: flutter pub get + shell: bash + run: | + flutter pub get + (cd packages/video_player_win/example && flutter pub get) - name: Analyze + if: matrix.platform == 'android' run: flutter analyze - name: Tests + if: matrix.platform == 'android' shell: bash run: bash tool/test_all.sh - name: Build macOS if: matrix.platform == 'macos' working-directory: apps/flutter_forge + env: + FLUTTER_XCODE_CC: ${{ github.workspace }}/apps/flutter_forge/tool/macos/compiler_probe.py run: flutter build macos --release - name: Package macOS if: matrix.platform == 'macos' @@ -53,6 +63,21 @@ jobs: if: matrix.platform == 'windows' working-directory: apps/flutter_forge run: flutter build windows --release + - name: Build Android ARM64 AAB + if: matrix.platform == 'android' + working-directory: apps/flutter_forge + run: flutter build appbundle --release + - name: Build Android ARM64 APK + if: matrix.platform == 'android' + working-directory: apps/flutter_forge + run: flutter build apk --release --target-platform android-arm64 --split-per-abi + - name: Stage Android ARM64 artifacts + if: matrix.platform == 'android' + shell: bash + run: | + mkdir -p "$GITHUB_WORKSPACE/apps/flutter_forge/release-assets" + cp "$GITHUB_WORKSPACE/apps/flutter_forge/build/app/outputs/bundle/release/app-release.aab" "$GITHUB_WORKSPACE/apps/flutter_forge/release-assets/flutter_forge-android-arm64.aab" + cp "$GITHUB_WORKSPACE/apps/flutter_forge/build/app/outputs/apk/release/app-arm64-v8a-release.apk" "$GITHUB_WORKSPACE/apps/flutter_forge/release-assets/flutter_forge-android-arm64.apk" - name: Install Inno Setup if: matrix.platform == 'windows' shell: pwsh @@ -75,6 +100,13 @@ jobs: name: flutter_forge-setup-x64.exe path: flutter_forge-setup-x64.exe retention-days: 14 + - name: Upload Android ARM64 artifacts + if: matrix.platform == 'android' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: apps/flutter_forge/release-assets/* + retention-days: 14 release: name: Create GitHub release @@ -82,7 +114,7 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/download-artifact@v4 @@ -112,7 +144,7 @@ jobs: fi } - printf '%s\n' '首个桌面安装器发布。macOS 构建和 Windows 安装器均未签名;首次运行时,macOS 请右键选择“打开”(或执行 `xattr -dr com.apple.quarantine`),Windows SmartScreen 请点击“更多信息(More info)”后选择“仍要运行(Run anyway)”。' > release-notes.md + printf '%s\n' 'Flutter Forge 多平台发布。macOS 和 Windows 产物未签名;首次运行时,macOS 请右键选择“打开”(或执行 `xattr -dr com.apple.quarantine`),Windows SmartScreen 请点击“更多信息(More info)”后选择“仍要运行(Run anyway)”。Android 产物使用工作流构建签名,仅用于项目当前发布轨道。' > release-notes.md printf '\n' >> release-notes.md write_group "Features" '^feat(\(.+\))?!?:' write_group "Fixes" '^fix(\(.+\))?!?:' @@ -131,4 +163,11 @@ jobs: - name: Publish release env: GH_TOKEN: ${{ github.token }} - run: gh release create "${{ github.ref_name }}" release-assets/* --title "Flutter Forge ${{ github.ref_name }}" --notes-file release-notes.md + shell: bash + run: | + if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then + gh release upload "${{ github.ref_name }}" release-assets/* --clobber + gh release edit "${{ github.ref_name }}" --title "Flutter Forge ${{ github.ref_name }}" --notes-file release-notes.md + else + gh release create "${{ github.ref_name }}" release-assets/* --title "Flutter Forge ${{ github.ref_name }}" --notes-file release-notes.md + fi diff --git a/.gitignore b/.gitignore index b5c0558..204365e 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ test-results/ # Local build artifacts / downloads .release/ +**/android/*.jks +**/android/key.properties diff --git a/.hermes/OPS-20260902-issue18-windows-build-gate.codex.json b/.hermes/OPS-20260902-issue18-windows-build-gate.codex.json new file mode 100644 index 0000000..f2d4c33 --- /dev/null +++ b/.hermes/OPS-20260902-issue18-windows-build-gate.codex.json @@ -0,0 +1,184 @@ +{ + "schema": "flutter_forge.agent_task.v1", + "task_id": "OPS-20260902-issue18-windows-build-gate", + "title": "Issue #18 Windows CMake/MSBuild ", + "status": "pending", + "manual_review": true, + "packaging_change": false, + "objective": "Issue #18 Windows CMake CompilerIdCXX Windows Windows ", + "scope": "windows_host_toolchain_diagnosis_then_targeted_acceptance", + "agent_role": "Windows Codex ;;Windows PASS; git push ;", + "context": "GitHub Issue #18 5503531625 Windows 10 x64 22H2、Visual Studio Community 2026 18.1.1、MSVC 14.50.35717、Windows SDK 10.0.26100.0。v1.2.3 Release 33511073139 Windows job success, Windows CMake/MSBuild CompilerIdCXX ; M1-M17/R1-R13 Windows integration ; Windows 。", + "background": { + "repo": "/Users/forest/code/langGraph/flutter_forge", + "branch": "dev", + "baseline": "HEAD=9cf1ad16a0499492cf8f5e1e9b50f0643c495cf0; origin/dev=9cf1ad16a0499492cf8f5e1e9b50f0643c495cf0; clean", + "issue_url": "https://github.com/lizy-coding/flutter_forge/issues/18#issue-5307389318", + "issue_comment_url": "https://github.com/lizy-coding/flutter_forge/issues/comment/5503531625", + "release_baseline": { + "tag": "v1.2.3", + "run_id": "33511073139", + "release_head": "0dd8f676388cb55b453844c4935ce58b9790b2b3", + "setup_sha256": "fd5de214dd5f5db83c685553368a0d98878fb3c131d9d8ea32250dcef9f9d67f" + }, + "reported_windows_environment": { + "os": "Windows 10 x64 22H2", + "visual_studio": "Community 2026 18.1.1", + "msvc": "14.50.35717", + "windows_sdk": "10.0.26100.0", + "local_flutter": "3.44.1", + "release_ci_flutter": "3.44.6" + }, + "reported_failure": "Windows flutter build/test CMake/MSBuild CompilerIdCXX ; Windows ; M1-M4/R9 ", + "macos_boundary": "macOS 565766f Engine/ ; Windows Windows ; macOS UI Windows Windows " + }, + "user_decisions": [ + { + "id": "UD-20260902-windows-issue18-next-stage", + "decision": " Windows CMake/MSBuild ; Windows CMake ;", + "detail": "Windows Windows ; Windows ; Windows Windows , " + } + ], + "design_decisions": [ + { + "id": "DD-1", + "title": "toolchain_gate_before_product_changes", + "rationale": "Failure occurs before Flutter application compilation at CMake CompilerIdCXX detection; source changes must not be invented until the toolchain blocker is isolated.", + "constraints": [ + "First batch is Windows-host diagnostics only.", + "Record exact command, elapsed time, exit code, CMake generator, cl.exe path, cmake version, ninja/MSBuild version, and process state.", + "Do not modify Flutter source, pubspec, Windows CMake, release workflow, or acceptance verdicts in the diagnostic batch.", + "Do not kill processes by executable name; use only a PID created by the diagnostic harness.", + "If Visual Studio 2026 or MSVC 14.50 compatibility is the blocker, record it as an environment blocker rather than changing application code." + ], + "acceptance": [ + "CompilerIdCXX failure is reproduced or explicitly not reproduced on the reported Windows environment.", + "The diagnostic report identifies the first failing command and a bounded remediation candidate.", + "No synthetic Windows PASS is produced." + ] + }, + { + "id": "DD-2", + "title": "version_and_environment_alignment", + "rationale": "The report uses local Flutter 3.44.1 while the release used CI Flutter 3.44.6; the Windows test must distinguish application defects from toolchain/version drift.", + "constraints": [ + "Capture flutter --version, dart --version, cmake --version, msbuild version, Visual Studio workloads, Windows SDK, and PATH resolution.", + "Compare against release.yml FLUTTER_VERSION=3.44.6.", + "Do not downgrade or upgrade Visual Studio/Flutter automatically.", + "Do not change lockfiles or dependencies during diagnosis." + ], + "acceptance": [ + "Version drift is explicitly classified as matched, tolerated, or blocker.", + "A clean rebuild is attempted only after the environment snapshot is captured." + ] + }, + { + "id": "DD-3", + "title": "acceptance_after_build_gate", + "rationale": "Real UI evidence is meaningful only from a fresh Windows artifact whose build identity and installer provenance are established.", + "constraints": [ + "Do not execute M1-M17/R1-R13 against v1.2.3 if the tested binary is not proven to match the current target commit.", + "After a successful build or verified release artifact, execute the existing Windows checklist item-by-item without aborting on individual failures.", + "Every FAIL includes exact reproduction, last successful operation, screenshot/log path, and Event Viewer/WER fields when applicable.", + "usb_detector remains Android-only/deferred and is not counted as a Windows failure." + ], + "acceptance": [ + "P1-P8, W1-W6, N1-N8, V1-V6, M1-M17, and R1-R13 each have a verdict.", + "windows_ready is true only when required Windows criteria pass with evidence; otherwise HOLD." + ] + } + ], + "implementation_steps": [ + { + "step": 1, + "action": "windows_preflight_snapshot", + "detail": "On the reported Windows machine, capture OS/architecture, Visual Studio/MSVC/SDK, Flutter/Dart/CMake/MSBuild versions, PATH tool locations, repository HEAD, and installed artifact identity.", + "verify": "Preflight JSON/log contains all versions and HEAD/SHA256 fields" + }, + { + "step": 2, + "action": "reproduce_compiler_probe_bounded", + "detail": "Run a bounded clean Windows build with timestamps and per-stage logging; isolate CMake CompilerIdCXX/CTest/MSBuild probe output and record process/PID state without name-based termination.", + "verify": "First failing command, exit/timeout, elapsed time, generator, compiler path, and raw log path recorded" + }, + { + "step": 3, + "action": "toolchain_remediation_decision", + "detail": "Based only on captured evidence, choose one bounded remediation: align Flutter to 3.44.6, repair Visual Studio C++ workload/environment, or record an external toolchain blocker. Do not modify product source unless application compilation proves a source defect.", + "verify": "Decision cites evidence and leaves an auditable before/after environment snapshot" + }, + { + "step": 4, + "action": "fresh_build_gate", + "detail": "After remediation, run flutter build windows --release or verify the v1.2.3 release artifact; record build exit code, artifact mtime, artifact SHA256, and required DLL inventory.", + "verify": "Fresh build/artifact provenance is complete; otherwise mark BUILD_BLOCKED" + }, + { + "step": 5, + "action": "windows_acceptance_run", + "detail": "Only with a valid fresh artifact, execute WINDOWS_SELF_TEST_CHECKLIST.md P1-P8, W1-W6, N1-N8, V1-V6, M1-M17, and R1-R13. Continue after individual failures and collect screenshots/logs/Event Viewer evidence.", + "verify": "All checklist rows have PASS/FAIL/BLOCKED/DEFERRED and evidence references" + }, + { + "step": 6, + "action": "report_and_handoff", + "detail": "Write Windows UI acceptance report and notes; distinguish toolchain failure, product failure, external conditions, and deferred Android-only capability. Do not modify source or push from the acceptance run.", + "verify": "Report contains windows_ready, final acceptance, blocker list, artifact identity, and complete verdict counts" + } + ], + "acceptance_criteria": [ + "DD-1: CompilerIdCXX timeout is reproduced or bounded as not reproduced with raw evidence; no speculative source fix is accepted.", + "DD-2: Flutter 3.44.1 vs release CI 3.44.6 and the complete Windows toolchain are compared explicitly.", + "DD-3: Full Windows checklist runs only against a verified fresh artifact and every item receives a truthful verdict.", + "The final report does not promote HOLD/BLOCKED/TIMEOUT to PASS.", + "No GitHub Issue mutation, tag/release mutation, or git push occurs in the acceptance task." + ], + "out_of_scope": [ + "Windows USB/HID/Serial/WinUSB implementation", + "Visual Studio automatic installation or system-wide configuration changes", + "Release workflow/version/tag changes", + "Flutter source changes before toolchain diagnosis", + "GitHub Issue comments/labels/closure", + "git push, tag, release, merge, rebase, force-push" + ], + "forbidden_changes": [ + "Do not fabricate Windows screenshots, WER logs, build results, or PASS verdicts.", + "Do not modify apps/flutter_forge/lib, apps/flutter_forge/windows, pubspec files, .github/workflows, or tool gate scripts in steps 1-3.", + "Do not kill processes by executable name.", + "Do not change usb_detector platform semantics.", + "Do not push or mutate GitHub Issue #18." + ], + "validation": [ + "Windows: flutter --version", + "Windows: dart --version", + "Windows: cmake --version", + "Windows: msbuild -version", + "Windows: flutter build windows --release", + "Windows: flutter test integration_test -d windows", + "Windows: Get-FileHash .\\flutter_forge-setup-x64.exe -Algorithm SHA256", + "Windows: Event Viewer/WER export", + "git diff --check", + "git status --short --branch", + "git rev-parse HEAD origin/dev" + ], + "risk": "high", + "pre_read": [ + "AGENTS.md", + "AI_ANALYSIS_SCHEMA.json", + "AI_PROJECT_CONTEXT.md", + "REFACTOR_PLAN.md", + "windows_acceptance/WINDOWS_SELF_TEST_CHECKLIST.md", + "windows_acceptance/evidence/2026-09-01-v1.2.3/WINDOWS_UI_ACCEPTANCE_REPORT.json", + "apps/flutter_forge/integration_test/app_test.dart", + "apps/flutter_forge/lib/shared/multi_window/multi_window_manager.dart", + "apps/flutter_forge/lib/app/app_bootstrap.dart", + "apps/flutter_forge/lib/app/category_window_app.dart", + ".github/workflows/release.yml" + ], + "notes": [ + "Issue comment 5503531625 is the source for the Windows host/toolchain report; re-fetch before execution if network is available.", + "v1.2.3 release workflow success proves the CI Windows job built an installer, but does not prove the reported Windows 10 local CMake environment or UI behavior.", + "The current repository is macOS; Windows-only build, installer launch, Event Viewer/WER, HWND/PID, and real UI results must be executed on Windows or authoritative Windows CI.", + "Only a local commit may be created after explicit approval; this task forbids commit and push during acceptance." + ] +} diff --git a/.nvmrc b/.nvmrc index 92bc26a..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.20.2 \ No newline at end of file +24 diff --git a/AGENTS.md b/AGENTS.md index 79a26d2..30b6ce6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ | `AI_ANALYSIS.md` | 模块机器契约:route、category、status、entrypoints、owns、depends、analysis_parent、validation | | 路由注册 | 在 `apps/flutter_forge/lib/app/router/app_route_table.dart` 的 `_modules` 中注册 | | 模块元数据 | `ModuleEntry` 必须填写 `category`、`difficulty`、`concepts`、`estimatedMinutes`、`status`、`subtitle` | -| 教学页面 | 至少 1 个页面使用外部 `flutter_study_learning` 包中的教学模板组件(`LearningScaffold` 等) | +| 教学页面 | 至少 1 个页面使用 `lib/shared/learning` 中的教学模板组件(`LearningScaffold` 等) | 平台可用性规则: @@ -97,7 +97,7 @@ bash tool/quality_gate.sh 1. 扫描 `apps/flutter_forge/lib/modules/` 下所有模块目录,检查是否都在 `_modules` 中注册 2. 检查每个模块是否有 `AI_ANALYSIS.md` -3. 检查重点模块是否使用教学模板(`flutter_study_learning` 包) +3. 检查重点模块是否使用教学模板(`lib/shared/learning`) 4. 检查 `ModuleEntry` 元数据是否完整(所有必填字段) 5. 标记低质量模块的 `status` 为 `ModuleStatus.pending` 6. 检查 `flutter analyze` 和 `dart format` 是否通过 diff --git a/AI_ANALYSIS.md b/AI_ANALYSIS.md index 5b025e9..6f3eb87 100644 --- a/AI_ANALYSIS.md +++ b/AI_ANALYSIS.md @@ -22,8 +22,8 @@ "host_integrations" ], "depends": [ - "packages/gcode_core", - "packages/flutter_study_learning", + "git:https://github.com/lizy-coding/gcode_core.git#v0.2.0-dev.1", + "packages/shared_learning", "packages/file_picker_bridge", "packages/flutter_ioc_core", "git:https://github.com/lizy-coding/flutterguard.git#9f9be84a73dc4b99a956a8529b8c334849566b03" @@ -34,19 +34,13 @@ "lib/module_registry/AI_ANALYSIS.md", "lib/shared/AI_ANALYSIS.md", "lib/modules/AI_ANALYSIS.md", - "packages/gcode_core/AI_ANALYSIS.md", - "packages/flutter_study_learning/AI_ANALYSIS.md", "packages/file_picker_bridge/AI_ANALYSIS.md", "packages/flutter_ioc_core/AI_ANALYSIS.md" ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "bash tool/generate_harness_ai_analysis.sh", diff --git a/AI_ANALYSIS_SCHEMA.json b/AI_ANALYSIS_SCHEMA.json index 40ee0a4..9e51186 100644 --- a/AI_ANALYSIS_SCHEMA.json +++ b/AI_ANALYSIS_SCHEMA.json @@ -15,8 +15,6 @@ "AI_ANALYSIS.md" ], "package_contract": [ - "packages/gcode_core/AI_ANALYSIS.md", - "packages/flutter_study_learning/AI_ANALYSIS.md", "packages/file_picker_bridge/AI_ANALYSIS.md", "packages/flutter_ioc_core/AI_ANALYSIS.md" ], @@ -62,8 +60,6 @@ ], "contracts_required": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", "doc_mode": "machine_contract" }, diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index 93ddfe7..eef5f99 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -33,8 +33,6 @@ "layout": "pub_workspace", "workspace_root": ".", "members": [ - "packages/gcode_core", - "packages/flutter_study_learning", "packages/file_picker_bridge", "packages/flutter_ioc_core" ], @@ -42,18 +40,6 @@ "resolution_blocker": "none" }, "internal_packages": [ - { - "name": "gcode_core", - "type": "flutter_package", - "path": "packages/gcode_core", - "entrypoint": "lib/gcode_core.dart" - }, - { - "name": "flutter_study_learning", - "type": "flutter_package", - "path": "packages/flutter_study_learning", - "entrypoint": "lib/flutter_study_learning.dart" - }, { "name": "file_picker_bridge", "type": "flutter_bridge_package", @@ -67,6 +53,24 @@ "entrypoint": "lib/flutter_ioc_core.dart" } ], + "external_packages": [ + { + "name": "gcode_core", + "source": "git", + "url": "https://github.com/lizy-coding/gcode_core.git", + "ref": "v0.2.0-dev.1", + "entrypoint": "lib/gcode_core.dart", + "flutter_min": "3.47.2", + "supported_platforms": [ + "macOS" + ], + "requires": [ + "impeller", + "flutter_gpu" + ], + "macos_deployment_target_min": "12.0" + } + ], "external_tools": [ { "package": "flutterguard_cli", @@ -145,7 +149,7 @@ "status", "subtitle" ], - "required_learning_dependency": "flutter_study_learning", + "required_learning_dependency": "shared_learning", "route_path_style": "kebab_case", "directory_style": "snake_case" }, diff --git a/CONTEXT.md b/CONTEXT.md index 2490368..98ffd30 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -23,3 +23,7 @@ _Avoid_: 业务窗口、模块窗口 **模块准入**: 新业务模块进入主分支前必须满足模块契约、测试和 Agent Hub 路径治理要求。 _Avoid_: 直接注册、手工接入 + +**内嵌网页模块**: +在学习页面内展示网页、提供网页导航与加载反馈的学习单元;由应用统一管理入口与平台可用性。 +_Avoid_: 内置浏览器应用、webview_flutter 模块、业务窗口 diff --git a/README.md b/README.md index 4b51338..e83f0ab 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,7 @@ apps/flutter_forge/lib/ 同级能力包: ``` -packages/gcode_core # 纯 Dart G-code 解析、读取、轨迹构建 -packages/flutter_study_learning # 教学模板组件 +apps/flutter_forge/lib/shared/learning # 应用内教学模板组件 packages/file_picker_bridge # 文件选择 Dart API / MethodChannel client packages/flutter_ioc_core # 纯 Dart IoC 容器核心 ``` @@ -93,8 +92,6 @@ packages/flutter_ioc_core # 纯 Dart IoC 容器核心 - `apps/flutter_forge/lib/shared/AI_ANALYSIS.md` - `apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md` - `packages/file_picker_bridge/AI_ANALYSIS.md` -- `packages/gcode_core/AI_ANALYSIS.md` -- `packages/flutter_study_learning/AI_ANALYSIS.md` - `packages/flutter_ioc_core/AI_ANALYSIS.md` - `apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md` @@ -152,7 +149,7 @@ chore(packages): update agent doc schema ### 教学模板 -`package:flutter_study_learning` 提供统一教学页面骨架,模块可以用学习目标、概念标签、代码片段、常见坑和练习卡片组织内容。 +`apps/flutter_forge/lib/shared/learning` 提供统一教学页面骨架,模块可以用学习目标、概念标签、代码片段、常见坑和练习卡片组织内容。 ### 模块平台可用性 @@ -171,14 +168,14 @@ chore(packages): update agent doc schema ### G-code 核心 -`packages/gcode_core` 提供纯 Dart G-code 能力: +[`gcode_core`](https://github.com/lizy-coding/gcode_core) 在独立仓库维护,应用通过 Git 依赖固定完整 commit,锁文件记录解析版本。升级时同步生成器中的外部依赖契约并执行完整质量门禁。该 Flutter 包提供: - 逐行读取抽象 - G0/G1 解析 - 错误收集 - 轨迹段构建 -Flutter UI、播放动画和 Canvas 绘制保留在 `modules/ui/gcode_visualizer`。 +Canvas、时间线和播放控件由该包提供;教学页面、文件选择编排及播放状态保留在 `modules/ui/gcode_visualizer`。当前 macOS 最低支持版本为 12.0。 ## 示例索引(按主题) diff --git a/REFACTOR_PLAN.md b/REFACTOR_PLAN.md index ccb12cd..bf0a295 100644 --- a/REFACTOR_PLAN.md +++ b/REFACTOR_PLAN.md @@ -9,13 +9,13 @@ "app_navigation_boundary", "host_bootstrap_boundary", "workspace_package_import", - "agent_takeover_ready" + "agent_takeover_ready", + "pc_window_lifecycle_baseline", + "pc_build_matrix" ], "dependency_migration": { "layout": "pub_workspace", "internal_packages": [ - "packages/gcode_core", - "packages/flutter_study_learning", "packages/file_picker_bridge", "packages/flutter_ioc_core" ], @@ -61,7 +61,7 @@ { "id": "platform_plugin_audit", "priority": 5, - "status": "pending", + "status": "completed", "targets": [ "desktop_multi_window", "file_picker_bridge", @@ -70,7 +70,14 @@ ], "acceptance": [ "android_support_matrix", - "unsupported_fallbacks" + "unsupported_fallbacks", + "android_file_selector_mapping" + ], + "evidence": [ + "desktop_multi_window is gated out of Android navigation", + "file_picker_bridge selects file_selector on Android", + "usb_detector uses the Android usb_detector/usb MethodChannel", + "device_info_plus is registered in GeneratedPluginRegistrant.java" ] }, { @@ -107,23 +114,99 @@ { "id": "android_host", "priority": 8, - "status": "blocked_by_dependencies", + "status": "completed", "depends_on": [ "module_platform_contract", - "platform_plugin_audit", - "mobile_layout_baseline" + "platform_plugin_audit" ], "acceptance": [ "android_directory", "manifest_capabilities", "debug_apk", - "emulator_smoke" + "emulator_smoke", + "single_window_navigation" + ], + "evidence": [ + "Android host directory and USB host manifest feature exist", + "debug APK builds and installs on API 35 emulator", + "MainActivity reaches Fully drawn with a live process and no fatal log", + "singleTop Activity and in-app NavigationPolicy keep Android single-window behavior" + ] + }, + { + "id": "android_compatibility_plan", + "priority": 9, + "status": "planned", + "depends_on": [ + "platform_plugin_audit", + "usb_platform_boundary", + "mobile_layout_baseline", + "android_host" + ], + "phases": [ + "android_host_and_manifest", + "platform_capability_fallbacks", + "mobile_navigation_and_layout", + "module_matrix_and_unavailable_states", + "emulator_smoke_and_release_candidate" + ], + "acceptance": [ + "flutter_build_apk_debug", + "android_emulator_smoke", + "single_window_in_app_navigation", + "unsupported_capability_state_visible", + "no_android_analyzer_or_test_regressions" + ] + }, + { + "id": "android_usb_permission_boundary", + "priority": 10, + "status": "completed", + "depends_on": [ + "android_host" + ], + "targets": [ + "apps/flutter_forge/android/app/src/main/kotlin", + "apps/flutter_forge/android/app/src/main/AndroidManifest.xml", + "apps/flutter_forge/lib/modules/platform/usb_detector", + "apps/flutter_forge/test/modules/platform/usb_detector" + ], + "acceptance": [ + "usb_permission_denied_is_observable", + "device_enumeration_falls_back_without_crash", + "android_usb_channel_contract_tested" + ], + "evidence": [ + "current Android MainActivity reports permission-safe USB enumeration", + "USB service preserves devices when optional fields are unavailable", + "Android USB service tests pass and APK builds successfully" + ] + }, + { + "id": "module_scaffold_generation", + "priority": 11, + "status": "completed", + "targets": [ + "tool/module_scaffold.dart", + "tool/module_scaffold_test.dart" + ], + "acceptance": [ + "preview_does_not_write_formal_module", + "apply_generates_module_entry_and_learning_page", + "generated_analysis_contract_is_valid", + "invalid_module_arguments_fail_with_usage_code", + "route_registration_remains_explicit" + ], + "evidence": [ + "module_scaffold_test passes preview/apply and contract assertions", + "dart analyze passes for scaffold CLI and acceptance test", + "route registration remains outside scaffold automatic writes" ] }, { "id": "pc_window_lifecycle_baseline", "priority": 3, - "status": "pending", + "status": "completed", "targets": [ "desktop_multi_window", "lib/shared/multi_window", @@ -139,7 +222,7 @@ { "id": "pc_build_matrix", "priority": 4, - "status": "blocked_by_host", + "status": "completed", "targets": [ "macos", "windows" diff --git a/apps/flutter_forge/analysis_options.yaml b/apps/flutter_forge/analysis_options.yaml index f9b3034..bdef109 100644 --- a/apps/flutter_forge/analysis_options.yaml +++ b/apps/flutter_forge/analysis_options.yaml @@ -1 +1,7 @@ +analyzer: + exclude: + - build/** + - android/** + - windows/** + - macos/** include: package:flutter_lints/flutter.yaml diff --git a/apps/flutter_forge/android/app/build.gradle.kts b/apps/flutter_forge/android/app/build.gradle.kts index a463e9f..117161e 100644 --- a/apps/flutter_forge/android/app/build.gradle.kts +++ b/apps/flutter_forge/android/app/build.gradle.kts @@ -4,6 +4,17 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +val androidKeystorePath = System.getenv("ANDROID_KEYSTORE_PATH") +val androidKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD") +val androidKeyAlias = System.getenv("ANDROID_KEY_ALIAS") +val androidKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") +val releaseSigningConfigured = listOf( + androidKeystorePath, + androidKeystorePassword, + androidKeyAlias, + androidKeyPassword, +).all { !it.isNullOrBlank() } + android { namespace = "com.flutterforge.preview" compileSdk = flutter.compileSdkVersion @@ -24,11 +35,24 @@ android { versionName = flutter.versionName } + signingConfigs { + if (releaseSigningConfigured) { + create("release") { + keyAlias = androidKeyAlias + keyPassword = androidKeyPassword + storeFile = file(androidKeystorePath!!) + storePassword = androidKeystorePassword + } + } + } + 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.getByName("debug") + signingConfig = if (releaseSigningConfigured) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/apps/flutter_forge/android/app/src/main/kotlin/com/flutterforge/preview/MainActivity.kt b/apps/flutter_forge/android/app/src/main/kotlin/com/flutterforge/preview/MainActivity.kt index 0dd2f88..383c2d7 100644 --- a/apps/flutter_forge/android/app/src/main/kotlin/com/flutterforge/preview/MainActivity.kt +++ b/apps/flutter_forge/android/app/src/main/kotlin/com/flutterforge/preview/MainActivity.kt @@ -1,6 +1,7 @@ package com.flutterforge.preview import android.content.Context +import android.hardware.usb.UsbDevice import android.hardware.usb.UsbManager import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -24,15 +25,31 @@ class MainActivity : FlutterActivity() { } private fun listUsbDevices(): List> { - val manager = getSystemService(Context.USB_SERVICE) as UsbManager - return manager.deviceList.values.map { device -> + val manager = getSystemService(Context.USB_SERVICE) as? UsbManager + ?: return emptyList() + + // Enumerating deviceList does not require user permission. Individual + // descriptors (notably serialNumber) may still throw until permission + // has been granted, so never let one device hide the rest. + return runCatching { manager.deviceList.entries.toList() } + .getOrElse { emptyList() } + .mapNotNull { (name, device) -> device.toSafeMap(name, manager) } + } + + private fun UsbDevice.toSafeMap(name: String, manager: UsbManager): Map? { + return runCatching { mapOf( - "vendorId" to device.vendorId, - "productId" to device.productId, - "manufacturer" to device.manufacturerName, - "product" to device.productName, - "serialNumber" to runCatching { device.serialNumber }.getOrNull(), + "id" to name, + "name" to name, + "vendorId" to vendorId, + "productId" to productId, + "manufacturer" to runCatching { manufacturerName }.getOrNull(), + "product" to runCatching { productName }.getOrNull(), + "serialNumber" to if (manager.hasPermission(this)) { + runCatching { serialNumber }.getOrNull() + } else null, + "hasPermission" to manager.hasPermission(this), ) - } + }.getOrNull() } } diff --git a/apps/flutter_forge/integration_test/app_test.dart b/apps/flutter_forge/integration_test/app_test.dart index 786dd60..6571ffd 100644 --- a/apps/flutter_forge/integration_test/app_test.dart +++ b/apps/flutter_forge/integration_test/app_test.dart @@ -33,9 +33,15 @@ void main() { 500, scrollable: find.byType(Scrollable).first, ); + debugPrint('Android module smoke: ${module.path}'); await tester.tap(tile); await tester.pump(const Duration(milliseconds: 600)); + final moduleException = tester.takeException(); + if (moduleException != null) { + fail('${module.path} raised during Android smoke: $moduleException'); + } + expect(find.byType(Scaffold), findsOneWidget, reason: module.path); await tester.pageBack(); await tester.pumpAndSettle(); @@ -58,8 +64,10 @@ void main() { await tester.pageBack(); await tester.pumpAndSettle(); - final listTile = find.widgetWithText(ListTile, '列表交互'); - await tester.tap(listTile); + await tester.pump(const Duration(milliseconds: 300)); + AppRouter.router.go('/popup-list-interaction'); + await tester.pumpAndSettle(); + AppRouter.router.go('/popup-list-interaction/list'); await tester.pumpAndSettle(); expect(find.text('二维滚动表格演示'), findsOneWidget); }); diff --git a/apps/flutter_forge/integration_test/webview_macos_test.dart b/apps/flutter_forge/integration_test/webview_macos_test.dart new file mode 100644 index 0000000..9f619f5 --- /dev/null +++ b/apps/flutter_forge/integration_test/webview_macos_test.dart @@ -0,0 +1,72 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/modules/platform/webview/core/webview_session.dart'; +import 'package:flutter_forge_app/modules/platform/webview/platforms/webview_flutter_backend.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('WKWebView loads, navigates, reloads and reopens', ( + tester, + ) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final requests = []; + server.listen((request) async { + requests.add(request.uri.path); + request.response.headers.contentType = ContentType.html; + request.response.write( + 'Forge WebView' + '

Forge WKWebView ${request.uri.path}

', + ); + await request.response.close(); + }); + addTearDown(() => server.close(force: true)); + final base = 'http://127.0.0.1:${server.port}'; + + Future waitFor(bool Function() condition) async { + for (var i = 0; i < 150; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pump(); + if (condition()) return; + } + fail('Native WKWebView condition timed out'); + } + + for (var attempt = 0; attempt < 2; attempt++) { + final backend = WebViewFlutterBackend(); + final session = WebViewSession(backend)..url = '$base/first'; + await session.start(); + expect(session.initialized, isTrue, reason: session.error); + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: backend.buildView())), + ); + await waitFor(() => !session.loading && session.progress == 1); + expect(session.error, isNull); + expect(requests, contains('/first')); + await session.navigate('$base/second'); + await waitFor(() => !session.loading && session.canBack); + expect(requests, contains('/second')); + await session.back(); + await waitFor(() => session.url.endsWith('/first') && session.canForward); + await session.forward(); + await waitFor(() => session.url.endsWith('/second') && !session.loading); + final previous = requests.where((p) => p == '/second').length; + await session.reload(); + await waitFor( + () => + !session.loading && + requests.where((p) => p == '/second').length > previous, + ); + expect(session.error, isNull); + await tester.pumpWidget(const SizedBox()); + session.dispose(); + await tester.pump(const Duration(seconds: 1)); + expect(tester.takeException(), isNull); + } + }, skip: !Platform.isMacOS); +} diff --git a/apps/flutter_forge/integration_test/windows_acceptance_test.dart b/apps/flutter_forge/integration_test/windows_acceptance_test.dart new file mode 100644 index 0000000..aabb493 --- /dev/null +++ b/apps/flutter_forge/integration_test/windows_acceptance_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/app/app.dart'; +import 'package:flutter_forge_app/app/router/app_router.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +const _filePickerRoute = '/file-picker'; +const _sampleFileName = 'README.md'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Windows file picker returns from native dialog', (tester) async { + await tester.pumpWidget(const App()); + await tester.pump(const Duration(milliseconds: 500)); + + AppRouter.router.go(_filePickerRoute); + await tester.pump(const Duration(milliseconds: 500)); + + // README.md is a valid text-mode target. The test must not claim that the + // native driver selected this particular file; the driver may select any + // file matching the filter. + await tester.tap(find.text('文本')); + await tester.pump(); + + final chooseButton = find.byKey(const ValueKey('pick-file-button')); + expect(chooseButton, findsOneWidget); + await tester.tap(chooseButton); + + // Native dialogs do not participate in Flutter's frame/settle protocol. + // The bounded poll lets the Windows driver close the dialog and return to + // the page without waiting forever on a platform-owned animation. + final returnedToFlutter = await _waitForTerminalState(tester); + + // The Windows driver selects $_sampleFileName in the native dialog. + // Keep the sample stable across machines and avoid generated temp files. + if (returnedToFlutter) { + debugPrint( + 'Windows file-picker handoff completed; driver target may be any ' + 'text file (for example $_sampleFileName).', + ); + } else { + debugPrint( + 'BLOCKED: Windows driver must select or cancel the native file ' + 'dialog; Flutter integration_test cannot control that dialog.', + ); + } + }); +} + +Future _waitForTerminalState( + WidgetTester tester, { + Duration timeout = const Duration(seconds: 8), + Duration step = const Duration(milliseconds: 80), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(step); + final terminalStates = [ + find.textContaining('未选择文件'), + find.textContaining('大小:'), + find.text('当前平台暂未实现原生文件选择桥接'), + find.textContaining('文件选择失败:'), + ]; + if (terminalStates.any((finder) => finder.evaluate().isNotEmpty)) { + return true; + } + } + return false; +} diff --git a/apps/flutter_forge/lib/AI_ANALYSIS.md b/apps/flutter_forge/lib/AI_ANALYSIS.md index 84e03bb..c63424d 100644 --- a/apps/flutter_forge/lib/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/AI_ANALYSIS.md @@ -33,12 +33,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/AI_MODULE_INDEX.md b/apps/flutter_forge/lib/AI_MODULE_INDEX.md index e0e8d50..aedce11 100644 --- a/apps/flutter_forge/lib/AI_MODULE_INDEX.md +++ b/apps/flutter_forge/lib/AI_MODULE_INDEX.md @@ -1,7 +1,7 @@ { "schema": "flutter_forge.agent_docs.module_index.v1", "registry": "lib/app/router/app_route_table.dart", - "count": 21, + "count": 22, "modules": [ { "id": "tree_state", @@ -10,7 +10,7 @@ "route": "/tree-state", "status": "recommended", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -23,7 +23,7 @@ "route": "/microtask", "status": "recommended", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -36,7 +36,7 @@ "route": "/debounce-throttle", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "analysis": "lib/modules/basic/debounce_throttle/AI_ANALYSIS.md" @@ -48,7 +48,7 @@ "route": "/stream-subscription", "status": "recommended", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -61,7 +61,7 @@ "route": "/isolate-basic", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -74,7 +74,7 @@ "route": "/isolate-stream", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "analysis": "lib/modules/async/isolate_task_manager/AI_ANALYSIS.md" @@ -86,7 +86,7 @@ "route": "/status-management", "status": "recommended", "depends": [ - "flutter_study_learning", + "shared_learning", "provider", "flutter_riverpod", "flutter_bloc", @@ -102,7 +102,7 @@ "route": "/flutter-ioc", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "flutter_ioc_core", "provider", "module_registry" @@ -116,7 +116,7 @@ "route": "/local-persistence", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "shared_preferences", "module_registry" ], @@ -129,7 +129,7 @@ "route": "/gcode-visualizer", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "gcode_core", "file_picker_bridge", "module_registry" @@ -143,7 +143,7 @@ "route": "/adsorption-line", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "provider", "module_registry" ], @@ -156,7 +156,7 @@ "route": "/download-animation", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -169,7 +169,7 @@ "route": "/font-picker", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "file_picker_bridge", "module_registry", "go_router" @@ -183,7 +183,7 @@ "route": "/popup-widgets", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "analysis": "lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md" @@ -195,7 +195,7 @@ "route": "/popup-list-interaction", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -208,7 +208,7 @@ "route": "/scroll-table", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "two_dimensional_scrollables", "module_registry" ], @@ -221,7 +221,7 @@ "route": "/overlay-compare", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "analysis": "lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md" @@ -233,7 +233,7 @@ "route": "/dio-interceptor", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "dio", "module_registry", "go_router" @@ -247,7 +247,7 @@ "route": "/usb-detector", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "device_info_plus", "module_registry" ], @@ -260,7 +260,7 @@ "route": "/file-picker", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "file_picker_bridge", "module_registry" ], @@ -273,13 +273,27 @@ "route": "/online-video-player", "status": "ready", "depends": [ - "flutter_study_learning", + "shared_learning", "dio", "video_player", "video_player_win", "module_registry" ], "analysis": "lib/modules/platform/online_video_player/AI_ANALYSIS.md" + }, + { + "id": "webview", + "category": "platform", + "path": "lib/modules/platform/webview", + "route": "/webview", + "status": "ready", + "depends": [ + "shared_learning", + "module_registry", + "webview_flutter", + "webview_windows" + ], + "analysis": "lib/modules/platform/webview/AI_ANALYSIS.md" } ] } diff --git a/apps/flutter_forge/lib/app/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/AI_ANALYSIS.md index 2b9e8a6..28f567c 100644 --- a/apps/flutter_forge/lib/app/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/AI_ANALYSIS.md @@ -38,12 +38,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/app/category_window_app.dart b/apps/flutter_forge/lib/app/category_window_app.dart index 8007f05..6a44dc2 100644 --- a/apps/flutter_forge/lib/app/category_window_app.dart +++ b/apps/flutter_forge/lib/app/category_window_app.dart @@ -68,10 +68,13 @@ class CategoryHomePage extends StatelessWidget { }, ), ), - body: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: modules.length, - itemBuilder: (context, index) => ModuleListTile(module: modules[index]), + body: SafeArea( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: modules.length, + itemBuilder: (context, index) => + ModuleListTile(module: modules[index]), + ), ), ); } diff --git a/apps/flutter_forge/lib/app/module_home_page.dart b/apps/flutter_forge/lib/app/module_home_page.dart index a3a0a61..ad3c9b1 100644 --- a/apps/flutter_forge/lib/app/module_home_page.dart +++ b/apps/flutter_forge/lib/app/module_home_page.dart @@ -19,59 +19,61 @@ class ModuleHomePage extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('Flutter 学习实验室')), - body: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: categories.length, - itemBuilder: (context, index) { - final category = categories[index]; - final categoryModules = modules - .where((module) => module.category == category) - .toList(); + body: SafeArea( + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + final categoryModules = modules + .where((module) => module.category == category) + .toList(); - if (categoryModules.isEmpty) return const SizedBox.shrink(); + if (categoryModules.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 8, 8), - child: Row( - children: [ - Expanded( - child: Text( - category.label, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + category.label, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), ), - ), - IconButton( - icon: Icon( - CategoryNavigation.modeFor(context) == - CategoryNavigationMode.separateWindow - ? Icons.open_in_new - : Icons.chevron_right, - size: 20, - ), - tooltip: '打开分类', - onPressed: () => CategoryNavigation.open( - context, - category: category, - modules: modules, + IconButton( + icon: Icon( + CategoryNavigation.modeFor(context) == + CategoryNavigationMode.separateWindow + ? Icons.open_in_new + : Icons.chevron_right, + size: 20, + ), + tooltip: '打开分类', + onPressed: () => CategoryNavigation.open( + context, + category: category, + modules: modules, + ), ), - ), - ], + ], + ), ), - ), - ...categoryModules.map( - (module) => ModuleListTile(module: module), - ), - const Divider(height: 1), - ], - ); - }, + ...categoryModules.map( + (module) => ModuleListTile(module: module), + ), + const Divider(height: 1), + ], + ); + }, + ), ), ); } diff --git a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md index e0feb8e..c79b97b 100644 --- a/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/app/router/AI_ANALYSIS.md @@ -25,12 +25,8 @@ "children": [], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/app/router/app_route_table.dart b/apps/flutter_forge/lib/app/router/app_route_table.dart index 1554e11..ec164b1 100644 --- a/apps/flutter_forge/lib/app/router/app_route_table.dart +++ b/apps/flutter_forge/lib/app/router/app_route_table.dart @@ -41,6 +41,7 @@ import '../../modules/platform/file_picker/module_entry.dart'; import '../../modules/platform/online_video_player/module_entry.dart'; import '../../modules/platform/usb_detector/module_entry.dart'; import '../../modules/state/local_persistence/module_entry.dart'; +import '../../modules/platform/webview/module_entry.dart'; // ==================== 状态管理子路由(模块内部已定义映射) ==================== @@ -178,6 +179,8 @@ final List _modules = [ concepts: ['G-code', 'Parser', 'CustomPaint', 'PathMetric', '动画控制'], estimatedMinutes: 45, status: ModuleStatus.ready, + // gcode_core v0.2.0-dev.1 validates macOS GPU rendering only. + supportedPlatforms: {TargetPlatform.macOS}, builder: (context) => const GcodeVisualizerEntry(), ), ModuleEntry( @@ -335,6 +338,23 @@ final List _modules = [ supportedPlatforms: {TargetPlatform.macOS, TargetPlatform.windows}, builder: (context) => const OnlineVideoPlayerEntry(), ), + ModuleEntry( + title: '网页容器与跨平台导航', + path: '/webview', + subtitle: '学习 Android、macOS、Windows 网页导航与生命周期', + category: ModuleCategory.platform, + difficulty: Difficulty.intermediate, + concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], + estimatedMinutes: 30, + status: ModuleStatus.ready, + // Android/macOS use webview_flutter; Windows uses WebView2. + supportedPlatforms: { + TargetPlatform.android, + TargetPlatform.macOS, + TargetPlatform.windows, + }, + builder: (context) => const WebViewEntry(), + ), ]; final List _routes = [ diff --git a/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md b/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md index 3b7aa8f..a868800 100644 --- a/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/module_registry/AI_ANALYSIS.md @@ -28,12 +28,8 @@ "children": [], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/AI_ANALYSIS.md index 8977ec8..2260b4a 100644 --- a/apps/flutter_forge/lib/modules/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/AI_ANALYSIS.md @@ -22,7 +22,7 @@ ], "depends": [ "module_registry", - "flutter_study_learning" + "shared_learning" ], "children": [ "basic/AI_ANALYSIS.md", @@ -34,12 +34,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/async/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/AI_ANALYSIS.md index b9d4643..0a6356c 100644 --- a/apps/flutter_forge/lib/modules/async/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/AI_ANALYSIS.md @@ -18,7 +18,7 @@ ], "depends": [ "module_registry", - "flutter_study_learning" + "shared_learning" ], "children": [ "stream_subscription/AI_ANALYSIS.md", @@ -27,12 +27,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md index 2c93172..9b6c0b3 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -29,12 +29,8 @@ "analysis_parent": "lib/modules/async/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/module_root.dart b/apps/flutter_forge/lib/modules/async/isolate_basic/module_root.dart index 156ed6d..414e008 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/module_root.dart +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/module_root.dart @@ -1,6 +1,6 @@ // ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; class HomePage extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/with_isolate_page.dart b/apps/flutter_forge/lib/modules/async/isolate_basic/with_isolate_page.dart index a7fc8e2..b900da2 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/with_isolate_page.dart +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/with_isolate_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'dart:math'; import 'dart:isolate'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class WithIsolatePage extends StatefulWidget { const WithIsolatePage({super.key}); diff --git a/apps/flutter_forge/lib/modules/async/isolate_basic/without_isolate_page.dart b/apps/flutter_forge/lib/modules/async/isolate_basic/without_isolate_page.dart index 9802625..eac06ab 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_basic/without_isolate_page.dart +++ b/apps/flutter_forge/lib/modules/async/isolate_basic/without_isolate_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:math'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class WithoutIsolatePage extends StatefulWidget { const WithoutIsolatePage({super.key}); diff --git a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md index b79d463..82104c0 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/isolate_task_manager/AI_ANALYSIS.md @@ -20,19 +20,15 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "children": [], "analysis_parent": "lib/modules/async/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/async/isolate_task_manager/module_root.dart b/apps/flutter_forge/lib/modules/async/isolate_task_manager/module_root.dart index a45dacc..5770f83 100644 --- a/apps/flutter_forge/lib/modules/async/isolate_task_manager/module_root.dart +++ b/apps/flutter_forge/lib/modules/async/isolate_task_manager/module_root.dart @@ -1,6 +1,6 @@ // ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'task_manager.dart'; diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md index 5dc06df..aaa44dc 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -29,12 +29,8 @@ "analysis_parent": "lib/modules/async/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart b/apps/flutter_forge/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart index 38d05cd..d9bcff7 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/pages/broadcast_demo_page.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class BroadcastDemoPage extends StatefulWidget { const BroadcastDemoPage({super.key}); diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/pages/home_page.dart b/apps/flutter_forge/lib/modules/async/stream_subscription/pages/home_page.dart index 33ccf5b..8ff250d 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/pages/home_page.dart +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/pages/home_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; class HomePage extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/async/stream_subscription/pages/stream_demo_page.dart b/apps/flutter_forge/lib/modules/async/stream_subscription/pages/stream_demo_page.dart index cefb32e..3d0e9ed 100644 --- a/apps/flutter_forge/lib/modules/async/stream_subscription/pages/stream_demo_page.dart +++ b/apps/flutter_forge/lib/modules/async/stream_subscription/pages/stream_demo_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'stream_demo_controller.dart'; diff --git a/apps/flutter_forge/lib/modules/basic/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/AI_ANALYSIS.md index ab7b80b..c62d1c9 100644 --- a/apps/flutter_forge/lib/modules/basic/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/AI_ANALYSIS.md @@ -18,7 +18,7 @@ ], "depends": [ "module_registry", - "flutter_study_learning" + "shared_learning" ], "children": [ "tree_state/AI_ANALYSIS.md", @@ -27,12 +27,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md index 290f163..0acab03 100644 --- a/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/debounce_throttle/AI_ANALYSIS.md @@ -20,19 +20,15 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "children": [], "analysis_parent": "lib/modules/basic/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/basic/debounce_throttle/module_root.dart b/apps/flutter_forge/lib/modules/basic/debounce_throttle/module_root.dart index cc550d1..4ec7972 100644 --- a/apps/flutter_forge/lib/modules/basic/debounce_throttle/module_root.dart +++ b/apps/flutter_forge/lib/modules/basic/debounce_throttle/module_root.dart @@ -1,6 +1,6 @@ // ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'utils/debounce_throttle.dart'; diff --git a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md index 8015f23..1aa12a4 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/microtask/AI_ANALYSIS.md @@ -22,7 +22,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -30,12 +30,8 @@ "analysis_parent": "lib/modules/basic/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/basic/microtask/pages/advanced_examples_page.dart b/apps/flutter_forge/lib/modules/basic/microtask/pages/advanced_examples_page.dart index 36c89e6..fc15bdb 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/pages/advanced_examples_page.dart +++ b/apps/flutter_forge/lib/modules/basic/microtask/pages/advanced_examples_page.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../models/event_log.dart'; import '../widgets/event_log_view.dart'; import '../widgets/code_snippet_view.dart'; diff --git a/apps/flutter_forge/lib/modules/basic/microtask/pages/event_queue_page.dart b/apps/flutter_forge/lib/modules/basic/microtask/pages/event_queue_page.dart index cd175cd..fb41ca8 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/pages/event_queue_page.dart +++ b/apps/flutter_forge/lib/modules/basic/microtask/pages/event_queue_page.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../models/event_log.dart'; import '../widgets/event_log_view.dart'; import '../widgets/code_snippet_view.dart'; diff --git a/apps/flutter_forge/lib/modules/basic/microtask/pages/home_page.dart b/apps/flutter_forge/lib/modules/basic/microtask/pages/home_page.dart index 5e157aa..4be8bd3 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/pages/home_page.dart +++ b/apps/flutter_forge/lib/modules/basic/microtask/pages/home_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; class HomePage extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/basic/microtask/pages/microtask_queue_page.dart b/apps/flutter_forge/lib/modules/basic/microtask/pages/microtask_queue_page.dart index 95fc702..bb3ba10 100644 --- a/apps/flutter_forge/lib/modules/basic/microtask/pages/microtask_queue_page.dart +++ b/apps/flutter_forge/lib/modules/basic/microtask/pages/microtask_queue_page.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../models/event_log.dart'; import '../widgets/event_log_view.dart'; import '../widgets/code_snippet_view.dart'; diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md index 752e914..270f77e 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/basic/tree_state/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -29,12 +29,8 @@ "analysis_parent": "lib/modules/basic/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/pages/basic_widgets_page.dart b/apps/flutter_forge/lib/modules/basic/tree_state/pages/basic_widgets_page.dart index 647e644..5ec5acc 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/pages/basic_widgets_page.dart +++ b/apps/flutter_forge/lib/modules/basic/tree_state/pages/basic_widgets_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; /// Stateless 与 Stateful 重建行为对比教学页 class BasicWidgetsPage extends StatefulWidget { diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/pages/demo_home_page.dart b/apps/flutter_forge/lib/modules/basic/tree_state/pages/demo_home_page.dart index 86e5e4c..6bf3bc0 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/pages/demo_home_page.dart +++ b/apps/flutter_forge/lib/modules/basic/tree_state/pages/demo_home_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../module_routes.dart'; diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/pages/painter_demo_page.dart b/apps/flutter_forge/lib/modules/basic/tree_state/pages/painter_demo_page.dart index d7e7859..414c380 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/pages/painter_demo_page.dart +++ b/apps/flutter_forge/lib/modules/basic/tree_state/pages/painter_demo_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; /// CustomPainter 的布局/重绘示例,日志标记了 build、shouldRepaint、paint 调用。 class PainterDemoPage extends StatefulWidget { diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart b/apps/flutter_forge/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart index 509f83d..d900a7f 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart +++ b/apps/flutter_forge/lib/modules/basic/tree_state/pages/repaint_boundary_demo_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; /// 对比使用/不使用 RepaintBoundary 时的重绘范围,方便解释 RenderObject 分叉。 class RepaintBoundaryDemoPage extends StatefulWidget { diff --git a/apps/flutter_forge/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart b/apps/flutter_forge/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart index 84af917..4f2a367 100644 --- a/apps/flutter_forge/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart +++ b/apps/flutter_forge/lib/modules/basic/tree_state/pages/state_lifecycle_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; /// 侧重打印 StatefulWidget 生命周期,配合 push/pop、setState 观察回调顺序。 class StateLifecyclePage extends StatefulWidget { diff --git a/apps/flutter_forge/lib/modules/platform/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/AI_ANALYSIS.md index b40aac3..82ae7a2 100644 --- a/apps/flutter_forge/lib/modules/platform/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/AI_ANALYSIS.md @@ -12,34 +12,33 @@ "dio_interceptor", "usb_detector", "file_picker", - "online_video_player" + "online_video_player", + "webview" ], "owns": [ "network_platform" ], "depends": [ "dio", - "usb_serial", "device_info_plus", "video_player", "video_player_win", - "flutter_study_learning", - "file_picker_bridge" + "shared_learning", + "file_picker_bridge", + "webview_flutter", + "webview_windows" ], "children": [ "dio_interceptor/AI_ANALYSIS.md", "usb_detector/AI_ANALYSIS.md", "file_picker/AI_ANALYSIS.md", - "online_video_player/AI_ANALYSIS.md" + "online_video_player/AI_ANALYSIS.md", + "webview/AI_ANALYSIS.md" ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md index 646096b..e3ff3b7 100644 --- a/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/dio_interceptor/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "dio", "module_registry", "go_router" @@ -30,12 +30,8 @@ "analysis_parent": "lib/modules/platform/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/home_page.dart b/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/home_page.dart index 033b384..b485868 100644 --- a/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/home_page.dart +++ b/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/home_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; import '../models/article.dart'; diff --git a/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/login_page.dart b/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/login_page.dart index d3f7542..2f5dcf2 100644 --- a/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/login_page.dart +++ b/apps/flutter_forge/lib/modules/platform/dio_interceptor/pages/login_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../network/api/api_service.dart'; import '../network/interceptor/auth_interceptor.dart'; diff --git a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md index dcf54c9..3a53b52 100644 --- a/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/file_picker/AI_ANALYSIS.md @@ -26,7 +26,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "file_picker_bridge", "module_registry" ], @@ -34,12 +34,8 @@ "analysis_parent": "lib/modules/platform/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/platform/file_picker/pages/file_picker_page.dart b/apps/flutter_forge/lib/modules/platform/file_picker/pages/file_picker_page.dart index 2fabf14..824ba3c 100644 --- a/apps/flutter_forge/lib/modules/platform/file_picker/pages/file_picker_page.dart +++ b/apps/flutter_forge/lib/modules/platform/file_picker/pages/file_picker_page.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../state/file_picker_controller.dart'; diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md index 4f3ede0..665c45d 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/AI_ANALYSIS.md @@ -26,7 +26,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "dio", "video_player", "video_player_win", @@ -36,12 +36,8 @@ "analysis_parent": "lib/modules/platform/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/platform/online_video_player/module_root.dart b/apps/flutter_forge/lib/modules/platform/online_video_player/module_root.dart index 6e5d743..7e5bf72 100644 --- a/apps/flutter_forge/lib/modules/platform/online_video_player/module_root.dart +++ b/apps/flutter_forge/lib/modules/platform/online_video_player/module_root.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:video_player/video_player.dart'; import 'state/video_player_adapter.dart'; diff --git a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md index 479a9b4..fd98ec6 100644 --- a/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/platform/usb_detector/AI_ANALYSIS.md @@ -23,7 +23,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "device_info_plus", "module_registry" ], @@ -31,12 +31,8 @@ "analysis_parent": "lib/modules/platform/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/platform/usb_detector/module_root.dart b/apps/flutter_forge/lib/modules/platform/usb_detector/module_root.dart index f16b078..dd62156 100644 --- a/apps/flutter_forge/lib/modules/platform/usb_detector/module_root.dart +++ b/apps/flutter_forge/lib/modules/platform/usb_detector/module_root.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'models/usb_device_info.dart'; import 'services/usb_detection_service.dart'; @@ -91,7 +91,10 @@ class _MyHomePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Icon( _isInitialized @@ -99,7 +102,6 @@ class _MyHomePageState extends State { : Icons.error_outline, color: _isInitialized ? Colors.green : Colors.red, ), - const SizedBox(width: 8), Text( 'USB驱动状态', style: Theme.of(context).textTheme.headlineSmall, diff --git a/apps/flutter_forge/lib/modules/platform/usb_detector/services/usb_detection_service.dart b/apps/flutter_forge/lib/modules/platform/usb_detector/services/usb_detection_service.dart index f0ed56e..3aa53a2 100644 --- a/apps/flutter_forge/lib/modules/platform/usb_detector/services/usb_detection_service.dart +++ b/apps/flutter_forge/lib/modules/platform/usb_detector/services/usb_detection_service.dart @@ -80,12 +80,12 @@ class UsbDetectionService { UsbDeviceInfo deviceInfo = UsbDeviceInfo( vendorId: (device['vendorId'] as num?)?.toInt() ?? 0, productId: (device['productId'] as num?)?.toInt() ?? 0, - manufacturer: device['manufacturer'] as String?, - product: (device['product'] ?? device['name']) as String?, - serialNumber: device['serialNumber'] as String?, - platformDeviceId: device['id'] as String?, - bus: device['bus'] as String?, - port: device['port'] as String?, + manufacturer: device['manufacturer']?.toString(), + product: (device['product'] ?? device['name'])?.toString(), + serialNumber: device['serialNumber']?.toString(), + platformDeviceId: device['id']?.toString(), + bus: device['bus']?.toString(), + port: device['port']?.toString(), status: UsbDeviceStatus.connected, ); diff --git a/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md new file mode 100644 index 0000000..6a00bb3 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/AI_ANALYSIS.md @@ -0,0 +1,43 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "module_contract", + "node": { + "id": "flutter_forge_app.modules.platform.webview", + "kind": "learning_module", + "package": "flutter_forge_app", + "path": "lib/modules/platform/webview", + "status": "ready" + }, + "route": "/webview", + "category": "platform", + "supported_platforms": [ + "android", + "macOS", + "windows" + ], + "entrypoints": [ + "module_entry.dart", + "module_root.dart" + ], + "owns": [ + "module_entry", + "module_ui", + "module_docs" + ], + "depends": [ + "shared_learning", + "module_registry", + "webview_flutter", + "webview_windows" + ], + "children": [], + "analysis_parent": "lib/modules/platform/AI_ANALYSIS.md", + "contracts": { + "no_natural_language": true, + "doc_consumer": "coding_agent", + "doc_mode": "machine_contract" + }, + "validation": [ + "flutter analyze" + ] +} diff --git a/apps/flutter_forge/lib/modules/platform/webview/SOURCE.md b/apps/flutter_forge/lib/modules/platform/webview/SOURCE.md new file mode 100644 index 0000000..4aa58fb --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/SOURCE.md @@ -0,0 +1,19 @@ +# Source provenance + +Integrated from https://github.com/lizy-coding/webview_plugin at commit +1e160be430a55612bd1c1711f56be7ed94a4957b (package name: webview_continer). +Source snapshot was downloaded before integration. + +The src Android/Windows backends, unified controller operations and loading manager +were adapted into this Forge-owned module. Duplicate legacy wrappers and standalone +application hosts are not retained. Navigation controls, the 30% / 3-second reveal, +300ms fade and Windows estimated progress retain the original behavior. +Windows now uses native loading events instead of a page-injected message listener. +Subscriptions, timers and asynchronous shutdown are owned by the module session. +No external repository dependency remains for this wrapper; platform engines remain +normal pub dependencies. Upstream did not include a LICENSE file in this snapshot. + +Forge now also enables macOS through the registered webview_flutter WKWebView +implementation. Android and macOS share WebViewFlutterBackend; Windows retains +its WebView2 backend. This is a Forge extension beyond the original wrapper's +Android/Windows platform selector. diff --git a/apps/flutter_forge/lib/modules/platform/webview/core/webview_backend.dart b/apps/flutter_forge/lib/modules/platform/webview/core/webview_backend.dart new file mode 100644 index 0000000..e458004 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/core/webview_backend.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; + +enum WebViewEventKind { started, progress, finished, error } + +class WebViewEvent { + const WebViewEvent(this.kind, {this.url, this.progress = 0, this.message}); + final WebViewEventKind kind; + final String? url; + final double progress; + final String? message; +} + +bool isWebUrl(String value) { + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'https' || uri.scheme == 'http') && + uri.host.isNotEmpty && + uri.userInfo.isEmpty; +} + +abstract class WebViewBackend { + Stream get events; + Future initialize(); + Widget buildView(); + Future loadUrl(String url); + Future canGoBack(); + Future canGoForward(); + Future goBack(); + Future goForward(); + Future reload(); + Future dispose(); +} diff --git a/apps/flutter_forge/lib/modules/platform/webview/core/webview_session.dart b/apps/flutter_forge/lib/modules/platform/webview/core/webview_session.dart new file mode 100644 index 0000000..44775e0 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/core/webview_session.dart @@ -0,0 +1,149 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'webview_backend.dart'; + +// Adapted from webview_plugin's loading manager and navigation controller. +class WebViewSession extends ChangeNotifier { + WebViewSession(this.backend, {this.timeout = const Duration(seconds: 3)}); + final WebViewBackend backend; + final Duration timeout; + StreamSubscription? _subscription; + Timer? _timer; + bool _disposed = false; + bool initialized = false; + bool busy = false; + bool contentVisible = false; + bool loading = false; + bool canBack = false; + bool canForward = false; + double progress = 0; + String url = 'https://example.com'; + String? error; + int _generation = 0; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + Future start() async { + if (_disposed || busy || initialized) return; + busy = true; + error = null; + _notify(); + _subscription ??= backend.events.listen( + _event, + onError: (Object e) => _fail(e), + ); + try { + await backend.initialize(); + if (_disposed) return; + initialized = true; + busy = false; + await navigate(url); + } catch (e) { + _fail(e); + } finally { + busy = false; + _notify(); + } + } + + void _begin() { + _generation++; + error = null; + progress = 0; + contentVisible = false; + loading = true; + _timer?.cancel(); + _timer = Timer(timeout, () { + if (_disposed) return; + contentVisible = true; + _notify(); + }); + _notify(); + } + + void _fail(Object e) { + if (_disposed) return; + _timer?.cancel(); + error = e.toString(); + loading = false; + _notify(); + } + + void _event(WebViewEvent event) { + if (_disposed) return; + if (event.url != null) url = event.url!; + switch (event.kind) { + case WebViewEventKind.started: + _begin(); + case WebViewEventKind.progress: + progress = event.progress.clamp(0, 1); + if (progress >= 0.3) contentVisible = true; + case WebViewEventKind.finished: + _timer?.cancel(); + progress = 1; + loading = false; + contentVisible = true; + unawaited(_history()); + case WebViewEventKind.error: + _fail(event.message ?? '网页加载失败'); + } + _notify(); + } + + Future _history() async { + final generation = _generation; + try { + final back = await backend.canGoBack(); + final forward = await backend.canGoForward(); + if (_disposed || generation != _generation) return; + canBack = back; + canForward = forward; + _notify(); + } catch (e) { + _fail(e); + } + } + + Future navigate(String value) async { + if (_disposed || !initialized) return; + if (!isWebUrl(value.trim())) { + _fail('请输入有效的 http 或 https 地址'); + return; + } + url = value.trim(); + _begin(); + await _command(() => backend.loadUrl(url)); + } + + Future _command(Future Function() action) async { + if (_disposed || !initialized) return; + try { + await action(); + } catch (e) { + _fail(e); + } + } + + Future back() => _command(backend.goBack); + Future forward() => _command(backend.goForward); + Future reload() async { + if (_disposed || !initialized) return; + _begin(); + await _command(backend.reload); + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + unawaited(_subscription?.cancel()); + unawaited( + backend.dispose().catchError((Object e) { + debugPrint('WebView cleanup failed: $e'); + }), + ); + super.dispose(); + } +} diff --git a/apps/flutter_forge/lib/modules/platform/webview/module_entry.dart b/apps/flutter_forge/lib/modules/platform/webview/module_entry.dart new file mode 100644 index 0000000..0ef20b4 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/module_entry.dart @@ -0,0 +1,8 @@ +import 'package:flutter/widgets.dart'; +import 'module_root.dart'; + +class WebViewEntry extends StatelessWidget { + const WebViewEntry({super.key}); + @override + Widget build(BuildContext context) => const WebViewPage(); +} diff --git a/apps/flutter_forge/lib/modules/platform/webview/module_root.dart b/apps/flutter_forge/lib/modules/platform/webview/module_root.dart new file mode 100644 index 0000000..cabe587 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/module_root.dart @@ -0,0 +1,159 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import '../../../shared/learning/learning_scaffold.dart'; +import 'core/webview_backend.dart'; +import 'core/webview_session.dart'; +import 'platforms/webview_flutter_backend.dart'; +import 'platforms/webview2_backend.dart'; + +class WebViewPage extends StatefulWidget { + const WebViewPage({super.key, this.backend}); + final WebViewBackend? backend; + @override + State createState() => _WebViewPageState(); +} + +class _WebViewPageState extends State { + WebViewSession? _session; + final _address = TextEditingController(text: 'https://example.com'); + @override + void initState() { + super.initState(); + final backend = + widget.backend ?? + (kIsWeb + ? null + : switch (defaultTargetPlatform) { + TargetPlatform.android || + TargetPlatform.macOS => WebViewFlutterBackend(), + TargetPlatform.windows => WebView2Backend(), + _ => null, + }); + if (backend != null) { + _session = WebViewSession(backend); + _session!.start(); + } + } + + @override + void dispose() { + _session?.dispose(); + _address.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => LearningScaffold( + title: '网页容器与跨平台导航', + interactiveDemo: _session == null + ? const Text('当前平台不可用:仅支持 Android、macOS 和 Windows') + : AnimatedBuilder( + animation: _session!, + builder: (context, child) { + final session = _session!; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _address, + key: const ValueKey('webview-url'), + decoration: const InputDecoration( + labelText: '网页地址(http / https)', + ), + onSubmitted: session.initialized ? session.navigate : null, + ), + Wrap( + spacing: 8, + children: [ + TextButton( + onPressed: session.initialized + ? () => session.navigate(_address.text) + : null, + child: const Text('打开'), + ), + IconButton( + tooltip: '网页后退', + onPressed: session.canBack ? session.back : null, + icon: const Icon(Icons.arrow_back), + ), + IconButton( + tooltip: '网页前进', + onPressed: session.canForward ? session.forward : null, + icon: const Icon(Icons.arrow_forward), + ), + IconButton( + tooltip: '刷新网页', + onPressed: session.initialized ? session.reload : null, + icon: const Icon(Icons.refresh), + ), + ], + ), + Text( + session.url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (session.loading) + LinearProgressIndicator( + value: session.progress > 0 ? session.progress : null, + ), + if (session.error != null) ...[ + Text(session.error!, key: const ValueKey('webview-error')), + TextButton( + onPressed: session.busy + ? null + : (session.initialized + ? session.reload + : session.start), + child: const Text('重试'), + ), + ], + SizedBox( + height: 320, + child: Stack( + fit: StackFit.expand, + children: [ + if (session.initialized) + AnimatedOpacity( + opacity: session.contentVisible ? 1 : 0, + duration: const Duration(milliseconds: 300), + child: session.backend.buildView(), + ), + if (!session.contentVisible && session.error == null) + const ColoredBox( + color: Color(0xffeeeeee), + child: Center(child: Text('正在加载网页…')), + ), + ], + ), + ), + ], + ); + }, + ), + sections: const [ + LearningObjectives( + objectives: [ + '比较 Android WebView、macOS WKWebView 与 Windows WebView2', + '掌握网页前进、后退与加载状态管理', + '退出页面时释放控制器、订阅和计时器', + ], + ), + ConceptChips(concepts: ['Platform View', 'WebView2', '异步生命周期', '导航安全']), + CodeSnippetCard( + title: '统一网页导航', + code: + 'await backend.loadUrl(url);\nawait backend.goBack();\nawait backend.reload();', + explanation: 'Forge 管理模块入口和平台可用性;模块使用统一接口隔离原生 WebView。', + ), + CommonPitfalls( + pitfalls: [ + 'Windows 需要安装 WebView2 Runtime。', + 'Windows 进度为估算;30% 或 3 秒只控制内容显示,不代表加载成功。', + '模块始终在现有页面中运行,不创建业务窗口。', + ], + ), + ExerciseCard(task: '打开两个网页,观察后退、前进、刷新和无效地址提示,再退出并重新进入。'), + ], + ); +} diff --git a/apps/flutter_forge/lib/modules/platform/webview/platforms/webview2_backend.dart b/apps/flutter_forge/lib/modules/platform/webview/platforms/webview2_backend.dart new file mode 100644 index 0000000..8b678c4 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/platforms/webview2_backend.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:webview_windows/webview_windows.dart'; +import '../core/webview_backend.dart'; + +class WebView2Backend implements WebViewBackend { + final _controller = WebviewController(); + final _events = StreamController.broadcast(); + final List> _subscriptions = []; + bool _disposed = false; + bool _ready = false; + bool _back = false; + bool _forward = false; + String _url = ''; + Timer? _progressTimer; + @override + Stream get events => _events.stream; + void _emit(WebViewEvent e) { + if (!_disposed) _events.add(e); + } + + @override + Future initialize() async { + await _controller.initialize(); + if (_disposed) { + await _controller.dispose(); + return; + } + _ready = true; + await _controller.setPopupWindowPolicy(WebviewPopupWindowPolicy.deny); + if (_disposed) return; + _subscriptions.add( + _controller.url.listen((url) { + _url = url; + }), + ); + _subscriptions.add( + _controller.historyChanged.listen((history) { + _back = history.canGoBack; + _forward = history.canGoForward; + }), + ); + _subscriptions.add( + _controller.onLoadError.listen((error) { + _progressTimer?.cancel(); + _emit(WebViewEvent(WebViewEventKind.error, message: error.name)); + }), + ); + _subscriptions.add( + _controller.loadingState.listen((state) { + _progressTimer?.cancel(); + if (state == LoadingState.loading) { + _emit(WebViewEvent(WebViewEventKind.started, url: _url)); + double progress = 0.1; + _progressTimer = Timer.periodic(const Duration(milliseconds: 200), ( + timer, + ) { + progress = (progress + 0.03).clamp(0, 0.95); + _emit(WebViewEvent(WebViewEventKind.progress, progress: progress)); + if (progress >= 0.95) timer.cancel(); + }); + } else if (state == LoadingState.navigationCompleted) { + _emit(WebViewEvent(WebViewEventKind.finished, url: _url)); + } + }), + ); + } + + @override + Widget buildView() => Webview( + _controller, + permissionRequested: (url, kind, initiated) async => + WebviewPermissionDecision.deny, + ); + @override + Future loadUrl(String url) { + _url = url; + return _controller.loadUrl(url); + } + + @override + Future canGoBack() async => _back; + @override + Future canGoForward() async => _forward; + @override + Future goBack() => _controller.goBack(); + @override + Future goForward() => _controller.goForward(); + @override + Future reload() => _controller.reload(); + @override + Future dispose() async { + _disposed = true; + _progressTimer?.cancel(); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + if (_ready) await _controller.dispose(); + await _events.close(); + } +} diff --git a/apps/flutter_forge/lib/modules/platform/webview/platforms/webview_flutter_backend.dart b/apps/flutter_forge/lib/modules/platform/webview/platforms/webview_flutter_backend.dart new file mode 100644 index 0000000..8ef2fa4 --- /dev/null +++ b/apps/flutter_forge/lib/modules/platform/webview/platforms/webview_flutter_backend.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import '../core/webview_backend.dart'; + +// webview_flutter selects Android WebView or macOS WKWebView at registration. +class WebViewFlutterBackend implements WebViewBackend { + final _events = StreamController.broadcast(); + WebViewController? _controller; + bool _disposed = false; + @override + Stream get events => _events.stream; + void _emit(WebViewEvent event) { + if (!_disposed) _events.add(event); + } + + @override + Future initialize() async { + final controller = WebViewController(); + _controller = controller; + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + if (_disposed) return; + await controller.setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) => isWebUrl(request.url) + ? NavigationDecision.navigate + : NavigationDecision.prevent, + onPageStarted: (url) => + _emit(WebViewEvent(WebViewEventKind.started, url: url)), + onProgress: (progress) => _emit( + WebViewEvent(WebViewEventKind.progress, progress: progress / 100), + ), + onPageFinished: (url) => + _emit(WebViewEvent(WebViewEventKind.finished, url: url)), + onWebResourceError: (error) { + if (error.isForMainFrame != false) { + _emit( + WebViewEvent(WebViewEventKind.error, message: error.description), + ); + } + }, + ), + ); + } + + @override + Widget buildView() => WebViewWidget(controller: _controller!); + @override + Future loadUrl(String url) => _controller!.loadRequest(Uri.parse(url)); + @override + Future canGoBack() => _controller!.canGoBack(); + @override + Future canGoForward() => _controller!.canGoForward(); + @override + Future goBack() => _controller!.goBack(); + @override + Future goForward() => _controller!.goForward(); + @override + Future reload() => _controller!.reload(); + @override + Future dispose() async { + _disposed = true; + // webview_flutter owns native disposal through WebViewWidget unmount. + await _events.close(); + } +} diff --git a/apps/flutter_forge/lib/modules/popup_table/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/AI_ANALYSIS.md index aa05a29..2d8bb57 100644 --- a/apps/flutter_forge/lib/modules/popup_table/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/AI_ANALYSIS.md @@ -19,7 +19,7 @@ ], "depends": [ "module_registry", - "flutter_study_learning", + "shared_learning", "two_dimensional_scrollables" ], "children": [ @@ -30,12 +30,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md index 31ded0c..7e57afa 100644 --- a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/AI_ANALYSIS.md @@ -21,19 +21,15 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "children": [], "analysis_parent": "lib/modules/popup_table/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/module_root.dart b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/module_root.dart index dd8578f..4148215 100644 --- a/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/module_root.dart +++ b/apps/flutter_forge/lib/modules/popup_table/overlay_follow_compare/module_root.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'widgets/compare_panel.dart'; import 'widgets/follower_demo.dart'; diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md index a938f05..f94653c 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/AI_ANALYSIS.md @@ -22,7 +22,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -30,12 +30,8 @@ "analysis_parent": "lib/modules/popup_table/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/module_root.dart b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/module_root.dart index 4f2e4d0..f698ae1 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/module_root.dart +++ b/apps/flutter_forge/lib/modules/popup_table/popup_list_interaction/module_root.dart @@ -1,6 +1,6 @@ // ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; import 'module_routes.dart'; diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md index 70f433f..ff5903e 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/AI_ANALYSIS.md @@ -21,19 +21,15 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry" ], "children": [], "analysis_parent": "lib/modules/popup_table/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/module_root.dart b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/module_root.dart index 30005fb..7d31bb9 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/module_root.dart +++ b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/module_root.dart @@ -1,6 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'widgets/bottom_sheet_demo.dart'; import 'widgets/demo_section.dart'; diff --git a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart index 6f36706..b19a0d7 100644 --- a/apps/flutter_forge/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart +++ b/apps/flutter_forge/lib/modules/popup_table/popup_widgets/widgets/learning_content.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; List buildPopupLearningSections() { return const [ diff --git a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md index 7463ac9..4fddfaa 100644 --- a/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/popup_table/scroll_table/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "two_dimensional_scrollables", "module_registry" ], @@ -29,12 +29,8 @@ "analysis_parent": "lib/modules/popup_table/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/popup_table/scroll_table/module_root.dart b/apps/flutter_forge/lib/modules/popup_table/scroll_table/module_root.dart index f32644f..c0f998c 100644 --- a/apps/flutter_forge/lib/modules/popup_table/scroll_table/module_root.dart +++ b/apps/flutter_forge/lib/modules/popup_table/scroll_table/module_root.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'widgets/scroll_table.dart'; diff --git a/apps/flutter_forge/lib/modules/state/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/AI_ANALYSIS.md index 11cfb7f..a2be89a 100644 --- a/apps/flutter_forge/lib/modules/state/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/AI_ANALYSIS.md @@ -30,12 +30,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md index dc8bd72..2df4a04 100644 --- a/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/flutter_ioc/AI_ANALYSIS.md @@ -20,7 +20,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "flutter_ioc_core", "provider", "module_registry" @@ -29,12 +29,8 @@ "analysis_parent": "lib/modules/state/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/state/flutter_ioc/module_root.dart b/apps/flutter_forge/lib/modules/state/flutter_ioc/module_root.dart index 11d42b2..53b22aa 100644 --- a/apps/flutter_forge/lib/modules/state/flutter_ioc/module_root.dart +++ b/apps/flutter_forge/lib/modules/state/flutter_ioc/module_root.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:provider/provider.dart'; import 'model/counter_model.dart'; diff --git a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md index 51c2509..003f27d 100644 --- a/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/local_persistence/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "shared_preferences", "module_registry" ], @@ -29,12 +29,8 @@ "analysis_parent": "lib/modules/state/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/state/local_persistence/module_root.dart b/apps/flutter_forge/lib/modules/state/local_persistence/module_root.dart index 2e34d7b..7d89f1b 100644 --- a/apps/flutter_forge/lib/modules/state/local_persistence/module_root.dart +++ b/apps/flutter_forge/lib/modules/state/local_persistence/module_root.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'services/preferences_service.dart'; import 'state/persistence_demo_controller.dart'; diff --git a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md index 4ccbc37..b8226d2 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/state/status_management/AI_ANALYSIS.md @@ -22,7 +22,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "provider", "flutter_riverpod", "flutter_bloc", @@ -33,12 +33,8 @@ "analysis_parent": "lib/modules/state/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/bloc/bloc_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/bloc/bloc_route.dart index 99a5ae1..e6fa05c 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/bloc/bloc_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/bloc/bloc_route.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../../widgets/state_flow_demo.dart'; import 'counter_bloc.dart'; diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/home_page.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/home_page.dart index 07c67ce..e942f6d 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/home_page.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/home_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; const String _statusManageBaseRoute = '/status-management'; @@ -184,35 +184,59 @@ class _RouteCategoryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( + LayoutBuilder( + builder: (context, constraints) { + final heading = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(category.title, style: theme.textTheme.titleLarge), + const SizedBox(height: 6), + Text( + category.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.black54, + ), + ), + ], + ); + final badge = Chip( + avatar: const Icon(Icons.filter_alt_outlined, size: 16), + label: Text(category.badge), + ); + final avatar = CircleAvatar( radius: 26, backgroundColor: theme.colorScheme.primaryContainer, child: Icon(category.icon, color: theme.colorScheme.primary), - ), - const SizedBox(width: 16), - Expanded( - child: Column( + ); + + if (constraints.maxWidth < 420) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(category.title, style: theme.textTheme.titleLarge), - const SizedBox(height: 6), - Text( - category.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: Colors.black54, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + avatar, + const SizedBox(width: 12), + Expanded(child: heading), + ], ), + const SizedBox(height: 8), + badge, ], - ), - ), - Chip( - avatar: const Icon(Icons.filter_alt_outlined, size: 16), - label: Text(category.badge), - ), - ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + avatar, + const SizedBox(width: 16), + Expanded(child: heading), + badge, + ], + ); + }, ), const SizedBox(height: 12), Column( @@ -238,27 +262,45 @@ class _RouteListTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ListTile( - onTap: () => context.push('$_statusManageBaseRoute${data.routeName}'), - contentPadding: EdgeInsets.zero, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - leading: CircleAvatar( - radius: 22, - backgroundColor: theme.colorScheme.secondaryContainer, - child: Icon(data.icon, color: theme.colorScheme.secondary), - ), - title: Text(data.title, style: theme.textTheme.titleMedium), - subtitle: Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - '刷新链路:${data.flow}', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.black54), - ), - ), - trailing: Chip( - avatar: const Icon(Icons.visibility, size: 16), - label: Text(data.chipLabel), - ), + return LayoutBuilder( + builder: (context, constraints) { + final chip = Chip( + avatar: const Icon(Icons.visibility, size: 16), + label: Text(data.chipLabel), + ); + final compact = constraints.maxWidth < 360; + final subtitle = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '刷新链路:${data.flow}', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.black54, + ), + ), + if (compact) chip, + ], + ); + + return ListTile( + onTap: () => context.push('$_statusManageBaseRoute${data.routeName}'), + contentPadding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + leading: CircleAvatar( + radius: 22, + backgroundColor: theme.colorScheme.secondaryContainer, + child: Icon(data.icon, color: theme.colorScheme.secondary), + ), + title: Text(data.title, style: theme.textTheme.titleMedium), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: subtitle, + ), + trailing: compact ? null : chip, + ); + }, ); } } diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_future_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_future_route.dart index 5523362..57137af 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_future_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_future_route.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:provider/provider.dart'; class ProviderFutureRoute extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart index f1991dc..97265be 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_lifting_route.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:provider/provider.dart'; class ProviderLiftingRoute extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_route.dart index baf7719..15c3b68 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_route.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:provider/provider.dart'; import '../../widgets/state_flow_demo.dart'; diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_todo_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_todo_route.dart index 717f18e..3a47b1d 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_todo_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/provider/provider_todo_route.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:provider/provider.dart'; class ProviderTodoRoute extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart index b16fce0..754e413 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_future_route.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class RiverpodFutureRoute extends ConsumerWidget { const RiverpodFutureRoute({super.key}); diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart index 86d9c63..fa0d5a3 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class RiverpodLiftingRoute extends ConsumerWidget { const RiverpodLiftingRoute({super.key}); @@ -68,15 +68,16 @@ class _LControls extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final n = ref.read(_liftProvider.notifier); - return Row( - mainAxisAlignment: MainAxisAlignment.center, + return Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 8, children: [ FilledButton.icon( onPressed: n.inc, icon: const Icon(Icons.exposure_plus_1), label: const Text('加 1'), ), - const SizedBox(width: 12), OutlinedButton.icon( onPressed: n.reset, icon: const Icon(Icons.restart_alt), diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart index b8e1e77..d78831f 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_route.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../../widgets/state_flow_demo.dart'; diff --git a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart index 303e031..0f4d5f3 100644 --- a/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart +++ b/apps/flutter_forge/lib/modules/state/status_management/pages/riverpod/riverpod_todo_route.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class RiverpodTodoRoute extends ConsumerWidget { const RiverpodTodoRoute({super.key}); diff --git a/apps/flutter_forge/lib/modules/ui/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/AI_ANALYSIS.md index faf60d7..7d0f418 100644 --- a/apps/flutter_forge/lib/modules/ui/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/AI_ANALYSIS.md @@ -21,7 +21,7 @@ "provider", "gcode_core", "file_picker_bridge", - "flutter_study_learning", + "shared_learning", "module_registry" ], "children": [ @@ -32,12 +32,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md index e896206..d02bd55 100644 --- a/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/adsorption_line/AI_ANALYSIS.md @@ -22,7 +22,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "provider", "module_registry" ], @@ -30,12 +30,8 @@ "analysis_parent": "lib/modules/ui/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart b/apps/flutter_forge/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart index 7f21779..a7df21b 100644 --- a/apps/flutter_forge/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart +++ b/apps/flutter_forge/lib/modules/ui/adsorption_line/pages/adsorption_line_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../state/drawing_state.dart'; import '../widgets/drawing_board.dart'; diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md index e58027c..bb94f00 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/download_animation/AI_ANALYSIS.md @@ -22,7 +22,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "module_registry", "go_router" ], @@ -30,12 +30,8 @@ "analysis_parent": "lib/modules/ui/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/module_root.dart b/apps/flutter_forge/lib/modules/ui/download_animation/module_root.dart index fe71a10..1721067 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/module_root.dart +++ b/apps/flutter_forge/lib/modules/ui/download_animation/module_root.dart @@ -1,6 +1,6 @@ // ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; class HomePage extends StatelessWidget { diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_animation_page.dart b/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_animation_page.dart index f9a256b..d067e07 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_animation_page.dart +++ b/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_animation_page.dart @@ -3,7 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../models/animation_config.dart'; import '../models/download_item.dart'; diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_comparison_page.dart b/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_comparison_page.dart index a268940..d67bfed 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_comparison_page.dart +++ b/apps/flutter_forge/lib/modules/ui/download_animation/pages/download_comparison_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'dart:math' as math; import '../models/download_item.dart'; diff --git a/apps/flutter_forge/lib/modules/ui/download_animation/pages/paint_animation_page.dart b/apps/flutter_forge/lib/modules/ui/download_animation/pages/paint_animation_page.dart index 84ae13f..8acd5e2 100644 --- a/apps/flutter_forge/lib/modules/ui/download_animation/pages/paint_animation_page.dart +++ b/apps/flutter_forge/lib/modules/ui/download_animation/pages/paint_animation_page.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import '../models/animation_config.dart'; diff --git a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md index d8296eb..4fbf423 100644 --- a/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/font_picker/AI_ANALYSIS.md @@ -24,7 +24,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "file_picker_bridge", "module_registry", "go_router" @@ -33,12 +33,8 @@ "analysis_parent": "lib/modules/ui/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/ui/font_picker/pages/font_picker_page.dart b/apps/flutter_forge/lib/modules/ui/font_picker/pages/font_picker_page.dart index f1b209c..d362ddf 100644 --- a/apps/flutter_forge/lib/modules/ui/font_picker/pages/font_picker_page.dart +++ b/apps/flutter_forge/lib/modules/ui/font_picker/pages/font_picker_page.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:file_picker_bridge/file_picker_bridge.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:go_router/go_router.dart'; import '../data/font_catalog.dart'; diff --git a/apps/flutter_forge/lib/modules/ui/font_picker/pages/weight_compare_page.dart b/apps/flutter_forge/lib/modules/ui/font_picker/pages/weight_compare_page.dart index f33e672..cf91f9a 100644 --- a/apps/flutter_forge/lib/modules/ui/font_picker/pages/weight_compare_page.dart +++ b/apps/flutter_forge/lib/modules/ui/font_picker/pages/weight_compare_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; class WeightComparePage extends StatefulWidget { const WeightComparePage({super.key}); diff --git a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md index cb51641..75e0b5c 100644 --- a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/AI_ANALYSIS.md @@ -10,6 +10,9 @@ }, "route": "/gcode-visualizer", "category": "ui", + "supported_platforms": [ + "macOS" + ], "entrypoints": [ "module_entry.dart", "pages", @@ -22,7 +25,7 @@ "module_docs" ], "depends": [ - "flutter_study_learning", + "shared_learning", "gcode_core", "file_picker_bridge", "module_registry" @@ -31,12 +34,8 @@ "analysis_parent": "lib/modules/ui/AI_ANALYSIS.md", "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart index 9bf13ee..975a3c4 100644 --- a/apps/flutter_forge/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart +++ b/apps/flutter_forge/lib/modules/ui/gcode_visualizer/pages/gcode_visualizer_page.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; import 'package:gcode_core/gcode_core.dart'; import '../widgets/current_segment_inspector.dart'; diff --git a/apps/flutter_forge/lib/shared/AI_ANALYSIS.md b/apps/flutter_forge/lib/shared/AI_ANALYSIS.md index 35c1c2d..cd25989 100644 --- a/apps/flutter_forge/lib/shared/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/shared/AI_ANALYSIS.md @@ -27,12 +27,8 @@ ], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/packages/flutter_study_learning/lib/src/learning_scaffold.dart b/apps/flutter_forge/lib/shared/learning/learning_scaffold.dart similarity index 91% rename from packages/flutter_study_learning/lib/src/learning_scaffold.dart rename to apps/flutter_forge/lib/shared/learning/learning_scaffold.dart index 3875f0b..52c85c5 100644 --- a/packages/flutter_study_learning/lib/src/learning_scaffold.dart +++ b/apps/flutter_forge/lib/shared/learning/learning_scaffold.dart @@ -48,8 +48,10 @@ class ConceptChips extends StatelessWidget { children: concepts .map( (c) => Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16), @@ -124,8 +126,11 @@ class CodeSnippetCard extends StatelessWidget { ), if (explanation != null) Expanded( - child: Text(explanation!, - style: const TextStyle(fontSize: 12))), + child: Text( + explanation!, + style: const TextStyle(fontSize: 12), + ), + ), ], ), ], @@ -186,8 +191,11 @@ class CommonPitfalls extends StatelessWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.warning_amber, - size: 18, color: Colors.orange), + const Icon( + Icons.warning_amber, + size: 18, + color: Colors.orange, + ), const SizedBox(width: 8), Expanded(child: Text(p)), ], @@ -244,9 +252,9 @@ class _Section extends StatelessWidget { children: [ Text( title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), child, @@ -285,8 +293,9 @@ class LearningScaffold extends StatelessWidget { padding: const EdgeInsets.all(16), child: Card( child: Padding( - padding: const EdgeInsets.all(16), - child: interactiveDemo), + padding: const EdgeInsets.all(16), + child: interactiveDemo, + ), ), ), const Divider(), diff --git a/apps/flutter_forge/lib/shared/multi_window/AI_ANALYSIS.md b/apps/flutter_forge/lib/shared/multi_window/AI_ANALYSIS.md index 2ccf929..06cd8d4 100644 --- a/apps/flutter_forge/lib/shared/multi_window/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/shared/multi_window/AI_ANALYSIS.md @@ -24,12 +24,8 @@ "children": [], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze" diff --git a/apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md b/apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md index 82b6d88..64b543e 100644 --- a/apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md +++ b/apps/flutter_forge/lib/shared/platform/AI_ANALYSIS.md @@ -22,12 +22,8 @@ "children": [], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter analyze", diff --git a/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift index fec1a7b..0154f02 100644 --- a/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter_forge/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import device_info_plus import file_selector_macos import shared_preferences_foundation import video_player_avfoundation +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin")) @@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/apps/flutter_forge/macos/Podfile b/apps/flutter_forge/macos/Podfile index ff5ddb3..167132a 100644 --- a/apps/flutter_forge/macos/Podfile +++ b/apps/flutter_forge/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/apps/flutter_forge/macos/Podfile.lock b/apps/flutter_forge/macos/Podfile.lock index 835e1b1..6e23284 100644 --- a/apps/flutter_forge/macos/Podfile.lock +++ b/apps/flutter_forge/macos/Podfile.lock @@ -12,6 +12,9 @@ PODS: - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`) @@ -20,6 +23,7 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) EXTERNAL SOURCES: desktop_multi_window: @@ -34,15 +38,18 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin video_player_avfoundation: :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin SPEC CHECKSUMS: desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + FlutterMacOS: c232990155153907050900a2e175c7773903ba4e shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 + webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 +PODFILE CHECKSUM: 1e95c36afbfd1cb6423ceca4de7a8e1b256fb6ac COCOAPODS: 1.17.0 diff --git a/apps/flutter_forge/macos/Runner.xcodeproj/project.pbxproj b/apps/flutter_forge/macos/Runner.xcodeproj/project.pbxproj index a90baa5..5749f92 100644 --- a/apps/flutter_forge/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/flutter_forge/macos/Runner.xcodeproj/project.pbxproj @@ -393,7 +393,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && /bin/sh \"$PROJECT_DIR/../tool/macos/flutter_assemble.sh\" embed\n"; }; 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -413,7 +413,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + shellScript = "/bin/sh \"$PROJECT_DIR/../tool/macos/flutter_assemble.sh\" && touch Flutter/ephemeral/tripwire"; }; BFC7F6ACF15C242701B9FB10 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -570,7 +570,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -652,7 +652,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -702,7 +702,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/apps/flutter_forge/macos/Runner/Info.plist b/apps/flutter_forge/macos/Runner/Info.plist index 4789daa..7641f8d 100644 --- a/apps/flutter_forge/macos/Runner/Info.plist +++ b/apps/flutter_forge/macos/Runner/Info.plist @@ -2,6 +2,10 @@ + FLTEnableImpeller + + FLTEnableFlutterGPU + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/apps/flutter_forge/pubspec.yaml b/apps/flutter_forge/pubspec.yaml index b981930..af8a120 100644 --- a/apps/flutter_forge/pubspec.yaml +++ b/apps/flutter_forge/pubspec.yaml @@ -1,18 +1,19 @@ name: flutter_forge_app description: Flutter Forge app shell. publish_to: 'none' -version: 1.2.3 +version: 1.2.6 environment: sdk: ^3.11.5 + flutter: '>=3.47.2' resolution: workspace dependencies: gcode_core: - path: ../../packages/gcode_core - flutter_study_learning: - path: ../../packages/flutter_study_learning + git: + url: https://github.com/lizy-coding/gcode_core.git + ref: v0.2.0-dev.1 file_picker_bridge: path: ../../packages/file_picker_bridge flutter_ioc_core: @@ -29,10 +30,14 @@ dependencies: flutter_svg: ^2.0.10+1 two_dimensional_scrollables: ^0.3.0 device_info_plus: ^10.1.2 - desktop_multi_window: ^0.3.0 + # Local Windows patch: deterministic first-frame redraw and lifecycle safety. + desktop_multi_window: + path: ../../packages/desktop_multi_window video_player: ^2.10.0 video_player_win: ^3.2.2 shared_preferences: ^2.5.5 + webview_flutter: ^4.9.0 + webview_windows: ^0.4.0 dev_dependencies: integration_test: @@ -47,4 +52,7 @@ dev_dependencies: ref: 9f9be84a73dc4b99a956a8529b8c334849566b03 flutter: + config: + # Keep plugin builds on CocoaPods so the scoped Xcode compiler probe applies. + enable-swift-package-manager: false uses-material-design: true diff --git a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detection_service_test.dart b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detection_service_test.dart index 6955381..fbd0a81 100644 --- a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detection_service_test.dart +++ b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detection_service_test.dart @@ -28,4 +28,26 @@ void main() { expect(invokedMethod, equals('listDevices')); expect(service.connectedDevices.single.displayName, equals('Test Device')); }); + + test( + 'keeps enumerated devices when optional fields are unavailable', + () async { + const channel = MethodChannel('usb_detector/usb-test-fallback'); + final service = UsbDetectionService.forTesting(channel: channel); + addTearDown(service.dispose); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + channel, + (call) async => [ + {'vendorId': 1, 'productId': 2, 'name': 'permission-required'}, + ], + ); + + expect(await service.initialize(), isTrue); + expect( + service.connectedDevices.single.displayName, + 'permission-required', + ); + }, + ); } diff --git a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart index 393de34..2750478 100644 --- a/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart +++ b/apps/flutter_forge/test/modules/platform/usb_detector/usb_detector_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_forge_app/modules/platform/usb_detector/module_entry.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,4 +6,13 @@ void main() { test('module entry is constructible', () { expect(const UsbDetectorEntry(), isA()); }); + + testWidgets('USB detector fits a compact Android viewport', (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(const MaterialApp(home: UsbDetectorEntry())); + await tester.pump(const Duration(milliseconds: 200)); + + expect(tester.takeException(), isNull); + }); } diff --git a/apps/flutter_forge/test/modules/platform/webview/webview_session_test.dart b/apps/flutter_forge/test/modules/platform/webview/webview_session_test.dart new file mode 100644 index 0000000..652f54a --- /dev/null +++ b/apps/flutter_forge/test/modules/platform/webview/webview_session_test.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/modules/platform/webview/core/webview_backend.dart'; +import 'package:flutter_forge_app/modules/platform/webview/core/webview_session.dart'; + +class FakeWebViewBackend implements WebViewBackend { + final controller = StreamController.broadcast(sync: true); + Completer? initialization; + bool disposed = false; + bool fail = false; + final List calls = []; + @override + Stream get events => controller.stream; + @override + Future initialize() async { + await initialization?.future; + if (fail) throw StateError('init failed'); + } + + @override + Widget buildView() => const Text('native view substitute'); + @override + Future loadUrl(String url) async { + calls.add(url); + } + + @override + Future canGoBack() async => true; + @override + Future canGoForward() async => true; + @override + Future goBack() async { + calls.add('back'); + } + + @override + Future goForward() async { + calls.add('forward'); + } + + @override + Future reload() async { + calls.add('reload'); + } + + @override + Future dispose() async { + disposed = true; + await controller.close(); + } +} + +void main() { + test( + 'rejects executable and malformed URLs before native navigation', + () async { + final backend = FakeWebViewBackend(); + final session = WebViewSession(backend); + await session.start(); + final count = backend.calls.length; + for (final url in [ + 'javascript:alert(1)', + 'file:///etc/passwd', + 'https://', + 'https://user:pass@example.com', + ]) { + await session.navigate(url); + expect(session.error, isNotNull); + } + expect(backend.calls.length, count); + session.dispose(); + }, + ); + test('late initialization never navigates after disposal', () async { + final backend = FakeWebViewBackend()..initialization = Completer(); + final session = WebViewSession(backend); + final pending = session.start(); + session.dispose(); + backend.initialization!.complete(); + await pending; + expect(backend.calls, isEmpty); + expect(backend.disposed, isTrue); + }); + test('reports initialization failure and allows retry', () async { + final backend = FakeWebViewBackend()..fail = true; + final session = WebViewSession(backend); + await session.start(); + expect(session.error, contains('init failed')); + backend.fail = false; + await session.start(); + expect(session.initialized, isTrue); + session.dispose(); + }); + testWidgets('threshold, timeout, completion and navigation remain distinct', ( + tester, + ) async { + final backend = FakeWebViewBackend(); + final session = WebViewSession(backend); + await session.start(); + backend.controller.add( + const WebViewEvent(WebViewEventKind.progress, progress: .3), + ); + expect(session.contentVisible, isTrue); + expect(session.loading, isTrue); + await session.reload(); + await tester.pump(const Duration(seconds: 3)); + expect(session.contentVisible, isTrue); + expect(session.loading, isTrue); + backend.controller.add(const WebViewEvent(WebViewEventKind.finished)); + await tester.pump(); + expect(session.canBack, isTrue); + expect(session.loading, isFalse); + await session.back(); + await session.forward(); + expect(backend.calls, containsAll(['reload', 'back', 'forward'])); + session.dispose(); + }); +} diff --git a/apps/flutter_forge/test/modules/platform/webview/webview_test.dart b/apps/flutter_forge/test/modules/platform/webview/webview_test.dart new file mode 100644 index 0000000..4d4de02 --- /dev/null +++ b/apps/flutter_forge/test/modules/platform/webview/webview_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_forge_app/app/router/app_route_table.dart'; +import 'package:flutter_forge_app/module_registry/module_catalog_utils.dart'; +import 'package:flutter_forge_app/modules/platform/webview/module_root.dart'; +import 'webview_session_test.dart' show FakeWebViewBackend; + +void main() { + test('catalog enables Android, macOS and Windows WebView backends', () { + final module = AppRouteTable.modules.singleWhere( + (m) => m.path == '/webview', + ); + expect(isModuleAvailable(module, TargetPlatform.android), isTrue); + expect(isModuleAvailable(module, TargetPlatform.windows), isTrue); + expect(isModuleAvailable(module, TargetPlatform.macOS), isTrue); + expect(isModuleAvailable(module, TargetPlatform.iOS), isFalse); + }); + testWidgets( + 'teaching page fits compact viewport and rejects invalid navigation', + (tester) async { + tester.view.physicalSize = const Size(360, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final backend = FakeWebViewBackend(); + await tester.pumpWidget(MaterialApp(home: WebViewPage(backend: backend))); + await tester.pump(); + expect(find.text('网页容器与跨平台导航'), findsOneWidget); + expect(find.byType(Scaffold), findsOneWidget); + await tester.enterText( + find.byKey(const ValueKey('webview-url')), + 'javascript:alert(1)', + ); + await tester.tap(find.text('打开')); + await tester.pump(); + expect(find.byKey(const ValueKey('webview-error')), findsOneWidget); + expect(backend.calls, ['https://example.com']); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + expect(backend.disposed, isTrue); + }, + ); +} diff --git a/apps/flutter_forge/test/modules/state/status_management/status_management_test.dart b/apps/flutter_forge/test/modules/state/status_management/status_management_test.dart index 51c65fb..2c30f34 100644 --- a/apps/flutter_forge/test/modules/state/status_management/status_management_test.dart +++ b/apps/flutter_forge/test/modules/state/status_management/status_management_test.dart @@ -1,4 +1,6 @@ -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/modules/state/status_management/pages/riverpod/riverpod_lifting_route.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_forge_app/modules/state/status_management/module_entry.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,4 +8,31 @@ void main() { test('module entry is constructible', () { expect(const StatusManageEntry(), isA()); }); + + testWidgets('riverpod lifting controls fit a compact Android viewport', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + const MediaQuery( + data: MediaQueryData(size: Size(320, 640)), + child: ProviderScope(child: MaterialApp(home: RiverpodLiftingRoute())), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('status management home fits a compact Android viewport', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(const MaterialApp(home: StatusManageEntry())); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); } diff --git a/packages/flutter_study_learning/test/learning_scaffold_test.dart b/apps/flutter_forge/test/shared/learning/learning_scaffold_test.dart similarity index 84% rename from packages/flutter_study_learning/test/learning_scaffold_test.dart rename to apps/flutter_forge/test/shared/learning/learning_scaffold_test.dart index daefb8d..3581b39 100644 --- a/packages/flutter_study_learning/test/learning_scaffold_test.dart +++ b/apps/flutter_forge/test/shared/learning/learning_scaffold_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_study_learning/flutter_study_learning.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; void main() { group('LearningObjectives', () { @@ -9,9 +9,7 @@ void main() { const MaterialApp( home: Scaffold( body: SingleChildScrollView( - child: LearningObjectives( - objectives: ['目标一', '目标二', '目标三'], - ), + child: LearningObjectives(objectives: ['目标一', '目标二', '目标三']), ), ), ), @@ -47,10 +45,7 @@ void main() { const MaterialApp( home: Scaffold( body: SingleChildScrollView( - child: CodeSnippetCard( - title: '示例代码', - code: 'print("hello");', - ), + child: CodeSnippetCard(title: '示例代码', code: 'print("hello");'), ), ), ), @@ -119,9 +114,7 @@ void main() { await tester.pumpWidget( const MaterialApp( home: Scaffold( - body: SingleChildScrollView( - child: ExerciseCard(task: '完成一个练习任务'), - ), + body: SingleChildScrollView(child: ExerciseCard(task: '完成一个练习任务')), ), ), ); @@ -134,10 +127,7 @@ void main() { const MaterialApp( home: Scaffold( body: SingleChildScrollView( - child: ExerciseCard( - task: '完成一个任务', - hint: '使用Future.delayed', - ), + child: ExerciseCard(task: '完成一个任务', hint: '使用Future.delayed'), ), ), ), @@ -150,13 +140,10 @@ void main() { group('LearningScaffold', () { testWidgets('renders title and sections', (tester) async { await tester.pumpWidget( - MaterialApp( + const MaterialApp( home: LearningScaffold( title: '测试页面', - sections: [ - const Text('区块一'), - const Text('区块二'), - ], + sections: [Text('区块一'), Text('区块二')], ), ), ); @@ -168,11 +155,11 @@ void main() { testWidgets('renders interactive demo when provided', (tester) async { await tester.pumpWidget( - MaterialApp( + const MaterialApp( home: LearningScaffold( title: '测试', - sections: const [], - interactiveDemo: const Text('交互演示区域'), + sections: [], + interactiveDemo: Text('交互演示区域'), ), ), ); diff --git a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart index 91c9712..336b505 100644 --- a/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart +++ b/apps/flutter_forge/test/shared/responsive_navigation_layout_test.dart @@ -9,7 +9,10 @@ void main() { testWidgets('module home fits a 360dp viewport', (tester) async { await tester.pumpWidget( MediaQuery( - data: const MediaQueryData(size: Size(360, 800)), + data: const MediaQueryData( + size: Size(360, 800), + padding: EdgeInsets.only(top: 24, bottom: 24), + ), child: MaterialApp( home: ModuleHomePage(modules: AppRouteTable.modules), ), @@ -23,7 +26,10 @@ void main() { testWidgets('category home fits a 360dp viewport', (tester) async { await tester.pumpWidget( MediaQuery( - data: const MediaQueryData(size: Size(360, 800)), + data: const MediaQueryData( + size: Size(360, 800), + padding: EdgeInsets.only(top: 24, bottom: 24), + ), child: MaterialApp( home: CategoryHomePage( category: ModuleCategory.basic, diff --git a/apps/flutter_forge/tool/macos/compiler_probe.py b/apps/flutter_forge/tool/macos/compiler_probe.py new file mode 100755 index 0000000..a316b25 --- /dev/null +++ b/apps/flutter_forge/tool/macos/compiler_probe.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +import subprocess,sys,os +compiler=os.environ.get('FORGE_REAL_CLANG') or subprocess.check_output( + ['xcrun', '--find', 'clang'], text=True).strip() +args=sys.argv[1:] +if all(x in args for x in ['-v','-E','-dM']) and args[-1]=='/dev/null': + r=subprocess.run([compiler,*args],capture_output=True) + # Drop only clang's verbose cc1 command echo; retain version, macros, diagnostics. + err=r.stderr if r.returncode else b'\n'.join(x for x in r.stderr.split(b'\n') if b'" -cc1 ' not in x) + sys.stdout.buffer.write(r.stdout) + sys.stderr.buffer.write(err) + sys.exit(r.returncode) +os.execv(compiler,[compiler,*args]) diff --git a/apps/flutter_forge/tool/macos/flutter_assemble.sh b/apps/flutter_forge/tool/macos/flutter_assemble.sh new file mode 100644 index 0000000..5683ab5 --- /dev/null +++ b/apps/flutter_forge/tool/macos/flutter_assemble.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Xcode exports targets for other platforms; clang's debug-framework invocation +# has no explicit target and interprets these as conflicting deployment targets. +unset IPHONEOS_DEPLOYMENT_TARGET TVOS_DEPLOYMENT_TARGET +unset WATCHOS_DEPLOYMENT_TARGET XROS_DEPLOYMENT_TARGET DRIVERKIT_DEPLOYMENT_TARGET +exec "$FLUTTER_ROOT/packages/flutter_tools/bin/macos_assemble.sh" "$@" diff --git a/apps/flutter_forge/windows/CMakeLists.txt b/apps/flutter_forge/windows/CMakeLists.txt index 7dba445..e6ee7e4 100644 --- a/apps/flutter_forge/windows/CMakeLists.txt +++ b/apps/flutter_forge/windows/CMakeLists.txt @@ -39,7 +39,7 @@ add_definitions(-DUNICODE -D_UNICODE) # of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd4100) target_compile_options(${TARGET} PRIVATE /EHsc) target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") @@ -57,6 +57,18 @@ add_subdirectory("runner") # them to the application. include(flutter/generated_plugins.cmake) +# Keep the Windows plugin graph compatible with NMake and avoid passing MSBuild +# .targets files as link inputs. video_player_win still owns downloading WIL; +# the explicit dependency makes that step complete before the plugin builds. +if(TARGET video_player_win_plugin) + add_dependencies(video_player_win_plugin video_player_win_DEPENDENCIES_DOWNLOAD) + target_compile_options(video_player_win_plugin PRIVATE /wd4100) +endif() +if(TARGET webview_windows_plugin) + target_compile_definitions(webview_windows_plugin PRIVATE _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS) +endif() +list(FILTER PLUGIN_BUNDLED_LIBRARIES EXCLUDE REGEX "\\.targets$") + # === Installation === # Support files are copied into place next to the executable, so that it can diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2470087..08dc91f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,9 +4,9 @@ | 组件 | 版本 | 说明 | |------|------|------| -| Flutter | 3.44.6 | 见 `.fvmrc` | -| Dart | 3.12.2 | 随 Flutter | -| Node.js | 20.20.2 | 见 `.nvmrc`(供 Agent 文档生成器) | +| Flutter | 3.47.2 | 见 `.fvmrc` | +| Dart | 3.13.2 | 随 Flutter | +| Node.js | 24 | 见 `.nvmrc`(供 Agent 文档生成器) | | Xcode | 26+ | 仅 macOS/iOS 构建 | | Android SDK | 36+ | 仅 Android 构建 | diff --git a/docs/reports/ANDROID_ACCEPTANCE_REPORT-20260903.md b/docs/reports/ANDROID_ACCEPTANCE_REPORT-20260903.md new file mode 100644 index 0000000..468da07 --- /dev/null +++ b/docs/reports/ANDROID_ACCEPTANCE_REPORT-20260903.md @@ -0,0 +1,51 @@ +# Android 端整体完整性评估与自测清单 + +日期:2026-09-03 +目标设备:API 35 AOSP ATD,`emulator-5554` +屏幕:320x640dp,密度 160 +应用包:`com.flutterforge.preview` + +## 结论 + +当前 Android host、APK 构建、安装启动、单窗口导航、平台插件映射、移动端布局和 USB 原生通道具备运行基础;模拟器验收项已通过,真机能力项仍为 **PENDING**。 + +已修复:`/usb-detector` 和 `/status-management` 在 320dp 下的横向布局问题,并通过 API 35 emulator 集成复验。 + +## 自测清单 + +| 编号 | 场景 | 结果 | 证据/备注 | +| --- | --- | --- | --- | +| A01 | Android host 目录、Manifest、MainActivity | PASS | `apps/flutter_forge/android`;`singleTop`;`adjustResize` | +| A02 | Debug APK 构建 | PASS | `flutter build apk --debug` | +| A03 | APK 安装 | PASS | `adb install -r` 返回 `Success` | +| A04 | Activity 启动与首帧 | PASS | `Displayed`、`Fully drawn` | +| A05 | 进程存活与崩溃日志 | PASS | `pidof com.flutterforge.preview`;无 `FATAL EXCEPTION` | +| A06 | Android 单窗口导航策略 | PASS | `NavigationPolicy` 测试;单 Activity 前台任务 | +| A07 | 首页 320x640 渲染 | PASS | 已采集 emulator 截图;首页列表可见 | +| A08 | Android 文件选择器映射 | PASS | `file_picker_bridge` Android 测试 | +| A09 | Android USB MethodChannel | PASS | USB service 定向测试;APK 构建通过 | +| A10 | Android USB 无权限/可选字段回退 | PASS(代码级) | 原生端隐藏序列号并保留设备枚举 | +| A11 | 20 个可用模块逐个打开返回 | PASS | API 35 emulator,全部模块通过 | +| A12 | USB/状态管理页面 320dp 无溢出 | PASS | 修复 `usb-detector` 与 `status-management` 后通过 | +| A13 | 弹窗/列表嵌套路由 | PASS | API 35 emulator 独立及完整集成测试通过 | +| A14 | 键盘避让 | PENDING | 尚无输入型 Android 真实交互证据 | +| A15 | USB 真机权限弹窗与物理设备 | PENDING | emulator 无物理 USB 设备,需真机 | + +## 已执行命令 + +```bash +flutter test test/modules/platform/usb_detector/usb_detector_test.dart \ + test/modules/platform/usb_detector/usb_detection_service_test.dart \ + test/modules/state/status_management/status_management_test.dart +flutter analyze +flutter build apk --debug +flutter test integration_test/app_test.dart -d emulator-5554 +``` + +定向测试、静态分析、APK 构建和完整 Android 集成测试均通过。 + +## 下一步 + +1. 在 emulator 上补充首页滚动、返回链路和屏幕截图证据。 +2. 增加键盘弹出时的真实 Android 交互验收。 +3. 使用真实 Android 设备完成 USB 权限请求/拒绝/重新插拔验证。 diff --git a/docs/reports/ANDROID_LOCAL_RELEASE_CHECKLIST-20260904.md b/docs/reports/ANDROID_LOCAL_RELEASE_CHECKLIST-20260904.md new file mode 100644 index 0000000..c767639 --- /dev/null +++ b/docs/reports/ANDROID_LOCAL_RELEASE_CHECKLIST-20260904.md @@ -0,0 +1,54 @@ +# Android 本地 Release 打包验收 + +日期:2026-09-04 +项目:Flutter Forge +构建目录:`apps/flutter_forge` + +## 本地标准 + +```bash +bash tool/android_release_local.sh +``` + +脚本行为: + +- 运行完整 `bash tool/quality_gate.sh` +- 自动生成本地临时 upload keystore 到 `apps/flutter_forge/build/android-release/` +- 通过环境变量注入 Release 签名 +- 构建 AAB 和分 ABI APK +- 使用 `apksigner` 校验 APK +- 使用 `jarsigner` 校验 AAB +- 生成 `SHA256SUMS` + +## 验收结果 + +| 项目 | 结果 | 证据 | +| --- | --- | --- | +| Agent 文档生成与校验 | PASS | 44/44 | +| Dart 格式 | PASS | quality gate | +| Flutter analyze | PASS | no issues | +| 全量测试 | PASS | 5 个 workspace package suites | +| 测试布局 | PASS | 21/21 modules have tests | +| FlutterGuard | PASS | 0 HIGH | +| AAB Release 构建 | PASS | `app-release.aab`,60.1MB | +| ARMv7 APK | PASS | v2 signature verified | +| ARM64 APK | PASS | v2 signature verified | +| x86_64 APK | PASS | v2 signature verified | +| ARM64 APK 安装 | PASS | `adb install -r` 返回 Success | +| ARM64 APK 启动 | PASS | Activity resumed,PID 3060 | +| Fatal Android 日志 | PASS | 无 `FATAL EXCEPTION` / `E/flutter` | + +## 产物校验和 + +```text +ff404fa6a2e2f2ae196cedf6a1ad8f106adf44ee0852171592f45f0dfb7b3903 app-release.aab +44def23bdd5a739ea6e78bff75258d53b61fc08fade785e51b97fe936284a329 app-arm64-v8a-release.apk +a2d4d380e8360df8fd8f36a086d3c987099e326740cf65c6a9334a35ea18a519 app-armeabi-v7a-release.apk +2bb0199dac685293357288fe50abef5f34c05082092bbf65b8899886607a5762 app-x86_64-release.apk +``` + +## 当前发布边界 + +- 当前签名是本地临时 upload key,只用于本地验收,不能用于正式发布。 +- 正式发布前必须接入受保护的 release keystore、Play App Signing 和版本递增校验。 +- 当前已证明本地 Release 可构建、可签名、可安装、可启动;远端流水线尚未新增。 diff --git a/docs/reports/MACOS_WEBVIEW_GCODE_RELEASE_SELF_TEST_PLAN-20260906.md b/docs/reports/MACOS_WEBVIEW_GCODE_RELEASE_SELF_TEST_PLAN-20260906.md new file mode 100644 index 0000000..2900999 --- /dev/null +++ b/docs/reports/MACOS_WEBVIEW_GCODE_RELEASE_SELF_TEST_PLAN-20260906.md @@ -0,0 +1,182 @@ +# Flutter Forge macOS WebView / G-code 发版自测计划 + +状态:`REVIEW_PENDING` + +用途:在融合 WebView 三端能力及 `gcode_core v0.2.0-dev.1` 后,对最终 macOS Release 候选执行可追溯的真机验收。本文件是执行计划,不代表任何项目已经通过。 + +## 1. 放行原则 + +只有以下所有必测项为 `PASS` 时,`MACOS_RELEASE_GATE` 才能设为 `PASS`。存在未提交源码、产物提交不一致、原生测试未完成、黑屏、崩溃、未处理异常、`Invalid engine handle` 或 `Failed to send message to Flutter engine` 时,结论必须为 `HOLD`。 + +macOS 结果只决定 macOS Release 是否可发布,不能替代 Windows WebView2 或 Android 真机验收。 + +## 2. 当前已知边界 + +| 项目 | 当前状态 | 发版前要求 | +|---|---|---| +| WebView 模块接入 | 已提交 | 基于最终候选重新验证 | +| macOS WKWebView 自动化 | `PENDING`,此前卡在 Xcode compiler probe | 必须完成真实 WKWebView 测试 | +| G-code 依赖 | 工作区指向 `v0.2.0-dev.1` | 固定并解析到明确 commit | +| G-code 核心能力 | `gcode_core` 已完成独立验证 | 本轮不重复解析、算法和完整 GPU 能力测试 | +| G-code Forge 集成 | 工作区升级到新版本 | 只验证依赖解析、模块启动、首帧和退出重进 | +| 工作区 | 当前有未提交改动 | 发版候选必须提交并保持干净 | +| Windows WebView2 | 本轮不具备 Windows 主机证据 | 保持 `PENDING` | + +## 3. 测试基线 + +执行前填写: + +| 字段 | 记录 | +|---|---| +| 执行人 | | +| 执行日期 | | +| Git commit | | +| Git branch | | +| `git status --porcelain` | 必须为空 | +| 应用版本 | | +| Flutter / Dart 版本 | | +| macOS 版本 | | +| Mac 型号、CPU、内存 | | +| G-code ref | `v0.2.0-dev.1` | +| G-code resolved commit | | +| WebView Flutter/WKWebView 版本 | | +| Release `.app` 路径 | | +| Release SHA256 | | +| 原始日志目录 | `macos_acceptance/evidence/YYYY-MM-DD-webview-gcode/` | + +基线命令: + +```bash +git status --porcelain +git rev-parse HEAD +git branch --show-current +flutter --version +sw_vers +uname -m +sed -n '1,80p' apps/flutter_forge/pubspec.yaml +sed -n '300,335p' pubspec.lock +``` + +## 4. 自动门禁 + +从仓库根目录依次执行,任一步失败即停止发版: + +```bash +bash tool/quality_gate.sh + +cd apps/flutter_forge +flutter test test/modules/platform/webview +flutter test test/modules/ui/gcode_visualizer/gcode_visualizer_test.dart +FLUTTER_XCODE_CC="$PWD/tool/macos/compiler_probe.py" flutter test integration_test/webview_macos_test.dart -d macos +FLUTTER_XCODE_CC="$PWD/tool/macos/compiler_probe.py" flutter build macos --release +``` + +| ID | 检查 | 预期 | 结果 | 证据 | +|---|---|---|---|---| +| A1 | 工作区干净 | `git status --porcelain` 无输出 | NOT_RUN | | +| A2 | Agent 文档 | 生成与校验无漂移 | NOT_RUN | | +| A3 | 格式和分析 | 无 error、warning、info | NOT_RUN | | +| A4 | 全量测试 | 所有 workspace suite 通过 | NOT_RUN | | +| A5 | FlutterGuard | 无 HIGH | NOT_RUN | | +| A6 | WebView 单元/Widget 测试 | URL、安全、状态、释放、平台声明通过 | NOT_RUN | | +| A7 | G-code Forge 消费测试 | 当前依赖下模块 Widget 可构建并显示关键入口 | NOT_RUN | | +| A8 | WKWebView 原生集成测试 | 加载、后退、前进、刷新、销毁重建通过 | NOT_RUN | | +| A9 | macOS Release 构建 | `Flutter Forge.app` 生成 | NOT_RUN | | + +若 A8 再次停在 compiler probe,保存 `flutter test -v`、`xcodebuild` 进程树和采样日志,结果填写 `BLOCKED`,不得用 A6 替代。 + +## 5. Release 产物身份 + +只操作本轮 A9 生成的完整路径: + +```text +apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app +``` + +执行并保存输出: + +```bash +shasum -a 256 "apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app/Contents/MacOS/Flutter Forge" +plutil -p "apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app/Contents/Info.plist" +codesign -dv --verbose=4 "apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app" 2>&1 +``` + +| ID | 检查 | 预期 | 结果 | 证据 | +|---|---|---|---|---| +| P1 | commit 与产物对应 | 构建日志记录最终 commit | NOT_RUN | | +| P2 | 应用版本 | 与候选版本一致 | NOT_RUN | | +| P3 | 最低系统版本 | 与 macOS 12+ 契约一致 | NOT_RUN | | +| P4 | Impeller/Flutter GPU | Release 配置包含预期能力 | NOT_RUN | | +| P5 | WKWebView 注册 | Release 中插件已注册 | NOT_RUN | | +| P6 | SHA256 | 已记录且后续测试不再重建产物 | NOT_RUN | | + +## 6. 启动和日志 + +关闭所有旧版 Flutter Forge 进程,然后直接启动上述 Release `.app`。至少执行两次完整启动,并为每轮记录 PID、SHA256、启动时间和原始日志。 + +| ID | 操作 | 预期 | 结果 | 证据 | +|---|---|---|---|---| +| S1 | 第一次冷启动 | 主窗口完成首帧,无黑屏或异常退出 | NOT_RUN | | +| S2 | 完全退出后第二次启动 | 可重复启动,状态正常 | NOT_RUN | | +| S3 | 启动日志 | 无 fatal、未处理异常、Engine 消息错误 | NOT_RUN | | +| S4 | 崩溃报告 | 无本轮 Flutter Forge crash | NOT_RUN | | + +## 7. WebView macOS 真机专项 + +建议先加载稳定的 HTTPS 页面,再加载一个不同页面形成历史记录。所有交互均在当前 Flutter Forge 窗口内完成。 + +| ID | 操作 | 预期 | 结果 | 证据 | +|---|---|---|---|---| +| W1 | 从“网络与平台”进入 WebView | 页面出现,无插件未注册异常 | NOT_RUN | | +| W2 | 加载默认 HTTPS 页面 | 占位状态出现后网页正常显示 | NOT_RUN | | +| W3 | 输入第二个 HTTPS 地址并打开 | 地址和页面更新 | NOT_RUN | | +| W4 | 网页后退、前进 | 页面历史和按钮状态正确 | NOT_RUN | | +| W5 | 刷新 | 当前页面重新加载并恢复显示 | NOT_RUN | | +| W6 | 输入 `javascript:`、`file:` 或非法地址 | 拒绝导航并显示明确错误 | NOT_RUN | | +| W7 | 断网或不可达地址 | 显示失败/重试状态,不永久停在成功态 | NOT_RUN | | +| W8 | 退出模块后重新进入 3 次 | WKWebView 可重建,无晚到回调异常 | NOT_RUN | | +| W9 | 紧凑窗口约 360dp | 输入框、按钮、网页区域可操作,无 overflow | NOT_RUN | | +| W10 | WebView 日志 | 无 WKWebView、PlatformView、dispose 致命错误 | NOT_RUN | | + +必须保存:首次加载、第二页面、历史导航、非法地址、失败态、第三次重开后的截图,以及 W1-W10 对应原始日志。 + +## 8. G-code Forge 集成冒烟 + +`gcode_core` 的解析、轨迹算法、Flutter GPU 绘制和完整交互由其独立仓库验收,本轮不重复执行。本节只证明 Forge 的最终 Release 能解析依赖并启动 G-code 模块。 + +| ID | 操作 | 预期 | 结果 | 证据 | +|---|---|---|---|---| +| G1 | 从 UI 分类进入 G-code 模块 | 模块成功启动,无依赖或插件加载错误 | NOT_RUN | | +| G2 | 检查模块首帧 | 编辑器、画布、时间线和播放控件出现 | NOT_RUN | | +| G3 | 返回后再次进入 | 模块可重新启动,无黑屏或异常退出 | NOT_RUN | | +| G4 | 检查启动日志 | 无 package resolution、Shader、Impeller 或 Flutter GPU 致命错误 | NOT_RUN | | + +必须保存:首次启动、首帧、再次进入的截图,以及 G1-G4 对应原始日志。无需准备额外 G-code 测试文件,也无需重复核心包的性能与算法验收。 + +## 9. 多窗口与交叉回归 + +WebView 和 G-code 都必须在真实桌面分类窗口中验证,避免只证明主窗口应用内导航。 + +| ID | 操作 | 预期 | 结果 | 证据 | +|---|---|---|---|---| +| M1 | 同时打开基础、UI、网络与平台分类 | 三个窗口首帧正常 | NOT_RUN | | +| M2 | UI 分类打开 G-code | 模块启动并完成首帧 | NOT_RUN | | +| M3 | 网络与平台分类打开 WebView | WKWebView 正常 | NOT_RUN | | +| M4 | 同分类重复打开 | 复用已有窗口 | NOT_RUN | | +| M5 | 关闭并重开分类窗口 3 轮 | 两个模块仍可使用,无黑屏 | NOT_RUN | | +| M6 | 子窗口打开并取消文件选择器 | 原生对话框正常返回 | NOT_RUN | | +| M7 | 检查窗口日志 | 无 `Invalid engine handle` | NOT_RUN | | +| M8 | 检查 Engine 消息 | 无 `Failed to send message to Flutter engine` | NOT_RUN | | + +## 10. 最终判定 + +| Gate | 条件 | 结论 | +|---|---|---| +| `STATIC_GATE` | A1-A6 全部通过 | PENDING | +| `MACOS_NATIVE_GATE` | A8-A9、P1-P6、S1-S4 全部通过 | PENDING | +| `WEBVIEW_GATE` | W1-W10 全部通过 | PENDING | +| `GCODE_INTEGRATION_GATE` | A7、G1-G4 全部通过 | PENDING | +| `DESKTOP_REGRESSION_GATE` | M1-M8 全部通过 | PENDING | +| `MACOS_RELEASE_GATE` | 上述五个 Gate 全部为 PASS | HOLD | + +最终报告必须包含:最终 commit、版本、Release SHA256、Mac 环境、每个 Gate 的结论、失败项、原始日志路径和截图索引。执行期间若修改任何源码、依赖、Podfile、Xcode 配置或生成器,必须重新冻结 commit,并从 A1 重新开始。 diff --git a/docs/reports/WEBVIEW_INTEGRATION.md b/docs/reports/WEBVIEW_INTEGRATION.md new file mode 100644 index 0000000..131e0ab --- /dev/null +++ b/docs/reports/WEBVIEW_INTEGRATION.md @@ -0,0 +1,65 @@ +# WebView source integration + +Upstream: `lizy-coding/webview_plugin`, revision `1e160be430a55612bd1c1711f56be7ed94a4957b`. +Forge base: `25394f1`. Agent Hub task: `integrate-historical-webview-module`. + +The project adapter freezes exact module, dependency, generated contract and test paths. +The external CLI Worker failed before changes because its version did not support the selected model. +The current session implements the same frozen task in the isolated managed checkout; +ScopeGuard and architecture review remain required before integration. + +Ownership: `apps/flutter_forge/lib/modules/platform/webview`, route `/webview`. +Native backends: Android, macOS and Windows. Other platforms retain an unavailable catalog entry. +The historical wrapper source is adapted locally, with source provenance in `SOURCE.md`. +Teaching content uses Forge's shared learning scaffold. There is no nested application or business window. +URL inputs accept HTTP/HTTPS, Windows permission requests and popup windows are denied. +The 30-percent / 3-second reveal is a display heuristic, not successful-load evidence. +Windows progress remains estimated; native completion/error events provide final state. + +Verification: +- Generated contracts: PASS (`agent_docs_valid:43`). +- Bare Flutter analysis: PASS. +- Complete quality gate: PASS, 6/6; existing five MEDIUM FlutterGuard findings remain. +- Tests cover URL rejection, dispose during initialization, retry after failure, + loading reveal/completion, back/forward/reload, compact teaching UI and platform metadata. +- Android native integration: PASS on emulator-5554 (API 35, system WebView 124.0.6367.219); debug APK built and installed, all available modules including /webview opened and returned, both integration tests passed in 32 seconds. This traversal covers native initialization and route exit, not exhaustive browsing behavior. +- Windows native runtime: PENDING; no Windows host available in this session. + +Windows requires WebView2 Runtime. No claim of Windows native acceptance is made from macOS tests. + +## macOS adaptation + +Android and macOS share `WebViewFlutterBackend`, using webview_flutter's registered +Android WebView / WKWebView implementation. The generated catalog now enables macOS; +the application already has network-client entitlements and a macOS 12 deployment target. +The original Android-only class/file name was replaced to reflect shared ownership. + +Candidate validation: full quality gate 6/6 PASS using a temporary Git index (the +user's staging area was not changed); platform catalog and lifecycle tests PASS. +The generator now emits multiline platform sets deterministically. + +Native test: `FLUTTER_XCODE_CC="$PWD/tool/macos/compiler_probe.py" flutter test +integration_test/webview_macos_test.dart -d macos`. The Xcode compiler probe had +blocked while writing verbose output through SwiftBuild; the project wrapper captures +that probe and Flutter passes it to xcodebuild as a command-line `CC` setting. +On macOS 26.5 / Xcode 26.6 with Flutter 3.47.2, the Debug integration application +built successfully and real WKWebView loading, back, forward, reload, disposal and +recreation passed. Release artifact validation remains separate from this result. + +## Naming contract + +Agent Hub task: `normalize-webview-naming`. The user approved project-owned names +with consistent `WebView` spelling. `WebViewEntry`, `WebViewPage`, `WebViewSession`, +`WebViewBackend`, `WebViewEvent` and `WebViewEventKind` are the canonical types. +`WebViewFlutterBackend` (`platforms/webview_flutter_backend.dart`) wraps +webview_flutter for Android/macOS. `WebView2Backend` +(`platforms/webview2_backend.dart`) wraps webview_windows for Windows. +Third-party type names and package imports keep their upstream spelling. + +The naming task froze 17 paths against the existing working tree, preserving pending +macOS adaptation and host-build changes. LangGraph scope and rename-equivalence +checks passed; route `/webview`, directory `webview`, dependencies and platforms +were unchanged. Full quality gate passed 6/6 using a temporary candidate index. +Evidence lives in Agent Hub `plans/webview-naming-frozen.json` and +`plans/webview-naming-review.json`. This naming validation does not supersede the +native-runtime limitations recorded above. diff --git a/macos_acceptance/evidence/2026-08-31-ops-rerun/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-08-31-ops-rerun/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index c375271..0000000 --- a/macos_acceptance/evidence/2026-08-31-ops-rerun/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "schema": "flutter_forge.macos_ui_acceptance_report.v1", - "task_id": "OPS-20260901-macos-ui-acceptance-rerun", - "executed_at": "2026-08-31T14:05:54+08:00", - "scope": "real_window_acceptance_only", - "environment": { - "macos_version": "26.5", - "macos_build": "25F71", - "architecture": "arm64", - "cpu": "Apple M5", - "memory_bytes": 25769803776, - "flutter_version": "3.44.6", - "dart_version": "3.12.2" - }, - "source": { - "branch": "dev", - "head": "ce814fbc249bd3c36ca5ab41151bb29fe70cc980", - "head_time": "2026-08-31T12:57:13+08:00", - "head_subject": "feat(ui): make debounce throttle demo responsive", - "upstream_ref": "origin/dev", - "upstream_head": "dfdbc1dbf8db5e25b714309251fdd76a6efd4fc1" - }, - "artifact": { - "path": "/Users/forest/code/langGraph/flutter_forge/apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app", - "version": "1.2.0", - "build": "1.2.0", - "bundle_id": "com.flutterforge.preview", - "bundle_mtime": "2026-08-27T17:57:14+08:00", - "executable_sha256": "35022d7fa3d01811a00747c987dd3a1cb3068a1995bc2bdddb950399414db80d", - "identity_verdict": "NOT_IN_ARTIFACT", - "identity_reason": "The only existing Release bundle predates current HEAD ce814fb and has no provenance mapping it to that commit." - }, - "automation": { - "tool": "Codex Computer Use", - "window_read": "PASS", - "screenshot": "PASS", - "accessibility_click": "BLOCKED", - "coordinate_click": "BLOCKED", - "error": "Sky Computer Use native pipe closed before response", - "observed_result": "After reconnecting, the application remained on the main catalog and the click had not taken effect." - }, - "ui_results": [ - {"id": "UI-01", "name": "first_release_startup", "verdict": "PASS", "evidence": "logs/runtime.log"}, - {"id": "UI-02", "name": "second_release_startup", "verdict": "PASS", "evidence": "logs/runtime.log"}, - {"id": "UI-03", "name": "main_catalog_first_frame", "verdict": "PASS", "evidence": "screenshots/UI-01-main-window.png"}, - {"id": "UI-04", "name": "basic_module_enter_return", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-05", "name": "state_module_enter_return", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-06", "name": "platform_module_enter_return", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-07", "name": "popup_table_navigation", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-08", "name": "module_return_chain", "verdict": "BLOCKED", "reason": "No module navigation could be performed."}, - {"id": "UI-09", "name": "usb_android_only_state", "verdict": "BLOCKED", "reason": "The target catalog section could not be reached by real interaction."}, - {"id": "UI-10", "name": "browse_all_macos_modules", "verdict": "BLOCKED", "reason": "Computer Use click and scroll interaction could not be completed."}, - {"id": "UI-11", "name": "open_basic_category_window", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-12", "name": "open_state_category_window", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-13", "name": "open_platform_category_window", "verdict": "BLOCKED", "reason": "Computer Use click did not take effect."}, - {"id": "UI-14", "name": "three_category_windows", "verdict": "BLOCKED", "reason": "Category windows could not be opened."}, - {"id": "UI-15", "name": "category_window_first_frame_no_black", "verdict": "BLOCKED", "reason": "No category window was created by the automation action."}, - {"id": "UI-16", "name": "same_category_reuse", "verdict": "BLOCKED", "reason": "Category windows could not be opened."}, - {"id": "UI-17", "name": "close_one_category_window", "verdict": "BLOCKED", "reason": "Category windows could not be opened."}, - {"id": "UI-18", "name": "close_all_keep_main_alive", "verdict": "BLOCKED", "reason": "Category windows could not be opened."}, - {"id": "UI-19", "name": "reopen_category_window", "verdict": "BLOCKED", "reason": "Category windows could not be opened."}, - {"id": "UI-20", "name": "three_open_close_reopen_cycles", "verdict": "BLOCKED", "reason": "Category windows could not be opened."}, - {"id": "UI-21", "name": "child_window_file_picker_open", "verdict": "BLOCKED", "reason": "No child category window was available."}, - {"id": "UI-22", "name": "child_window_file_picker_cancel", "verdict": "BLOCKED", "reason": "The system file picker could not be opened."}, - {"id": "UI-23", "name": "child_window_file_picker_select", "verdict": "BLOCKED", "reason": "The system file picker could not be opened."}, - {"id": "UI-24", "name": "invalid_engine_handle_absent", "verdict": "FAIL", "evidence": "logs/app-console.log", "observed": "Invalid engine handle appeared on both startups."}, - {"id": "UI-25", "name": "failed_engine_message_absent", "verdict": "FAIL", "evidence": "logs/app-console.log", "observed": "Failed to send message to Flutter engine appeared on both startups."}, - {"id": "UI-26", "name": "no_recent_crash_report", "verdict": "PASS", "evidence": "logs/final-runtime-evidence.log"}, - {"id": "UI-27", "name": "responsive_artifact_identity", "verdict": "NOT_IN_ARTIFACT", "evidence": "logs/preflight.log"}, - {"id": "UI-28", "name": "debounce_throttle_open", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-29", "name": "responsive_360dp_buttons", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-30", "name": "responsive_360dp_counters", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-31", "name": "responsive_scroll_scenario", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-32", "name": "responsive_event_visualization", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-33", "name": "responsive_600dp_boundary", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-34", "name": "responsive_1024dp_layout", "verdict": "NOT_IN_ARTIFACT"}, - {"id": "UI-35", "name": "responsive_nested_scrolling", "verdict": "NOT_IN_ARTIFACT"} - ], - "ui_summary": {"PASS": 4, "FAIL": 2, "BLOCKED": 20, "NOT_IN_ARTIFACT": 9}, - "checklist_summary": { - "P1_P9": {"PASS": 9, "FAIL": 0, "BLOCKED": 0, "NOT_IN_ARTIFACT": 0}, - "S1_S6": {"PASS": 6, "FAIL": 0, "BLOCKED": 0, "NOT_IN_ARTIFACT": 0}, - "N1_N8": {"PASS": 1, "FAIL": 0, "BLOCKED": 7, "NOT_IN_ARTIFACT": 0}, - "V1_V6": {"PASS": 0, "FAIL": 0, "BLOCKED": 6, "NOT_IN_ARTIFACT": 0}, - "M1_M15": {"PASS": 1, "FAIL": 2, "BLOCKED": 12, "NOT_IN_ARTIFACT": 0}, - "R1_R10": {"PASS": 0, "FAIL": 0, "BLOCKED": 0, "NOT_IN_ARTIFACT": 10}, - "H1_H10": {"PASS": 0, "FAIL": 0, "BLOCKED": 0, "NOT_IN_ARTIFACT": 10}, - "total": {"PASS": 17, "FAIL": 2, "BLOCKED": 25, "NOT_IN_ARTIFACT": 20} - }, - "macos_gate": "HOLD", - "windows_ready": false, - "hold_reasons": [ - "The available Release artifact does not include current HEAD ce814fb.", - "Invalid engine handle and Failed to send message to Flutter engine recur on every startup.", - "Core navigation, multi-window, file picker, and responsive interactions could not be executed because Computer Use clicks did not take effect." - ], - "mutations": {"source": false, "artifact": false, "ci": false, "commit": false, "push": false} -} diff --git a/macos_acceptance/evidence/2026-08-31-ops-rerun/notes.md b/macos_acceptance/evidence/2026-08-31-ops-rerun/notes.md deleted file mode 100644 index a665af5..0000000 --- a/macos_acceptance/evidence/2026-08-31-ops-rerun/notes.md +++ /dev/null @@ -1,54 +0,0 @@ -# macOS 最新产物界面化自测复验 - -任务:`OPS-20260901-macos-ui-acceptance-rerun` - -## 门禁结论 - -- `PRECHECK artifact_identity=NOT_IN_ARTIFACT` -- `MACOS_GATE=HOLD` -- `windows_ready=false` -- UI 任务结果:`PASS 4 / FAIL 2 / BLOCKED 20 / NOT_IN_ARTIFACT 9` -- 自测清单结果:`PASS 17 / FAIL 2 / BLOCKED 25 / NOT_IN_ARTIFACT 20` - -本轮不能进入 Windows 验收。唯一现有 Release bundle 的修改时间为 2026-08-27 17:57:14 CST,早于当前源码 HEAD `ce814fbc249bd3c36ca5ab41151bb29fe70cc980` 的提交时间 2026-08-31 12:57:13 +08:00,且没有产物来源记录证明其包含该提交。 - -## 已确认结果 - -- 两次启动 Release 产物均出现主进程并持续存活,首屏完成渲染,窗口标题为 Flutter Forge。 -- 首屏真实可见中文分类、模块标题、副标题、难度、概念、预计时长和状态。 -- 两次启动均出现 `Invalid engine handle`,M13 / UI-24 为 `FAIL`。 -- 两次启动均出现 `Failed to send message to Flutter engine`,M14 / UI-25 为 `FAIL`。 -- `Running with merged UI and platform thread. Experimental.` 仅记录运行模式,不作为独立 FAIL。 -- 最近一小时未发现 Flutter Forge 崩溃报告,进程日志未匹配 `EXC_BAD_ACCESS` 或 `SIGABRT`;这不覆盖 M13/M14 的失败。 - -## 交互阻塞 - -Computer Use 可以读取辅助功能树并保存截图,但对 Flutter Forge 执行 accessibility element 点击或坐标点击时均返回: - -```text -Sky Computer Use native pipe closed before response -``` - -重新连接后主窗口仍停留在原首屏,证明动作没有生效。因此核心导航、三分类多窗口、同类复用、关闭重开、三轮循环和子窗口 file_picker 均逐项标记 `BLOCKED`,未根据静态首屏推断通过。 - -## 响应式边界 - -R1-R10、H1-H10 全部为 `NOT_IN_ARTIFACT`。即使旧产物首屏可见,也不能归因于 `ce814fb feat(ui): make debounce throttle demo responsive`。 - -## 证据 - -- `MACOS_UI_ACCEPTANCE_REPORT.json`:UI-01 至 UI-35 逐项判定、清单汇总、门禁和 Windows 决策。 -- `screenshots/UI-01-main-window.png`:本轮首屏截图。 -- `logs/preflight.log`:系统、Flutter、Git、产物路径、bundle mtime、版本和 SHA256。 -- `logs/runtime.log`:两次启动 PID 与存活证据。 -- `logs/app-console.log`:两次启动的完整终端输出和引擎错误。 -- `logs/final-runtime-evidence.log`:错误计数、进程、崩溃报告查询和自动化阻塞。 - -## 解除 HOLD 条件 - -1. 提供明确包含当前 HEAD `ce814fb` 或更新提交的 macOS Release 产物,并记录可校验的来源。 -2. 修复或解释并消除两项多窗口引擎错误,再复验 M13/M14。 -3. 在点击可工作的真实桌面会话完成核心导航、三分类窗口、复用、关闭重开、三轮循环和子窗口 file_picker。 -4. 使用最新产物补齐 360dp、600dp、1024dp 的 `debounce_throttle` 截图与操作证据。 - -本任务未修改源码、产物、CI 或门禁脚本,未 commit,未 push。 diff --git a/macos_acceptance/evidence/2026-08-31-ops-rerun/screenshots/UI-01-main-window.png b/macos_acceptance/evidence/2026-08-31-ops-rerun/screenshots/UI-01-main-window.png deleted file mode 100644 index 13bd53e..0000000 Binary files a/macos_acceptance/evidence/2026-08-31-ops-rerun/screenshots/UI-01-main-window.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-08-31/MAINTENANCE_ASSESSMENT.md b/macos_acceptance/evidence/2026-08-31/MAINTENANCE_ASSESSMENT.md deleted file mode 100644 index 0076803..0000000 --- a/macos_acceptance/evidence/2026-08-31/MAINTENANCE_ASSESSMENT.md +++ /dev/null @@ -1,155 +0,0 @@ -# macOS 验收后续维护评估 - -日期:2026-08-31 记录复核 -依据:`macos_acceptance/evidence/2026-08-31/notes.md` - -## 结论 - -需要进一步维护代码,但当前应先创建独立的多窗口稳定性修复/复现任务,不应把问题归入响应式布局改造。 - -### 需要维护的原因 - -记录中两次启动均出现: - -```text -Invalid engine handle -Failed to send message to Flutter engine on channel 'mixin.one/desktop_multi_window' -``` - -这属于桌面多窗口 Engine 生命周期或消息发送路径的真实代码维护候选。主窗口仍存活、没有 `.crash` 文件,不能抵消该错误。 - -### 暂不判定为响应式代码缺陷的原因 - -当前 Release bundle 的修改时间为: - -```text -2026-08-27 17:57:14 CST -``` - -响应式提交 `ce814fbc249bd3c36ca5ab41151bb29fe70cc980` 的提交时间为: - -```text -2026-08-31 12:57:13 +08:00 -``` - -因此响应式模块验收结果应保持: - -```text -R1-R10:NOT_IN_ARTIFACT -H1-H10:NOT_IN_ARTIFACT 或 BLOCKED -``` - -不能用旧 Release 的界面结果判断 `debounce_throttle` 新代码。 - -## 问题分类 - -| 现象 | 分类 | 是否需要代码维护 | 当前处理 | -|---|---|---:|---| -| 主窗口可启动并显示首屏 | 通过事实 | 否 | 保留 PASS | -| Computer Use 点击管道关闭 | 验收环境/工具限制 | 否,不能据此改源码 | 更新清单为 BLOCKED | -| 旧产物不含响应式提交 | 产物一致性问题 | 否 | R/H 标记 NOT_IN_ARTIFACT | -| Invalid engine handle | 多窗口 Engine 生命周期候选 | 是 | 创建独立 FIX 任务 | -| Failed to send message to Flutter engine | 多窗口通道/失效 Engine 候选 | 是 | 创建独立 FIX 任务 | -| 没有 macOS crash report | 非充分通过证据 | 否 | 不能覆盖 M13/M14 FAIL | -| Range probe HTTP 403 | 外部网络/资源条件 | 暂不 | 不据此判视频代码失败 | - -## 建议的代码维护范围 - -独立任务应只围绕以下路径和行为展开: - -```text -apps/flutter_forge/macos/Runner/AppDelegate.swift -apps/flutter_forge/macos/Runner/MainFlutterWindow.swift -apps/flutter_forge/lib/shared/multi_window/multi_window_manager.dart -apps/flutter_forge/lib/app/app_bootstrap.dart -``` - -重点核查: - -```text -1. 主 Engine 和每个子 Engine 是否都完成 GeneratedPlugin 注册 -2. 主 Engine 和每个子 Engine 是否都注册 file_picker_bridge 通道 -3. onWindowsChanged 是否可能向已销毁 Engine 发送消息 -4. 关闭窗口后 Dart 注册表是否及时移除 stale window -5. createCategoryWindow 前后的 WindowController 列表与本地注册表是否一致 -6. show、关闭、重开之间是否存在异步消息竞态 -7. 三个不同分类窗口并存时是否触发失效 Engine 广播 -``` - -## 推荐复现矩阵 - -必须使用三个不同分类: - -```text -基础机制 -状态管理 -网络与平台 -``` - -执行阶段应分开采集日志: - -```text -A. 仅启动主窗口 -B. 打开第一个分类窗口 -C. 打开第二个分类窗口 -D. 打开第三个分类窗口 -E. 重复打开已存在分类 -F. 关闭一个分类窗口 -G. 关闭全部分类窗口 -H. 重新打开已关闭分类 -I. 子窗口打开并取消 file_picker -``` - -每阶段记录: - -```text -时间 -主进程 PID -窗口 ID -分类 -Console 原始日志 -截图 -是否出现 Invalid engine handle -是否出现 Failed to send message to Flutter engine -``` - -## 修复验收门槛 - -修复任务不能只依赖单次启动成功,必须满足: - -```text -- 主窗口启动无目标引擎错误 -- 三个不同分类窗口均可首帧显示 -- 子窗口 file_picker 可打开并取消返回 -- 同分类重复打开复用既有窗口 -- 关闭后重开成功 -- 连续打开/关闭/重开至少 3 轮 -- 无 Invalid engine handle -- 无 Failed to send message to Flutter engine -- 无 EXC_BAD_ACCESS / SIGABRT -- 主窗口始终存活 -``` - -## 自测清单已更新的内容 - -`macos_acceptance/MACOS_SELF_TEST_CHECKLIST.md` 已增加: - -```text -1. 启动日志专项判定 -2. 引擎错误与普通实验性日志的区分 -3. M13/M14 失败时的代码维护判定 -4. WindowController 与本地注册表一致性记录 -5. 响应式产物 mtime、SHA256、HEAD 时间一致性校验 -6. 自动化点击未生效时的 BLOCKED 规则 -7. 本次 notes.md 对应的后续维护触发条件 -``` - -## 最终状态 - -```text -多窗口代码维护:需要,待独立复现/FIX 任务 -响应式代码维护:debounce_throttle 已有本地改动;本次 macOS 旧产物无法验收 -自测清单:已更新 -无障碍:仍为第二阶段,不因本次问题提前展开 -远端推送:未执行,继续禁止 -``` diff --git a/macos_acceptance/evidence/2026-08-31/logs/baseline.txt b/macos_acceptance/evidence/2026-08-31/logs/baseline.txt deleted file mode 100644 index 12e3e92..0000000 --- a/macos_acceptance/evidence/2026-08-31/logs/baseline.txt +++ /dev/null @@ -1,17 +0,0 @@ -2026-08-31 13:47:08 CST -ProductName: macOS -ProductVersion: 26.5 -BuildVersion: 25F71 -arm64 -Apple M5 -25769803776 -/dev/disk3s5 926Gi 627Gi 277Gi 70% 2.2M 2.9G 0% /System/Volumes/Data -Flutter 3.44.6 • channel stable • https://github.com/flutter/flutter.git -Framework • revision ee80f08bbf (8 weeks ago) • 2026-07-08 15:02:06 -0700 -Engine • hash d3a3293399556a85388faf8c6f0723a7a5597aa8 (revision 83675ed276) (1 months ago) • 2026-06-30 16:59:03.000Z -Tools • Dart 3.12.2 • DevTools 2.57.0 -35022d7fa3d01811a00747c987dd3a1cb3068a1995bc2bdddb950399414db80d apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app/Contents/MacOS/Flutter Forge -1.2.0 -1.2.0 -com.flutterforge.preview -PID=29048 diff --git a/macos_acceptance/evidence/2026-08-31/logs/runtime-checks.txt b/macos_acceptance/evidence/2026-08-31/logs/runtime-checks.txt deleted file mode 100644 index f98522c..0000000 --- a/macos_acceptance/evidence/2026-08-31/logs/runtime-checks.txt +++ /dev/null @@ -1,13 +0,0 @@ --- network -- - gateway: 10.152.42.1 - interface: en0 -range_probe_http=403 --- restart -- -29816 00:04 SN apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app/Contents/MacOS/Flutter Forge -restart_pid=29816 --- crash reports -- --- artifact timestamps -- -bundle_mtime=2026-08-27 17:57:14 CST -head=ce814fbc249bd3c36ca5ab41151bb29fe70cc980 -head_date=2026-08-31T12:57:13+08:00 -head_subject=feat(ui): make debounce throttle demo responsive diff --git a/macos_acceptance/evidence/2026-08-31/notes.md b/macos_acceptance/evidence/2026-08-31/notes.md deleted file mode 100644 index f5d562b..0000000 --- a/macos_acceptance/evidence/2026-08-31/notes.md +++ /dev/null @@ -1,55 +0,0 @@ -# Flutter Forge macOS 界面化验收记录 - -## 结论 - -- 总体结论:`FAIL` -- 已执行:`PASS 17`、`FAIL 2` -- 未执行:`BLOCKED 35`、`NOT_IN_ARTIFACT 10` -- 失败原因:Release 产物每次启动均出现 `Invalid engine handle` 和 `Failed to send message to Flutter engine`,不满足 M13、M14。 -- 阻塞原因:Computer Use 可以读取和截图窗口,但对 Flutter Forge 执行任意点击时原生管道关闭,动作未生效;因此未把未真实操作的项目判为 PASS。 -- 响应式产物边界:Release bundle 修改时间为 2026-08-27 17:57:14 CST,当前响应式提交 `ce814fbc249bd3c36ca5ab41151bb29fe70cc980` 的提交时间为 2026-08-31 12:57:13 +08:00。R1-R10 全部判为 `NOT_IN_ARTIFACT`。 - -## 测试基线 - -| 项目 | 记录 | -|---|---| -| 测试执行人 | Codex Computer Use | -| 测试日期 | 2026-08-31 | -| macOS 版本 | 26.5 (25F71) | -| Mac 型号 / CPU | Apple M5 | -| CPU 架构 | arm64 | -| 内存 | 24 GiB | -| Flutter 版本 | 3.44.6 stable;Dart 3.12.2 | -| 应用版本 | 1.2.0 (1.2.0) | -| 应用来源 | 本地 Release build | -| 应用路径 | `apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app` | -| 主可执行文件 SHA256 | `35022d7fa3d01811a00747c987dd3a1cb3068a1995bc2bdddb950399414db80d` | -| Bundle ID | `com.flutterforge.preview` | -| 网络初始状态 | `en0` 存在默认路由;样例视频 Range 探测返回 HTTP 403,未据此判定视频播放结果 | - -## 执行结果 - -| 分组 | PASS | FAIL | BLOCKED | NOT_IN_ARTIFACT | 说明 | -|---|---:|---:|---:|---:|---| -| P1-P9 | 9 | 0 | 0 | 0 | 产物、SHA、架构、磁盘、目录、Flutter、网络、旧进程和来源均已记录 | -| S1-S6 | 6 | 0 | 0 | 0 | Release 启动、首帧、标题、重启、进程存活、bundle 结构通过;启动日志异常计入 M13/M14 | -| N1-N8 | 1 | 0 | 7 | 0 | N1 首屏分类、模块标题、副标题、难度和状态可见;点击操作受阻 | -| V1-V6 | 0 | 0 | 6 | 0 | 无法进入在线视频模块,也未修改系统网络状态 | -| M1-M15 | 1 | 2 | 12 | 0 | M13/M14 失败;M15 未发现本轮崩溃报告;其余多窗口操作受阻 | -| R1-R10 | 0 | 0 | 0 | 10 | 当前 Release 产物早于响应式提交 | -| H1-H10 | 0 | 0 | 10 | 0 | 无法进入模块进行窄窗口操作 | -| 总计 | 17 | 2 | 35 | 10 | 总体 FAIL | - -## 关键证据 - -- `screenshots/S2-main-window.png`:主窗口首帧,窗口标题为 Flutter Forge,主目录完整显示基础机制、异步并发、状态管理等分类;可见模块中文标题、副标题、难度、概念、预计时长和状态。 -- `logs/baseline.txt`:系统、Flutter、应用版本、Bundle ID、SHA256 和启动 PID。 -- `logs/app-console.log`:两次启动均出现多窗口引擎句柄错误;应用进程随后仍存活。 -- `logs/runtime-checks.txt`:网络路由、重启 PID、崩溃报告查询、产物与当前提交时间边界。 - -## 复现与后续验收条件 - -1. 使用包含 `ce814fbc249bd3c36ca5ab41151bb29fe70cc980` 或更新提交的 macOS Release 重新构建产物。 -2. 启动产物并观察控制台;若仍出现 `Invalid engine handle` 或 `Failed to send message to Flutter engine`,M13/M14 保持 FAIL,并另建修复任务。 -3. 由可正常向 Flutter 窗口注入点击的人工桌面会话执行 N2-N8、V1-V6、M1-M12、R1-R10、H1-H10;本轮自动化工具的点击动作没有生效。 -4. 多窗口首帧、关闭重开、file_picker、断网重试和 360/600/1024dp 布局必须补齐截图或日志后才能改判 PASS。 diff --git a/macos_acceptance/evidence/2026-08-31/screenshots/S2-main-window.png b/macos_acceptance/evidence/2026-08-31/screenshots/S2-main-window.png deleted file mode 100644 index 13bd53e..0000000 Binary files a/macos_acceptance/evidence/2026-08-31/screenshots/S2-main-window.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression-rerun/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression-rerun/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 87f2a44..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression-rerun/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "head": "5ef5a81", - "configuration": "Release", - "build_result": "PASS", - "change_under_test": "AppDelegate.applicationShouldTerminateAfterLastWindowClosed=false", - "checks": [ - {"name":"category_window_first_frame","verdict":"PASS","evidence":"Isolate category rendered with interactive demo and controls"}, - {"name":"process_survives_category_close","verdict":"PARTIAL_PASS","evidence":"Flutter Forge process remained alive after close (pid 43719 observed)"}, - {"name":"main_window_reconnect_after_close","verdict":"BLOCKED","reason":"Computer Use get_app_state timed out after close despite process remaining alive"} - ], - "macos_ui_interaction": "BLOCKED", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression-rerun/notes.md b/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression-rerun/notes.md deleted file mode 100644 index 0c17c4d..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression-rerun/notes.md +++ /dev/null @@ -1,5 +0,0 @@ -# 5ef5a81 macOS lifecycle fix rerun - -Cold-started the freshly built Release and opened the Isolate category window successfully. After closing it, the Release process remained alive (`ps` observed pid 43719), so the previous immediate process-exit symptom is not reproduced. - -The Computer Use accessibility channel still timed out while reconnecting to the post-close UI. This leaves main-window recovery and the remaining adsorption_line visual path blocked. Keep `MACOS_GATE=HOLD`; do not start Windows self-test. diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index fff5106..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "head": "5ef5a81", - "configuration": "Release", - "build_result": "PASS", - "change": "AppDelegate.applicationShouldTerminateAfterLastWindowClosed returns false", - "checks": [ - {"name":"process_survives_category_close","verdict":"PARTIAL_PASS","evidence":"Flutter Forge process remained alive after Computer Use timeout (pid observed via ps)"}, - {"name":"post_close_ui_reconnect","verdict":"BLOCKED","reason":"Computer Use get_app_state timed out repeatedly while process remained alive"}, - {"name":"microtask_and_isolate_logic_tests","verdict":"PASS","evidence":"quality_gate full test suite"}, - {"name":"adsorption_line_ui","verdict":"NOT_COMPLETED"} - ], - "macos_ui_interaction": "BLOCKED", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression/notes.md b/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression/notes.md deleted file mode 100644 index e619d3c..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-fix-regression/notes.md +++ /dev/null @@ -1,5 +0,0 @@ -# 5ef5a81 window lifecycle fix regression - -Changed `apps/flutter_forge/macos/Runner/AppDelegate.swift` so closing the last window does not terminate the application process. The fresh macOS Release build passed and `bash tool/quality_gate.sh` passed all six stages. - -The prior immediate process exit was not reproduced: after opening the Isolate category window, the Flutter Forge process remained alive according to `ps` after the close/read sequence. However, the Computer Use channel timed out repeatedly while reading the UI, so post-close UI reconnection and adsorption_line visual checks remain blocked. The report therefore keeps `MACOS_GATE=HOLD` and does not authorize Windows testing. diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-regression/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-5ef5a81-regression/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 727757e..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-regression/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "baseline": "565766f9c7d63ae160289d87da43d8023509105d", - "head": "5ef5a81", - "configuration": "Release", - "build_result": "PASS", - "checks": [ - {"name":"microtask_default_layout","verdict":"PASS","evidence":"AX tree exposed four navigation cards and full learning sections at desktop width"}, - {"name":"microtask_compact_layout_360dp","verdict":"PASS","evidence":"AX tree exposed compact single-column navigation cards without clipping"}, - {"name":"isolate_default_layout","verdict":"PASS","evidence":"AX tree exposed scrollable interactive demo, controls, and learning sections"}, - {"name":"adsorption_line_toolbar_compact_layout","verdict":"NOT_COMPLETED","reason":"App process exited while closing the Isolate category window before the module could be opened"}, - {"name":"category_close_lifecycle","verdict":"FAIL","reason":"Closing the Isolate category window caused the Release process to exit; subsequent get_app_state returned procNotFound"}, - {"name":"full_regression_gate","verdict":"HOLD"} - ], - "macos_ui_interaction": "FAIL", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-regression/notes.md b/macos_acceptance/evidence/2026-09-01-5ef5a81-regression/notes.md deleted file mode 100644 index 4b964cb..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-regression/notes.md +++ /dev/null @@ -1,7 +0,0 @@ -# 5ef5a81 macOS UI regression - -The current HEAD is `5ef5a81` (`fix(ui): repair compact layouts for issue 18`) and a fresh macOS Release build completed successfully. - -Real desktop interaction passed for the changed `microtask` and `isolate_basic` pages. The microtask page switched from two columns to a compact single-column layout at 360dp. The Isolate page rendered its interactive demo and controls. - -When closing the Isolate category window, the Release process exited. The next desktop query failed with `procNotFound`, so the main directory could not be restored and `adsorption_line` could not be opened. This is a lifecycle regression and keeps `MACOS_GATE=HOLD`; Windows self-test remains not started. diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-ui-blocker-fix/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-5ef5a81-ui-blocker-fix/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index d6f488c..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-ui-blocker-fix/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "head": "5ef5a81", - "configuration": "Release", - "build_result": "PASS", - "change": "Keep macOS process alive and reactivate main window after child close", - "checks": [ - {"name":"category_window_open","verdict":"PASS"}, - {"name":"native_close_action","verdict":"PASS","evidence":"close action sent successfully"}, - {"name":"process_survives_close","verdict":"PASS","evidence":"Release process remained alive after close (pid 49287)"}, - {"name":"post_close_accessibility_reconnect","verdict":"NOT_REPRODUCED","reason":"Computer Use close action returned; post-close state query was not repeated because channel previously timed out"} - ], - "macos_ui_interaction": "PARTIAL_PASS", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-5ef5a81-ui-blocker-fix/notes.md b/macos_acceptance/evidence/2026-09-01-5ef5a81-ui-blocker-fix/notes.md deleted file mode 100644 index 830027e..0000000 --- a/macos_acceptance/evidence/2026-09-01-5ef5a81-ui-blocker-fix/notes.md +++ /dev/null @@ -1,5 +0,0 @@ -# macOS UI blocker fix - -Added AppDelegate handling that keeps the process alive after the last window closes and reactivates the main window when a child category window closes or the app becomes active. - -Fresh Release build and quality gate passed. A cold-start desktop check opened the Isolate category, sent the native close action successfully, and confirmed the Release process remained alive. The final post-close accessibility read was not repeated in this bounded run, so the full macOS interaction gate remains HOLD until a clean session proves the main-window accessibility tree after close. diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 2fcd654..0000000 --- a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "artifact": { - "head": "b80e41fb53aa01498b587d7fbe54bfb21b2f578e", - "configuration": "Release", - "application": "apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app" - }, - "session": { - "channel": "Codex Desktop Computer Use", - "interaction_available": true, - "date": "2026-09-01" - }, - "checks": [ - {"name": "basic_category_window_first_frame", "verdict": "PASS", "evidence": "screenshots/UI-basic-window.png"}, - {"name": "state_category_window_first_frame", "verdict": "PASS", "evidence": "screenshots/UI-state-window.png"}, - {"name": "platform_category_window_first_frame", "verdict": "PASS", "evidence": "screenshots/UI-platform-window.png"}, - {"name": "three_category_windows_no_black_screen", "verdict": "PASS"}, - {"name": "same_category_reuse", "verdict": "NOT_COMPLETED"}, - {"name": "close_and_reopen_three_rounds", "verdict": "NOT_COMPLETED"}, - {"name": "child_window_file_picker_open", "verdict": "PASS", "evidence": "screenshots/UI-file-picker-dialog.png"}, - {"name": "child_window_file_picker_cancel", "verdict": "PASS"}, - {"name": "debounce_throttle_1024dp", "verdict": "PASS", "evidence": "screenshots/UI-debounce-1024.png"}, - {"name": "debounce_throttle_600dp", "verdict": "PASS", "evidence": "screenshots/UI-debounce-600.png"}, - {"name": "debounce_throttle_360dp", "verdict": "FAIL", "reason": "The right visualization card is horizontally clipped at the compact width.", "evidence": "screenshots/UI-debounce-360-fail.png"} - ], - "macos_ui_interaction": "FAIL", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/notes.md b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/notes.md deleted file mode 100644 index e5d6560..0000000 --- a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/notes.md +++ /dev/null @@ -1,9 +0,0 @@ -# b80e41f macOS Desktop UI Acceptance - -The Codex Desktop interaction channel was operational in this run. The Release application created the basic, state, and platform category windows, and each window rendered its first frame without a black screen. - -The platform child window opened the native file picker and returned normally after Cancel. - -The `debounce_throttle` page rendered acceptably at 1024dp and 600dp. At 360dp, the visualization remains laid out wider than the viewport and the right card is clipped. This is a real UI failure, so the macOS gate remains HOLD. Same-category reuse and three complete close/reopen rounds were not used to override this result because the 360dp failure already prevents PASS. - -Windows self-test was not started. diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-basic-window.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-basic-window.png deleted file mode 100644 index 2b6cf3c..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-basic-window.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-1024.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-1024.png deleted file mode 100644 index b703175..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-1024.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-360-fail.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-360-fail.png deleted file mode 100644 index 301a6e5..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-360-fail.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-600.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-600.png deleted file mode 100644 index d793742..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-debounce-600.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-file-picker-dialog.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-file-picker-dialog.png deleted file mode 100644 index a708cdb..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-file-picker-dialog.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-platform-window.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-platform-window.png deleted file mode 100644 index 2d63184..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-platform-window.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-state-window.png b/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-state-window.png deleted file mode 100644 index 8a1fd45..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-b80e41f-desktop-session/screenshots/UI-state-window.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-current-macos-rerun/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-current-macos-rerun/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 7fbf6e3..0000000 --- a/macos_acceptance/evidence/2026-09-01-current-macos-rerun/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "head": "92bb625-plus-working-tree-window-timing-fix", - "configuration": "Release", - "build_result": "PASS", - "checks": [ - {"name":"async_category_window_first_frame","verdict":"PASS"}, - {"name":"category_close_main_window_readable_round_1","verdict":"PASS"}, - {"name":"category_close_main_window_readable_round_2","verdict":"PASS"}, - {"name":"category_close_reopen_round_3","verdict":"BLOCKED","reason":"After close the main window retained its prior module route; fixed coordinate did not target the category entry"}, - {"name":"adsorption_line_desktop_ui","verdict":"NOT_COMPLETED"} - ], - "macos_ui_interaction": "BLOCKED", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-current-macos-rerun/notes.md b/macos_acceptance/evidence/2026-09-01-current-macos-rerun/notes.md deleted file mode 100644 index 2f4d87b..0000000 --- a/macos_acceptance/evidence/2026-09-01-current-macos-rerun/notes.md +++ /dev/null @@ -1,5 +0,0 @@ -# Current macOS rerun - -The fresh Release opened the correct asynchronous category window and rendered its first frame. Two complete close cycles were executed with the native close button; after each close the main Flutter Forge window remained readable through the accessibility tree. - -The third cycle was blocked by the verification procedure retaining the previous main-window route, so the fixed coordinate no longer targeted the category entry. This is not counted as a product PASS or FAIL. `adsorption_line` was not reached. Keep the macOS gate HOLD and Windows readiness false. diff --git a/macos_acceptance/evidence/2026-09-01-current-window-timing-rerun/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-current-window-timing-rerun/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 392a0b3..0000000 --- a/macos_acceptance/evidence/2026-09-01-current-window-timing-rerun/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "head": "92bb625-plus-working-tree-window-timing-fix", - "configuration": "Release", - "build_result": "PASS", - "checks": [ - {"name":"async_category_window_first_frame","verdict":"PASS","evidence":"分类窗口显示异步并发列表及三项模块"}, - {"name":"category_close_process_survival","verdict":"PASS","evidence":"原生关闭后进程未退出"}, - {"name":"main_window_recovery","verdict":"PASS","evidence":"通过 Window 菜单重新聚焦后主目录 AX 树完整可读"}, - {"name":"three_close_reopen_rounds","verdict":"NOT_COMPLETED"}, - {"name":"adsorption_line_ui","verdict":"NOT_COMPLETED"} - ], - "macos_ui_interaction": "PARTIAL_PASS", - "macos_gate": "HOLD", - "windows_ready": false, - "windows_self_test_started": false -} diff --git a/macos_acceptance/evidence/2026-09-01-current-window-timing-rerun/notes.md b/macos_acceptance/evidence/2026-09-01-current-window-timing-rerun/notes.md deleted file mode 100644 index 18b8413..0000000 --- a/macos_acceptance/evidence/2026-09-01-current-window-timing-rerun/notes.md +++ /dev/null @@ -1,5 +0,0 @@ -# macOS current window timing rerun - -Fresh Release from the current workspace created the asynchronous category window with a visible first frame. Closing the category window no longer terminated the process. The main window was recoverable through the macOS Window menu and its full accessibility tree was readable. - -Three close/reopen rounds and adsorption_line visual regression remain to be completed before changing the gate to PASS. Windows self-test remains blocked. diff --git a/macos_acceptance/evidence/2026-09-01-engine-message-fix/MACOS_ENGINE_MESSAGE_HANDOFF.json b/macos_acceptance/evidence/2026-09-01-engine-message-fix/MACOS_ENGINE_MESSAGE_HANDOFF.json deleted file mode 100644 index f6c40bd..0000000 --- a/macos_acceptance/evidence/2026-09-01-engine-message-fix/MACOS_ENGINE_MESSAGE_HANDOFF.json +++ /dev/null @@ -1,126 +0,0 @@ -{ - "schema": "flutter_forge.macos_engine_message_handoff.v1", - "task_id": "FIX-20260901-macos-engine-startup-broadcast", - "generated_at": "2026-09-01T00:00:00+08:00", - "scope": "macos_multiwindow_lifecycle_and_acceptance_assets", - "preflight": { - "branch": "dev", - "source_head": "ce814fbc249bd3c36ca5ab41151bb29fe70cc980", - "source_head_subject": "feat(ui): make debounce throttle demo responsive", - "upstream_ref": "origin/dev", - "upstream_head": "dfdbc1dbf8db5e25b714309251fdd76a6efd4fc1", - "upstream_mutated": false, - "existing_dirty_files_preserved": true - }, - "native_reproduction": { - "verdict": "REPRODUCED_FROM_EXISTING_REAL_PROCESS_LOG", - "fresh_candidate_artifact_verdict": "BUILD_PASS_RUNTIME_BLOCKED_BY_SANDBOX", - "evidence": "../2026-08-31/logs/app-console.log", - "observations": [ - "Invalid engine handle appeared on both recorded startups.", - "Failed to send message to Flutter engine appeared on both recorded startups.", - "Both failures named channel mixin.one/desktop_multi_window and occurred before any recorded category-window interaction.", - "The task environment cannot provide a fresh native runtime result, so the candidate fix remains unverified on a running macOS engine." - ] - }, - "candidate_build": { - "command": "flutter --no-version-check build macos --release", - "verdict": "NOT_RUN_FOR_THIS_CANDIDATE", - "artifact": "apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app", - "runtime_smoke": "BLOCKED", - "runtime_blocker": "native_runtime_unavailable; no real-window interaction or fresh startup log was produced for the registration-order candidate.", - "interaction_claim": false - }, - "root_cause_hypotheses": [ - { - "rank": 1, - "id": "desktop_multi_window_initial_registration_broadcast", - "status": "SOURCE_CONFIRMED", - "supporting_evidence": [ - "desktop_multi_window 0.3.0 macOS MultiWindowManager.AttachWindow registers the main-window channel and immediately calls notifyWindowsChanged.", - "notifyWindowsChanged invokes mixin.one/desktop_multi_window on every registered window.", - "The captured error occurs on the same channel during each startup without requiring a child window." - ], - "counterevidence": [ - "A newly built candidate has not yet been launched and logged after this task's native registration-order change." - ], - "isolation": "The startup send originates inside the package macOS implementation when GeneratedPlugins registers desktop_multi_window. Flutter framework source shows the implicit engine launch normally occurs later in FlutterViewController.viewWillAppear. The application-side candidate explicitly starts the attached main engine immediately before the unchanged GeneratedPlugins registration in MainFlutterWindow.awakeFromNib. Real macOS startup logs are still required before this can be called fixed." - }, - { - "rank": 2, - "id": "category_window_closes_between_calibration_and_show", - "status": "REGRESSION_TEST_REPRODUCED_AND_FIXED", - "supporting_evidence": [ - "The red test produced Bad state: window closed before show from MultiWindowManager.createCategoryWindow.", - "The manager previously reused the controller returned by getAll without rechecking liveness after show failed." - ], - "counterevidence": [ - "This race does not explain errors recorded before category-window interaction." - ], - "fix_invariant": "After show fails, re-query native windows; recreate only when the target id is absent, otherwise rethrow the platform error." - }, - { - "rank": 3, - "id": "missing_main_or_child_plugin_registration", - "status": "NOT_SUPPORTED_BY_SOURCE", - "supporting_evidence": [], - "counterevidence": [ - "MainFlutterWindow still calls RegisterGeneratedPlugins for the main FlutterViewController.", - "AppDelegate still calls RegisterGeneratedPlugins for each child FlutterViewController.", - "AppDelegate still registers file_picker_bridge on main and child engine messengers." - ] - } - ], - "dart_regression": { - "command": "flutter test test/shared/multi_window_manager_test.dart", - "red_verdict": "FAIL_AS_EXPECTED", - "red_failure": "Bad state: window closed before show", - "green_verdict": "BLOCKED_IN_CURRENT_ENVIRONMENT", - "current_environment_blocker": "Flutter attempted to update /Users/forest/development/flutter/bin/cache/engine.stamp.tmp and engine.realm, but the SDK is read-only in this task sandbox.", - "covered_invariants": [ - "A live same-category controller is reused.", - "A controller confirmed closed after show failure is not reused.", - "Calibration removes a closed category entry and reopening creates a new controller." - ] - }, - "native_registration_invariants": { - "main_generated_plugins": "PRESENT", - "main_registration_point": "MainFlutterWindow.awakeFromNib after an explicit successful FlutterEngine.run", - "child_generated_plugins": "PRESENT", - "child_registration_point": "FlutterMultiWindowPlugin onWindowCreatedCallback", - "main_file_picker_bridge": "PRESENT", - "child_file_picker_bridge": "PRESENT" - }, - "next_stage_acceptance": { - "checklist": "macos_acceptance/MACOS_SELF_TEST_CHECKLIST.md", - "artifact_requirements": [ - "Build a new macOS Release artifact from the local candidate commit.", - "Record candidate commit, bundle mtime, executable SHA256, version, build number, and bundle id.", - "Reject the 2026-08-27 Release bundle because it predates source head ce814fb." - ], - "required_results_before_macos_pass": [ - "Two clean startups with raw logs and no Invalid engine handle.", - "Two clean startups with raw logs and no Failed to send message to Flutter engine.", - "Three distinct category windows render without black frames.", - "Same-category open reuses a live window.", - "Close and reopen succeeds for at least three cycles.", - "Child-window file picker opens and cancel or selection returns safely.", - "Responsive checks use the newly built candidate artifact at narrow, 600dp, and wide viewports." - ], - "windows_self_test_precondition": "MACOS_GATE must equal PASS with all required real-window and raw-log evidence present." - }, - "macos_gate": "HOLD", - "windows_ready": false, - "hold_reasons": [ - "A fresh candidate macOS artifact has not yet completed real-window acceptance.", - "The application-side registration-order candidate has not yet demonstrated two clean native startups.", - "The previous real-process logs contain both forbidden engine-message errors.", - "Real interaction evidence for three windows, reuse, close/reopen, child file picker, and responsive viewports is not yet available." - ], - "forbidden_claims": { - "native_issue_fixed_from_flutter_test": false, - "windows_pass": false, - "windows_ready": false, - "historical_evidence_rewritten": false - } -} diff --git a/macos_acceptance/evidence/2026-09-01-full-automation/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-full-automation/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 2eabc21..0000000 --- a/macos_acceptance/evidence/2026-09-01-full-automation/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "head": "92bb625-plus-working-tree-window-timing-fix", - "configuration": "Release", - "automation_channel": "Codex Desktop Computer Use", - "checks": [ - {"id":"startup_main_first_frame","verdict":"PASS"}, - {"id":"async_category_window_first_frame","verdict":"PASS"}, - {"id":"category_close_main_process_survival","verdict":"PASS"}, - {"id":"category_close_main_window_reconnect","verdict":"PASS","evidence":"main window AX tree readable after Window-menu refocus"}, - {"id":"category_close_reopen_round_1","verdict":"PASS"}, - {"id":"category_close_reopen_round_2","verdict":"PASS"}, - {"id":"category_close_reopen_round_3","verdict":"BLOCKED","reason":"Fixed-coordinate automation did not re-target the category entry after route state changed"}, - {"id":"adsorption_line_desktop_layout","verdict":"PASS","evidence":"toolbar, canvas, status bar and controls rendered at desktop width"}, - {"id":"adsorption_line_compact_360dp","verdict":"FAIL","reason":"Toolbar color controls and line-width controls are clipped at 360dp"}, - {"id":"file_picker_native_flow","verdict":"NOT_COMPLETED"} - ], - "macos_ui_interaction":"FAIL", - "macos_gate":"HOLD", - "windows_ready":false, - "windows_self_test_started":false -} diff --git a/macos_acceptance/evidence/2026-09-01-full-automation/notes.md b/macos_acceptance/evidence/2026-09-01-full-automation/notes.md deleted file mode 100644 index a796db4..0000000 --- a/macos_acceptance/evidence/2026-09-01-full-automation/notes.md +++ /dev/null @@ -1,7 +0,0 @@ -# Full macOS UI automation - -Fresh Release automation verified startup, the asynchronous category window first frame, process survival after category close, and main-window accessibility recovery after explicit Window-menu refocus. Two close/reopen rounds passed. - -The full run found a concrete product failure in `adsorption_line`: at 360dp the horizontally scrolling toolbar still clips the color controls and line-width controls. The third lifecycle round was blocked by coordinate-based retargeting after route state changed, and the native file picker flow was not reached in this run. - -Because the compact layout failure is explicit, `macos_ui_interaction=FAIL`, `MACOS_GATE=HOLD`, and `windows_ready=false`. Windows self-test was not started. diff --git a/macos_acceptance/evidence/2026-09-01-repack-validation/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-repack-validation/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index 4e2d3fb..0000000 --- a/macos_acceptance/evidence/2026-09-01-repack-validation/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "schema": "flutter_forge.macos_ui_acceptance_report.v1", - "task": "repack_and_validate_after_engine_message_fix", - "executed_at": "2026-08-31T14:34:43+08:00", - "artifact": { - "path": "/Users/forest/code/langGraph/flutter_forge/apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app", - "source_head": "ce814fbc249bd3c36ca5ab41151bb29fe70cc980", - "source_state": "HEAD plus staged multi-window fix and regression test", - "staged_diff_sha256": "e880cf41e1b6a599079b558d22a9c1b8a722c53c5357f48b44fb054c2fd003cd", - "executable_mtime": "2026-08-31T14:34:21+08:00", - "executable_sha256": "e435751b987860f134ed7c903cf1ea2aebfcf15181fa64f2e3b44114b2ecb50e", - "version": "1.2.0", - "build": "1.2.0", - "bundle_id": "com.flutterforge.preview", - "identity_verdict": "PASS", - "provenance": [ - "logs/build-macos-release.log", - "logs/artifact-provenance.log" - ], - "note": "The bundle directory mtime was preserved by the build; executable mtime, SHA256, build log, and staged diff fingerprint prove this candidate was rebuilt in the current run." - }, - "validation": { - "targeted_test": "PASS_3_OF_3", - "quality_gate": "PASS_6_OF_6", - "release_build": "PASS", - "analyze": "PASS_NO_ISSUES", - "flutterguard": "PASS_NO_HIGH" - }, - "runtime": { - "startup_count": 2, - "startup_process_survived": true, - "invalid_engine_handle_count": 2, - "failed_engine_message_count": 2, - "fatal_signal_count": 0, - "recent_crash_report_count": 0, - "console_log": "logs/app-console.log" - }, - "automation": { - "window_read": "PASS", - "screenshot": "PASS", - "click": "BLOCKED", - "error": "Sky Computer Use native pipe closed before response", - "result": "The new candidate remained on the main catalog, so navigation and multi-window actions were not inferred as successful." - }, - "ui_results": [ - {"id":"UI-01","name":"first_release_startup","verdict":"PASS","evidence":"logs/runtime.log"}, - {"id":"UI-02","name":"second_release_startup","verdict":"PASS","evidence":"logs/runtime.log"}, - {"id":"UI-03","name":"main_catalog_first_frame","verdict":"PASS","evidence":"screenshots/UI-01-main-window.png"}, - {"id":"UI-04","name":"basic_module_enter_return","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-05","name":"state_module_enter_return","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-06","name":"platform_module_enter_return","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-07","name":"popup_table_navigation","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-08","name":"module_return_chain","verdict":"BLOCKED","reason":"No module navigation was completed."}, - {"id":"UI-09","name":"usb_android_only_state","verdict":"BLOCKED","reason":"Target catalog section was not reached."}, - {"id":"UI-10","name":"browse_all_macos_modules","verdict":"BLOCKED","reason":"Real interaction was unavailable."}, - {"id":"UI-11","name":"open_basic_category_window","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-12","name":"open_state_category_window","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-13","name":"open_platform_category_window","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-14","name":"three_category_windows","verdict":"BLOCKED","reason":"Category windows could not be opened."}, - {"id":"UI-15","name":"category_window_first_frame_no_black","verdict":"BLOCKED","reason":"No category window interaction evidence."}, - {"id":"UI-16","name":"same_category_reuse","verdict":"BLOCKED","reason":"Category windows could not be opened."}, - {"id":"UI-17","name":"close_one_category_window","verdict":"BLOCKED","reason":"Category windows could not be opened."}, - {"id":"UI-18","name":"close_all_keep_main_alive","verdict":"BLOCKED","reason":"Category windows could not be opened."}, - {"id":"UI-19","name":"reopen_category_window","verdict":"BLOCKED","reason":"Category windows could not be opened."}, - {"id":"UI-20","name":"three_open_close_reopen_cycles","verdict":"BLOCKED","reason":"Category windows could not be opened."}, - {"id":"UI-21","name":"child_window_file_picker_open","verdict":"BLOCKED","reason":"No child window was available."}, - {"id":"UI-22","name":"child_window_file_picker_cancel","verdict":"BLOCKED","reason":"The file picker could not be opened."}, - {"id":"UI-23","name":"child_window_file_picker_select","verdict":"BLOCKED","reason":"The file picker could not be opened."}, - {"id":"UI-24","name":"invalid_engine_handle_absent","verdict":"FAIL","evidence":"logs/app-console.log"}, - {"id":"UI-25","name":"failed_engine_message_absent","verdict":"FAIL","evidence":"logs/app-console.log"}, - {"id":"UI-26","name":"no_recent_crash_report","verdict":"PASS","evidence":"logs/final-runtime-evidence.log"}, - {"id":"UI-27","name":"responsive_artifact_identity","verdict":"PASS","evidence":"logs/artifact-provenance.log"}, - {"id":"UI-28","name":"debounce_throttle_open","verdict":"BLOCKED","reason":"Computer Use click failed."}, - {"id":"UI-29","name":"responsive_360dp_buttons","verdict":"BLOCKED","reason":"Module could not be opened or resized by Computer Use."}, - {"id":"UI-30","name":"responsive_360dp_counters","verdict":"BLOCKED","reason":"Module could not be opened."}, - {"id":"UI-31","name":"responsive_scroll_scenario","verdict":"BLOCKED","reason":"Module could not be opened."}, - {"id":"UI-32","name":"responsive_event_visualization","verdict":"BLOCKED","reason":"Module could not be opened."}, - {"id":"UI-33","name":"responsive_600dp_boundary","verdict":"BLOCKED","reason":"Module could not be opened or resized by Computer Use."}, - {"id":"UI-34","name":"responsive_1024dp_layout","verdict":"BLOCKED","reason":"Module could not be opened or resized by Computer Use."}, - {"id":"UI-35","name":"responsive_nested_scrolling","verdict":"BLOCKED","reason":"Module could not be opened."} - ], - "ui_summary": {"PASS":5,"FAIL":2,"BLOCKED":28,"NOT_IN_ARTIFACT":0}, - "checklist_summary": { - "P1_P9":{"PASS":9,"FAIL":0,"BLOCKED":0,"NOT_IN_ARTIFACT":0}, - "S1_S6":{"PASS":6,"FAIL":0,"BLOCKED":0,"NOT_IN_ARTIFACT":0}, - "N1_N8":{"PASS":1,"FAIL":0,"BLOCKED":7,"NOT_IN_ARTIFACT":0}, - "V1_V6":{"PASS":0,"FAIL":0,"BLOCKED":6,"NOT_IN_ARTIFACT":0}, - "M1_M15":{"PASS":1,"FAIL":2,"BLOCKED":12,"NOT_IN_ARTIFACT":0}, - "R1_R10":{"PASS":1,"FAIL":0,"BLOCKED":9,"NOT_IN_ARTIFACT":0}, - "H1_H10":{"PASS":0,"FAIL":0,"BLOCKED":10,"NOT_IN_ARTIFACT":0}, - "total":{"PASS":18,"FAIL":2,"BLOCKED":44,"NOT_IN_ARTIFACT":0} - }, - "macos_gate": "HOLD", - "windows_ready": false, - "hold_reasons": [ - "Both forbidden engine-message errors still reproduce on each startup of the rebuilt candidate.", - "Computer Use cannot complete clicks, so core navigation, multi-window, file picker, and responsive behavior remain unverified." - ], - "mutations": {"source_by_this_validation":false,"commit":false,"push":false} -} diff --git a/macos_acceptance/evidence/2026-09-01-repack-validation/NEXT_ACTION_DECISION.md b/macos_acceptance/evidence/2026-09-01-repack-validation/NEXT_ACTION_DECISION.md deleted file mode 100644 index f0a3181..0000000 --- a/macos_acceptance/evidence/2026-09-01-repack-validation/NEXT_ACTION_DECISION.md +++ /dev/null @@ -1,161 +0,0 @@ -# macOS UI 验收结果分析与下一步决策 - -依据: - -```text -macos_acceptance/evidence/2026-09-01-repack-validation/MACOS_UI_ACCEPTANCE_REPORT.json -macos_acceptance/evidence/2026-09-01-repack-validation/notes.md -``` - -## 结论 - -```json -{ - "macos_gate": "HOLD", - "windows_ready": false, - "next_action": "继续 macOS 多窗口 Engine 根因修复与真实交互验收", - "windows_full_self_test": "NOT_READY", - "responsive_status": "artifact_identity_pass_but_interaction_blocked", - "remote_push": "FORBIDDEN" -} -``` - -## 证据摘要 - -```text -fresh_artifact: PASS -artifact_identity: PASS -source_head: ce814fbc249bd3c36ca5ab41151bb29fe70cc980 -executable_mtime: 2026-08-31T14:34:21+08:00 -executable_sha256: e435751b987860f134ed7c903cf1ea2aebfcf15181fa64f2e3b44114b2ecb50e -quality_gate: PASS_6_OF_6 -targeted_test: PASS_3_OF_3 -``` - -## 阻断项 - -| id | condition | verdict | classification | action | -|---|---|---|---|---| -| B-001 | Invalid engine handle on both fresh-candidate startups | FAIL | macOS shared Engine/native lifecycle candidate | continue FIX; do not suppress logs | -| B-002 | Failed to send message to Flutter engine on both fresh-candidate startups | FAIL | desktop_multi_window startup broadcast candidate | continue FIX; reproduce by stage | -| B-003 | Codex Computer Use click pipe closed | BLOCKED | acceptance-tool/environment limitation | use functional desktop session or manual-assisted run | -| B-004 | Navigation/multi-window/file picker not actually operated | BLOCKED | missing interaction evidence | do not infer PASS | -| B-005 | Responsive artifact identity | PASS | artifact provenance | R2-R10 still require real interaction | - -## macOS 是否需要继续优化 - -```json -{ - "code_optimization": "REQUIRED", - "responsive_code_optimization": "NOT_BLOCKING_FROM_THIS_REPORT", - "acceptance_process_optimization": "REQUIRED", - "release_artifact_optimization": "NOT_REQUIRED_FOR_CURRENT_BLOCKER" -} -``` - -### 必须继续维护的代码方向 - -```text -1. desktop_multi_window 启动阶段注册广播时机 -2. 失效 Engine 是否被 desktop_multi_window 通知 -3. 主/子 Engine 的 plugin/channel 注册顺序 -4. stale window registry 与 native controller 列表一致性 -5. create/show/close/reopen 异步竞态 -``` - -当前 Dart 侧 stale controller 修复已获得: - -```text -- targeted_test: 3/3 PASS -- quality_gate: 6/6 PASS -``` - -但它不能解释启动前已出现的 Engine message 错误。因此该修复只能标记为局部正确,不能关闭 macOS 门禁。 - -## 是否启动 Windows 自测 - -```json -{ - "windows_preflight": "ALLOW", - "windows_full_self_test": "HOLD", - "windows_final_acceptance": "HOLD" -} -``` - -### 允许提前做的 Windows 工作 - -```text -1. 检查 Windows 测试机器、权限、Event Viewer 和 UI 自动化能力 -2. 准备 setup.exe 及 SHA256 -3. 确认 Windows 产物对应当前源码候选 -4. 准备 windows_acceptance/evidence// 目录 -5. 预置安装、卸载、崩溃日志和截图采集流程 -6. 确认 integration_test -d windows 的执行条件 -``` - -### 当前禁止宣布或执行的 Windows 结论 - -```text -1. 不得宣布 Windows PASS -2. 不得宣布 Windows ready -3. 不得用 v1.2.2 旧产物验收当前响应式提交 -4. 不得用 Windows 结果替代 macOS 多窗口证据 -5. 不得在 macOS_GATE=HOLD 时执行 Windows 完整放行验收 -``` - -## macOS 放行门槛 - -只有下列条件全部满足,才允许将 `windows_ready` 改为 `true`: - -```text -M-01 fresh macOS artifact identity = PASS -M-02 startup x2 without Invalid engine handle = PASS -M-03 startup x2 without Failed to send message to Flutter engine = PASS -M-04 basic/state/platform three category windows = PASS -M-05 child file picker open + cancel/selection = PASS -M-06 same-category reuse = PASS -M-07 close + reopen >= 3 cycles = PASS -M-08 main window survives = PASS -M-09 responsive debounce_throttle 360/600/1024 = PASS -M-10 raw logs + screenshots complete = PASS -``` - -## 下一阶段执行顺序 - -```text -STEP-1 继续 macOS Engine 错误 FIX -STEP-2 本地 targeted test + quality_gate -STEP-3 重新构建最新 macOS Release -STEP-4 真实桌面会话执行 macOS 多窗口与 file_picker -STEP-5 真实桌面会话执行 debounce_throttle 响应式验收 -STEP-6 生成 MACOS_GATE=PASS/HOLD -STEP-7 仅当 PASS 时准备并启动 Windows 完整自测 -``` - -## 不应做的优化 - -```text -- 不要为了通过验收过滤 Engine 错误日志 -- 不要把无 crash report 当成 Engine 安全证明 -- 不要继续修改已通过的 debounce_throttle 响应式代码,除非真实视口测试发现新缺陷 -- 不要重做 LearningScaffold 全局响应式封装 -- 不要修改 NavigationPolicy 600dp 语义 -- 不要恢复 Windows USB -- 不要提前实施无障碍第二阶段 -- 不要推送远端 -``` - -## 最终决策 - -```text -macOS 产物:PASS -macOS 自动化可用性:BLOCKED -macOS Engine 日志:FAIL -macOS 多窗口功能:未完成验证 -macOS 响应式功能:产物已包含当前代码,但真实交互未完成 -MACOS_GATE:HOLD -Windows preflight:可以准备 -Windows full self-test:暂缓 -Windows ready:false -下一步:继续 macOS Engine 修复 + 获取可用真实桌面交互通道 -``` diff --git a/macos_acceptance/evidence/2026-09-01-repack-validation/notes.md b/macos_acceptance/evidence/2026-09-01-repack-validation/notes.md deleted file mode 100644 index d4a0c0c..0000000 --- a/macos_acceptance/evidence/2026-09-01-repack-validation/notes.md +++ /dev/null @@ -1,50 +0,0 @@ -# macOS 重新打包验证结果 - -## 结论 - -- 新 Release 构建:`PASS` -- 定向测试:`3/3 PASS` -- 质量门禁:`6/6 PASS` -- 产物身份:`PASS` -- macOS 界面门禁:`HOLD` -- Windows 放行:`false` - -新产物确实由当前工作树重新构建。主可执行文件修改时间为 2026-08-31 14:34:21 +08:00,SHA256 为 `e435751b987860f134ed7c903cf1ea2aebfcf15181fa64f2e3b44114b2ecb50e`。构建包含 HEAD `ce814fbc249bd3c36ca5ab41151bb29fe70cc980` 以及暂存的多窗口修复;暂存 diff 指纹为 `e880cf41e1b6a599079b558d22a9c1b8a722c53c5357f48b44fb054c2fd003cd`。 - -## 关键失败 - -两次启动新 Release 均出现: - -```text -Invalid engine handle -Failed to send message to Flutter engine on channel 'mixin.one/desktop_multi_window' -``` - -因此 M13、M14 / UI-24、UI-25 继续为 `FAIL`。当前 Dart 修复通过了 stale controller、live controller 复用和关闭后重建测试,但没有消除启动期 `desktop_multi_window` 原生广播错误。 - -## 界面执行 - -新产物首屏正常渲染并已截图。Computer Use 能读取窗口和保存截图,但坐标点击时仍返回 `Sky Computer Use native pipe closed before response`,没有产生导航。因此导航、多窗口、file_picker 和响应式交互全部据实标记 `BLOCKED`。 - -本轮响应式项目不再是 `NOT_IN_ARTIFACT`:产物已包含 `ce814fb`,但缺少真实交互证据,故 R2-R10 为 `BLOCKED`。 - -## 汇总 - -- UI-01 至 UI-35:`PASS 5 / FAIL 2 / BLOCKED 28` -- 完整自测清单:`PASS 18 / FAIL 2 / BLOCKED 44` -- `MACOS_GATE=HOLD` -- `windows_ready=false` - -## 证据 - -- `MACOS_UI_ACCEPTANCE_REPORT.json` -- `screenshots/UI-01-main-window.png` -- `logs/targeted-test.log` -- `logs/quality-gate.log` -- `logs/build-macos-release.log` -- `logs/artifact-provenance.log` -- `logs/runtime.log` -- `logs/app-console.log` -- `logs/final-runtime-evidence.log` - -本轮未修改源码、未 commit、未 push;仅构建产物并新增本轮验收证据。 diff --git a/macos_acceptance/evidence/2026-09-01-repack-validation/screenshots/UI-01-main-window.png b/macos_acceptance/evidence/2026-09-01-repack-validation/screenshots/UI-01-main-window.png deleted file mode 100644 index 13bd53e..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-repack-validation/screenshots/UI-01-main-window.png and /dev/null differ diff --git a/macos_acceptance/evidence/2026-09-01-startup-order-validation/MACOS_UI_ACCEPTANCE_REPORT.json b/macos_acceptance/evidence/2026-09-01-startup-order-validation/MACOS_UI_ACCEPTANCE_REPORT.json deleted file mode 100644 index f2f1c72..0000000 --- a/macos_acceptance/evidence/2026-09-01-startup-order-validation/MACOS_UI_ACCEPTANCE_REPORT.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "schema": "flutter_forge.macos_ui_acceptance_report.v1", - "task": "validate_engine_startup_order_fix", - "source": { - "head": "b80e41fb53aa01498b587d7fbe54bfb21b2f578e", - "subject": "fix(macos): start engine before multi-window registration", - "upstream_head": "dfdbc1dbf8db5e25b714309251fdd76a6efd4fc1" - }, - "claimed_temp_self_test": { - "path": "/private/var/folders/x4/dv4r23qn30q5dzvvkmf83z2c0000gn/T/ResultBundle_2026-31-08_15-35-0015.xcresult", - "result": "UNKNOWN", - "total_test_count": 0, - "passed_test_count": 0, - "failed_test_count": 0, - "acceptance_value": "NO_PASS_EVIDENCE" - }, - "artifact": { - "path": "/Users/forest/code/langGraph/flutter_forge/apps/flutter_forge/build/macos/Build/Products/Release/Flutter Forge.app", - "identity_verdict": "PASS", - "executable_mtime": "2026-08-31T15:47:03+08:00", - "executable_sha256": "327597c619ec028a063f9c4ebfe437f9cc0bc627730f7c9b6a67a6b285dfc3db", - "version": "1.2.0", - "build": "1.2.0" - }, - "validation": { - "targeted_multi_window_tests": "PASS_3_OF_3", - "quality_gate": "PASS_6_OF_6", - "release_build": "PASS", - "analyze": "PASS_NO_ISSUES", - "flutterguard": "PASS_NO_HIGH" - }, - "runtime": { - "startup_count": 2, - "process_survived": true, - "invalid_engine_handle_count": 0, - "failed_engine_message_count": 0, - "fatal_signal_count": 0, - "recent_crash_report_count": 0, - "startup_engine_fix_verdict": "PASS" - }, - "automation": { - "window_read": "PASS", - "screenshot": "PASS", - "click": "BLOCKED", - "error": "Sky Computer Use native pipe closed before response" - }, - "ui_results": [ - {"id":"UI-01","verdict":"PASS","name":"first_release_startup"}, - {"id":"UI-02","verdict":"PASS","name":"second_release_startup"}, - {"id":"UI-03","verdict":"PASS","name":"main_catalog_first_frame"}, - {"id":"UI-04","verdict":"BLOCKED","name":"basic_module_enter_return"}, - {"id":"UI-05","verdict":"BLOCKED","name":"state_module_enter_return"}, - {"id":"UI-06","verdict":"BLOCKED","name":"platform_module_enter_return"}, - {"id":"UI-07","verdict":"BLOCKED","name":"popup_table_navigation"}, - {"id":"UI-08","verdict":"BLOCKED","name":"module_return_chain"}, - {"id":"UI-09","verdict":"BLOCKED","name":"usb_android_only_state"}, - {"id":"UI-10","verdict":"BLOCKED","name":"browse_all_macos_modules"}, - {"id":"UI-11","verdict":"BLOCKED","name":"open_basic_category_window"}, - {"id":"UI-12","verdict":"BLOCKED","name":"open_state_category_window"}, - {"id":"UI-13","verdict":"BLOCKED","name":"open_platform_category_window"}, - {"id":"UI-14","verdict":"BLOCKED","name":"three_category_windows"}, - {"id":"UI-15","verdict":"BLOCKED","name":"category_window_first_frame_no_black"}, - {"id":"UI-16","verdict":"BLOCKED","name":"same_category_reuse"}, - {"id":"UI-17","verdict":"BLOCKED","name":"close_one_category_window"}, - {"id":"UI-18","verdict":"BLOCKED","name":"close_all_keep_main_alive"}, - {"id":"UI-19","verdict":"BLOCKED","name":"reopen_category_window"}, - {"id":"UI-20","verdict":"BLOCKED","name":"three_open_close_reopen_cycles"}, - {"id":"UI-21","verdict":"BLOCKED","name":"child_window_file_picker_open"}, - {"id":"UI-22","verdict":"BLOCKED","name":"child_window_file_picker_cancel"}, - {"id":"UI-23","verdict":"BLOCKED","name":"child_window_file_picker_select"}, - {"id":"UI-24","verdict":"PASS","name":"invalid_engine_handle_absent"}, - {"id":"UI-25","verdict":"PASS","name":"failed_engine_message_absent"}, - {"id":"UI-26","verdict":"PASS","name":"no_recent_crash_report"}, - {"id":"UI-27","verdict":"PASS","name":"responsive_artifact_identity"}, - {"id":"UI-28","verdict":"BLOCKED","name":"debounce_throttle_open"}, - {"id":"UI-29","verdict":"BLOCKED","name":"responsive_360dp_buttons"}, - {"id":"UI-30","verdict":"BLOCKED","name":"responsive_360dp_counters"}, - {"id":"UI-31","verdict":"BLOCKED","name":"responsive_scroll_scenario"}, - {"id":"UI-32","verdict":"BLOCKED","name":"responsive_event_visualization"}, - {"id":"UI-33","verdict":"BLOCKED","name":"responsive_600dp_boundary"}, - {"id":"UI-34","verdict":"BLOCKED","name":"responsive_1024dp_layout"}, - {"id":"UI-35","verdict":"BLOCKED","name":"responsive_nested_scrolling"} - ], - "ui_summary": {"PASS": 7, "FAIL": 0, "BLOCKED": 28, "NOT_IN_ARTIFACT": 0}, - "checklist_summary": { - "P1_P9":{"PASS":9,"FAIL":0,"BLOCKED":0}, - "S1_S6":{"PASS":6,"FAIL":0,"BLOCKED":0}, - "N1_N8":{"PASS":1,"FAIL":0,"BLOCKED":7}, - "V1_V6":{"PASS":0,"FAIL":0,"BLOCKED":6}, - "M1_M15":{"PASS":3,"FAIL":0,"BLOCKED":12}, - "R1_R10":{"PASS":1,"FAIL":0,"BLOCKED":9}, - "H1_H10":{"PASS":0,"FAIL":0,"BLOCKED":10}, - "total":{"PASS":20,"FAIL":0,"BLOCKED":44} - }, - "macos_gate": "HOLD", - "windows_ready": false, - "hold_reasons": [ - "The startup engine-message defect is fixed in two real-process launches, but three-category windows, reuse, close/reopen, child file picker, video, and responsive interactions remain unexecuted because Computer Use clicks fail." - ], - "mutations": {"commit": false, "push": false} -} diff --git a/macos_acceptance/evidence/2026-09-01-startup-order-validation/notes.md b/macos_acceptance/evidence/2026-09-01-startup-order-validation/notes.md deleted file mode 100644 index 413378f..0000000 --- a/macos_acceptance/evidence/2026-09-01-startup-order-validation/notes.md +++ /dev/null @@ -1,45 +0,0 @@ -# macOS Engine 启动顺序修复验收 - -## 结论 - -- Engine 启动错误修复:`PASS` -- 当前完整 macOS 门禁:`HOLD` -- Windows 放行:`false` -- UI-01 至 UI-35:`PASS 7 / FAIL 0 / BLOCKED 28` - -提交 `b80e41f` 的新 Release 连续启动两次,`Invalid engine handle` 和 `Failed to send message to Flutter engine` 均为 0。M13、M14 已从上一轮 FAIL 转为 PASS。 - -## 临时自测声明核验 - -用户给出的临时目录中发现: - -```text -/private/var/folders/x4/dv4r23qn30q5dzvvkmf83z2c0000gn/T/ResultBundle_2026-31-08_15-35-0015.xcresult -``` - -`xcresulttool` 显示 `result=unknown`、`totalTestCount=0`、`passedTests=0`、`failedTests=0`。它不能作为测试通过证据。本轮独立执行的仓库定向测试为 3/3 PASS,质量门禁为 6/6 PASS。 - -## 新产物 - -- HEAD:`b80e41fb53aa01498b587d7fbe54bfb21b2f578e` -- 可执行文件时间:2026-08-31 15:47:03 +08:00 -- SHA256:`327597c619ec028a063f9c4ebfe437f9cc0bc627730f7c9b6a67a6b285dfc3db` -- Release 构建:PASS,52.2MB - -## 剩余阻塞 - -Computer Use 可以读取窗口和保存截图,但点击 Flutter Forge 时仍返回 `Sky Computer Use native pipe closed before response`。因此三分类窗口、同类复用、关闭重开三轮、子窗口 file_picker、视频和响应式视口尚无真实交互证据,不能把完整 macOS 门禁判为 PASS。 - -## 证据 - -- `MACOS_UI_ACCEPTANCE_REPORT.json` -- `screenshots/UI-01-main-window.png` -- `logs/targeted-test.log` -- `logs/quality-gate.log` -- `logs/build-macos-release.log` -- `logs/artifact-provenance.log` -- `logs/runtime.log` -- `logs/app-console.log` -- `logs/final-runtime-evidence.log` - -本轮未修改源码、未 commit、未 push。 diff --git a/macos_acceptance/evidence/2026-09-01-startup-order-validation/screenshots/UI-01-main-window.png b/macos_acceptance/evidence/2026-09-01-startup-order-validation/screenshots/UI-01-main-window.png deleted file mode 100644 index 13bd53e..0000000 Binary files a/macos_acceptance/evidence/2026-09-01-startup-order-validation/screenshots/UI-01-main-window.png and /dev/null differ diff --git a/packages/desktop_multi_window/AI_ANALYSIS.md b/packages/desktop_multi_window/AI_ANALYSIS.md new file mode 100644 index 0000000..4049d0c --- /dev/null +++ b/packages/desktop_multi_window/AI_ANALYSIS.md @@ -0,0 +1,11 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "index", + "node": {"id":"desktop_multi_window","kind":"workspace_package","package":"desktop_multi_window","path":"packages/desktop_multi_window","status":"active"}, + "entrypoints": ["lib/desktop_multi_window.dart"], + "owns": ["desktop_window_backend"], + "depends": ["flutter"], + "children": [], + "contracts": {"no_natural_language":true,"index_only":true,"max_index_depth":2,"doc_consumer":"coding_agent","doc_mode":"machine_contract"}, + "validation": ["flutter analyze"] +} diff --git a/packages/desktop_multi_window/CHANGELOG.md b/packages/desktop_multi_window/CHANGELOG.md new file mode 100644 index 0000000..27bbd18 --- /dev/null +++ b/packages/desktop_multi_window/CHANGELOG.md @@ -0,0 +1,25 @@ +## 0.3.0 + +* [BREAK CHANGE] rewritten, please refer to readme + +## 0.2.1 + +* bug fixed + +## 0.2.0 +* Added the ability to determine whether a created window will be resizable or not +([#101](https://github.com/MixinNetwork/flutter-plugins/issues/101) and [#130](https://github.com/MixinNetwork/flutter-plugins/pull/130)) + +## 0.1.0 + +* [BREAK CHANGE] upgrade min flutter version to 3.0.0 +* fix macOS memory leak issue. [#123](https://github.com/MixinNetwork/flutter-plugins/issues/123) + +## 0.0.2 + +* [Windows] fix free window_channel_ may cause crash. [#78](https://github.com/MixinNetwork/flutter-plugins/pull/78) +* add getAllSubWindowIds api. [#77](https://github.com/MixinNetwork/flutter-plugins/pull/77) + +## 0.0.1 + +* Initial release. support Linux, macOS, Windows. diff --git a/packages/desktop_multi_window/LICENSE b/packages/desktop_multi_window/LICENSE new file mode 100644 index 0000000..2832753 --- /dev/null +++ b/packages/desktop_multi_window/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2021] [Mixin] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/packages/desktop_multi_window/README.md b/packages/desktop_multi_window/README.md new file mode 100644 index 0000000..d831cf5 --- /dev/null +++ b/packages/desktop_multi_window/README.md @@ -0,0 +1,303 @@ +# desktop_multi_window + +[![Pub](https://img.shields.io/pub/v/desktop_multi_window.svg)](https://pub.dev/packages/desktop_multi_window) + +A Flutter plugin to create and manage multiple windows on desktop platforms. + +| | | +|---------|-----| +| Windows | ✅ | +| Linux | ✅ | +| macOS | ✅ | + +## Installation + +Add `desktop_multi_window` to your `pubspec.yaml`: + +```yaml +dependencies: + desktop_multi_window: ^latest_version +``` + +## Getting Started + +### 1. Initialize Multi-Window Support + +In your `main()` function, initialize multi-window support before running your app: + +```dart +import 'package:desktop_multi_window/desktop_multi_window.dart'; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + + // Get the current window controller + final windowController = await WindowController.fromCurrentEngine(); + + // Parse window arguments to determine which window to show + final arguments = parseArguments(windowController.arguments); + + // Run different apps based on the window type + switch (arguments.type) { + case YourArgumentDefinitions.main: + runApp(const MainWindow()); + case YourArgumentDefinitions.sample: + runApp(const SampleWindow()); + // Add more window types as needed + } +} +``` + +### 2. Create New Windows + +Use `WindowController.create()` to create and manage new windows: + +```dart +// Create a new window +final controller = await WindowController.create( + WindowConfiguration( + hiddenAtLaunch: true, + arguments: 'YOUR_WINDOW_ARGUMENTS_HERE', + ), +); + +// Show the window (if hidden at launch) +await controller.show(); +``` + +### 3. Manage Existing Windows + +Get all window controllers and manage them: + +```dart +// Get all windows +final controllers = await WindowController.getAll(); + +// Find a specific window by business ID +for (var controller in controllers) { + final args = parseArguments(controller.arguments); + // Check window type + if (args.type == YourArgumentDefinitions.sample) { + await controller.center(); + await controller.show(); + return; + } +} + +// Listen to window changes +onWindowsChanged.listen((_) { + // Handle window changes +}); +``` + +### 4. Communication Between Windows + +Use `WindowMethodChannel` for bidirectional communication between windows: + +```dart +// In the target window, set up a method call handler +const channel = WindowMethodChannel('my_channel'); +channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'play': + // Handle the method call + return 'success'; + default: + throw MissingPluginException('Not implemented: ${call.method}'); + } +}); + +// From another window, invoke methods +const channel = WindowMethodChannel('my_channel'); +final result = await channel.invokeMethod('play'); +``` + +### 5. Extend WindowController with Custom Methods + +Create an extension to add custom functionality: + +```dart +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:window_manager/window_manager.dart'; + +extension WindowControllerExtension on WindowController { + Future doCustomInitialize() async { + return await setWindowMethodHandler((call) async { + switch (call.method) { + case 'window_center': + return await windowManager.center(); + case 'window_close': + return await windowManager.close(); + default: + throw MissingPluginException('Not implemented: ${call.method}'); + } + }); + } + + Future center() { + return invokeMethod('window_center'); + } + + Future close() { + return invokeMethod('window_close'); + } +} +``` + +And now, you can center or close the window in the other window: + +```dart +final controller = await WindowController.fromWindowId(other_window_id); + +// Center the window +await controller.center(); + +// Close the window +await controller.close(); +``` + +## Working with Plugins in Sub-Windows + +Each window created by this plugin has its own dedicated Flutter engine. Method channels cannot be shared between engines, so plugins must be manually registered for each new window. + +### Platform-Specific Plugin Registration + +#### Windows + +Edit `windows/runner/flutter_window.cpp`: + +1. Add the include at the top of the file: + +```diff + #include "flutter_window.h" + + #include + + #include "flutter/generated_plugin_registrant.h" ++#include "desktop_multi_window/desktop_multi_window_plugin.h" +``` + +2. Register the callback in the `OnCreate()` method: + +```diff + RegisterPlugins(flutter_controller_->engine()); ++ DesktopMultiWindowSetWindowCreatedCallback([](void *controller) { ++ auto *flutter_view_controller = ++ reinterpret_cast(controller); ++ auto *registry = flutter_view_controller->engine(); ++ RegisterPlugins(registry); ++ }); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); +``` + +The `RegisterPlugins` function will automatically register all plugins for each new window. + +#### macOS + +Edit `macos/Runner/MainFlutterWindow.swift`: + +1. Add the import at the top of the file: + +```diff + import Cocoa + import FlutterMacOS ++import desktop_multi_window +``` + +2. Register the callback in the `awakeFromNib()` method: + +```diff + RegisterGeneratedPlugins(registry: flutterViewController) + ++ FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in ++ // Register the plugin which you want access from other isolate. ++ RegisterGeneratedPlugins(registry: controller) ++ } ++ + super.awakeFromNib() +``` + +The `RegisterGeneratedPlugins` function will automatically register all plugins for each new window. + +#### Linux + +Edit `linux/my_application.cc`: + +1. Add the include at the top of the file: + +```diff + #include "my_application.h" + + #include + #ifdef GDK_WINDOWING_X11 + #include + #endif + + #include "flutter/generated_plugin_registrant.h" + ++#include "desktop_multi_window/desktop_multi_window_plugin.h" +``` + +2. Register the callback in the `my_application_activate()` function: + +```diff + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + ++ desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry){ ++ fl_register_plugins(registry); ++ }); ++ + gtk_widget_grab_focus(GTK_WIDGET(view)); +``` + +The `fl_register_plugins` function will automatically register all plugins for each new window. + +## Integration with window_manager + +This plugin works great with [window_manager](https://pub.dev/packages/window_manager) to control window properties: + +by now, you should this fork version with a bit fix + +```yaml + window_manager: + git: + url: https://github.com/boyan01/window_manager.git + path: packages/window_manager + ref: 6fae92d21b4c80ce1b8f71c1190d7970cf722bd4 +``` + +```dart +import 'package:window_manager/window_manager.dart'; + +// Configure window options +WindowOptions windowOptions = const WindowOptions( + size: Size(800, 600), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.hidden, +); + +windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); +}); + +// Prevent window from closing immediately +windowManager.setPreventClose(true); +windowManager.addListener(this); // Must implement WindowListener +``` + +## Example + +Check out the [example](example) directory for a complete working application that demonstrates: +- Creating multiple window types +- Single instance vs multi-instance windows +- Communication between windows +- Custom window extensions +- Plugin registration for video playback +- Window lifecycle management + +## License + +MIT diff --git a/packages/desktop_multi_window/analysis_options.yaml b/packages/desktop_multi_window/analysis_options.yaml new file mode 100644 index 0000000..5b656bd --- /dev/null +++ b/packages/desktop_multi_window/analysis_options.yaml @@ -0,0 +1,17 @@ +include: package:lints/recommended.yaml + +analyzer: + exclude: + - build/** + - windows/** + - macos/** + - linux/** + language: + strict-raw-types: true + strong-mode: + implicit-casts: false + +linter: + rules: + - prefer_relative_imports + - prefer_single_quotes \ No newline at end of file diff --git a/packages/desktop_multi_window/example/README.md b/packages/desktop_multi_window/example/README.md new file mode 100644 index 0000000..b779a86 --- /dev/null +++ b/packages/desktop_multi_window/example/README.md @@ -0,0 +1,16 @@ +# flutter_multi_window_example + +Demonstrates how to use the flutter_multi_window plugin. + +## 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://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/desktop_multi_window/example/analysis_options.yaml b/packages/desktop_multi_window/example/analysis_options.yaml new file mode 100644 index 0000000..b0a8005 --- /dev/null +++ b/packages/desktop_multi_window/example/analysis_options.yaml @@ -0,0 +1,35 @@ +# 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. +analyzer: + exclude: + - build/** + - windows/** + - macos/** + - linux/** +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-lang.github.io/linter/lints/index.html. + # + # 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/packages/desktop_multi_window/example/lib/extensions/window_controller.dart b/packages/desktop_multi_window/example/lib/extensions/window_controller.dart new file mode 100644 index 0000000..4c776ab --- /dev/null +++ b/packages/desktop_multi_window/example/lib/extensions/window_controller.dart @@ -0,0 +1,27 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/services.dart'; +import 'package:window_manager/window_manager.dart'; + +extension WindowControllerExtension on WindowController { + Future doCustomInitialize() async { + return await setWindowMethodHandler((call) async { + switch (call.method) { + case 'window_center': + return await windowManager.center(); + case 'window_close': + return await windowManager.close(); + default: + throw MissingPluginException( + 'Not implemented method: ${call.method}'); + } + }); + } + + Future center() { + return invokeMethod('window_center'); + } + + Future close() { + return invokeMethod('window_close'); + } +} diff --git a/packages/desktop_multi_window/example/lib/main.dart b/packages/desktop_multi_window/example/lib/main.dart new file mode 100644 index 0000000..470dc52 --- /dev/null +++ b/packages/desktop_multi_window/example/lib/main.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:mixin_logger/mixin_logger.dart'; +import 'package:window_manager/window_manager.dart'; + +import 'extensions/window_controller.dart'; +import 'windows/argumet.dart'; +import 'windows/main_window.dart'; +import 'windows/sample_window.dart'; +import 'windows/video_player_window.dart'; +import 'package:fvp/fvp.dart' as fvp; + +Future main(List args) async { + i('App started with arguments: $args'); + WidgetsFlutterBinding.ensureInitialized(); + await windowManager.ensureInitialized(); + final windowController = await WindowController.fromCurrentEngine(); + windowController.doCustomInitialize(); + final arguments = WindowArguments.fromArguments(windowController.arguments); + i('Window arguments: $arguments'); + switch (arguments.businessId) { + case WindowArguments.businessIdMain: + runApp(const ExampleMainWindow()); + case WindowArguments.businessIdVideoPlayer: + fvp.registerWith(); + + WindowOptions windowOptions = const WindowOptions( + size: Size(800, 600), + center: true, + backgroundColor: Colors.transparent, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.hidden, + windowButtonVisibility: false, + ); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + runApp(const VideoPlayerWindow()); + + case WindowArguments.businessIdSample: + WindowOptions windowOptions = const WindowOptions( + size: Size(600, 400), + center: true, + backgroundColor: Colors.transparent, + windowButtonVisibility: false, + ); + windowManager.waitUntilReadyToShow(windowOptions, () async { + await windowManager.show(); + await windowManager.focus(); + }); + runApp(const SampleWindow()); + } +} diff --git a/packages/desktop_multi_window/example/lib/widgets/window_caption.dart b/packages/desktop_multi_window/example/lib/widgets/window_caption.dart new file mode 100644 index 0000000..73dd20a --- /dev/null +++ b/packages/desktop_multi_window/example/lib/widgets/window_caption.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; + +class CustomWindowCaption extends StatefulWidget { + const CustomWindowCaption({ + super.key, + this.title, + this.backgroundColor, + this.brightness, + this.onClose, + }); + + final Widget? title; + final Color? backgroundColor; + final Brightness? brightness; + final VoidCallback? onClose; + + @override + State createState() => _CustomWindowCaptionState(); +} + +class _CustomWindowCaptionState extends State + with WindowListener { + @override + void initState() { + windowManager.addListener(this); + super.initState(); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: widget.backgroundColor ?? + (widget.brightness == Brightness.dark + ? const Color(0xff1C1C1C) + : Colors.transparent), + ), + child: Row( + children: [ + Expanded( + child: DragToMoveArea( + child: SizedBox( + height: double.infinity, + child: Row( + children: [ + Container( + padding: const EdgeInsets.only(left: 16), + child: DefaultTextStyle( + style: TextStyle( + color: widget.brightness == Brightness.light + ? Colors.black.withValues(alpha: 0.8956) + : Colors.white, + fontSize: 14, + ), + child: widget.title ?? Container(), + ), + ), + ], + ), + ), + ), + ), + WindowCaptionButton.minimize( + brightness: widget.brightness, + onPressed: () async { + bool isMinimized = await windowManager.isMinimized(); + if (isMinimized) { + windowManager.restore(); + } else { + windowManager.minimize(); + } + }, + ), + FutureBuilder( + future: windowManager.isMaximized(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.data == true) { + return WindowCaptionButton.unmaximize( + brightness: widget.brightness, + onPressed: () { + windowManager.unmaximize(); + }, + ); + } + return WindowCaptionButton.maximize( + brightness: widget.brightness, + onPressed: () { + windowManager.maximize(); + }, + ); + }, + ), + WindowCaptionButton.close( + brightness: widget.brightness, + onPressed: () { + if (widget.onClose != null) { + widget.onClose?.call(); + } else { + windowManager.close(); + } + }, + ), + ], + ), + ); + } + + @override + void onWindowMaximize() { + setState(() {}); + } + + @override + void onWindowUnmaximize() { + setState(() {}); + } +} diff --git a/packages/desktop_multi_window/example/lib/widgets/window_list.dart b/packages/desktop_multi_window/example/lib/widgets/window_list.dart new file mode 100644 index 0000000..a5f3674 --- /dev/null +++ b/packages/desktop_multi_window/example/lib/widgets/window_list.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_multi_window_example/extensions/window_controller.dart'; +import 'package:mixin_logger/mixin_logger.dart'; + +import '../windows/argumet.dart'; + +class WindowList extends StatefulWidget { + const WindowList({super.key}); + + @override + State createState() => _WindowListState(); +} + +class _WindowListState extends State { + var _controllers = []; + var _windowArguments = []; + + StreamSubscription? _windowsChangedSubscription; + + @override + void initState() { + super.initState(); + _refreshWindows(); + _windowsChangedSubscription = onWindowsChanged.listen((_) { + i('Windows changed event received1'); + _refreshWindows(); + }); + } + + @override + void dispose() { + _windowsChangedSubscription?.cancel(); + super.dispose(); + } + + Future _refreshWindows() async { + _controllers = await WindowController.getAll(); + setState(() { + _windowArguments = _controllers + .map((e) => WindowArguments.fromArguments(e.arguments)) + .toList(); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TextButton( + onPressed: _refreshWindows, + child: const Text('Refresh Windows'), + ), + for (var i = 0; i < _controllers.length; i++) + ListTile( + title: Text('Window ID: ${_controllers[i].windowId}'), + subtitle: Text('Arguments: ${_windowArguments[i]}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + _controllers[i].center(); + }, + icon: const Icon(Icons.center_focus_strong)), + IconButton( + icon: const Icon(Icons.close), + onPressed: () async { + await _controllers[i].close(); + }, + ), + ], + )), + ], + ); + } +} diff --git a/packages/desktop_multi_window/example/lib/windows/argumet.dart b/packages/desktop_multi_window/example/lib/windows/argumet.dart new file mode 100644 index 0000000..0f05df9 --- /dev/null +++ b/packages/desktop_multi_window/example/lib/windows/argumet.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; + +abstract class WindowArguments { + const WindowArguments(); + + static const String businessIdMain = 'main'; + static const String businessIdVideoPlayer = 'video_player'; + static const String businessIdSample = 'sample'; + + factory WindowArguments.fromArguments(String arguments) { + if (arguments == '') { + return const MainWindowArguments(); + } + final json = jsonDecode(arguments) as Map; + final businessId = json['businessId'] as String? ?? ''; + switch (businessId) { + case businessIdVideoPlayer: + return VideoPlayerWindowArguments.fromJson(json); + case businessIdSample: + return SampleWindowArguments.fromJson(json); + default: + throw Exception('Unknown businessId: $businessId'); + } + } + + Map toJson(); + + String get businessId; + + String toArguments() => jsonEncode({"businessId": businessId, ...toJson()}); + + @override + String toString() { + return 'WindowArguments(businessId: $businessId, data: ${toJson()})'; + } +} + +class MainWindowArguments extends WindowArguments { + const MainWindowArguments(); + + @override + Map toJson() { + return {}; + } + + @override + String get businessId => WindowArguments.businessIdMain; +} + +class VideoPlayerWindowArguments extends WindowArguments { + const VideoPlayerWindowArguments({required this.videoUrl}); + + factory VideoPlayerWindowArguments.fromJson(Map json) { + return VideoPlayerWindowArguments( + videoUrl: json['videoUrl'] as String? ?? '', + ); + } + + final String videoUrl; + + @override + Map toJson() { + return {'videoUrl': videoUrl}; + } + + @override + String get businessId => WindowArguments.businessIdVideoPlayer; +} + +class SampleWindowArguments extends WindowArguments { + const SampleWindowArguments(); + + factory SampleWindowArguments.fromJson(Map json) { + return const SampleWindowArguments(); + } + + @override + Map toJson() { + return {}; + } + + @override + String get businessId => WindowArguments.businessIdSample; +} diff --git a/packages/desktop_multi_window/example/lib/windows/main_window.dart b/packages/desktop_multi_window/example/lib/windows/main_window.dart new file mode 100644 index 0000000..094997b --- /dev/null +++ b/packages/desktop_multi_window/example/lib/windows/main_window.dart @@ -0,0 +1,140 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_multi_window_example/extensions/window_controller.dart'; +import 'package:mixin_logger/mixin_logger.dart'; + +import '../widgets/window_list.dart'; +import 'argumet.dart'; + +class ExampleMainWindow extends StatefulWidget { + const ExampleMainWindow({Key? key}) : super(key: key); + + @override + State createState() => _ExampleMainWindowState(); +} + +class _ExampleMainWindowState extends State { + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Plugin example app'), actions: const [ + SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(), + ), + ]), + body: SingleChildScrollView( + child: Column( + children: [ + TextButton( + onPressed: () async { + final controller = await WindowController.create( + WindowConfiguration( + hiddenAtLaunch: true, + arguments: const VideoPlayerWindowArguments( + videoUrl: '', + ).toArguments(), + ), + ); + d( + 'Created video player window: ${controller.windowId} ${controller.arguments}', + ); + }, + child: const Text('Launch video player window'), + ), + Row( + children: [ + TextButton( + onPressed: () async { + // check existing windows + final controllers = WindowController.getAll(); + for (var controller in await controllers) { + final args = + WindowArguments.fromArguments(controller.arguments); + if (args.businessId == + WindowArguments.businessIdSample) { + await controller.center(); + await controller.show(); + return; + } + } + + final controller = await WindowController.create( + WindowConfiguration( + hiddenAtLaunch: true, + arguments: + const SampleWindowArguments().toArguments(), + ), + ); + d( + 'Created sample window: ${controller.windowId} ${controller.arguments}', + ); + }, + child: const Text('Sample window (single instance)'), + ), + TextButton( + onPressed: () async { + final controller = await WindowController.create( + WindowConfiguration( + hiddenAtLaunch: true, + arguments: + const SampleWindowArguments().toArguments(), + ), + ); + d( + 'Created sample window: ${controller.windowId} ${controller.arguments}', + ); + }, + child: const Text('Sample window (multi instance)'), + ), + TextButton( + onPressed: () async { + for (int i = 0; i < 15; i++) { + await WindowController.create( + WindowConfiguration( + hiddenAtLaunch: true, + arguments: + const SampleWindowArguments().toArguments(), + ), + ); + } + }, + child: const Text('Batch sample window'), + ), + TextButton( + onPressed: () async { + final controllers = WindowController.getAll(); + for (var controller in await controllers) { + final args = + WindowArguments.fromArguments(controller.arguments); + if (args.businessId == + WindowArguments.businessIdSample) { + await controller.close(); + } + } + }, + child: const Text('Close all sample windows'), + ), + ], + ), + TextButton( + onPressed: () async { + const channel = + WindowMethodChannel('example_video_player_window'); + channel.setMethodCallHandler((call) async { + d('Main window received method call: ${call.method} ${call.arguments}'); + }); + final result = await channel.invokeMethod('play'); + d('Invoked play method on video player window, result: $result'); + }, + child: const Text('Play'), + ), + const WindowList(), + ], + ), + ), + ), + ); + } +} diff --git a/packages/desktop_multi_window/example/lib/windows/sample_window.dart b/packages/desktop_multi_window/example/lib/windows/sample_window.dart new file mode 100644 index 0000000..72a90db --- /dev/null +++ b/packages/desktop_multi_window/example/lib/windows/sample_window.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +class SampleWindow extends StatelessWidget { + const SampleWindow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('Sample Child Window'), + ), + body: const Column( + children: [ + Center( + child: Text('This is a sample child window.'), + ), + SizedBox(width: 15, height: 15, child: CircularProgressIndicator()), + TextField( + decoration: InputDecoration( + border: OutlineInputBorder(), + labelText: 'Sample Input', + ), + ) + ], + ), + ), + ); + } +} diff --git a/packages/desktop_multi_window/example/lib/windows/video_player_window.dart b/packages/desktop_multi_window/example/lib/windows/video_player_window.dart new file mode 100644 index 0000000..5508d4d --- /dev/null +++ b/packages/desktop_multi_window/example/lib/windows/video_player_window.dart @@ -0,0 +1,164 @@ +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:mixin_logger/mixin_logger.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:video_player/video_player.dart'; + +const _channel = WindowMethodChannel('example_video_player_window'); + +class VideoPlayerWindow extends StatelessWidget { + const VideoPlayerWindow({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(kWindowCaptionHeight), + child: WindowCaption( + brightness: Theme.of(context).brightness, + title: const Text('Video Player Window'), + ), + ), + body: const VideoPlayerView(), + )); + } +} + +// example from video_player_win package +class VideoPlayerView extends StatefulWidget { + const VideoPlayerView({Key? key}) : super(key: key); + + @override + State createState() => _VideoPlayerViewState(); +} + +class _VideoPlayerViewState extends State with WindowListener { + VideoPlayerController? controller; + final httpHeaders = { + "User-Agent": "ergerthertherth", + "key3": "value3_ccccc", + }; + + void reload() { + controller?.dispose(); + // controller = VideoPlayerController.file(File("D:\\test\\test_4k.mp4")); + //controller = WinVideoPlayerController.file(File("E:\\test_youtube.mp4")); + //controller = VideoPlayerController.networkUrl(Uri.parse("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8")); + + controller = VideoPlayerController.networkUrl( + Uri.parse("https://media.w3.org/2010/05/sintel/trailer.mp4"), + httpHeaders: httpHeaders, + ); + + //controller = WinVideoPlayerController.file(File("E:\\Downloads\\0.FDM\\sample-file-1.flac")); + + controller!.initialize().then((value) { + if (controller!.value.isInitialized) { + controller!.play(); + setState(() {}); + + controller!.addListener(() { + if (controller!.value.isCompleted) { + i("ui: player completed, pos=${controller!.value.position}"); + } + }); + } else { + i("video file load failed"); + } + }).catchError((e) { + i("controller.initialize() error occurs: $e"); + }); + setState(() {}); + } + + @override + void initState() { + super.initState(); + reload(); + _channel.setMethodCallHandler((call) async { + d('Received method call: ${call.method} with arguments: ${call.arguments}'); + return 'from video player window'; + }); + _channel.invokeMethod('ready'); + windowManager.setPreventClose(true); + windowManager.addListener(this); + } + + @override + void onWindowClose() async { + i("Video player window onWindowClose called."); + controller?.dispose(); + await windowManager.setPreventClose(false); + await windowManager.close(); + } + + @override + void dispose() { + super.dispose(); + controller?.dispose(); + _channel.setMethodCallHandler(null); + windowManager.removeListener(this); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container(color: Colors.black, child: VideoPlayer(controller!)), + Positioned( + bottom: 0, + child: Column( + children: [ + ValueListenableBuilder( + valueListenable: controller!, + builder: ((context, value, child) { + int minute = value.position.inMinutes; + int second = value.position.inSeconds % 60; + String timeStr = "$minute:$second"; + if (value.isCompleted) timeStr = "$timeStr (completed)"; + return Text( + timeStr, + style: Theme.of(context).textTheme.headlineMedium!.copyWith( + color: Colors.white, + backgroundColor: Colors.black54, + ), + ); + }), + ), + ElevatedButton( + onPressed: () => reload(), + child: const Text("Reload"), + ), + ElevatedButton( + onPressed: () => controller?.play(), + child: const Text("Play"), + ), + ElevatedButton( + onPressed: () => controller?.pause(), + child: const Text("Pause"), + ), + ElevatedButton( + onPressed: () => controller?.seekTo( + Duration( + milliseconds: + controller!.value.position.inMilliseconds + 10 * 1000, + ), + ), + child: const Text("Forward"), + ), + ElevatedButton( + onPressed: () { + int ms = controller!.value.duration.inMilliseconds; + var tt = Duration(milliseconds: ms - 1000); + controller?.seekTo(tt); + }, + child: const Text("End"), + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/desktop_multi_window/example/linux/CMakeLists.txt b/packages/desktop_multi_window/example/linux/CMakeLists.txt new file mode 100644 index 0000000..05d748c --- /dev/null +++ b/packages/desktop_multi_window/example/linux/CMakeLists.txt @@ -0,0 +1,116 @@ +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +set(BINARY_NAME "desktop_multi_window_example") +set(APPLICATION_ID "com.example.desktop_multi_window") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Configure build options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Application build +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) +apply_standard_settings(${BINARY_NAME}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +add_dependencies(${BINARY_NAME} flutter_assemble) +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/desktop_multi_window/example/linux/flutter/CMakeLists.txt b/packages/desktop_multi_window/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..33fd580 --- /dev/null +++ b/packages/desktop_multi_window/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,87 @@ +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/desktop_multi_window/example/linux/main.cc b/packages/desktop_multi_window/example/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/packages/desktop_multi_window/example/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/desktop_multi_window/example/linux/my_application.cc b/packages/desktop_multi_window/example/linux/my_application.cc new file mode 100644 index 0000000..68abd25 --- /dev/null +++ b/packages/desktop_multi_window/example/linux/my_application.cc @@ -0,0 +1,145 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +#include "desktop_multi_window/desktop_multi_window_plugin.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "desktop_multi_window_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "desktop_multi_window_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry){ + fl_register_plugins(registry); + }); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + g_warning("MyApplication shutting down."); + //MyApplication* self = MY_APPLICATION(object); + + GList* windows = gtk_application_get_windows(GTK_APPLICATION(application)); + if (windows != nullptr && g_list_length(windows) > 0) { + g_warning("Ignore premature shutdown (still %d windows alive)", g_list_length(windows)); + return; // 不让它真关闭 + } + g_warning("MyApplication shutting down."); + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/packages/desktop_multi_window/example/linux/my_application.h b/packages/desktop_multi_window/example/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/packages/desktop_multi_window/example/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/gcode_core/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/desktop_multi_window/example/macos/Flutter/Flutter-Debug.xcconfig similarity index 100% rename from packages/gcode_core/example/macos/Flutter/Flutter-Debug.xcconfig rename to packages/desktop_multi_window/example/macos/Flutter/Flutter-Debug.xcconfig diff --git a/packages/gcode_core/example/macos/Flutter/Flutter-Release.xcconfig b/packages/desktop_multi_window/example/macos/Flutter/Flutter-Release.xcconfig similarity index 100% rename from packages/gcode_core/example/macos/Flutter/Flutter-Release.xcconfig rename to packages/desktop_multi_window/example/macos/Flutter/Flutter-Release.xcconfig diff --git a/packages/desktop_multi_window/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/desktop_multi_window/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..713d9ea --- /dev/null +++ b/packages/desktop_multi_window/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,22 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import desktop_lifecycle +import desktop_multi_window +import fvp +import screen_retriever_macos +import video_player_avfoundation +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopLifecyclePlugin.register(with: registry.registrar(forPlugin: "DesktopLifecyclePlugin")) + FlutterMultiWindowPlugin.register(with: registry.registrar(forPlugin: "FlutterMultiWindowPlugin")) + FvpPlugin.register(with: registry.registrar(forPlugin: "FvpPlugin")) + ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/packages/gcode_core/example/macos/Podfile b/packages/desktop_multi_window/example/macos/Podfile similarity index 95% rename from packages/gcode_core/example/macos/Podfile rename to packages/desktop_multi_window/example/macos/Podfile index ff5ddb3..9ec46f8 100644 --- a/packages/gcode_core/example/macos/Podfile +++ b/packages/desktop_multi_window/example/macos/Podfile @@ -28,11 +28,9 @@ flutter_macos_podfile_setup target 'Runner' do use_frameworks! + use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end end post_install do |installer| diff --git a/packages/gcode_core/example/macos/Runner.xcodeproj/project.pbxproj b/packages/desktop_multi_window/example/macos/Runner.xcodeproj/project.pbxproj similarity index 71% rename from packages/gcode_core/example/macos/Runner.xcodeproj/project.pbxproj rename to packages/desktop_multi_window/example/macos/Runner.xcodeproj/project.pbxproj index c59ba60..f3aa266 100644 --- a/packages/gcode_core/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/desktop_multi_window/example/macos/Runner.xcodeproj/project.pbxproj @@ -21,24 +21,15 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 110DD9541D412F57D0FC25B1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BFF0FA50648256D262FB427 /* Pods_Runner.framework */; }; - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - EE563B3C323D38A90A7815D1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15B5728AAD4B596071FFF779 /* Pods_RunnerTests.framework */; }; + CC0B098B211E0948E46E62C0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 39921C44D639FF2E70CBEF5B /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 33CC10E52044A3C60003C045 /* Project object */; @@ -62,14 +53,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 02484B4ED94AC349495FF031 /* 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 = ""; }; - 0FB2C87507FF7C43AE0AB5FE /* 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 = ""; }; - 15B5728AAD4B596071FFF779 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* flutter_multi_window_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = flutter_multi_window_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -81,43 +67,26 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 57F4617597A1FF85FCC8B1C5 /* 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 = ""; }; + 39921C44D639FF2E70CBEF5B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 8BFF0FA50648256D262FB427 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - C969A4D026E4EABF8F0A69F8 /* 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 = ""; }; - CF5D4B7C8248EF71D487E239 /* 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 = ""; }; - D74F4B79EB67493B99E1AC43 /* 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 = ""; }; + AF3A64458E98D8652057B601 /* 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 = ""; }; + DEA6C9E202FDF3F66F4C21C0 /* 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 = ""; }; + EE8DF618EDF6B1F6380EB9FE /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - EE563B3C323D38A90A7815D1 /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 110DD9541D412F57D0FC25B1 /* Pods_Runner.framework in Frameworks */, + CC0B098B211E0948E46E62C0 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; 33BA886A226E78AF003329D5 /* Configs */ = { isa = PBXGroup; children = ( @@ -134,18 +103,16 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 807FC030647A3AE78EB79582 /* Pods */, + D76056812F14168F5D1D0BDA /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* example.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + 33CC10ED2044A3C60003C045 /* flutter_multi_window_example.app */, ); name = Products; sourceTree = ""; @@ -185,62 +152,39 @@ path = Runner; sourceTree = ""; }; - 807FC030647A3AE78EB79582 /* Pods */ = { + D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 0FB2C87507FF7C43AE0AB5FE /* Pods-Runner.debug.xcconfig */, - CF5D4B7C8248EF71D487E239 /* Pods-Runner.release.xcconfig */, - 57F4617597A1FF85FCC8B1C5 /* Pods-Runner.profile.xcconfig */, - C969A4D026E4EABF8F0A69F8 /* Pods-RunnerTests.debug.xcconfig */, - D74F4B79EB67493B99E1AC43 /* Pods-RunnerTests.release.xcconfig */, - 02484B4ED94AC349495FF031 /* Pods-RunnerTests.profile.xcconfig */, + 39921C44D639FF2E70CBEF5B /* Pods_Runner.framework */, ); - name = Pods; - path = Pods; + name = Frameworks; sourceTree = ""; }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { + D76056812F14168F5D1D0BDA /* Pods */ = { isa = PBXGroup; children = ( - 8BFF0FA50648256D262FB427 /* Pods_Runner.framework */, - 15B5728AAD4B596071FFF779 /* Pods_RunnerTests.framework */, + AF3A64458E98D8652057B601 /* Pods-Runner.debug.xcconfig */, + DEA6C9E202FDF3F66F4C21C0 /* Pods-Runner.release.xcconfig */, + EE8DF618EDF6B1F6380EB9FE /* Pods-Runner.profile.xcconfig */, ); - name = Frameworks; + name = Pods; + path = Pods; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - A007395C57EBD64C92800C10 /* [CP] Check Pods Manifest.lock */, - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 940F9EE8C72997939F9C78C1 /* [CP] Check Pods Manifest.lock */, + 2B0F6CEE668638055FF9A463 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 1BAF74549412E04249338CF8 /* [CP] Embed Pods Frameworks */, + 19802C360E6DF26726D103F0 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -249,7 +193,7 @@ ); name = Runner; productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productReference = 33CC10ED2044A3C60003C045 /* flutter_multi_window_example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -258,15 +202,10 @@ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { - BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; @@ -297,20 +236,12 @@ projectRoot = ""; targets = ( 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 33CC10EB2044A3C60003C045 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -323,7 +254,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 1BAF74549412E04249338CF8 /* [CP] Embed Pods Frameworks */ = { + 19802C360E6DF26726D103F0 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -340,99 +271,69 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { + 2B0F6CEE668638055FF9A463 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, ); inputPaths = ( - Flutter/ephemeral/tripwire, + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, ); outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + 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; }; - 940F9EE8C72997939F9C78C1 /* [CP] Check Pods Manifest.lock */ = { + 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; 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; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; }; - A007395C57EBD64C92800C10 /* [CP] Check Pods Manifest.lock */ = { + 33CC111E2044C6BF0003C045 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", + Flutter/ephemeral/tripwire, ); - name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, ); 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; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 33CC10E92044A3C60003C045 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -446,11 +347,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; @@ -471,57 +367,11 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = C969A4D026E4EABF8F0A69F8 /* Pods-RunnerTests.debug.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D74F4B79EB67493B99E1AC43 /* Pods-RunnerTests.release.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 02484B4ED94AC349495FF031 /* Pods-RunnerTests.profile.xcconfig */; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; - }; - name = Profile; - }; 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -545,11 +395,9 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -597,7 +445,6 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -621,11 +468,9 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -653,7 +498,6 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -677,11 +521,9 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -755,16 +597,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/gcode_core/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/desktop_multi_window/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from packages/gcode_core/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to packages/desktop_multi_window/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/packages/gcode_core/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/desktop_multi_window/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 82% rename from packages/gcode_core/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to packages/desktop_multi_window/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ac78810..afc8b4a 100644 --- a/packages/gcode_core/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/desktop_multi_window/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -31,23 +31,12 @@ - - - - @@ -83,7 +72,7 @@ diff --git a/packages/gcode_core/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/desktop_multi_window/example/macos/Runner.xcworkspace/contents.xcworkspacedata similarity index 100% rename from packages/gcode_core/example/macos/Runner.xcworkspace/contents.xcworkspacedata rename to packages/desktop_multi_window/example/macos/Runner.xcworkspace/contents.xcworkspacedata diff --git a/packages/gcode_core/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/desktop_multi_window/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from packages/gcode_core/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to packages/desktop_multi_window/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/packages/gcode_core/example/macos/Runner/AppDelegate.swift b/packages/desktop_multi_window/example/macos/Runner/AppDelegate.swift similarity index 100% rename from packages/gcode_core/example/macos/Runner/AppDelegate.swift rename to packages/desktop_multi_window/example/macos/Runner/AppDelegate.swift diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..3c4935a Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..ed4cc16 Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..483be61 Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bcbf36d Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..9c0a652 Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..e71a726 Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..8a31fe2 Binary files /dev/null and b/packages/desktop_multi_window/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/gcode_core/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/desktop_multi_window/example/macos/Runner/Base.lproj/MainMenu.xib similarity index 98% rename from packages/gcode_core/example/macos/Runner/Base.lproj/MainMenu.xib rename to packages/desktop_multi_window/example/macos/Runner/Base.lproj/MainMenu.xib index 80e867a..b3e46bb 100644 --- a/packages/gcode_core/example/macos/Runner/Base.lproj/MainMenu.xib +++ b/packages/desktop_multi_window/example/macos/Runner/Base.lproj/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -13,7 +13,7 @@ - + @@ -330,14 +330,15 @@ - + - + + diff --git a/packages/gcode_core/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/desktop_multi_window/example/macos/Runner/Configs/AppInfo.xcconfig similarity index 72% rename from packages/gcode_core/example/macos/Runner/Configs/AppInfo.xcconfig rename to packages/desktop_multi_window/example/macos/Runner/Configs/AppInfo.xcconfig index f67a84b..bd79e55 100644 --- a/packages/gcode_core/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/desktop_multi_window/example/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = example +PRODUCT_NAME = flutter_multi_window_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.example +PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterMultiWindowExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2022 com.example. All rights reserved. diff --git a/packages/gcode_core/example/macos/Runner/Configs/Debug.xcconfig b/packages/desktop_multi_window/example/macos/Runner/Configs/Debug.xcconfig similarity index 100% rename from packages/gcode_core/example/macos/Runner/Configs/Debug.xcconfig rename to packages/desktop_multi_window/example/macos/Runner/Configs/Debug.xcconfig diff --git a/packages/gcode_core/example/macos/Runner/Configs/Release.xcconfig b/packages/desktop_multi_window/example/macos/Runner/Configs/Release.xcconfig similarity index 100% rename from packages/gcode_core/example/macos/Runner/Configs/Release.xcconfig rename to packages/desktop_multi_window/example/macos/Runner/Configs/Release.xcconfig diff --git a/packages/gcode_core/example/macos/Runner/Configs/Warnings.xcconfig b/packages/desktop_multi_window/example/macos/Runner/Configs/Warnings.xcconfig similarity index 100% rename from packages/gcode_core/example/macos/Runner/Configs/Warnings.xcconfig rename to packages/desktop_multi_window/example/macos/Runner/Configs/Warnings.xcconfig diff --git a/packages/gcode_core/example/macos/Runner/DebugProfile.entitlements b/packages/desktop_multi_window/example/macos/Runner/DebugProfile.entitlements similarity index 100% rename from packages/gcode_core/example/macos/Runner/DebugProfile.entitlements rename to packages/desktop_multi_window/example/macos/Runner/DebugProfile.entitlements diff --git a/packages/gcode_core/example/macos/Runner/Info.plist b/packages/desktop_multi_window/example/macos/Runner/Info.plist similarity index 100% rename from packages/gcode_core/example/macos/Runner/Info.plist rename to packages/desktop_multi_window/example/macos/Runner/Info.plist diff --git a/packages/gcode_core/example/macos/Runner/MainFlutterWindow.swift b/packages/desktop_multi_window/example/macos/Runner/MainFlutterWindow.swift similarity index 51% rename from packages/gcode_core/example/macos/Runner/MainFlutterWindow.swift rename to packages/desktop_multi_window/example/macos/Runner/MainFlutterWindow.swift index 3cc05eb..3ef619c 100644 --- a/packages/gcode_core/example/macos/Runner/MainFlutterWindow.swift +++ b/packages/desktop_multi_window/example/macos/Runner/MainFlutterWindow.swift @@ -1,15 +1,22 @@ import Cocoa import FlutterMacOS +import desktop_multi_window +import desktop_lifecycle class MainFlutterWindow: NSWindow { override func awakeFromNib() { - let flutterViewController = FlutterViewController() + let flutterViewController = FlutterViewController.init() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + FlutterMultiWindowPlugin.setOnWindowCreatedCallback { controller in + // Register the plugin which you want access from other isolate. + RegisterGeneratedPlugins(registry: controller) + } + super.awakeFromNib() } } diff --git a/packages/gcode_core/example/macos/Runner/Release.entitlements b/packages/desktop_multi_window/example/macos/Runner/Release.entitlements similarity index 100% rename from packages/gcode_core/example/macos/Runner/Release.entitlements rename to packages/desktop_multi_window/example/macos/Runner/Release.entitlements diff --git a/packages/desktop_multi_window/example/pubspec.lock b/packages/desktop_multi_window/example/pubspec.lock new file mode 100644 index 0000000..03aca31 --- /dev/null +++ b/packages/desktop_multi_window/example/pubspec.lock @@ -0,0 +1,586 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.3" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + desktop_lifecycle: + dependency: "direct main" + description: + name: desktop_lifecycle + sha256: "97172984460cdd348a86043f392ca2467467abc27c367f4986f0ba71861248c2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.2" + desktop_multi_window: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.3.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4" + 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" + fvp: + dependency: "direct main" + description: + name: fvp + sha256: e03c4ba02c367cde8610c09325d085c9b1efe4f7d98a563950993a1fee17a28b + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.35.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.15.7" + 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" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.0" + mixin_logger: + dependency: "direct main" + description: + name: mixin_logger + sha256: "13585c41685082f3fb6342ce109378d40bdd02a56901f820ae8835b316a8ff67" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.3" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + 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" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" + screen_retriever: + dependency: transitive + description: + name: screen_retriever + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + screen_retriever_linux: + dependency: transitive + description: + name: screen_retriever_linux + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + screen_retriever_macos: + dependency: transitive + description: + name: screen_retriever_macos + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + screen_retriever_platform_interface: + dependency: transitive + description: + name: screen_retriever_platform_interface + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + screen_retriever_windows: + dependency: transitive + description: + name: screen_retriever_windows + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.12" + 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: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + video_player: + dependency: transitive + description: + name: video_player + sha256: "8c837b570dccb9ae6ff73d2e0b03c7e708bfefd3bd1194faa7f3e7f200dfc399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.14.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.12.2" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "436fd029bd1c1e303b2d95ebd76948893f3c28dab286e7235ba9dd7b22533bf0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + window_manager: + dependency: "direct main" + description: + path: "packages/window_manager" + ref: "6fae92d21b4c80ce1b8f71c1190d7970cf722bd4" + resolved-ref: "6fae92d21b4c80ce1b8f71c1190d7970cf722bd4" + url: "https://github.com/boyan01/window_manager.git" + source: git + version: "0.5.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/packages/desktop_multi_window/example/pubspec.yaml b/packages/desktop_multi_window/example/pubspec.yaml new file mode 100644 index 0000000..5d24413 --- /dev/null +++ b/packages/desktop_multi_window/example/pubspec.yaml @@ -0,0 +1,91 @@ +name: flutter_multi_window_example +description: Demonstrates how to use the flutter_multi_window plugin. + +# 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 + +environment: + sdk: ">=3.5.0 <4.0.0" + +# 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 + + desktop_multi_window: + # When depending on this package from a real application you should use: + # flutter_multi_window: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + desktop_lifecycle: ^0.1.0 + mixin_logger: ^0.1.3 + window_manager: + git: + url: https://github.com/boyan01/window_manager.git + path: packages/window_manager + ref: 6fae92d21b4c80ce1b8f71c1190d7970cf722bd4 + fvp: ^0.35.0 + +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: ^1.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. +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/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # 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/custom-fonts/#from-packages diff --git a/packages/desktop_multi_window/example/test/widget_test.dart b/packages/desktop_multi_window/example/test/widget_test.dart new file mode 100644 index 0000000..570e0e4 --- /dev/null +++ b/packages/desktop_multi_window/example/test/widget_test.dart @@ -0,0 +1,8 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +void main() {} diff --git a/packages/desktop_multi_window/example/windows/CMakeLists.txt b/packages/desktop_multi_window/example/windows/CMakeLists.txt new file mode 100644 index 0000000..e1f0b8d --- /dev/null +++ b/packages/desktop_multi_window/example/windows/CMakeLists.txt @@ -0,0 +1,97 @@ +cmake_minimum_required(VERSION 3.14) +project(desktop_multi_window_example LANGUAGES CXX) + +set(BINARY_NAME "desktop_multi_window_example") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + if (NOT DISABLE_PROJECT_WARNING_CHECK) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + endif () + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/desktop_multi_window/example/windows/flutter/CMakeLists.txt b/packages/desktop_multi_window/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..4f2af69 --- /dev/null +++ b/packages/desktop_multi_window/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,108 @@ +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/desktop_multi_window/example/windows/runner/CMakeLists.txt b/packages/desktop_multi_window/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/desktop_multi_window/example/windows/runner/Runner.rc b/packages/desktop_multi_window/example/windows/runner/Runner.rc new file mode 100644 index 0000000..d29866a --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "desktop_multi_window_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "desktop_multi_window_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "desktop_multi_window_example.exe" "\0" + VALUE "ProductName", "desktop_multi_window_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/desktop_multi_window/example/windows/runner/flutter_window.cpp b/packages/desktop_multi_window/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..9c69cdb --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/flutter_window.cpp @@ -0,0 +1,78 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" +#include "desktop_multi_window/desktop_multi_window_plugin.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + DesktopMultiWindowSetWindowCreatedCallback([](void *controller) { + auto *flutter_view_controller = + reinterpret_cast(controller); + auto *registry = flutter_view_controller->engine(); + RegisterPlugins(registry); + }); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/desktop_multi_window/example/windows/runner/flutter_window.h b/packages/desktop_multi_window/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/desktop_multi_window/example/windows/runner/main.cpp b/packages/desktop_multi_window/example/windows/runner/main.cpp new file mode 100644 index 0000000..b140d6c --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"desktop_multi_window_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(false); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/desktop_multi_window/example/windows/runner/resource.h b/packages/desktop_multi_window/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/desktop_multi_window/example/windows/runner/resources/app_icon.ico b/packages/desktop_multi_window/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/packages/desktop_multi_window/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/desktop_multi_window/example/windows/runner/runner.exe.manifest b/packages/desktop_multi_window/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/desktop_multi_window/example/windows/runner/utils.cpp b/packages/desktop_multi_window/example/windows/runner/utils.cpp new file mode 100644 index 0000000..d19bdbb --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/desktop_multi_window/example/windows/runner/utils.h b/packages/desktop_multi_window/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/desktop_multi_window/example/windows/runner/win32_window.cpp b/packages/desktop_multi_window/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/desktop_multi_window/example/windows/runner/win32_window.h b/packages/desktop_multi_window/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/packages/desktop_multi_window/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/desktop_multi_window/lib/desktop_multi_window.dart b/packages/desktop_multi_window/lib/desktop_multi_window.dart new file mode 100644 index 0000000..2ae30eb --- /dev/null +++ b/packages/desktop_multi_window/lib/desktop_multi_window.dart @@ -0,0 +1,3 @@ +export 'src/window_controller.dart'; +export 'src/window_configuration.dart'; +export 'src/window_channel.dart'; diff --git a/packages/desktop_multi_window/lib/src/window_channel.dart b/packages/desktop_multi_window/lib/src/window_channel.dart new file mode 100644 index 0000000..52032c3 --- /dev/null +++ b/packages/desktop_multi_window/lib/src/window_channel.dart @@ -0,0 +1,220 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +typedef MethodCallHandler = Future Function(MethodCall call); + +/// Channel communication mode +enum ChannelMode { + /// Unidirectional mode: All engines can invoke this channel + /// Only one engine can register as handler + unidirectional('unidirectional'), + + /// Bidirectional mode: Only paired engines can invoke each other + /// Maximum of 2 engines can register, and only they can call each other + bidirectional('bidirectional'); + + final String value; + const ChannelMode(this.value); +} + +/// Exception thrown when a window channel operation fails. +class WindowChannelException implements Exception { + final String code; + final String message; + final dynamic details; + + WindowChannelException(this.code, this.message, [this.details]); + + @override + String toString() { + if (details != null) { + return 'WindowChannelException($code, $message, $details)'; + } + return 'WindowChannelException($code, $message)'; + } +} + +/// A method channel for cross-window communication. +/// +/// Supports two modes: +/// - [ChannelMode.unidirectional]: One engine registers as handler, all engines can invoke +/// - [ChannelMode.bidirectional]: Two engines form a pair and can only invoke each other +class WindowMethodChannel { + final String name; + final ChannelMode mode; + + const WindowMethodChannel(this.name, {this.mode = ChannelMode.bidirectional}); + + /// Invokes a method on the target engine that has registered this channel. + /// + /// For unidirectional channels: Invokes the single registered handler + /// For bidirectional channels: Invokes the peer engine in the pair + /// + /// Throws [WindowChannelException] if: + /// - The channel is not registered + /// - The target engine is not available + /// - For bidirectional: caller is not part of the pair + @optionalTypeArgs + Future invokeMethod(String method, [dynamic arguments]) async { + _initializeChannelManager(); + try { + return await _invokeMethodOnChannel(name, method, arguments); + } on PlatformException catch (e) { + throw WindowChannelException( + e.code, + e.message ?? 'Failed to invoke method on channel $name', + e.details, + ); + } + } + + /// Sets the method call handler for this channel. + /// + /// The communication mode is determined by the [mode] parameter passed to the constructor: + /// - [ChannelMode.unidirectional]: Only one engine can register, all can invoke + /// - [ChannelMode.bidirectional]: Up to 2 engines can register, only they can invoke each other + /// + /// Pass `null` as handler to remove the handler and unregister the channel. + /// + /// Throws [WindowChannelException] if: + /// - Registration fails (e.g., channel limit reached) + /// - Mode conflicts with existing registration + Future setMethodCallHandler( + Future Function(MethodCall call)? handler, + ) async { + _initializeChannelManager(); + + if (handler != null) { + // Update handler if already registered + if (_registeredHandlers.containsKey(name)) { + _registeredHandlers[name] = handler; + return; + } + + // Register new handler + try { + await _registerMethodHandler(name, mode); + _registeredHandlers[name] = handler; + } on PlatformException catch (e) { + throw WindowChannelException( + e.code, + e.message ?? 'Failed to register handler for channel $name', + e.details, + ); + } + } else { + // Remove handler + if (!_registeredHandlers.containsKey(name)) { + return; + } + + try { + await _unregisterMethodHandler(name); + _registeredHandlers.remove(name); + } on PlatformException catch (e) { + // Even if unregistration fails, remove the handler locally + _registeredHandlers.remove(name); + if (kDebugMode) { + print( + 'Warning: Failed to unregister handler for channel $name: ${e.message}', + ); + } + } + } + } +} + +final _registeredHandlers = {}; + +const _methodChannel = MethodChannel('mixin.one/desktop_multi_window/channels'); + +bool _initialized = false; + +void _initializeChannelManager() { + if (_initialized) { + return; + } + _initialized = true; + _methodChannel.setMethodCallHandler((call) async { + if (call.method == 'methodCall') { + final arguments = call.arguments as Map; + final channelName = arguments['channel'] as String; + final method = arguments['method'] as String; + final args = arguments['arguments']; + + final handler = _registeredHandlers[channelName]; + if (handler == null) { + throw WindowChannelException( + 'NO_HANDLER', + 'No method call handler registered for channel $channelName', + ); + } + + final methodCall = MethodCall(method, args); + return await handler.call(methodCall); + } else { + throw MissingPluginException('No handler for method ${call.method}'); + } + }); +} + +Future _registerMethodHandler(String name, ChannelMode mode) async { + try { + await _methodChannel.invokeMethod('registerMethodHandler', { + 'channel': name, + 'mode': mode.value, + }); + } on PlatformException catch (e) { + if (e.code == 'CHANNEL_LIMIT_REACHED') { + throw WindowChannelException( + e.code, + mode == ChannelMode.unidirectional + ? 'Cannot register channel "$name": already registered in unidirectional mode' + : 'Cannot register channel "$name": maximum of 2 engines allowed per channel', + e.details, + ); + } else if (e.code == 'CHANNEL_MODE_CONFLICT') { + throw WindowChannelException( + e.code, + 'Cannot register channel "$name": already registered in a different mode', + e.details, + ); + } + rethrow; + } +} + +Future _unregisterMethodHandler(String name) async { + await _methodChannel.invokeMethod('unregisterMethodHandler', { + 'channel': name, + }); +} + +Future _invokeMethodOnChannel( + String name, + String method, + dynamic arguments, +) async { + try { + return await _methodChannel.invokeMethod('invokeMethod', { + 'channel': name, + 'method': method, + 'arguments': arguments, + }); + } on PlatformException catch (e) { + if (e.code == 'CHANNEL_UNREGISTERED') { + throw WindowChannelException( + e.code, + 'Channel "$name" not accessible (may be unregistered, bidirectional pair, or permission denied)', + e.details, + ); + } else if (e.code == 'CHANNEL_NOT_FOUND') { + throw WindowChannelException( + e.code, + 'Channel "$name" not found in target engine', + e.details, + ); + } + rethrow; + } +} diff --git a/packages/desktop_multi_window/lib/src/window_configuration.dart b/packages/desktop_multi_window/lib/src/window_configuration.dart new file mode 100644 index 0000000..adf4b30 --- /dev/null +++ b/packages/desktop_multi_window/lib/src/window_configuration.dart @@ -0,0 +1,40 @@ +class WindowConfiguration { + const WindowConfiguration({ + required this.arguments, + this.hiddenAtLaunch = true, + }); + + /// The arguments passed to the new window. + final String arguments; + + final bool hiddenAtLaunch; + + factory WindowConfiguration.fromJson(Map json) { + return WindowConfiguration( + arguments: json['arguments'] as String? ?? '', + hiddenAtLaunch: json['hiddenAtLaunch'] as bool? ?? false, + ); + } + + Map toJson() { + return {'arguments': arguments, 'hiddenAtLaunch': hiddenAtLaunch}; + } + + @override + String toString() { + return 'WindowConfiguration(arguments: $arguments, hiddenAtLaunch: $hiddenAtLaunch)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is WindowConfiguration && + other.arguments == arguments && + other.hiddenAtLaunch == hiddenAtLaunch; + } + + @override + int get hashCode { + return arguments.hashCode ^ hiddenAtLaunch.hashCode; + } +} diff --git a/packages/desktop_multi_window/lib/src/window_controller.dart b/packages/desktop_multi_window/lib/src/window_controller.dart new file mode 100644 index 0000000..f3e7180 --- /dev/null +++ b/packages/desktop_multi_window/lib/src/window_controller.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'window_channel.dart'; +import 'window_configuration.dart'; + +final _windowEvent = _windowEventAsStream(); + +/// A listenable that notifies when the windows list changes. +/// Listen to this to be notified when windows are created or destroyed. +Stream get onWindowsChanged => _windowEvent + .map((call) { + if (call.method == 'onWindowsChanged') { + return call.method; + } + return null; + }) + .where((event) => event != null); + +/// The [WindowController] instance that is used to control this window. +class WindowController { + WindowController._(this.windowId, this.arguments) + : _windowChannel = WindowMethodChannel( + 'mixin.one/window_controller/$windowId', + mode: ChannelMode.unidirectional, + ); + + final String windowId; + final String arguments; + + final WindowMethodChannel _windowChannel; + + factory WindowController.fromWindowId(String id) => + WindowController._(id, ''); + + static Future create( + WindowConfiguration configuration, + ) async { + final windowId = await _channel.invokeMethod( + 'createWindow', + configuration.toJson(), + ); + assert(windowId != null, 'windowId is null'); + assert(windowId!.isNotEmpty, 'windowId is empty'); + return WindowController._(windowId!, configuration.arguments); + } + + static Future fromCurrentEngine() async { + final definition = await _channel.invokeMethod>( + 'getWindowDefinition', + ); + if (definition == null) { + throw Exception('Failed to get window definition'); + } + final windowId = definition['windowId'] as String; + final windowArgument = definition['windowArgument'] as String; + return WindowController._(windowId, windowArgument); + } + + static Future> getAll() async { + final result = await _channel.invokeMethod>('getAllWindows'); + if (result == null) { + return []; + } + return result.cast>().map((e) { + final windowId = e['windowId'] as String; + final windowArgument = e['windowArgument'] as String; + return WindowController._(windowId, windowArgument); + }).toList(); + } + + Future _callWindowMethod( + String method, [ + Map? arguments, + ]) { + assert(windowId.isNotEmpty, 'windowId is empty'); + assert(method.startsWith('window_'), 'method must start with "window_"'); + return _channel.invokeMethod(method, {'windowId': windowId, ...?arguments}); + } + + Future show() => _callWindowMethod('window_show', {}); + + Future hide() => _callWindowMethod('window_hide', {}); + + @optionalTypeArgs + Future invokeMethod(String method, [dynamic arguments]) => + _windowChannel.invokeMethod(method, arguments); + + Future setWindowMethodHandler( + Future Function(MethodCall call)? handler, + ) { + assert(() { + scheduleMicrotask(() async { + final c = await WindowController.fromCurrentEngine(); + if (c.windowId != windowId) { + throw FlutterError( + 'setWindowMethodHandler can only be called on the current window controller. ' + 'Current windowId: ${c.windowId}, this windowId: $windowId', + ); + } + }); + return true; + }()); + return _windowChannel.setMethodCallHandler(handler); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + final WindowController otherController = other as WindowController; + return windowId == otherController.windowId && + arguments == otherController.arguments; + } + + @override + int get hashCode => windowId.hashCode ^ arguments.hashCode; + + @override + String toString() { + return 'WindowController(windowId: $windowId, arguments: $arguments)'; + } +} + +final _channel = MethodChannel('mixin.one/desktop_multi_window'); + +Stream _windowEventAsStream() { + late StreamController controller; + controller = StreamController.broadcast( + onListen: () { + _channel.setMethodCallHandler((call) async { + controller.add(call); + }); + }, + onCancel: () { + _channel.setMethodCallHandler(null); + }, + ); + return controller.stream; +} diff --git a/packages/desktop_multi_window/linux/CMakeLists.txt b/packages/desktop_multi_window/linux/CMakeLists.txt new file mode 100644 index 0000000..a97788a --- /dev/null +++ b/packages/desktop_multi_window/linux/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "desktop_multi_window") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "desktop_multi_window_plugin") + +add_library(${PLUGIN_NAME} SHARED + "desktop_multi_window_plugin.cc" + "multi_window_manager.cc" + "flutter_window.cc" + "window_channel_plugin.cc") +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +# List of absolute paths to libraries that should be bundled with the plugin +set(desktop_multi_window_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/desktop_multi_window/linux/desktop_multi_window_plugin.cc b/packages/desktop_multi_window/linux/desktop_multi_window_plugin.cc new file mode 100644 index 0000000..07eb21c --- /dev/null +++ b/packages/desktop_multi_window/linux/desktop_multi_window_plugin.cc @@ -0,0 +1,137 @@ +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" + +#include +#include + +#include + +#include "desktop_multi_window_plugin_internal.h" +#include "flutter_window.h" +#include "multi_window_manager.h" +#include "window_channel_plugin.h" + +#define DESKTOP_MULTI_WINDOW_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), desktop_multi_window_plugin_get_type(), \ + DesktopMultiWindowPlugin)) + +struct _DesktopMultiWindowPlugin { + GObject parent_instance; + FlutterWindow* window; +}; + +G_DEFINE_TYPE(DesktopMultiWindowPlugin, + desktop_multi_window_plugin, + g_object_get_type()) + +// Called when a method call is received from Flutter. +static void desktop_multi_window_plugin_handle_method_call( + DesktopMultiWindowPlugin* self, + FlMethodCall* method_call) { + const gchar* method = fl_method_call_get_name(method_call); + + // Check if this is a window-specific method (starts with "window_") + if (g_str_has_prefix(method, "window_")) { + auto* args = fl_method_call_get_args(method_call); + auto window_id_value = fl_value_lookup_string(args, "windowId"); + if (window_id_value == nullptr) { + g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE( + fl_method_error_response_new("-1", "windowId is required", nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + const gchar* window_id = fl_value_get_string(window_id_value); + auto window = MultiWindowManager::Instance()->GetWindow(window_id); + if (!window) { + g_autofree gchar* error_msg = + g_strdup_printf("failed to find target window: %s", window_id); + g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE( + fl_method_error_response_new("-1", error_msg, nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + window->HandleWindowMethod(method, args, method_call); + return; // Window handles the response + } + + g_autoptr(FlMethodResponse) response = nullptr; + + if (strcmp(method, "createWindow") == 0) { + auto* args = fl_method_call_get_args(method_call); + auto window_id = MultiWindowManager::Instance()->Create(args); + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_string(window_id.c_str()))); + } else if (strcmp(method, "getWindowDefinition") == 0) { + auto window_id = self->window->GetWindowId(); + auto window_argument = self->window->GetWindowArgument(); + + g_autoptr(FlValue) definition = fl_value_new_map(); + fl_value_set_string_take(definition, "windowId", + fl_value_new_string(window_id.c_str())); + fl_value_set_string_take(definition, "windowArgument", + fl_value_new_string(window_argument.c_str())); + + response = FL_METHOD_RESPONSE(fl_method_success_response_new(definition)); + } else if (strcmp(method, "getAllWindows") == 0) { + auto windows = MultiWindowManager::Instance()->GetAllWindows(); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(windows)); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + } + + fl_method_call_respond(method_call, response, nullptr); +} + +static void desktop_multi_window_plugin_dispose(GObject* object) { + G_OBJECT_CLASS(desktop_multi_window_plugin_parent_class)->dispose(object); +} + +static void desktop_multi_window_plugin_class_init( + DesktopMultiWindowPluginClass +* klass) { + G_OBJECT_CLASS(klass)->dispose = desktop_multi_window_plugin_dispose; +} + +static void desktop_multi_window_plugin_init(DesktopMultiWindowPlugin* self) {} + +static void method_call_cb(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN(user_data); + desktop_multi_window_plugin_handle_method_call(plugin, method_call); +} + +void desktop_multi_window_plugin_register_with_registrar_internal( + FlPluginRegistrar* registrar, + FlutterWindow* window) { + DesktopMultiWindowPlugin* plugin = DESKTOP_MULTI_WINDOW_PLUGIN( + g_object_new(desktop_multi_window_plugin_get_type(), nullptr)); + plugin->window = window; + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "mixin.one/desktop_multi_window", FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler( + channel, method_call_cb, g_object_ref(plugin), g_object_unref); + + // Set channel to window for event notifications + window->SetChannel(channel); + + // Register WindowChannel plugin for each engine + window_channel_plugin_register_with_registrar(registrar); + + g_object_unref(plugin); +} + +void desktop_multi_window_plugin_register_with_registrar( + FlPluginRegistrar* registrar) { + auto view = fl_plugin_registrar_get_view(registrar); + auto window = gtk_widget_get_toplevel(GTK_WIDGET(view)); + if (GTK_IS_WINDOW(window)) { + MultiWindowManager::Instance()->AttachMainWindow(window, registrar); + } else { // variant + g_critical("can not find GtkWindow instance for main window."); + } +} diff --git a/packages/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h b/packages/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h new file mode 100644 index 0000000..21242da --- /dev/null +++ b/packages/desktop_multi_window/linux/desktop_multi_window_plugin_internal.h @@ -0,0 +1,12 @@ +#ifndef DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_ +#define DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_ + +#include "flutter_linux/flutter_linux.h" + +class FlutterWindow; + +void desktop_multi_window_plugin_register_with_registrar_internal( + FlPluginRegistrar* registrar, + FlutterWindow* window); + +#endif // DESKTOP_MULTI_WINDOW_LINUX_DESKTOP_MULTI_WINDOW_PLUGIN_INTERNAL_H_ diff --git a/packages/desktop_multi_window/linux/flutter_window.cc b/packages/desktop_multi_window/linux/flutter_window.cc new file mode 100755 index 0000000..8a7a209 --- /dev/null +++ b/packages/desktop_multi_window/linux/flutter_window.cc @@ -0,0 +1,52 @@ +#include "flutter_window.h" + +#include + +FlutterWindow::FlutterWindow(const std::string& id, + const std::string& argument, + GtkWidget* window) + : id_(id), window_argument_(argument), window_(window) {} + +FlutterWindow::~FlutterWindow() = default; + +void FlutterWindow::SetChannel(FlMethodChannel* channel) { + channel_ = channel; +} + +void FlutterWindow::NotifyWindowEvent(const gchar* event, FlValue* data) { + if (channel_) { + fl_method_channel_invoke_method(channel_, event, data, nullptr, nullptr, nullptr); + } +} + +void FlutterWindow::Show() { + if (window_) { + gtk_widget_show(GTK_WIDGET(window_)); + } +} + +void FlutterWindow::Hide() { + if (window_) { + gtk_widget_hide(GTK_WIDGET(window_)); + } +} + +void FlutterWindow::HandleWindowMethod(const gchar* method, + FlValue* arguments, + FlMethodCall* method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + + if (strcmp(method, "window_show") == 0) { + Show(); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else if (strcmp(method, "window_hide") == 0) { + Hide(); + response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr)); + } else { + g_autofree gchar* error_msg = g_strdup_printf("unknown method: %s", method); + response = FL_METHOD_RESPONSE( + fl_method_error_response_new("-1", error_msg, nullptr)); + } + + fl_method_call_respond(method_call, response, nullptr); +} diff --git a/packages/desktop_multi_window/linux/flutter_window.h b/packages/desktop_multi_window/linux/flutter_window.h new file mode 100755 index 0000000..fcce292 --- /dev/null +++ b/packages/desktop_multi_window/linux/flutter_window.h @@ -0,0 +1,44 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ + +#include +#include +#include +#include + +#include +#include + +class FlutterWindow { + public: + FlutterWindow(const std::string& id, + const std::string& argument, + GtkWidget* window); + ~FlutterWindow(); + + std::string GetWindowId() const { return id_; } + + std::string GetWindowArgument() const { return window_argument_; } + + GtkWindow* GetWindow() { return GTK_WINDOW(window_); } + + void SetChannel(FlMethodChannel* channel); + + void NotifyWindowEvent(const gchar* event, FlValue* data); + + void Show(); + + void Hide(); + + void HandleWindowMethod(const gchar* method, + FlValue* arguments, + FlMethodCall* method_call); + + private: + std::string id_; + std::string window_argument_; + GtkWidget* window_ = nullptr; + FlMethodChannel* channel_ = nullptr; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ diff --git a/packages/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h b/packages/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h new file mode 100644 index 0000000..dd5c136 --- /dev/null +++ b/packages/desktop_multi_window/linux/include/desktop_multi_window/desktop_multi_window_plugin.h @@ -0,0 +1,32 @@ +#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ +#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _DesktopMultiWindowPlugin DesktopMultiWindowPlugin; +typedef struct { + GObjectClass parent_class; +} DesktopMultiWindowPluginClass; + +FLUTTER_PLUGIN_EXPORT GType desktop_multi_window_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +typedef void (*WindowCreatedCallback)(FlPluginRegistry *registry); + +FLUTTER_PLUGIN_EXPORT void desktop_multi_window_plugin_set_window_created_callback( + WindowCreatedCallback callback); + + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ diff --git a/packages/desktop_multi_window/linux/multi_window_manager.cc b/packages/desktop_multi_window/linux/multi_window_manager.cc new file mode 100755 index 0000000..f00c6dd --- /dev/null +++ b/packages/desktop_multi_window/linux/multi_window_manager.cc @@ -0,0 +1,235 @@ +#include "multi_window_manager.h" + +#include +#include +#include + +#include "desktop_multi_window_plugin_internal.h" +#include "flutter_window.h" +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" +#include "window_configuration.h" +#ifdef GDK_WINDOWING_X11 +#include +#endif + +namespace { + +std::string GenerateWindowId() { + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dis; + + uint64_t part1 = dis(gen); + uint64_t part2 = dis(gen); + + part1 = (part1 & 0xFFFFFFFFFFFF0FFFULL) | 0x0000000000004000ULL; + part2 = (part2 & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL; + + char uuid_str[37]; + snprintf(uuid_str, sizeof(uuid_str), "%08x-%04x-%04x-%04x-%012llx", + static_cast(part1 >> 32), + static_cast(part1 >> 16), static_cast(part1), + static_cast(part2 >> 48), part2 & 0xFFFFFFFFFFFFULL); + + return std::string(uuid_str); +} + +WindowCreatedCallback _g_window_created_callback = nullptr; + +} // namespace + +// static +MultiWindowManager* MultiWindowManager::Instance() { + static auto manager = std::make_shared(); + return manager.get(); +} + +MultiWindowManager::MultiWindowManager() : windows_() {} + +MultiWindowManager::~MultiWindowManager() = default; + +std::string MultiWindowManager::Create(FlValue* args) { + WindowConfiguration config = WindowConfiguration::FromFlValue(args); + std::string window_id = GenerateWindowId(); + + // Create GTK window + GtkApplication* app = GTK_APPLICATION(g_application_get_default()); + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(app)); + gtk_application_add_window(app, window); + + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, ""); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, ""); + } + + gtk_window_set_default_size(window, 1280, 720); + + gtk_window_set_title(window, ""); + if (config.hidden_at_launch) { + gtk_widget_realize(GTK_WIDGET(window)); + } else { + gtk_widget_show(GTK_WIDGET(window)); + } + + // Create FlutterWindow instance + auto w = std::make_unique(window_id, config.arguments, + GTK_WIDGET(window)); + windows_[window_id] = std::move(w); + + // Setup Flutter project + g_autoptr(FlDartProject) project = fl_dart_project_new(); + const char* entrypoint_args[] = {"multi_window", window_id.c_str(), + config.arguments.c_str(), nullptr}; + fl_dart_project_set_dart_entrypoint_arguments( + project, const_cast(entrypoint_args)); + + // Create Flutter view + auto fl_view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(fl_view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(fl_view)); + + // Issues from flutter/engine: https://github.com/flutter/engine/pull/40033 + // Prevent delete-event from flutter engine shell, which will quit the whole + // appplication when the window is closed. this can be done by + // [window_manager] plugin, but we need it here if user is not using that + // plugin. + guint handler_id = g_signal_handler_find(window, G_SIGNAL_MATCH_DATA, 0, 0, + NULL, NULL, fl_view); + if (handler_id > 0) { + g_signal_handler_disconnect(window, handler_id); + } + + // Call window created callback + if (_g_window_created_callback) { + _g_window_created_callback(FL_PLUGIN_REGISTRY(fl_view)); + } + + ObserveWindowClose(window_id, window); + // Register plugin + g_autoptr(FlPluginRegistrar) desktop_multi_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(FL_PLUGIN_REGISTRY(fl_view), + "DesktopMultiWindowPlugin"); + + desktop_multi_window_plugin_register_with_registrar_internal( + desktop_multi_window_registrar, windows_[window_id].get()); + + gtk_widget_grab_focus(GTK_WIDGET(fl_view)); + + // Notify all windows about the change + NotifyWindowsChanged(); + + return window_id; +} + +void MultiWindowManager::AttachMainWindow(GtkWidget* window_widget, + FlPluginRegistrar* registrar) { + // check window widget is in windows_ + for (const auto& pair : windows_) { + if (pair.second->GetWindow() == GTK_WINDOW(window_widget)) { + return; + } + } + + const std::string main_window_id = GenerateWindowId(); + auto window = + std::make_unique(main_window_id, "", window_widget); + windows_[main_window_id] = std::move(window); + + ObserveWindowClose(main_window_id, GTK_WINDOW(window_widget)); + desktop_multi_window_plugin_register_with_registrar_internal( + registrar, windows_[main_window_id].get()); + + // Notify all windows about the change + NotifyWindowsChanged(); +} + +void MultiWindowManager::ObserveWindowClose(const std::string& window_id, + GtkWindow* window) { + g_signal_connect( + GTK_WIDGET(window), "destroy", + G_CALLBACK(+[](GtkWidget* widget, gpointer arg) { + auto* window_id_ptr = static_cast(arg); + + GtkWidget* child = gtk_bin_get_child(GTK_BIN(widget)); + if (child && FL_IS_VIEW(child)) { + gtk_container_remove(GTK_CONTAINER(widget), child); + } + + MultiWindowManager::Instance()->RemoveWindow(*window_id_ptr); + delete window_id_ptr; + }), + new std::string(window_id)); +} + +FlutterWindow* MultiWindowManager::GetWindow(const std::string& window_id) { + auto it = windows_.find(window_id); + if (it != windows_.end()) { + return it->second.get(); + } + return nullptr; +} + +FlValue* MultiWindowManager::GetAllWindows() { + g_autoptr(FlValue) windows = fl_value_new_list(); + for (const auto& pair : windows_) { + g_autoptr(FlValue) window_info = fl_value_new_map(); + fl_value_set_string_take( + window_info, "windowId", + fl_value_new_string(pair.second->GetWindowId().c_str())); + fl_value_set_string_take( + window_info, "windowArgument", + fl_value_new_string(pair.second->GetWindowArgument().c_str())); + fl_value_append_take(windows, fl_value_ref(window_info)); + } + return fl_value_ref(windows); +} + +std::vector MultiWindowManager::GetAllWindowIds() { + std::vector window_ids; + for (const auto& pair : windows_) { + window_ids.push_back(pair.first); + } + return window_ids; +} + +void MultiWindowManager::NotifyWindowsChanged() { + auto window_ids = GetAllWindowIds(); + + g_autoptr(FlValue) window_ids_list = fl_value_new_list(); + for (const auto& id : window_ids) { + fl_value_append_take(window_ids_list, fl_value_new_string(id.c_str())); + } + + g_autoptr(FlValue) data = fl_value_new_map(); + fl_value_set_string_take(data, "windowIds", fl_value_ref(window_ids_list)); + + for (const auto& pair : windows_) { + pair.second->NotifyWindowEvent("onWindowsChanged", data); + } +} + +void MultiWindowManager::RemoveWindow(const std::string& window_id) { + g_warning("RemoveWindow: %s", window_id.c_str()); + windows_.erase(window_id); + NotifyWindowsChanged(); +} + +void desktop_multi_window_plugin_set_window_created_callback( + WindowCreatedCallback callback) { + _g_window_created_callback = callback; +} diff --git a/packages/desktop_multi_window/linux/multi_window_manager.h b/packages/desktop_multi_window/linux/multi_window_manager.h new file mode 100755 index 0000000..c064948 --- /dev/null +++ b/packages/desktop_multi_window/linux/multi_window_manager.h @@ -0,0 +1,47 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ + +#include +#include +#include +#include +#include + +#include +#include + +#include "flutter_window.h" + +class MultiWindowManager + : public std::enable_shared_from_this { + public: + static MultiWindowManager* Instance(); + + MultiWindowManager(); + + virtual ~MultiWindowManager(); + + std::string Create(FlValue* args); + + void AttachMainWindow(GtkWidget* main_flutter_window, + FlPluginRegistrar* registrar); + + FlutterWindow* GetWindow(const std::string& window_id); + + FlValue* GetAllWindows(); + + std::vector GetAllWindowIds(); + + void RemoveWindow(const std::string& window_id); + + private: + + void ObserveWindowClose(const std::string& window_id, + GtkWindow* window); + + void NotifyWindowsChanged(); + + std::map> windows_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ diff --git a/packages/desktop_multi_window/linux/window_channel_plugin.cc b/packages/desktop_multi_window/linux/window_channel_plugin.cc new file mode 100644 index 0000000..bb91937 --- /dev/null +++ b/packages/desktop_multi_window/linux/window_channel_plugin.cc @@ -0,0 +1,370 @@ +#include "window_channel_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +enum class ChannelMode { kUnidirectional, kBidirectional }; + +enum class RegistrationOutcome { + kAdded, + kAlreadyRegistered, + kLimitReached, + kModeConflict +}; + +struct _WindowChannelPlugin { + GObject parent_instance; + FlMethodChannel* channel; + std::vector* registered_channels; +}; + +G_DEFINE_TYPE(WindowChannelPlugin, window_channel_plugin, G_TYPE_OBJECT) + +class ChannelRegistry { + public: + static ChannelRegistry& GetInstance() { + static ChannelRegistry instance; + return instance; + } + + RegistrationOutcome Register(const std::string& channel, + WindowChannelPlugin* plugin, + ChannelMode mode) { + std::lock_guard lock(mutex_); + + if (mode == ChannelMode::kUnidirectional) { + return RegisterUnidirectional(channel, plugin); + } else { + return RegisterBidirectional(channel, plugin); + } + } + + private: + RegistrationOutcome RegisterUnidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in bidirectional mode + if (bidirectional_channels_.find(channel) != + bidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto it = unidirectional_channels_.find(channel); + if (it != unidirectional_channels_.end()) { + if (it->second == plugin) { + return RegistrationOutcome::kAlreadyRegistered; + } + // Already registered by another plugin + return RegistrationOutcome::kLimitReached; + } + + unidirectional_channels_[channel] = plugin; + return RegistrationOutcome::kAdded; + } + + RegistrationOutcome RegisterBidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in unidirectional mode + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto& plugins = bidirectional_channels_[channel]; + + // Check if already registered + if (plugins.find(plugin) != plugins.end()) { + return RegistrationOutcome::kAlreadyRegistered; + } + + // Check limit + if (plugins.size() >= 2) { + return RegistrationOutcome::kLimitReached; + } + + plugins.insert(plugin); + return RegistrationOutcome::kAdded; + } + + public: + void Unregister(const std::string& channel, WindowChannelPlugin* plugin) { + std::lock_guard lock(mutex_); + + // Try unidirectional + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end() && + uni_it->second == plugin) { + unidirectional_channels_.erase(uni_it); + return; + } + + // Try bidirectional + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + bi_it->second.erase(plugin); + if (bi_it->second.empty()) { + bidirectional_channels_.erase(bi_it); + } + } + } + + WindowChannelPlugin* GetTarget(const std::string& channel, + WindowChannelPlugin* from) { + std::lock_guard lock(mutex_); + + // Check unidirectional - anyone can call + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end()) { + return uni_it->second; + } + + // Check bidirectional - only peer can call + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + const auto& plugins = bi_it->second; + + // Check if caller is in the pair + if (plugins.find(from) == plugins.end()) { + return nullptr; + } + + // Return the peer + for (auto* plugin : plugins) { + if (plugin != from) { + return plugin; + } + } + } + + return nullptr; + } + + bool HasRegistrations(const std::string& channel) { + std::lock_guard lock(mutex_); + + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return true; + } + + auto it = bidirectional_channels_.find(channel); + return it != bidirectional_channels_.end() && !it->second.empty(); + } + + private: + ChannelRegistry() = default; + std::mutex mutex_; + std::map unidirectional_channels_; + std::map> + bidirectional_channels_; +}; + +static void window_channel_plugin_dispose(GObject* object) { + WindowChannelPlugin* self = (WindowChannelPlugin*)object; + + if (self->registered_channels) { + for (const auto& channel : *self->registered_channels) { + ChannelRegistry::GetInstance().Unregister(channel, self); + } + delete self->registered_channels; + self->registered_channels = nullptr; + } + + G_OBJECT_CLASS(window_channel_plugin_parent_class)->dispose(object); +} + +static void window_channel_plugin_class_init(WindowChannelPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = window_channel_plugin_dispose; +} + +static void window_channel_plugin_init(WindowChannelPlugin* self) { + self->registered_channels = new std::vector(); +} + +void window_channel_plugin_invoke_method(WindowChannelPlugin* self, + const gchar* channel, + FlValue* arguments, + FlMethodCall* method_call) { + // Check if this plugin has registered this channel + auto it = std::find(self->registered_channels->begin(), + self->registered_channels->end(), std::string(channel)); + if (it == self->registered_channels->end()) { + g_autofree gchar* error_msg = + g_strdup_printf("channel %s not found in this engine", channel); + fl_method_call_respond_error(method_call, "CHANNEL_NOT_FOUND", error_msg, + nullptr, nullptr); + return; + } + + fl_method_channel_invoke_method(self->channel, "methodCall", arguments, + nullptr, + +[](GObject* source_object, GAsyncResult* res, + gpointer user_data) { + auto* call = (FlMethodCall*)user_data; + GError* error = nullptr; + auto* result = fl_method_channel_invoke_method_finish( + FL_METHOD_CHANNEL(source_object), res, + &error); + if (error != nullptr) { + fl_method_call_respond_error( + call, "INVOKE_ERROR", error->message, + nullptr, nullptr); + g_error_free(error); + } else { + fl_method_call_respond(call, result, + nullptr); + } + g_object_unref(call); + }, + g_object_ref(method_call)); +} + +static void handle_method_call(FlMethodChannel* channel, + FlMethodCall* method_call, + gpointer user_data) { + WindowChannelPlugin* self = (WindowChannelPlugin*)user_data; + + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "registerMethodHandler") == 0) { + FlValue* channel_value = fl_value_lookup_string(args, "channel"); + if (channel_value == nullptr || + fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) { + fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS", + "channel is required", nullptr, nullptr); + return; + } + + const gchar* channel_name = fl_value_get_string(channel_value); + + // Get mode (default to bidirectional) + ChannelMode mode = ChannelMode::kBidirectional; + FlValue* mode_value = fl_value_lookup_string(args, "mode"); + if (mode_value != nullptr && + fl_value_get_type(mode_value) == FL_VALUE_TYPE_STRING) { + const gchar* mode_str = fl_value_get_string(mode_value); + if (strcmp(mode_str, "unidirectional") == 0) { + mode = ChannelMode::kUnidirectional; + } else if (strcmp(mode_str, "bidirectional") == 0) { + mode = ChannelMode::kBidirectional; + } else { + g_autofree gchar* error_msg = g_strdup_printf( + "invalid mode: %s, must be 'unidirectional' or 'bidirectional'", + mode_str); + fl_method_call_respond_error(method_call, "INVALID_MODE", error_msg, + nullptr, nullptr); + return; + } + } + + auto outcome = + ChannelRegistry::GetInstance().Register(channel_name, self, mode); + + switch (outcome) { + case RegistrationOutcome::kAdded: + self->registered_channels->push_back(channel_name); + fl_method_call_respond_success(method_call, nullptr, nullptr); + break; + case RegistrationOutcome::kAlreadyRegistered: + fl_method_call_respond_success(method_call, nullptr, nullptr); + break; + case RegistrationOutcome::kLimitReached: { + g_autofree gchar* error_msg; + if (mode == ChannelMode::kUnidirectional) { + error_msg = g_strdup_printf( + "channel %s already registered in unidirectional mode", + channel_name); + } else { + error_msg = g_strdup_printf( + "channel %s already has the maximum number of registrations (2)", + channel_name); + } + fl_method_call_respond_error(method_call, "CHANNEL_LIMIT_REACHED", + error_msg, nullptr, nullptr); + break; + } + case RegistrationOutcome::kModeConflict: { + g_autofree gchar* error_msg = g_strdup_printf( + "channel %s is already registered in a different mode", + channel_name); + fl_method_call_respond_error(method_call, "CHANNEL_MODE_CONFLICT", + error_msg, nullptr, nullptr); + break; + } + } + } else if (strcmp(method, "unregisterMethodHandler") == 0) { + FlValue* channel_value = fl_value_lookup_string(args, "channel"); + if (channel_value == nullptr || + fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) { + fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS", + "channel is required", nullptr, nullptr); + return; + } + + const gchar* channel_name = fl_value_get_string(channel_value); + ChannelRegistry::GetInstance().Unregister(channel_name, self); + + auto it = std::find(self->registered_channels->begin(), + self->registered_channels->end(), + std::string(channel_name)); + if (it != self->registered_channels->end()) { + self->registered_channels->erase(it); + } + + fl_method_call_respond_success(method_call, nullptr, nullptr); + } else if (strcmp(method, "invokeMethod") == 0) { + FlValue* channel_value = fl_value_lookup_string(args, "channel"); + if (channel_value == nullptr || + fl_value_get_type(channel_value) != FL_VALUE_TYPE_STRING) { + fl_method_call_respond_error(method_call, "INVALID_ARGUMENTS", + "channel is required", nullptr, nullptr); + return; + } + + const gchar* channel_name = fl_value_get_string(channel_value); + auto* target = ChannelRegistry::GetInstance().GetTarget(channel_name, self); + + if (target) { + window_channel_plugin_invoke_method(target, channel_name, args, + method_call); + } else { + g_autofree gchar* error_msg; + if (ChannelRegistry::GetInstance().HasRegistrations(channel_name)) { + error_msg = g_strdup_printf( + "channel %s not accessible from this engine (may be bidirectional " + "pair or not registered)", + channel_name); + } else { + error_msg = + g_strdup_printf("unknown registered channel %s", channel_name); + } + fl_method_call_respond_error(method_call, "CHANNEL_UNREGISTERED", + error_msg, nullptr, nullptr); + } + } else { + fl_method_call_respond_not_implemented(method_call, nullptr); + } +} + +void window_channel_plugin_register_with_registrar( + FlPluginRegistrar* registrar) { + WindowChannelPlugin* plugin = (WindowChannelPlugin*)g_object_new( + window_channel_plugin_get_type(), nullptr); + + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + plugin->channel = fl_method_channel_new( + fl_plugin_registrar_get_messenger(registrar), + "mixin.one/desktop_multi_window/channels", FL_METHOD_CODEC(codec)); + + fl_method_channel_set_method_call_handler(plugin->channel, handle_method_call, + plugin, g_object_unref); + + // Keep plugin alive - it will be cleaned up when the registrar is destroyed + g_object_ref(plugin); +} diff --git a/packages/desktop_multi_window/linux/window_channel_plugin.h b/packages/desktop_multi_window/linux/window_channel_plugin.h new file mode 100644 index 0000000..60254ce --- /dev/null +++ b/packages/desktop_multi_window/linux/window_channel_plugin.h @@ -0,0 +1,18 @@ +#ifndef DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_ +#define DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +G_DECLARE_FINAL_TYPE(WindowChannelPlugin, + window_channel_plugin, + WINDOW, + CHANNEL_PLUGIN, + GObject) + +void window_channel_plugin_register_with_registrar(FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // DESKTOP_MULTI_WINDOW_LINUX_WINDOW_CHANNEL_PLUGIN_H_ diff --git a/packages/desktop_multi_window/linux/window_configuration.h b/packages/desktop_multi_window/linux/window_configuration.h new file mode 100644 index 0000000..1ab3ff6 --- /dev/null +++ b/packages/desktop_multi_window/linux/window_configuration.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +struct WindowConfiguration { + std::string arguments; + bool hidden_at_launch = false; + + static WindowConfiguration FromFlValue(FlValue* value) { + WindowConfiguration config; + + if (!value || fl_value_get_type(value) != FL_VALUE_TYPE_MAP) { + return config; + } + + FlValue* arguments_value = fl_value_lookup_string(value, "arguments"); + if (arguments_value && + fl_value_get_type(arguments_value) == FL_VALUE_TYPE_STRING) { + config.arguments = fl_value_get_string(arguments_value); + } + + FlValue* hidden_value = fl_value_lookup_string(value, "hiddenAtLaunch"); + if (hidden_value && fl_value_get_type(hidden_value) == FL_VALUE_TYPE_BOOL) { + config.hidden_at_launch = fl_value_get_bool(hidden_value); + } + + return config; + } + + FlValue* ToFlValue() const { + g_autoptr(FlValue) result = fl_value_new_map(); + + fl_value_set_string_take(result, "arguments", + fl_value_new_string(arguments.c_str())); + + fl_value_set_string_take(result, "hiddenAtLaunch", + fl_value_new_bool(hidden_at_launch)); + + return fl_value_ref(result); + } +}; \ No newline at end of file diff --git a/packages/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift b/packages/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift new file mode 100644 index 0000000..000a43b --- /dev/null +++ b/packages/desktop_multi_window/macos/Classes/FlutterMultiWindowPlugin.swift @@ -0,0 +1,171 @@ +import Cocoa +import FlutterMacOS + +public class FlutterMultiWindowPlugin: NSObject, FlutterPlugin { + + private let windowId: WindowId + private let windowArgument: String + + + init(window: FlutterWindow) { + self.windowId = window.windowId + self.windowArgument = window.windowArgument + super.init() + } + + public static func register(with registrar: FlutterPluginRegistrar) { + guard let app = NSApplication.shared.delegate as? FlutterAppDelegate else { + debugPrint( + "failed to find flutter main window, application delegate is not FlutterAppDelegate" + ) + return + } + guard let window = app.mainFlutterWindow else { + debugPrint("failed to find flutter main window") + return + } + MultiWindowManager.shared.AttachWindow(window: window, registrar: registrar) + } + + public typealias OnWindowCreatedCallback = (FlutterViewController) -> Void + static var onWindowCreatedCallback: OnWindowCreatedCallback? + + public static func setOnWindowCreatedCallback(_ callback: @escaping OnWindowCreatedCallback) { + onWindowCreatedCallback = callback + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let isWindowEvent = call.method.hasPrefix("window_") + if isWindowEvent { + let arguments = call.arguments as! [String: Any?] + let windowId = arguments["windowId"] as! WindowId + guard let window = MultiWindowManager.shared.windows[windowId] else { + result( + FlutterError( + code: "-1", message: "failed to find target window. \(windowId)", + details: nil)) + return + } + + window.handleWindowMethod(method: call.method, arguments: arguments, result: result) + return + } + + switch call.method { + case "createWindow": + let arguments = call.arguments as! [String: Any?] + let windowId = MultiWindowManager.shared.CreateWindow(arguments: arguments) + result(windowId) + case "getWindowDefinition": + let definition: [String: Any] = [ + "windowId": windowId, + "windowArgument": windowArgument, + ] + result(definition) + case "getAllWindows": + let windows = MultiWindowManager.shared.getAllWindows() + result(windows) + default: + result(FlutterMethodNotImplemented) + } + + } +} + +class MultiWindowManager: NSObject { + + static let shared: MultiWindowManager = MultiWindowManager() + + private override init() {} + + var windows: [WindowId: FlutterWindow] = [:] + + func AttachWindow(window: NSWindow, registrar: FlutterPluginRegistrar) { + // check window exists + for (_, flutterWindow) in windows { + if flutterWindow.window == window { + return + } + } + let windowId = WindowId.generate() + let flutterWindow = FlutterWindow(windowId: windowId, windowArgument: "", window: window) + windows[windowId] = flutterWindow + + let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar) + flutterWindow.setChannel(channel) + + notifyWindowsChanged() + } + + func CreateWindow(arguments: [String: Any?]) -> WindowId { + let windowId = WindowId.generate() + + let config = WindowConfiguration.fromJson(arguments) + + let window = CustomWindow(configuration: config) + + let project = FlutterDartProject() + project.dartEntrypointArguments = ["multi_window", windowId, config.arguments] + let flutterViewController = FlutterViewController(project: project) + window.contentViewController = flutterViewController + window.setFrame(NSRect(x: 0, y: 0, width: 800, height: 600), display: true) + + window.orderFront(nil) + window.setIsVisible(!config.hiddenAtLaunch) + + FlutterMultiWindowPlugin.onWindowCreatedCallback?(flutterViewController) + + let registrar = flutterViewController.registrar(forPlugin: "DesktopMultiWindowPlugin") + + let flutterWindow = FlutterWindow( + windowId: windowId, windowArgument: config.arguments, window: window) + windows[windowId] = flutterWindow + + let channel = registerMultiWindowChannel(window: flutterWindow, with: registrar) + flutterWindow.setChannel(channel) + + notifyWindowsChanged() + + return windowId + } + + func removeWindow(windowId: WindowId) { + if windows.removeValue(forKey: windowId) != nil { + notifyWindowsChanged() + } + } + + func getAllWindowIds() -> [WindowId] { + return Array(windows.keys) + } + + func getAllWindows() -> [[String: String]] { + return windows.values.map { window in + [ + "windowId": window.windowId, + "windowArgument": window.windowArgument, + ] + } + } + + private func notifyWindowsChanged() { + for (_, window) in windows { + window.notifyWindowEvent("onWindowsChanged", data: [:]) + } + } + + // register multi window method channel for all engine. include main or created by this plugin + private func registerMultiWindowChannel( + window: FlutterWindow, with registrar: FlutterPluginRegistrar + ) -> FlutterMethodChannel { + let channel = FlutterMethodChannel( + name: "mixin.one/desktop_multi_window", binaryMessenger: registrar.messenger) + registrar.addMethodCallDelegate(FlutterMultiWindowPlugin(window: window), channel: channel) + + // register window method channel plugin + WindowChannel.register(with: registrar) + + return channel + } + +} diff --git a/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift b/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift new file mode 100644 index 0000000..67e81ab --- /dev/null +++ b/packages/desktop_multi_window/macos/Classes/FlutterWindow.swift @@ -0,0 +1,114 @@ +import Cocoa +import FlutterMacOS +import Foundation + +typealias WindowId = String + +extension WindowId { + static func generate() -> WindowId { + return UUID().uuidString + } +} + +class CustomWindow: NSWindow { + + init(configuration: WindowConfiguration) { + super.init( + contentRect: NSRect(x: 10, y: 10, width: 800, height: 600), + styleMask: [.miniaturizable, .closable, .titled, .resizable], backing: .buffered, + defer: false) + + self.isReleasedWhenClosed = false + } + + deinit { + debugPrint("Child window deinit") + } + +} + +class FlutterWindow: NSObject { + let windowId: WindowId + let windowArgument: String + private(set) var window: NSWindow + private var channel: FlutterMethodChannel? + + private var willBecomeActiveObserver: NSObjectProtocol? + private var didResignActiveObserver: NSObjectProtocol? + private var closeObserver: NSObjectProtocol? + + init(windowId: WindowId, windowArgument: String, window: NSWindow) { + self.windowId = windowId + self.windowArgument = windowArgument + self.window = window + super.init() + + willBecomeActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.didChangeOcclusionState(notification) + } + + didResignActiveObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.didChangeOcclusionState(notification) + } + + closeObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, object: window, queue: .main + ) { [windowId] _ in + MultiWindowManager.shared.removeWindow(windowId: windowId) + } + } + + deinit { + if let willBecomeActiveObserver = willBecomeActiveObserver { + NotificationCenter.default.removeObserver(willBecomeActiveObserver) + } + if let didResignActiveObserver = didResignActiveObserver { + NotificationCenter.default.removeObserver(didResignActiveObserver) + } + if let closeObserver = closeObserver { + NotificationCenter.default.removeObserver(closeObserver) + } + } + + @objc func didChangeOcclusionState(_ notification: Notification) { + if let controller = window.contentViewController as? FlutterViewController { + controller.engine.handleDidChangeOcclusionState(notification) + } + } + + func setChannel(_ channel: FlutterMethodChannel) { + self.channel = channel + } + + func notifyWindowEvent(_ event: String, data: [String: Any]) { + if let channel = channel { + channel.invokeMethod(event, arguments: data) + } else { + debugPrint("Channel not set for window \(windowId), cannot notify event \(event)") + } + } + + func handleWindowMethod(method: String, arguments: Any?, result: @escaping FlutterResult) { + switch method { + case "window_show": + window.makeKeyAndOrderFront(nil) + window.setIsVisible(true) + NSApp.activate(ignoringOtherApps: true) + result(nil) + case "window_hide": + window.orderOut(nil) + result(nil) + default: + result(FlutterError(code: "-1", message: "unknown method \(method)", details: nil)) + } + } + +} diff --git a/packages/desktop_multi_window/macos/Classes/WindowChannel.swift b/packages/desktop_multi_window/macos/Classes/WindowChannel.swift new file mode 100644 index 0000000..4ce0733 --- /dev/null +++ b/packages/desktop_multi_window/macos/Classes/WindowChannel.swift @@ -0,0 +1,281 @@ +// +// WindowChannel.swift +// desktop_multi_window +// +// Created by Bin Yang on 2022/1/28. +// + +import FlutterMacOS +import Foundation + +typealias ChannelId = String + +/// Channel communication mode +enum ChannelMode: String { + /// Unidirectional mode: All engines can invoke this channel + case unidirectional = "unidirectional" + /// Bidirectional mode: Only paired engines can invoke each other + case bidirectional = "bidirectional" +} + +private class ChannelRegistry { + static let shared = ChannelRegistry() + + private let lock = NSLock() + + // Unidirectional channels: channel -> single window + private var unidirectionalChannels = [String: WeakBox]() + + // Bidirectional channels: channel -> pair of windows + private var bidirectionalChannels = [String: NSHashTable]() + + enum RegistrationOutcome { + case added + case alreadyRegistered + case limitReached + case modeConflict + } + + private init() {} + + // Helper class to wrap weak reference + private class WeakBox { + weak var value: T? + init(_ value: T) { + self.value = value + } + } + + @discardableResult + func register(_ channel: String, window: WindowChannel, mode: ChannelMode) -> RegistrationOutcome { + lock.lock(); defer { lock.unlock() } + + switch mode { + case .unidirectional: + return registerUnidirectional(channel, window: window) + case .bidirectional: + return registerBidirectional(channel, window: window) + } + } + + private func registerUnidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome { + // Check if channel is already used in bidirectional mode + if bidirectionalChannels[channel] != nil { + return .modeConflict + } + + if let existing = unidirectionalChannels[channel]?.value { + if existing === window { + return .alreadyRegistered + } + // Already registered by another window + return .limitReached + } + + unidirectionalChannels[channel] = WeakBox(window) + return .added + } + + private func registerBidirectional(_ channel: String, window: WindowChannel) -> RegistrationOutcome { + // Check if channel is already used in unidirectional mode + if unidirectionalChannels[channel] != nil { + return .modeConflict + } + + let table: NSHashTable + if let existing = bidirectionalChannels[channel] { + table = existing + } else { + table = NSHashTable.weakObjects() + bidirectionalChannels[channel] = table + } + + let activeWindows = table.allObjects.compactMap { $0 as? WindowChannel } + + if activeWindows.contains(where: { $0 === window }) { + return .alreadyRegistered + } + + if activeWindows.count >= 2 { + return .limitReached + } + + table.add(window) + return .added + } + + func unregister(_ channel: String, window: WindowChannel) { + lock.lock(); defer { lock.unlock() } + + // Try unidirectional + if let existing = unidirectionalChannels[channel]?.value, existing === window { + unidirectionalChannels.removeValue(forKey: channel) + return + } + + // Try bidirectional + if let table = bidirectionalChannels[channel] { + table.remove(window) + if table.allObjects.isEmpty { + bidirectionalChannels.removeValue(forKey: channel) + } + } + } + + func getTarget(for channel: String, from window: WindowChannel) -> WindowChannel? { + lock.lock(); defer { lock.unlock() } + + // Check unidirectional + if let target = unidirectionalChannels[channel]?.value { + // Anyone can call unidirectional channel + return target + } + + // Check bidirectional - only peer can call + if let table = bidirectionalChannels[channel] { + let candidates = table.allObjects.compactMap { $0 as? WindowChannel } + if candidates.isEmpty { + bidirectionalChannels.removeValue(forKey: channel) + return nil + } + + // Check if caller is in the pair + guard candidates.contains(where: { $0 === window }) else { + return nil + } + + // Return the peer + return candidates.first { $0 !== window } + } + + return nil + } + + func hasRegistrations(for channel: String) -> Bool { + lock.lock(); defer { lock.unlock() } + + if let box = unidirectionalChannels[channel], box.value != nil { + return true + } + + if let table = bidirectionalChannels[channel] { + let hasActive = !table.allObjects.isEmpty + if !hasActive { + bidirectionalChannels.removeValue(forKey: channel) + } + return hasActive + } + + return false + } +} + + +class WindowChannel: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "mixin.one/desktop_multi_window/channels", binaryMessenger: registrar.messenger) + let instance = WindowChannel(methodChannel: channel) + registrar.addMethodCallDelegate(instance, channel: channel) + } + + init(methodChannel: FlutterMethodChannel) { + self.methodChannel = methodChannel + super.init() + } + + private let methodChannel: FlutterMethodChannel + + private var methodChannels: [String] = [] + + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "registerMethodHandler": + let arguments = call.arguments as! [String: Any?] + let channel = arguments["channel"] as! String + let modeString = arguments["mode"] as? String ?? "bidirectional" + + guard let mode = ChannelMode(rawValue: modeString) else { + result( + FlutterError( + code: "INVALID_MODE", + message: "invalid mode: \(modeString), must be 'unidirectional' or 'bidirectional'", + details: nil)) + return + } + + let outcome = ChannelRegistry.shared.register(channel, window: self, mode: mode) + switch outcome { + case .added: + methodChannels.append(channel) + result(nil) + case .alreadyRegistered: + result(nil) + case .limitReached: + let message = mode == .unidirectional + ? "channel \(channel) already registered in unidirectional mode" + : "channel \(channel) already has the maximum number of registrations (2)" + result( + FlutterError( + code: "CHANNEL_LIMIT_REACHED", + message: message, + details: nil)) + case .modeConflict: + result( + FlutterError( + code: "CHANNEL_MODE_CONFLICT", + message: "channel \(channel) is already registered in a different mode", + details: nil)) + } + case "unregisterMethodHandler": + let arguments = call.arguments as! [String: Any?] + let channel = arguments["channel"] as! String + + ChannelRegistry.shared.unregister(channel, window: self) + + if let index = methodChannels.firstIndex(of: channel) { + methodChannels.remove(at: index) + } + + result(nil) + case "invokeMethod": + let arguments = call.arguments as! [String: Any?] + let channel = arguments["channel"] as! String + + if let targetChannel = ChannelRegistry.shared.getTarget(for: channel, from: self) { + targetChannel.invokeMethod(channel: channel, arguments: call.arguments, result: result) + } else { + let message: String + if ChannelRegistry.shared.hasRegistrations(for: channel) { + message = "channel \(channel) not accessible from this engine (may be bidirectional pair or not registered)" + } else { + message = "unknown registered channel \(channel)" + } + result( + FlutterError( + code: "CHANNEL_UNREGISTERED", message: message, + details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + } + + func invokeMethod(channel: String, arguments: Any?, result: @escaping FlutterResult) { + // check channelIds contains channel + if !methodChannels.contains(channel) { + result( + FlutterError( + code: "CHANNEL_NOT_FOUND", message: "channel \(channel) not found in this engine", + details: nil)) + return + } + methodChannel.invokeMethod("methodCall", arguments: arguments, result: result) + } + + deinit { + for channel in methodChannels { + ChannelRegistry.shared.unregister(channel, window: self) + } + } +} diff --git a/packages/desktop_multi_window/macos/Classes/WindowConfiguration.swift b/packages/desktop_multi_window/macos/Classes/WindowConfiguration.swift new file mode 100644 index 0000000..cd99b72 --- /dev/null +++ b/packages/desktop_multi_window/macos/Classes/WindowConfiguration.swift @@ -0,0 +1,51 @@ +import Foundation +import Cocoa + + +struct WindowConfiguration: Codable { + + let arguments: String + let hiddenAtLaunch: Bool + + enum CodingKeys: String, CodingKey { + case arguments + case hiddenAtLaunch + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + arguments = try container.decodeIfPresent(String.self, forKey: .arguments) ?? "" + hiddenAtLaunch = try container.decodeIfPresent(Bool.self, forKey: .hiddenAtLaunch) ?? false + } + + init(arguments: String, hiddenAtLaunch: Bool) { + self.arguments = arguments + self.hiddenAtLaunch = hiddenAtLaunch + } + + static let defaultConfiguration = WindowConfiguration( + arguments: "", + hiddenAtLaunch: false + ) + + static func fromJson(_ json: [String: Any?]) -> WindowConfiguration { + guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else { + debugPrint("invalid json object: \(json)") + return defaultConfiguration + } + + do { + let decoder = JSONDecoder() + return try decoder.decode(WindowConfiguration.self, from: jsonData) + } catch { + debugPrint("Failed to parse window configuration: \(error)") + return defaultConfiguration + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(arguments, forKey: .arguments) + try container.encode(hiddenAtLaunch, forKey: .hiddenAtLaunch) + } +} diff --git a/packages/desktop_multi_window/macos/desktop_multi_window.podspec b/packages/desktop_multi_window/macos/desktop_multi_window.podspec new file mode 100644 index 0000000..51a2a48 --- /dev/null +++ b/packages/desktop_multi_window/macos/desktop_multi_window.podspec @@ -0,0 +1,22 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint flutter_multi_window.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'desktop_multi_window' + s.version = '0.0.1' + s.summary = 'A new flutter plugin project.' + s.description = <<-DESC +A new flutter plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/packages/desktop_multi_window/pubspec.yaml b/packages/desktop_multi_window/pubspec.yaml new file mode 100644 index 0000000..9154624 --- /dev/null +++ b/packages/desktop_multi_window/pubspec.yaml @@ -0,0 +1,29 @@ +name: desktop_multi_window +description: A flutter plugin that create and manager multi window in desktop. +version: 0.3.0 +resolution: workspace +homepage: https://github.com/MixinNetwork/flutter-plugins/tree/main/packages/desktop_multi_window + +environment: + sdk: ">=3.11.5 <4.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +# The following section is specific to Flutter. +flutter: + plugin: + platforms: + macos: + pluginClass: FlutterMultiWindowPlugin + windows: + pluginClass: DesktopMultiWindowPlugin + linux: + pluginClass: DesktopMultiWindowPlugin diff --git a/packages/desktop_multi_window/test/desktop_multi_window_test.dart b/packages/desktop_multi_window/test/desktop_multi_window_test.dart new file mode 100644 index 0000000..ab73b3a --- /dev/null +++ b/packages/desktop_multi_window/test/desktop_multi_window_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/packages/desktop_multi_window/windows/CMakeLists.txt b/packages/desktop_multi_window/windows/CMakeLists.txt new file mode 100644 index 0000000..3a1617d --- /dev/null +++ b/packages/desktop_multi_window/windows/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.14) +set(PROJECT_NAME "desktop_multi_window") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "desktop_multi_window_plugin") + +add_library(${PLUGIN_NAME} SHARED + "desktop_multi_window_plugin.cpp" + "multi_window_manager.cc" + "flutter_window.cc" + "window_channel_plugin.cc" + "win32_window.cpp" + ) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin flutter_wrapper_app) +target_link_libraries(${PLUGIN_NAME} PRIVATE "dwmapi.lib") + +# List of absolute paths to libraries that should be bundled with the plugin +set(desktop_multi_window_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/desktop_multi_window/windows/desktop_multi_window_plugin.cpp b/packages/desktop_multi_window/windows/desktop_multi_window_plugin.cpp new file mode 100644 index 0000000..3f31c72 --- /dev/null +++ b/packages/desktop_multi_window/windows/desktop_multi_window_plugin.cpp @@ -0,0 +1,118 @@ +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" +#include "multi_window_plugin_internal.h" + +#include +#include +#include + +#include + +#include "flutter_window_wrapper.h" +#include "multi_window_manager.h" +#include "window_channel_plugin.h" + +namespace { + +class DesktopMultiWindowPlugin : public flutter::Plugin { + public: + DesktopMultiWindowPlugin(FlutterWindowWrapper* window, + flutter::PluginRegistrarWindows* registrar); + + ~DesktopMultiWindowPlugin() override; + + private: + void HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result); + + FlutterWindowWrapper* window_; + flutter::PluginRegistrarWindows* registrar_; +}; + +DesktopMultiWindowPlugin::DesktopMultiWindowPlugin( + FlutterWindowWrapper* window, + flutter::PluginRegistrarWindows* registrar) + : window_(window), registrar_(registrar) { + auto channel = + std::make_shared>( + registrar->messenger(), "mixin.one/desktop_multi_window", + &flutter::StandardMethodCodec::GetInstance()); + channel->SetMethodCallHandler([this](const auto& call, auto result) { + HandleMethodCall(call, std::move(result)); + }); + + // Set channel to window for event notifications + window_->SetChannel(channel); + + // Register WindowChannel plugin for each engine + WindowChannelPluginRegisterWithRegistrar(registrar); +} + +DesktopMultiWindowPlugin::~DesktopMultiWindowPlugin() { + MultiWindowManager::Instance()->RemoveWindow(window_->GetWindowId()); +} + +void DesktopMultiWindowPlugin::HandleMethodCall( + const flutter::MethodCall& method_call, + std::unique_ptr> result) { + // Check if this is a window-specific method (starts with "window_") + const auto& method = method_call.method_name(); + if (method.rfind("window_", 0) == 0) { + auto* arguments = + std::get_if(method_call.arguments()); + auto window_id = std::get( + arguments->at(flutter::EncodableValue("windowId"))); + + auto window = MultiWindowManager::Instance()->GetWindow(window_id); + if (!window) { + result->Error("-1", "failed to find target window: " + window_id); + return; + } + + window->HandleWindowMethod(method, arguments, std::move(result)); + return; + } + + if (method == "createWindow") { + auto args = std::get_if(method_call.arguments()); + auto window_id = MultiWindowManager::Instance()->Create(args); + result->Success(flutter::EncodableValue(window_id)); + return; + } else if (method == "getWindowDefinition") { + flutter::EncodableMap definition; + definition[flutter::EncodableValue("windowId")] = + flutter::EncodableValue(window_->GetWindowId()); + definition[flutter::EncodableValue("windowArgument")] = + flutter::EncodableValue(window_->GetWindowArgument()); + result->Success(flutter::EncodableValue(definition)); + return; + } else if (method == "getAllWindows") { + auto windows = MultiWindowManager::Instance()->GetAllWindows(); + result->Success(flutter::EncodableValue(windows)); + return; + } + + result->NotImplemented(); +} + +} // namespace + +void DesktopMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + // Attach MainWindow + auto hwnd = FlutterDesktopViewGetHWND( + FlutterDesktopPluginRegistrarGetView(registrar)); + MultiWindowManager::Instance()->AttachFlutterMainWindow( + GetAncestor(hwnd, GA_ROOT), registrar); +} + +void InternalMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar, + FlutterWindowWrapper* window) { + auto plugin_registrar = + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar); + auto plugin = + std::make_unique(window, plugin_registrar); + plugin_registrar->AddPlugin(std::move(plugin)); +} diff --git a/packages/desktop_multi_window/windows/flutter_window.cc b/packages/desktop_multi_window/windows/flutter_window.cc new file mode 100644 index 0000000..4a199d4 --- /dev/null +++ b/packages/desktop_multi_window/windows/flutter_window.cc @@ -0,0 +1,82 @@ +#include "flutter_window.h" + +#include "flutter_windows.h" + +#include "tchar.h" + +#include + +#include "multi_window_manager.h" +#include "multi_window_plugin_internal.h" + +FlutterWindow::FlutterWindow(const std::string& id, + const WindowConfiguration config) + : id_(id), window_argument_(config.arguments) {} + +bool FlutterWindow::OnCreate() { + // Called when the window is created + RECT frame = GetClientArea(); + + flutter::DartProject project(L"data"); + std::vector entrypoint_args = {"multi_window", id_, + window_argument_}; + project.set_dart_entrypoint_arguments(entrypoint_args); + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project); + + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + std::cerr << "Failed to setup FlutterViewController." << std::endl; + return false; + } + + auto view_handle = flutter_controller_->view()->GetNativeWindow(); + SetChildContent(view_handle); + + // The first frame can be scheduled before the child HWND is repainted. + // Capture only the HWND: FlutterWindow may be destroyed before the callback. + const HWND child_hwnd = view_handle; + flutter_controller_->engine()->SetNextFrameCallback([child_hwnd]() { + if (IsWindow(child_hwnd)) { + InvalidateRect(child_hwnd, nullptr, FALSE); + UpdateWindow(child_hwnd); + } + }); + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + MultiWindowManager::Instance()->RemoveManagedFlutterWindowLater(id_); +} + +LRESULT FlutterWindow::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} + +FlutterWindow::~FlutterWindow() { + // Cleanup is handled by Win32Window::Destroy() +} diff --git a/packages/desktop_multi_window/windows/flutter_window.h b/packages/desktop_multi_window/windows/flutter_window.h new file mode 100644 index 0000000..417f5a6 --- /dev/null +++ b/packages/desktop_multi_window/windows/flutter_window.h @@ -0,0 +1,44 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ + +#include + +#include + +#include +#include + +#include "win32_window.h" +#include "window_configuration.h" + +class FlutterWindow : public Win32Window { + public: + FlutterWindow(const std::string& id, const WindowConfiguration config); + ~FlutterWindow() override; + + std::string GetWindowId() const { return id_; } + + std::string GetWindowArgument() const { return window_argument_; } + + flutter::FlutterViewController* GetFlutterViewController() const { + return flutter_controller_.get(); + } + + protected: + // Win32Window overrides + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + std::string id_; + std::string window_argument_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_H_ diff --git a/packages/desktop_multi_window/windows/flutter_window_wrapper.h b/packages/desktop_multi_window/windows/flutter_window_wrapper.h new file mode 100644 index 0000000..a4281a9 --- /dev/null +++ b/packages/desktop_multi_window/windows/flutter_window_wrapper.h @@ -0,0 +1,69 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_ + +#include +#include +#include +#include +#include +#include + +class FlutterWindowWrapper { + public: + FlutterWindowWrapper(const std::string& window_id, + HWND hwnd, + const std::string& window_argument = "") + : window_id_(window_id), hwnd_(hwnd), window_argument_(window_argument) {} + + ~FlutterWindowWrapper() = default; + + std::string GetWindowId() const { return window_id_; } + + std::string GetWindowArgument() const { return window_argument_; } + + HWND GetWindowHandle() { return hwnd_; } + + void SetChannel( + std::shared_ptr> + channel) { + channel_ = channel; + } + + void NotifyWindowEvent(const std::string& event, + const flutter::EncodableMap& data) { + if (channel_) { + channel_->InvokeMethod(event, + std::make_unique(data)); + } + } + + void HandleWindowMethod( + const std::string& method, + const flutter::EncodableMap* arguments, + std::unique_ptr> result) { + if (method == "window_show") { + if (hwnd_) { + ::ShowWindow(hwnd_, SW_SHOW); + } + result->Success(); + } else if (method == "window_hide") { + if (hwnd_) { + ::ShowWindow(hwnd_, SW_HIDE); + } + result->Success(); + } else { + result->Error("-1", "unknown method: " + method); + } + } + + protected: + void SetWindowHandle(HWND hwnd) { hwnd_ = hwnd; } + + private: + std::string window_id_; + HWND hwnd_; + std::string window_argument_; + std::shared_ptr> channel_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_FLUTTER_WINDOW_WRAPPER_H_ diff --git a/packages/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h b/packages/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h new file mode 100644 index 0000000..24a6e04 --- /dev/null +++ b/packages/desktop_multi_window/windows/include/desktop_multi_window/desktop_multi_window_plugin.h @@ -0,0 +1,27 @@ +#ifndef FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ +#define FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +// flutter_view_controller: pointer to the flutter::FlutterViewController +typedef void (*WindowCreatedCallback)(void *flutter_view_controller); +FLUTTER_PLUGIN_EXPORT void DesktopMultiWindowSetWindowCreatedCallback(WindowCreatedCallback callback); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_DESKTOP_MULTI_WINDOW_PLUGIN_H_ diff --git a/packages/desktop_multi_window/windows/multi_window_manager.cc b/packages/desktop_multi_window/windows/multi_window_manager.cc new file mode 100644 index 0000000..4e9153b --- /dev/null +++ b/packages/desktop_multi_window/windows/multi_window_manager.cc @@ -0,0 +1,190 @@ +#include "multi_window_manager.h" + +#include +#include +#include +#include +#include +#pragma comment(lib, "rpcrt4.lib") + +#include +#include "flutter_window.h" +#include "flutter_window_wrapper.h" +#include "include/desktop_multi_window/desktop_multi_window_plugin.h" +#include "multi_window_plugin_internal.h" +#include "win32_window.h" +#include "window_configuration.h" + +namespace { + +std::string GenerateWindowId() { + UUID uuid; + UuidCreate(&uuid); + + RPC_CSTR uuid_str = nullptr; + UuidToStringA(&uuid, &uuid_str); + + std::string result(reinterpret_cast(uuid_str)); + RpcStringFreeA(&uuid_str); + + return result; +} + +WindowCreatedCallback _g_window_created_callback = nullptr; + +} // namespace + +// static +MultiWindowManager* MultiWindowManager::Instance() { + static auto manager = std::make_shared(); + return manager.get(); +} + +MultiWindowManager::MultiWindowManager() : windows_() {} + +std::string MultiWindowManager::Create(const flutter::EncodableMap* args) { + std::string window_id = GenerateWindowId(); + WindowConfiguration config = WindowConfiguration::FromEncodableMap(args); + + auto flutter_window = std::make_unique(window_id, config); + + std::wstring title = L""; + Win32Window::Point origin(10, 10); + Win32Window::Size size(800, 600); + + if (!flutter_window->Create(title, origin, size)) { + std::cerr << "Failed to create window." << std::endl; + return ""; + } + + ::ShowWindow(flutter_window->GetHandle(), + config.hidden_at_launch ? SW_HIDE : SW_SHOW); + + auto wrapper = std::make_unique( + window_id, flutter_window->GetHandle(), config.arguments); + + windows_[window_id] = std::move(wrapper); + + if (_g_window_created_callback) { + _g_window_created_callback(flutter_window->GetFlutterViewController()); + } + auto registrar = flutter_window->GetFlutterViewController() + ->engine() + ->GetRegistrarForPlugin("DesktopMultiWindowPlugin"); + InternalMultiWindowPluginRegisterWithRegistrar(registrar, + windows_[window_id].get()); + + // keep flutter_window alive + managed_flutter_windows_[window_id] = std::move(flutter_window); + + // Notify all windows about the change + NotifyWindowsChanged(); + + CleanupRemovedWindows(); + + return window_id; +} + +void MultiWindowManager::AttachFlutterMainWindow( + HWND window_handle, + FlutterDesktopPluginRegistrarRef registrar) { + // check if window already exists + for (const auto& [id, window] : windows_) { + if (GetAncestor(window->GetWindowHandle(), GA_ROOT) == window_handle) { + return; + } + } + + const std::string window_id = GenerateWindowId(); + auto wrapper = + std::make_unique(window_id, window_handle); + + windows_[window_id] = std::move(wrapper); + + InternalMultiWindowPluginRegisterWithRegistrar(registrar, + windows_[window_id].get()); + + // Notify all windows about the change + NotifyWindowsChanged(); +} + +FlutterWindowWrapper* MultiWindowManager::GetWindow( + const std::string& window_id) { + auto it = windows_.find(window_id); + if (it != windows_.end()) { + return it->second.get(); + } + return nullptr; +} + +flutter::EncodableList MultiWindowManager::GetAllWindows() { + flutter::EncodableList windows; + for (const auto& [id, window] : windows_) { + flutter::EncodableMap window_info; + window_info[flutter::EncodableValue("windowId")] = + flutter::EncodableValue(window->GetWindowId()); + window_info[flutter::EncodableValue("windowArgument")] = + flutter::EncodableValue(window->GetWindowArgument()); + windows.push_back(flutter::EncodableValue(window_info)); + } + return windows; +} + +std::vector MultiWindowManager::GetAllWindowIds() { + std::vector window_ids; + for (const auto& [id, window] : windows_) { + window_ids.push_back(id); + } + return window_ids; +} + +void MultiWindowManager::RemoveWindow(const std::string& window_id) { + auto it = windows_.find(window_id); + if (it != windows_.end()) { + windows_.erase(it); + NotifyWindowsChanged(); + } + + // quit application if no windows left + if (windows_.empty()) { + PostQuitMessage(0); + } +} + +void MultiWindowManager::RemoveManagedFlutterWindowLater( + const std::string& window_id) { + pending_remove_ids_.push_back(window_id); +} + +// FIXME:maybe need a more robust way to cleanup removed windows +void MultiWindowManager::CleanupRemovedWindows() { + for (auto& id : pending_remove_ids_) { + auto it = managed_flutter_windows_.find(id); + if (it != managed_flutter_windows_.end()) { + std::cout << "Destroyed managed flutter window: " << id << std::endl; + managed_flutter_windows_.erase(it); + } + } + pending_remove_ids_.clear(); +} + +void MultiWindowManager::NotifyWindowsChanged() { + auto window_ids = GetAllWindowIds(); + flutter::EncodableList window_ids_list; + for (const auto& id : window_ids) { + window_ids_list.push_back(flutter::EncodableValue(id)); + } + + flutter::EncodableMap data; + data[flutter::EncodableValue("windowIds")] = + flutter::EncodableValue(window_ids_list); + + for (const auto& [id, window] : windows_) { + window->NotifyWindowEvent("onWindowsChanged", data); + } +} + +void DesktopMultiWindowSetWindowCreatedCallback( + WindowCreatedCallback callback) { + _g_window_created_callback = callback; +} \ No newline at end of file diff --git a/packages/desktop_multi_window/windows/multi_window_manager.h b/packages/desktop_multi_window/windows/multi_window_manager.h new file mode 100644 index 0000000..ac4b866 --- /dev/null +++ b/packages/desktop_multi_window/windows/multi_window_manager.h @@ -0,0 +1,44 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ + +#include +#include +#include + +#include "flutter_plugin_registrar.h" +#include "flutter_window.h" +#include "flutter_window_wrapper.h" + +class MultiWindowManager { + public: + static MultiWindowManager* Instance(); + + MultiWindowManager(); + + std::string Create(const flutter::EncodableMap* args); + + void AttachFlutterMainWindow(HWND main_window_handle, + FlutterDesktopPluginRegistrarRef registrar); + + FlutterWindowWrapper* GetWindow(const std::string& window_id); + + void RemoveWindow(const std::string& window_id); + + void RemoveManagedFlutterWindowLater(const std::string& window_id); + + flutter::EncodableList GetAllWindows(); + + std::vector GetAllWindowIds(); + + private: + void NotifyWindowsChanged(); + + void CleanupRemovedWindows(); + + std::map> windows_; + std::map> + managed_flutter_windows_; + std::vector pending_remove_ids_; +}; + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_MANAGER_H_ diff --git a/packages/desktop_multi_window/windows/multi_window_plugin_internal.h b/packages/desktop_multi_window/windows/multi_window_plugin_internal.h new file mode 100644 index 0000000..8628f66 --- /dev/null +++ b/packages/desktop_multi_window/windows/multi_window_plugin_internal.h @@ -0,0 +1,12 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_ + +#include "flutter_plugin_registrar.h" + +class FlutterWindowWrapper; + +void InternalMultiWindowPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar, + FlutterWindowWrapper* window); + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_MULTI_WINDOW_PLUGIN_INTERNAL_H_ diff --git a/packages/desktop_multi_window/windows/win32_window.cpp b/packages/desktop_multi_window/windows/win32_window.cpp new file mode 100644 index 0000000..df4f92a --- /dev/null +++ b/packages/desktop_multi_window/windows/win32_window.cpp @@ -0,0 +1,301 @@ +#include "win32_window.h" + +#include +#include + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: +/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = + L"FLUTTER_MULTI_WINDOW_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = + L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + + TCHAR exePath[MAX_PATH]; + GetModuleFileName(NULL, exePath, MAX_PATH); + HICON hIcon = ExtractIcon(GetModuleHandle(NULL), exePath, 0); + if (hIcon) { + window_class.hIcon = hIcon; + } else { + window_class.hIcon = LoadIcon(window_class.hInstance, IDI_APPLICATION); + } + + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + if (window_handle_) { + return false; + } + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = + RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, + &light_mode, &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/desktop_multi_window/windows/win32_window.h b/packages/desktop_multi_window/windows/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/packages/desktop_multi_window/windows/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/desktop_multi_window/windows/window_channel_plugin.cc b/packages/desktop_multi_window/windows/window_channel_plugin.cc new file mode 100644 index 0000000..f5541da --- /dev/null +++ b/packages/desktop_multi_window/windows/window_channel_plugin.cc @@ -0,0 +1,347 @@ +#include "window_channel_plugin.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { + +enum class ChannelMode { kUnidirectional, kBidirectional }; + +enum class RegistrationOutcome { + kAdded, + kAlreadyRegistered, + kLimitReached, + kModeConflict +}; + +class WindowChannelPlugin; + +class ChannelRegistry { + public: + static ChannelRegistry& GetInstance() { + static ChannelRegistry instance; + return instance; + } + + RegistrationOutcome Register(const std::string& channel, + WindowChannelPlugin* plugin, + ChannelMode mode) { + std::lock_guard lock(mutex_); + + if (mode == ChannelMode::kUnidirectional) { + return RegisterUnidirectional(channel, plugin); + } else { + return RegisterBidirectional(channel, plugin); + } + } + + private: + RegistrationOutcome RegisterUnidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in bidirectional mode + if (bidirectional_channels_.find(channel) != + bidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto it = unidirectional_channels_.find(channel); + if (it != unidirectional_channels_.end()) { + if (it->second == plugin) { + return RegistrationOutcome::kAlreadyRegistered; + } + // Already registered by another plugin + return RegistrationOutcome::kLimitReached; + } + + unidirectional_channels_[channel] = plugin; + return RegistrationOutcome::kAdded; + } + + RegistrationOutcome RegisterBidirectional(const std::string& channel, + WindowChannelPlugin* plugin) { + // Check if already used in unidirectional mode + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return RegistrationOutcome::kModeConflict; + } + + auto& plugins = bidirectional_channels_[channel]; + + // Check if already registered + if (plugins.find(plugin) != plugins.end()) { + return RegistrationOutcome::kAlreadyRegistered; + } + + // Check limit + if (plugins.size() >= 2) { + return RegistrationOutcome::kLimitReached; + } + + plugins.insert(plugin); + return RegistrationOutcome::kAdded; + } + + public: + void Unregister(const std::string& channel, WindowChannelPlugin* plugin) { + std::lock_guard lock(mutex_); + + // Try unidirectional + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end() && + uni_it->second == plugin) { + unidirectional_channels_.erase(uni_it); + return; + } + + // Try bidirectional + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + bi_it->second.erase(plugin); + if (bi_it->second.empty()) { + bidirectional_channels_.erase(bi_it); + } + } + } + + WindowChannelPlugin* GetTarget(const std::string& channel, + WindowChannelPlugin* from) { + std::lock_guard lock(mutex_); + + // Check unidirectional - anyone can call + auto uni_it = unidirectional_channels_.find(channel); + if (uni_it != unidirectional_channels_.end()) { + return uni_it->second; + } + + // Check bidirectional - only peer can call + auto bi_it = bidirectional_channels_.find(channel); + if (bi_it != bidirectional_channels_.end()) { + const auto& plugins = bi_it->second; + + // Check if caller is in the pair + if (plugins.find(from) == plugins.end()) { + return nullptr; + } + + // Return the peer + for (auto* plugin : plugins) { + if (plugin != from) { + return plugin; + } + } + } + + return nullptr; + } + + bool HasRegistrations(const std::string& channel) { + std::lock_guard lock(mutex_); + + if (unidirectional_channels_.find(channel) != + unidirectional_channels_.end()) { + return true; + } + + auto it = bidirectional_channels_.find(channel); + return it != bidirectional_channels_.end() && !it->second.empty(); + } + + private: + ChannelRegistry() = default; + std::mutex mutex_; + std::map unidirectional_channels_; + std::map> + bidirectional_channels_; +}; + +class WindowChannelPlugin : public flutter::Plugin { + public: + WindowChannelPlugin(flutter::PluginRegistrarWindows* registrar) + : registrar_(registrar) { + channel_ = std::make_unique>( + registrar->messenger(), "mixin.one/desktop_multi_window/channels", + &flutter::StandardMethodCodec::GetInstance()); + + channel_->SetMethodCallHandler( + [this](const flutter::MethodCall<>& call, + std::unique_ptr> result) { + HandleMethodCall(call, std::move(result)); + }); + } + + ~WindowChannelPlugin() { + for (const auto& channel : registered_channels_) { + ChannelRegistry::GetInstance().Unregister(channel, this); + } + } + + void InvokeMethod(const std::string& channel, + const flutter::EncodableValue& arguments, + std::unique_ptr> result) { + // Check if this plugin has registered this channel + if (std::find(registered_channels_.begin(), registered_channels_.end(), + channel) == registered_channels_.end()) { + result->Error("CHANNEL_NOT_FOUND", + "channel " + channel + " not found in this engine"); + return; + } + + channel_->InvokeMethod("methodCall", std::make_unique(arguments), + std::move(result)); + } + + private: + void HandleMethodCall(const flutter::MethodCall<>& call, + std::unique_ptr> result) { + const auto& method = call.method_name(); + + if (method == "registerMethodHandler") { + auto* args = std::get_if(call.arguments()); + if (!args) { + result->Error("INVALID_ARGUMENTS", "arguments must be a map"); + return; + } + + auto channel_it = args->find(flutter::EncodableValue("channel")); + if (channel_it == args->end()) { + result->Error("INVALID_ARGUMENTS", "channel is required"); + return; + } + + auto* channel = std::get_if(&channel_it->second); + if (!channel) { + result->Error("INVALID_ARGUMENTS", "channel must be a string"); + return; + } + + // Get mode (default to bidirectional) + ChannelMode mode = ChannelMode::kBidirectional; + auto mode_it = args->find(flutter::EncodableValue("mode")); + if (mode_it != args->end()) { + auto* mode_str = std::get_if(&mode_it->second); + if (mode_str) { + if (*mode_str == "unidirectional") { + mode = ChannelMode::kUnidirectional; + } else if (*mode_str == "bidirectional") { + mode = ChannelMode::kBidirectional; + } else { + result->Error("INVALID_MODE", + "invalid mode: " + *mode_str + + ", must be 'unidirectional' or 'bidirectional'"); + return; + } + } + } + + auto outcome = ChannelRegistry::GetInstance().Register(*channel, this, mode); + switch (outcome) { + case RegistrationOutcome::kAdded: + registered_channels_.push_back(*channel); + result->Success(); + break; + case RegistrationOutcome::kAlreadyRegistered: + result->Success(); + break; + case RegistrationOutcome::kLimitReached: { + std::string message = mode == ChannelMode::kUnidirectional + ? "channel " + *channel + + " already registered in " + "unidirectional mode" + : "channel " + *channel + + " already has the maximum number of " + "registrations (2)"; + result->Error("CHANNEL_LIMIT_REACHED", message); + break; + } + case RegistrationOutcome::kModeConflict: + result->Error("CHANNEL_MODE_CONFLICT", + "channel " + *channel + + " is already registered in a different mode"); + break; + } + } else if (method == "unregisterMethodHandler") { + auto* args = std::get_if(call.arguments()); + if (!args) { + result->Error("INVALID_ARGUMENTS", "arguments must be a map"); + return; + } + + auto channel_it = args->find(flutter::EncodableValue("channel")); + if (channel_it == args->end()) { + result->Error("INVALID_ARGUMENTS", "channel is required"); + return; + } + + auto* channel = std::get_if(&channel_it->second); + if (!channel) { + result->Error("INVALID_ARGUMENTS", "channel must be a string"); + return; + } + + ChannelRegistry::GetInstance().Unregister(*channel, this); + + auto it = std::find(registered_channels_.begin(), + registered_channels_.end(), *channel); + if (it != registered_channels_.end()) { + registered_channels_.erase(it); + } + + result->Success(); + } else if (method == "invokeMethod") { + auto* args = std::get_if(call.arguments()); + if (!args) { + result->Error("INVALID_ARGUMENTS", "arguments must be a map"); + return; + } + + auto channel_it = args->find(flutter::EncodableValue("channel")); + if (channel_it == args->end()) { + result->Error("INVALID_ARGUMENTS", "channel is required"); + return; + } + + auto* channel = std::get_if(&channel_it->second); + if (!channel) { + result->Error("INVALID_ARGUMENTS", "channel must be a string"); + return; + } + + auto* target = ChannelRegistry::GetInstance().GetTarget(*channel, this); + if (target) { + target->InvokeMethod(*channel, *call.arguments(), std::move(result)); + } else { + std::string message; + if (ChannelRegistry::GetInstance().HasRegistrations(*channel)) { + message = "channel " + *channel + + " not accessible from this engine (may be bidirectional " + "pair or not registered)"; + } else { + message = "unknown registered channel " + *channel; + } + result->Error("CHANNEL_UNREGISTERED", message); + } + } else { + result->NotImplemented(); + } + } + + flutter::PluginRegistrarWindows* registrar_; + std::unique_ptr> channel_; + std::vector registered_channels_; +}; + +} // namespace + +void WindowChannelPluginRegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar) { + auto plugin = std::make_unique(registrar); + registrar->AddPlugin(std::move(plugin)); +} diff --git a/packages/desktop_multi_window/windows/window_channel_plugin.h b/packages/desktop_multi_window/windows/window_channel_plugin.h new file mode 100644 index 0000000..ce9b6f8 --- /dev/null +++ b/packages/desktop_multi_window/windows/window_channel_plugin.h @@ -0,0 +1,17 @@ +#ifndef DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_ +#define DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_ + +#include +#include +#include + +#include +#include +#include +#include +#include + +void WindowChannelPluginRegisterWithRegistrar( + flutter::PluginRegistrarWindows* registrar); + +#endif // DESKTOP_MULTI_WINDOW_WINDOWS_WINDOW_CHANNEL_PLUGIN_H_ diff --git a/packages/desktop_multi_window/windows/window_configuration.h b/packages/desktop_multi_window/windows/window_configuration.h new file mode 100644 index 0000000..b62a4a4 --- /dev/null +++ b/packages/desktop_multi_window/windows/window_configuration.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +struct WindowConfiguration { + std::string arguments; + bool hidden_at_launch = false; + + static WindowConfiguration FromEncodableMap( + const flutter::EncodableMap* map) { + WindowConfiguration config; + + if (!map) return config; + + try { + auto it = map->find(flutter::EncodableValue("arguments")); + if (it != map->end()) { + config.arguments = std::get(it->second); + } + + it = map->find(flutter::EncodableValue("hiddenAtLaunch")); + if (it != map->end()) { + config.hidden_at_launch = std::get(it->second); + } + } catch (const std::exception& e) { + std::cerr << "Failed to parse WindowConfiguration: " << e.what() + << std::endl; + } + + return config; + } +}; \ No newline at end of file diff --git a/packages/file_picker_bridge/AI_ANALYSIS.md b/packages/file_picker_bridge/AI_ANALYSIS.md index 9af318f..fe6a273 100644 --- a/packages/file_picker_bridge/AI_ANALYSIS.md +++ b/packages/file_picker_bridge/AI_ANALYSIS.md @@ -30,12 +30,8 @@ "children": [], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "flutter pub get", diff --git a/packages/file_picker_bridge/lib/file_picker_bridge.dart b/packages/file_picker_bridge/lib/file_picker_bridge.dart index 473c478..161501f 100644 --- a/packages/file_picker_bridge/lib/file_picker_bridge.dart +++ b/packages/file_picker_bridge/lib/file_picker_bridge.dart @@ -13,6 +13,7 @@ import 'src/method_channel_file_picker.dart'; FilePickerService createFilePickerService({TargetPlatform? platform}) { final targetPlatform = platform ?? defaultTargetPlatform; return switch (targetPlatform) { + TargetPlatform.android => const FileSelectorFilePicker(), TargetPlatform.windows || TargetPlatform.linux => const FileSelectorFilePicker(), diff --git a/packages/file_picker_bridge/test/file_picker_bridge_test.dart b/packages/file_picker_bridge/test/file_picker_bridge_test.dart index f1ab784..c2b9c52 100644 --- a/packages/file_picker_bridge/test/file_picker_bridge_test.dart +++ b/packages/file_picker_bridge/test/file_picker_bridge_test.dart @@ -11,6 +11,13 @@ void main() { ); }); + test('selects the file selector implementation on Android', () { + expect( + createFilePickerService(platform: TargetPlatform.android), + isA(), + ); + }); + test('keeps the MethodChannel implementation on macOS', () { expect( createFilePickerService(platform: TargetPlatform.macOS), diff --git a/packages/flutter_ioc_core/AI_ANALYSIS.md b/packages/flutter_ioc_core/AI_ANALYSIS.md index 7c52af3..9b0e86c 100644 --- a/packages/flutter_ioc_core/AI_ANALYSIS.md +++ b/packages/flutter_ioc_core/AI_ANALYSIS.md @@ -27,12 +27,8 @@ "children": [], "contracts": { "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true + "doc_mode": "machine_contract" }, "validation": [ "dart pub get", diff --git a/packages/flutter_study_learning/AI_ANALYSIS.md b/packages/flutter_study_learning/AI_ANALYSIS.md deleted file mode 100644 index b30c083..0000000 --- a/packages/flutter_study_learning/AI_ANALYSIS.md +++ /dev/null @@ -1,44 +0,0 @@ -{ - "schema": "vibecoding.harness.ai_analysis.v2", - "mode": "package_contract", - "node": { - "id": "flutter_forge.workspace.flutter_study_learning", - "kind": "flutter_package", - "package": "flutter_study_learning", - "path": "packages/flutter_study_learning", - "status": "active" - }, - "package_type": "flutter_package", - "workspace": { - "member": true, - "resolution": "workspace", - "resolution_status": "active", - "resolution_blocker": "none" - }, - "entrypoints": [ - "lib/flutter_study_learning.dart" - ], - "owns": [ - "learning_scaffold_widgets", - "teaching_ui_components" - ], - "depends": [ - "flutter_sdk" - ], - "children": [], - "contracts": { - "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, - "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true - }, - "validation": [ - "flutter pub get", - "flutter analyze", - "flutter test" - ], - "test_status": "configured" -} diff --git a/packages/flutter_study_learning/OWNERS.md b/packages/flutter_study_learning/OWNERS.md deleted file mode 100644 index bbdff24..0000000 --- a/packages/flutter_study_learning/OWNERS.md +++ /dev/null @@ -1,16 +0,0 @@ -# flutter_study_learning Ownership Contract - -## Package - -`flutter_study_learning` - -## Public Contract - -`flutter_study_learning` owns shared teaching UI components for Flutter Forge learning modules, including scaffolds, learning objectives, concept chips, code snippets, state logs, pitfalls, and exercise cards. - -It must not own module-specific business logic, route registration, module metadata, persistence, platform capabilities, or app shell behavior. - -## Maintenance Owners - -- Flutter Forge package maintainers -- Learning UI maintainers diff --git a/packages/flutter_study_learning/README.md b/packages/flutter_study_learning/README.md deleted file mode 100644 index 68bae4d..0000000 --- a/packages/flutter_study_learning/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# flutter_study_learning - -Shared teaching page widgets for Flutter study modules. - -## Scope - -- `LearningScaffold` -- Learning objectives, concept chips, code snippets, state logs, pitfalls, and exercise cards - -This package has no module-specific business logic. diff --git a/packages/flutter_study_learning/lib/flutter_study_learning.dart b/packages/flutter_study_learning/lib/flutter_study_learning.dart deleted file mode 100644 index 09df57d..0000000 --- a/packages/flutter_study_learning/lib/flutter_study_learning.dart +++ /dev/null @@ -1,3 +0,0 @@ -library flutter_study_learning; - -export 'src/learning_scaffold.dart'; diff --git a/packages/flutter_study_learning/pubspec.yaml b/packages/flutter_study_learning/pubspec.yaml deleted file mode 100644 index 012d995..0000000 --- a/packages/flutter_study_learning/pubspec.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: flutter_study_learning -description: Shared learning scaffold widgets for Flutter study modules. -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.6.0 <4.0.0' - -resolution: workspace - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^4.0.0 - -flutter: - uses-material-design: true diff --git a/packages/gcode_core/.gitignore b/packages/gcode_core/.gitignore deleted file mode 100644 index d4cddb8..0000000 --- a/packages/gcode_core/.gitignore +++ /dev/null @@ -1,41 +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/ - -# Dart/Pub related -.dart_tool/ -.pub-cache/ -.pub/ -/build/ -/coverage/ -/pubspec.lock - -# Generated code -*.g.dart -*.freezed.dart -*.mocks.dart -*.pb.dart -*.pbjson.dart -*.pbenum.dart -*.grpc.dart diff --git a/packages/gcode_core/AI_ANALYSIS.md b/packages/gcode_core/AI_ANALYSIS.md deleted file mode 100644 index 277ed2b..0000000 --- a/packages/gcode_core/AI_ANALYSIS.md +++ /dev/null @@ -1,46 +0,0 @@ -{ - "schema": "vibecoding.harness.ai_analysis.v2", - "mode": "package_contract", - "node": { - "id": "flutter_forge.workspace.gcode_core", - "kind": "flutter_package", - "package": "gcode_core", - "path": "packages/gcode_core", - "status": "active" - }, - "package_type": "flutter_package", - "workspace": { - "member": true, - "resolution": "workspace", - "resolution_status": "active", - "resolution_blocker": "none" - }, - "entrypoints": [ - "lib/gcode_core.dart" - ], - "owns": [ - "gcode_parsing", - "line_reading", - "toolpath_building", - "flutter_visualization_widgets" - ], - "depends": [ - "flutter_sdk" - ], - "children": [], - "contracts": { - "no_natural_language": true, - "index_only": true, - "max_index_depth": 2, - "doc_consumer": "coding_agent", - "doc_mode": "machine_contract", - "update_required_on_file_change": true, - "import_direction_enforced": true - }, - "validation": [ - "flutter pub get", - "flutter analyze", - "flutter test" - ], - "test_status": "configured" -} diff --git a/packages/gcode_core/OWNERS.md b/packages/gcode_core/OWNERS.md deleted file mode 100644 index 83cc343..0000000 --- a/packages/gcode_core/OWNERS.md +++ /dev/null @@ -1,16 +0,0 @@ -# gcode_core Ownership Contract - -## Package - -`gcode_core` - -## Public Contract - -`gcode_core` owns the reusable G-code package boundary for parsing G-code, reading line streams, collecting parse errors, building toolpath segments, and rendering Flutter visualization widgets such as the canvas, command timeline, and playback controls. - -It must not own host file picking, app-level route composition, app playback state, module catalog metadata, platform bootstrap policy, or feature-specific Flutter Forge screens outside this package. - -## Maintenance Owners - -- Flutter Forge package maintainers -- G-code package maintainers diff --git a/packages/gcode_core/PHASE_SUMMARY.md b/packages/gcode_core/PHASE_SUMMARY.md deleted file mode 100644 index 65c7773..0000000 --- a/packages/gcode_core/PHASE_SUMMARY.md +++ /dev/null @@ -1,96 +0,0 @@ -# Phase Summary — gcode_core - -> 2026-06-14 · 当前版本 0.1.0 · 基于 xgimi_gcode2d 对比分析后的首轮优化完成 - -## 当前架构 - -``` -lib/src/ -├── application/ ── 编排层 -│ └── gcode_readline_pipeline.dart Stream 流式管道 + isolate 后台解析 -├── core/ ── 核心抽象(本次新增) -│ ├── gcode_bounds.dart 包围盒,增量合并,供 painter 复用 -│ └── gcode_style.dart 绘制样式,预创建 Paint,light/dark 工厂 -├── data/readers/ ── 输入/IO层 -│ ├── gcode_line_reader.dart 抽象接口 Stream -│ ├── string_gcode_line_reader.dart 内存字符串读取 -│ └── file_gcode_line_reader.dart 流式文件读取(openRead + LineSplitter) -├── domain/ ── 领域类型 -│ ├── gcode_load_stage.dart idle/reading/parsing/ready/failed 枚举 -│ ├── gcode_line_record.dart 原始行元数据 -│ ├── gcode_load_snapshot.dart 不可变进度快照(含 bounds) -│ └── parsed_gcode_line.dart sealed class: command/error/skipped -├── models/ ── 数据模型 -│ ├── gcode_command.dart G0/G1 + 参数 + 注释 -│ ├── machine_position.dart X/Y/F 位置状态 -│ └── toolpath_segment.dart 起止点 + 类型(rapid/linear) -├── parser/ ── 解析 -│ ├── gcode_parser.dart 词法/语法解析,支持流式 parseRecord -│ └── gcode_parse_result.dart 批量解析结果 + 错误 DTO -├── services/ ── 业务逻辑 -│ └── toolpath_builder.dart 批量/增量 toolpath 构建 + bounds 增量跟踪 -└── widgets/ ── Flutter 控件 - ├── gcode_canvas.dart CustomPaint 可视化(支持 Bounds + Style) - ├── command_timeline.dart 指令/错误时间线列表 - └── playback_controls.dart 播放/暂停/进度/速度控件 -``` - -## 首轮优化完成项 (2026-06-14) - -### 1. GcodeBounds — 边界预计算 -- **问题**:`_ToolpathPainter.paint()` 每帧 O(n) 遍历 segments 计算 bounds -- **方案**:`IncrementalToolpathBuilder.accept()` 增量维护 `GcodeBounds`,经由 `GcodeLoadSnapshot.bounds` 透传至 painter -- **效果**:painter 直接接收预计算 bounds,删除内部 `_calculateBounds()` 遍历 - -### 2. GcodeStyle — 样式抽离 -- **问题**:painter 内每帧 `new Paint()` + 颜色硬编码 -- **方案**:`GcodeStyle` 类预创建所有 Paint(rapidMoveBg/rapidMove/linearMoveBg/linearMove/toolHead/toolHeadGlow/origin/originDot/grid),`GcodeStyle.light()` 工厂 -- **效果**:零帧内开销 + 外部可自定义配色 - -### 3. Isolate 后台流式解析 -- **问题**:大文件解析阻塞 UI 线程 -- **方案**:`loadFileInBackground(path)` / `loadStringInBackground(source)` 使用 `Isolate.spawn` + `SendPort`/`ReceivePort` 流式返回 `Stream` -- **效果**:与 `load()` 完全一致的 `await for` 消费方式,仅调用入口不同 - -### 不采纳的优化(已评估排除) - -| 项 | 排除原因 | -|---|---| -| SoA 数据模型 (Float32List) | gcode_core 面向中小规模 G-code,Dart 对象开销可忽略;SoA 增加维护负担 | -| Viewport/Transform 两层分离 | 当前无 pan/zoom 交互需求,引入两层会增加不必要的复杂度 | -| Picture 缓存 / Checkpoint 缓存 | 当前 segment 量级下收益有限,后续如需要可作为第二轮专项 | -| G25/G102/G103 指令支持 | 业务领域不同,gcode_core 聚焦 G0/G1 | -| SceneClassifier(矢量/光栅分类) | 仅处理矢量路径,无分类需求 | -| GCodeMemoryTrace 调试日志 | 面向生产环境,教学级项目暂不需要 | - -## 后续规划 - -### 优先级 A — 近期可做 - -| 任务 | 预估工作量 | 说明 | -|---|---|---| -| **Picture 缓存** | 中 | 已完成路径录制成 `ui.Picture`,播放时只画 tail,避免全量重绘 | -| **GcodeController** | 中 | ChangeNotifier 控制器,封装 play/pause/seek/speed 逻辑,替代示例 app 中手写 Timer | -| **错误统计增强** | 小 | `scannedLineCount` / `skippedLineCount` 透传至 snapshot | - -### 优先级 B — 按需启动 - -| 任务 | 预估工作量 | 说明 | -|---|---|---| -| **G2 圆弧支持** | 大 | 新增 `GcodeSegmentType.arc`,painter 实现弧线绘制 | -| **多层绘制** | 大 | 背景网格/辅助线/路径分层,独立 togglable | -| **撤销/重做** | 中 | 编辑场景下的状态回退能力 | - -### 优先级 C — 远期探索 - -| 任务 | 说明 | -|---|---| -| **SVG/Bitmap → G-code 生成** | 当前包只做解析+预览,生成是反向需求 | -| **3D 预览** | 需要整体架构升级 | - -## 测试覆盖 - -``` -flutter test → 22 tests passed (parser × 11, toolpath × 3, pipeline × 3, widget × 1) -flutter analyze → 0 issues -``` diff --git a/packages/gcode_core/README.md b/packages/gcode_core/README.md deleted file mode 100644 index 1a013f5..0000000 --- a/packages/gcode_core/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# gcode_core - -![example](https://github.com/lizy-coding/gcode_core/blob/master/gcode_print.gif) - -G-code parsing and visualization package extracted for Flutter Forge. - -## Scope - -- Read G-code from strings or files line by line. -- Parse G0/G1 commands with X/Y/F parameters. -- Collect parse errors with line metadata. -- Build incremental or batch toolpath segments. -- Render toolpaths with Flutter `CustomPaint`. -- Render command timelines and playback controls for Flutter frontends. - -This package does not open system file pickers or own app-level playback state. - -## Test - -```bash -flutter test -``` - -## Example - -Run the Flutter example app: - -```bash -cd example -flutter run -``` - -The example demonstrates local file selection, streaming parse snapshots, -`GcodeCanvas` drawing, `CommandTimeline`, and `PlaybackControls`. - -Run the console example: - -```bash -dart run example/gcode_core_example.dart -``` - -Minimal usage: - -```dart -import 'package:gcode_core/gcode_core.dart'; - -Future main() async { - const source = ''' -G0 X0 Y0 -G1 X10 Y0 F1200 -G1 X10 Y10 -'''; - - final pipeline = GcodeReadlinePipeline(); - - await for (final snapshot - in pipeline.load(const StringGcodeLineReader(source))) { - if (snapshot.stage == GcodeLoadStage.ready) { - print(snapshot.commands.length); - print(snapshot.segments.length); - print(snapshot.errors.length); - } - } -} -``` diff --git a/packages/gcode_core/example/.gitignore b/packages/gcode_core/example/.gitignore deleted file mode 100644 index 6a40388..0000000 --- a/packages/gcode_core/example/.gitignore +++ /dev/null @@ -1,54 +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-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Generated code -*.g.dart -*.freezed.dart -*.mocks.dart -*.pb.dart -*.pbjson.dart -*.pbenum.dart -*.grpc.dart - -# 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/packages/gcode_core/example/.metadata b/packages/gcode_core/example/.metadata deleted file mode 100644 index c24b9a1..0000000 --- a/packages/gcode_core/example/.metadata +++ /dev/null @@ -1,30 +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: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - - platform: macos - create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 - - # 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/packages/gcode_core/example/README.md b/packages/gcode_core/example/README.md deleted file mode 100644 index f7139cd..0000000 --- a/packages/gcode_core/example/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# gcode_core example - -Flutter example for the `gcode_core` package. - -It demonstrates the full local workflow: - -- Pick a local `.gcode`, `.nc`, `.tap`, or `.txt` file. -- Read the file line by line with `FileGcodeLineReader`. -- Parse supported `G0/G1` commands with `GcodeReadlinePipeline`. -- Dynamically refresh parsed snapshots while reading. -- Draw toolpath segments with the package-provided `GcodeCanvas`. -- Show `G0` jump moves as red dashed lines and `G1` cutting moves as solid paths. -- Show commands and parse errors with `CommandTimeline`. -- Preview the generated path with `PlaybackControls`. - -The main integration points are: - -```dart -final pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 1), -); - -await for (final snapshot in pipeline.load(FileGcodeLineReader(file.path))) { - setState(() => _snapshot = snapshot); -} - -GcodeCanvas( - segments: snapshot.segments, - progress: playbackProgress, - errorCount: snapshot.errors.length, -); -``` - -Run it from this directory: - -```bash -flutter run -``` diff --git a/packages/gcode_core/example/lib/main.dart b/packages/gcode_core/example/lib/main.dart deleted file mode 100644 index 1e12219..0000000 --- a/packages/gcode_core/example/lib/main.dart +++ /dev/null @@ -1,485 +0,0 @@ -import 'dart:async'; - -import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; -import 'package:gcode_core/gcode_core.dart'; - -void main() { - runApp(const GcodeCoreExampleApp()); -} - -class GcodeCoreExampleApp extends StatelessWidget { - const GcodeCoreExampleApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'G-code Core Example', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff2563eb)), - useMaterial3: true, - ), - home: const GcodeExamplePage(), - ); - } -} - -class GcodeExamplePage extends StatefulWidget { - const GcodeExamplePage({super.key}); - - @override - State createState() => _GcodeExamplePageState(); -} - -class _GcodeExamplePageState extends State { - static const _sampleSource = ''' -G0 X0 Y0 -G1 X30 Y0 F1200 -G1 X30 Y18 -G1 X12 Y18 -G0 X6 Y8 -G1 X22 Y8 -G2 X40 Y40 -'''; - - final _pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 1), - ); - - GcodeLoadSnapshot? _snapshot; - String _sourceName = '未选择文件'; - String _status = '请选择本地 G-code 文件,或加载内置示例。'; - bool _loading = false; - bool _isPlaying = false; - double _playbackProgress = 1; - double _speedMultiplier = 1; - Timer? _playbackTimer; - - @override - void dispose() { - _playbackTimer?.cancel(); - super.dispose(); - } - - Future _pickAndParseFile() async { - const typeGroup = XTypeGroup( - label: 'G-code', - extensions: ['gcode', 'nc', 'tap', 'txt'], - ); - - final file = await openFile(acceptedTypeGroups: [typeGroup]); - if (file == null) return; - - await _parseReader(FileGcodeLineReader(file.path), sourceName: file.name); - } - - Future _loadSample() { - return _parseReader( - const StringGcodeLineReader(_sampleSource), - sourceName: '内置示例', - ); - } - - Future _parseReader( - GcodeLineReader reader, { - required String sourceName, - }) async { - _playbackTimer?.cancel(); - setState(() { - _loading = true; - _isPlaying = false; - _playbackProgress = 1; - _sourceName = sourceName; - _snapshot = null; - _status = '正在读取 $sourceName'; - }); - - await for (final snapshot in _pipeline.load(reader)) { - if (!mounted) return; - setState(() { - _snapshot = snapshot; - _status = snapshot.message; - _playbackProgress = 1; - }); - if (snapshot.stage == GcodeLoadStage.parsing) { - await Future.delayed(const Duration(milliseconds: 16)); - } - } - - if (!mounted) return; - setState(() => _loading = false); - } - - void _play() { - if ((_snapshot?.segments.isEmpty ?? true) || _loading) return; - - _playbackTimer?.cancel(); - setState(() => _isPlaying = true); - _playbackTimer = Timer.periodic(const Duration(milliseconds: 16), (_) { - if (!mounted) return; - final next = _playbackProgress + 0.004 * _speedMultiplier; - setState(() { - _playbackProgress = next.clamp(0, 1); - _isPlaying = _playbackProgress < 1; - }); - if (_playbackProgress >= 1) { - _playbackTimer?.cancel(); - } - }); - } - - void _pause() { - _playbackTimer?.cancel(); - setState(() => _isPlaying = false); - } - - void _resetPlayback() { - _playbackTimer?.cancel(); - setState(() { - _isPlaying = false; - _playbackProgress = 0; - }); - } - - void _seekPlayback(double value) { - setState(() => _playbackProgress = value); - } - - void _setSpeed(double value) { - setState(() => _speedMultiplier = value); - } - - int _currentCommandIndex(GcodeLoadSnapshot? snapshot) { - final commandCount = snapshot?.commands.length ?? 0; - if (commandCount == 0) return -1; - return (_playbackProgress * commandCount).ceil().clamp(1, commandCount) - 1; - } - - @override - Widget build(BuildContext context) { - final snapshot = _snapshot; - - return Scaffold( - appBar: AppBar( - title: const Text('G-code Core 绘制示例'), - actions: [ - TextButton.icon( - onPressed: _loading ? null : _loadSample, - icon: const Icon(Icons.data_object), - label: const Text('示例数据'), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: _loading ? null : _pickAndParseFile, - icon: const Icon(Icons.folder_open), - label: const Text('选择 G-code'), - ), - const SizedBox(width: 16), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _StatusBar( - sourceName: _sourceName, - status: _status, - loading: _loading, - ), - const SizedBox(height: 16), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - flex: 3, - child: _CanvasPanel( - snapshot: snapshot, - parsing: _loading, - progress: _playbackProgress, - isPlaying: _isPlaying, - speedMultiplier: _speedMultiplier, - onPlay: _play, - onPause: _pause, - onReset: _resetPlayback, - onSeek: _seekPlayback, - onSpeedChange: _setSpeed, - ), - ), - const SizedBox(width: 16), - SizedBox( - width: 360, - child: _ResultPanel( - snapshot: snapshot, - currentIndex: _currentCommandIndex(snapshot), - onCommandTap: (index) { - final total = snapshot?.commands.length ?? 0; - if (total == 0) return; - _pause(); - setState(() => _playbackProgress = (index + 1) / total); - }, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _StatusBar extends StatelessWidget { - const _StatusBar({ - required this.sourceName, - required this.status, - required this.loading, - }); - - final String sourceName; - final String status; - final bool loading; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return DecoratedBox( - decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (loading) - const SizedBox.square( - dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - const Icon(Icons.route), - const SizedBox(width: 12), - Expanded( - child: Text( - '$sourceName - $status', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ); - } -} - -class _CanvasPanel extends StatelessWidget { - const _CanvasPanel({ - required this.snapshot, - required this.parsing, - required this.progress, - required this.isPlaying, - required this.speedMultiplier, - required this.onPlay, - required this.onPause, - required this.onReset, - required this.onSeek, - required this.onSpeedChange, - }); - - final GcodeLoadSnapshot? snapshot; - final bool parsing; - final double progress; - final bool isPlaying; - final double speedMultiplier; - final VoidCallback onPlay; - final VoidCallback onPause; - final VoidCallback onReset; - final ValueChanged onSeek; - final ValueChanged onSpeedChange; - - @override - Widget build(BuildContext context) { - final segments = snapshot?.segments ?? const []; - final errors = snapshot?.errors.length ?? 0; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Stack( - children: [ - Positioned.fill( - child: GcodeCanvas( - segments: segments, - progress: parsing ? 1 : progress, - errorCount: errors, - bounds: snapshot?.bounds, - ), - ), - Positioned( - left: 12, - top: 12, - child: _CanvasLegend( - parsing: parsing, - segments: segments.length, - mainSegments: segments - .where( - (segment) => segment.type == GcodeSegmentType.linear, - ) - .length, - ), - ), - ], - ), - ), - const SizedBox(height: 12), - PlaybackControls( - isPlaying: isPlaying, - progress: parsing ? 1 : progress, - speedMultiplier: speedMultiplier, - onPlay: onPlay, - onPause: onPause, - onReset: onReset, - onSeek: onSeek, - onSpeedChange: onSpeedChange, - ), - ], - ); - } -} - -class _CanvasLegend extends StatelessWidget { - const _CanvasLegend({ - required this.parsing, - required this.segments, - required this.mainSegments, - }); - - final bool parsing; - final int segments; - final int mainSegments; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surface.withValues(alpha: 0.9), - border: Border.all(color: theme.colorScheme.outlineVariant), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: DefaultTextStyle( - style: theme.textTheme.labelMedium!, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(parsing ? '动态解析绘制中' : 'GcodeCanvas 绘制'), - const SizedBox(height: 4), - Text('主线段 G1: $mainSegments'), - Text('移动段 G0/G1: $segments'), - ], - ), - ), - ), - ); - } -} - -class _ResultPanel extends StatelessWidget { - const _ResultPanel({ - required this.snapshot, - required this.currentIndex, - required this.onCommandTap, - }); - - final GcodeLoadSnapshot? snapshot; - final int currentIndex; - final ValueChanged onCommandTap; - - @override - Widget build(BuildContext context) { - final current = snapshot; - - if (current == null) { - return const Center(child: Text('解析结果会显示在这里')); - } - - return ListView( - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _Metric(label: '行数', value: current.linesRead.toString()), - _Metric(label: '指令', value: current.commands.length.toString()), - _Metric(label: '轨迹', value: current.segments.length.toString()), - _Metric(label: '错误', value: current.errors.length.toString()), - ], - ), - const SizedBox(height: 16), - CommandTimeline( - commands: current.commands, - errors: current.errors, - currentIndex: currentIndex, - onTap: onCommandTap, - maxHeight: 360, - ), - const SizedBox(height: 16), - Text('解析错误', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - if (current.errors.isEmpty) - const Text('无') - else - for (final error in current.errors) - ListTile( - dense: true, - leading: const Icon(Icons.warning_amber), - title: Text('第 ${error.lineNumber} 行'), - subtitle: Text('${error.message}\n${error.rawLine}'), - ), - ], - ); - } -} - -class _Metric extends StatelessWidget { - const _Metric({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return SizedBox( - width: 78, - child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.labelMedium), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.titleLarge), - ], - ), - ), - ), - ); - } -} diff --git a/packages/gcode_core/example/macos/.gitignore b/packages/gcode_core/example/macos/.gitignore deleted file mode 100644 index 746adbb..0000000 --- a/packages/gcode_core/example/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/packages/gcode_core/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/gcode_core/example/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 14b5f7c..0000000 --- a/packages/gcode_core/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import file_selector_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) -} diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 82b6f9d..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 13b35eb..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 0a3f5fa..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bdb5722..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index f083318..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 326c0e7..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 2f1632c..0000000 Binary files a/packages/gcode_core/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/packages/gcode_core/example/macos/RunnerTests/RunnerTests.swift b/packages/gcode_core/example/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1..0000000 --- a/packages/gcode_core/example/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -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/packages/gcode_core/example/test/widget_test.dart b/packages/gcode_core/example/test/widget_test.dart deleted file mode 100644 index 7c72bb2..0000000 --- a/packages/gcode_core/example/test/widget_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:gcode_core/gcode_core.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - testWidgets('GcodeReadlinePipeline smoke test', (WidgetTester tester) async { - final pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 2), - ); - - final snapshots = await pipeline - .load(const StringGcodeLineReader('G0 X0 Y0\nG1 X10 Y0\n')) - .toList(); - - expect(snapshots.isNotEmpty, true); - final last = snapshots.last; - expect(last.stage, GcodeLoadStage.ready); - expect(last.commands.length, 2); - }); -} diff --git a/packages/gcode_core/gcode_print.gif b/packages/gcode_core/gcode_print.gif deleted file mode 100644 index 29c3f7c..0000000 Binary files a/packages/gcode_core/gcode_print.gif and /dev/null differ diff --git a/packages/gcode_core/lib/gcode_core.dart b/packages/gcode_core/lib/gcode_core.dart deleted file mode 100644 index c100cd6..0000000 --- a/packages/gcode_core/lib/gcode_core.dart +++ /dev/null @@ -1,21 +0,0 @@ -library gcode_core; - -export 'src/application/gcode_readline_pipeline.dart'; -export 'src/core/gcode_bounds.dart'; -export 'src/core/gcode_style.dart'; -export 'src/data/readers/file_gcode_line_reader.dart'; -export 'src/data/readers/gcode_line_reader.dart'; -export 'src/data/readers/string_gcode_line_reader.dart'; -export 'src/domain/gcode_line_record.dart'; -export 'src/domain/gcode_load_snapshot.dart'; -export 'src/domain/gcode_load_stage.dart'; -export 'src/domain/parsed_gcode_line.dart'; -export 'src/models/gcode_command.dart'; -export 'src/models/machine_position.dart'; -export 'src/models/toolpath_segment.dart'; -export 'src/parser/gcode_parse_result.dart'; -export 'src/parser/gcode_parser.dart'; -export 'src/services/toolpath_builder.dart'; -export 'src/widgets/command_timeline.dart'; -export 'src/widgets/gcode_canvas.dart'; -export 'src/widgets/playback_controls.dart'; diff --git a/packages/gcode_core/lib/src/application/gcode_readline_pipeline.dart b/packages/gcode_core/lib/src/application/gcode_readline_pipeline.dart deleted file mode 100644 index 378415c..0000000 --- a/packages/gcode_core/lib/src/application/gcode_readline_pipeline.dart +++ /dev/null @@ -1,370 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; - -import '../data/readers/gcode_line_reader.dart'; -import '../domain/gcode_line_record.dart'; -import '../domain/gcode_load_snapshot.dart'; -import '../domain/gcode_load_stage.dart'; -import '../domain/parsed_gcode_line.dart'; -import '../models/gcode_command.dart'; -import '../models/toolpath_segment.dart'; -import '../parser/gcode_parse_result.dart'; -import '../parser/gcode_parser.dart'; -import '../services/toolpath_builder.dart'; - -class GcodeReadlineOptions { - const GcodeReadlineOptions({ - this.snapshotBatchSize = 200, - }); - - final int snapshotBatchSize; -} - -class GcodeReadlinePipeline { - GcodeReadlinePipeline({ - GcodeParser? parser, - IncrementalToolpathBuilder? toolpathBuilder, - this.options = const GcodeReadlineOptions(), - }) : _parser = parser ?? GcodeParser(), - _toolpathBuilder = toolpathBuilder ?? IncrementalToolpathBuilder(); - - final GcodeParser _parser; - final IncrementalToolpathBuilder _toolpathBuilder; - final GcodeReadlineOptions options; - - Stream load(GcodeLineReader reader) async* { - final commands = []; - final errors = []; - final segments = []; - var linesRead = 0; - var changedSinceSnapshot = 0; - - _toolpathBuilder.reset(); - - yield const GcodeLoadSnapshot( - stage: GcodeLoadStage.reading, - commands: [], - errors: [], - segments: [], - linesRead: 0, - message: '开始逐行读取', - ); - - try { - await for (final record in reader.readLines()) { - linesRead = record.lineNumber; - final parsed = _parser.parseRecord(record); - - switch (parsed.kind) { - case ParsedGcodeLineKind.command: - final command = parsed.command!; - commands.add(command); - final segment = _toolpathBuilder.accept(command); - if (segment != null) { - segments.add(segment); - } - case ParsedGcodeLineKind.error: - errors.add(parsed.error!); - case ParsedGcodeLineKind.skipped: - break; - } - - changedSinceSnapshot++; - if (changedSinceSnapshot >= options.snapshotBatchSize) { - changedSinceSnapshot = 0; - yield _snapshot( - stage: GcodeLoadStage.parsing, - commands: commands, - errors: errors, - segments: segments, - linesRead: linesRead, - message: '已读取 $linesRead 行', - ); - } - } - - yield _snapshot( - stage: GcodeLoadStage.ready, - commands: commands, - errors: errors, - segments: segments, - linesRead: linesRead, - message: '逐行读取完成: $linesRead 行, ' - '${commands.length} 条指令, ${errors.length} 个错误, ' - '${segments.length} 条轨迹段', - ); - } catch (error) { - yield _snapshot( - stage: GcodeLoadStage.failed, - commands: commands, - errors: errors, - segments: segments, - linesRead: linesRead, - message: '读取失败: $error', - ); - } - } - - Stream loadFileInBackground(String filePath) { - final receivePort = ReceivePort(); - - Isolate.spawn( - _isolateLoadFile, - (filePath, options, receivePort.sendPort), - ); - - return receivePort - .takeWhile((msg) => msg is! _IsolateDone) - .cast(); - } - - Stream loadStringInBackground(String source) { - final receivePort = ReceivePort(); - - Isolate.spawn( - _isolateLoadString, - (source, options, receivePort.sendPort), - ); - - return receivePort - .takeWhile((msg) => msg is! _IsolateDone) - .cast(); - } - - GcodeLoadSnapshot _snapshot({ - required GcodeLoadStage stage, - required List commands, - required List errors, - required List segments, - required int linesRead, - required String message, - }) { - final b = _toolpathBuilder.bounds; - return GcodeLoadSnapshot( - stage: stage, - commands: List.unmodifiable(commands), - errors: List.unmodifiable(errors), - segments: List.unmodifiable(segments), - linesRead: linesRead, - message: message, - bounds: - b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 ? b : null, - ); - } - - static void _isolateLoadFile( - (String, GcodeReadlineOptions, SendPort) args, - ) { - () async { - final (filePath, options, sendPort) = args; - final parser = GcodeParser(); - final builder = IncrementalToolpathBuilder(); - - final commands = []; - final errors = []; - final segments = []; - var linesRead = 0; - var changedSinceSnapshot = 0; - - try { - sendPort.send( - const GcodeLoadSnapshot( - stage: GcodeLoadStage.reading, - commands: [], - errors: [], - segments: [], - linesRead: 0, - message: '开始逐行读取', - ), - ); - - final file = File(filePath); - final stream = file - .openRead() - .transform(utf8.decoder) - .transform(const LineSplitter()); - - await for (final line in stream) { - linesRead++; - final record = GcodeLineRecord( - lineNumber: linesRead, - rawLine: line, - byteOffset: 0, - ); - - final parsed = parser.parseRecord(record); - - switch (parsed.kind) { - case ParsedGcodeLineKind.command: - final command = parsed.command!; - commands.add(command); - final segment = builder.accept(command); - if (segment != null) { - segments.add(segment); - } - case ParsedGcodeLineKind.error: - errors.add(parsed.error!); - case ParsedGcodeLineKind.skipped: - break; - } - - changedSinceSnapshot++; - if (changedSinceSnapshot >= options.snapshotBatchSize) { - changedSinceSnapshot = 0; - final b = builder.bounds; - sendPort.send( - GcodeLoadSnapshot( - stage: GcodeLoadStage.parsing, - commands: List.unmodifiable(commands), - errors: List.unmodifiable(errors), - segments: List.unmodifiable(segments), - linesRead: linesRead, - message: '已读取 $linesRead 行', - bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 - ? b - : null, - ), - ); - } - } - - final b = builder.bounds; - sendPort.send( - GcodeLoadSnapshot( - stage: GcodeLoadStage.ready, - commands: List.unmodifiable(commands), - errors: List.unmodifiable(errors), - segments: List.unmodifiable(segments), - linesRead: linesRead, - message: '逐行读取完成: $linesRead 行, ' - '${commands.length} 条指令, ${errors.length} 个错误, ' - '${segments.length} 条轨迹段', - bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 - ? b - : null, - ), - ); - } catch (error) { - sendPort.send( - GcodeLoadSnapshot( - stage: GcodeLoadStage.failed, - commands: List.unmodifiable(commands), - errors: List.unmodifiable(errors), - segments: List.unmodifiable(segments), - linesRead: linesRead, - message: '读取失败: $error', - ), - ); - } - - sendPort.send(const _IsolateDone()); - }(); - } - - static void _isolateLoadString( - (String, GcodeReadlineOptions, SendPort) args, - ) { - final (source, options, sendPort) = args; - final parser = GcodeParser(); - final builder = IncrementalToolpathBuilder(); - - final commands = []; - final errors = []; - final segments = []; - var linesRead = 0; - var changedSinceSnapshot = 0; - - try { - sendPort.send( - const GcodeLoadSnapshot( - stage: GcodeLoadStage.reading, - commands: [], - errors: [], - segments: [], - linesRead: 0, - message: '开始逐行读取', - ), - ); - - for (final line in const LineSplitter().convert(source)) { - linesRead++; - final record = GcodeLineRecord( - lineNumber: linesRead, - rawLine: line, - byteOffset: 0, - ); - - final parsed = parser.parseRecord(record); - - switch (parsed.kind) { - case ParsedGcodeLineKind.command: - final command = parsed.command!; - commands.add(command); - final segment = builder.accept(command); - if (segment != null) { - segments.add(segment); - } - case ParsedGcodeLineKind.error: - errors.add(parsed.error!); - case ParsedGcodeLineKind.skipped: - break; - } - - changedSinceSnapshot++; - if (changedSinceSnapshot >= options.snapshotBatchSize) { - changedSinceSnapshot = 0; - final b = builder.bounds; - sendPort.send( - GcodeLoadSnapshot( - stage: GcodeLoadStage.parsing, - commands: commands, - errors: errors, - segments: segments, - linesRead: linesRead, - message: '已读取 $linesRead 行', - bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 - ? b - : null, - ), - ); - } - } - - final b = builder.bounds; - sendPort.send( - GcodeLoadSnapshot( - stage: GcodeLoadStage.ready, - commands: commands, - errors: errors, - segments: segments, - linesRead: linesRead, - message: '逐行读取完成: $linesRead 行, ' - '${commands.length} 条指令, ${errors.length} 个错误, ' - '${segments.length} 条轨迹段', - bounds: b.minX != 0 || b.maxX != 0 || b.minY != 0 || b.maxY != 0 - ? b - : null, - ), - ); - } catch (error) { - sendPort.send( - GcodeLoadSnapshot( - stage: GcodeLoadStage.failed, - commands: commands, - errors: errors, - segments: segments, - linesRead: linesRead, - message: '读取失败: $error', - ), - ); - } - - sendPort.send(const _IsolateDone()); - } -} - -class _IsolateDone { - const _IsolateDone(); -} diff --git a/packages/gcode_core/lib/src/core/gcode_bounds.dart b/packages/gcode_core/lib/src/core/gcode_bounds.dart deleted file mode 100644 index 22c26a2..0000000 --- a/packages/gcode_core/lib/src/core/gcode_bounds.dart +++ /dev/null @@ -1,24 +0,0 @@ -class GcodeBounds { - const GcodeBounds({ - required this.minX, - required this.maxX, - required this.minY, - required this.maxY, - }); - - static const zero = GcodeBounds(minX: 0, maxX: 0, minY: 0, maxY: 0); - - final double minX; - final double maxX; - final double minY; - final double maxY; - - GcodeBounds expand(double x, double y) { - return GcodeBounds( - minX: x < minX ? x : minX, - maxX: x > maxX ? x : maxX, - minY: y < minY ? y : minY, - maxY: y > maxY ? y : maxY, - ); - } -} diff --git a/packages/gcode_core/lib/src/core/gcode_style.dart b/packages/gcode_core/lib/src/core/gcode_style.dart deleted file mode 100644 index 4cc5ec2..0000000 --- a/packages/gcode_core/lib/src/core/gcode_style.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; - -class GcodeStyle { - const GcodeStyle({ - required this.rapidMovePaint, - required this.rapidMoveBgPaint, - required this.linearMovePaint, - required this.linearMoveBgPaint, - required this.toolHeadPaint, - required this.toolHeadGlowPaint, - required this.originPaint, - required this.originDotPaint, - required this.gridPaint, - }); - - final Paint rapidMovePaint; - final Paint rapidMoveBgPaint; - final Paint linearMovePaint; - final Paint linearMoveBgPaint; - final Paint toolHeadPaint; - final Paint toolHeadGlowPaint; - final Paint originPaint; - final Paint originDotPaint; - final Paint gridPaint; - - factory GcodeStyle.light({ - Color rapidColor = Colors.red, - Color linearColor = Colors.green, - Color toolHeadColor = Colors.red, - Color originColor = const Color(0x99FF9800), - Color gridColor = const Color(0x26000000), - }) { - return GcodeStyle( - rapidMovePaint: Paint() - ..color = rapidColor - ..strokeWidth = 1.5 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round, - rapidMoveBgPaint: Paint() - ..color = rapidColor.withValues(alpha: 0.25) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke, - linearMovePaint: Paint() - ..color = linearColor - ..strokeWidth = 2.5 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round, - linearMoveBgPaint: Paint() - ..color = linearColor.withValues(alpha: 0.15) - ..strokeWidth = 1 - ..style = PaintingStyle.stroke, - toolHeadPaint: Paint() - ..color = toolHeadColor - ..style = PaintingStyle.fill, - toolHeadGlowPaint: Paint() - ..color = toolHeadColor.withValues(alpha: 0.3) - ..style = PaintingStyle.fill, - originPaint: Paint() - ..color = originColor - ..strokeWidth = 1.5 - ..style = PaintingStyle.stroke, - originDotPaint: Paint() - ..color = originColor - ..style = PaintingStyle.fill, - gridPaint: Paint() - ..color = gridColor - ..strokeWidth = 0.5, - ); - } -} diff --git a/packages/gcode_core/lib/src/data/readers/file_gcode_line_reader.dart b/packages/gcode_core/lib/src/data/readers/file_gcode_line_reader.dart deleted file mode 100644 index e46c307..0000000 --- a/packages/gcode_core/lib/src/data/readers/file_gcode_line_reader.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import '../../domain/gcode_line_record.dart'; -import 'gcode_line_reader.dart'; - -class FileGcodeLineReader implements GcodeLineReader { - const FileGcodeLineReader(this.path); - - final String path; - - static String normalizePath(String value) { - var normalized = value.trim(); - if (normalized.length >= 2) { - final first = normalized[0]; - final last = normalized[normalized.length - 1]; - if ((first == '"' && last == '"') || (first == "'" && last == "'")) { - normalized = normalized.substring(1, normalized.length - 1).trim(); - } - } - - if (normalized.startsWith('file://')) { - normalized = - Uri.parse(normalized).toFilePath(windows: Platform.isWindows); - } - - final home = Platform.environment['HOME'] ?? - Platform.environment['USERPROFILE'] ?? - ''; - if (home.isNotEmpty && normalized == '~') { - normalized = home; - } else if (home.isNotEmpty && normalized.startsWith('~/')) { - normalized = '$home/${normalized.substring(2)}'; - } - - normalized = normalized.replaceAllMapped( - RegExp(r'\\([ ()\[\]&;])'), - (match) => match.group(1)!, - ); - - return normalized; - } - - @override - Stream readLines() async* { - final normalizedPath = normalizePath(path); - final type = await FileSystemEntity.type(normalizedPath); - if (type == FileSystemEntityType.notFound) { - throw FileSystemException('文件不存在', normalizedPath); - } - if (type == FileSystemEntityType.directory) { - throw FileSystemException('路径是目录,不是 G-code 文件', normalizedPath); - } - - final file = File(normalizedPath); - var lineNumber = 0; - var byteOffset = 0; - - await for (final line in file - .openRead() - .transform(utf8.decoder) - .transform(const LineSplitter())) { - lineNumber++; - yield GcodeLineRecord( - lineNumber: lineNumber, - rawLine: line, - byteOffset: byteOffset, - ); - byteOffset += utf8.encode(line).length + 1; - } - } -} diff --git a/packages/gcode_core/lib/src/data/readers/gcode_line_reader.dart b/packages/gcode_core/lib/src/data/readers/gcode_line_reader.dart deleted file mode 100644 index 4691395..0000000 --- a/packages/gcode_core/lib/src/data/readers/gcode_line_reader.dart +++ /dev/null @@ -1,5 +0,0 @@ -import '../../domain/gcode_line_record.dart'; - -abstract interface class GcodeLineReader { - Stream readLines(); -} diff --git a/packages/gcode_core/lib/src/data/readers/string_gcode_line_reader.dart b/packages/gcode_core/lib/src/data/readers/string_gcode_line_reader.dart deleted file mode 100644 index a122ef8..0000000 --- a/packages/gcode_core/lib/src/data/readers/string_gcode_line_reader.dart +++ /dev/null @@ -1,39 +0,0 @@ -import '../../domain/gcode_line_record.dart'; -import 'gcode_line_reader.dart'; - -class StringGcodeLineReader implements GcodeLineReader { - const StringGcodeLineReader(this.source); - - final String source; - - @override - Stream readLines() async* { - var lineNumber = 0; - var byteOffset = 0; - var start = 0; - - for (var i = 0; i < source.length; i++) { - final codeUnit = source.codeUnitAt(i); - if (codeUnit != 10) continue; - - lineNumber++; - final end = i > start && source.codeUnitAt(i - 1) == 13 ? i - 1 : i; - yield GcodeLineRecord( - lineNumber: lineNumber, - rawLine: source.substring(start, end), - byteOffset: byteOffset, - ); - byteOffset = i + 1; - start = i + 1; - } - - if (start < source.length || source.isEmpty) { - lineNumber++; - yield GcodeLineRecord( - lineNumber: lineNumber, - rawLine: source.substring(start), - byteOffset: byteOffset, - ); - } - } -} diff --git a/packages/gcode_core/lib/src/domain/gcode_line_record.dart b/packages/gcode_core/lib/src/domain/gcode_line_record.dart deleted file mode 100644 index be8fa24..0000000 --- a/packages/gcode_core/lib/src/domain/gcode_line_record.dart +++ /dev/null @@ -1,11 +0,0 @@ -class GcodeLineRecord { - const GcodeLineRecord({ - required this.lineNumber, - required this.rawLine, - required this.byteOffset, - }); - - final int lineNumber; - final String rawLine; - final int byteOffset; -} diff --git a/packages/gcode_core/lib/src/domain/gcode_load_snapshot.dart b/packages/gcode_core/lib/src/domain/gcode_load_snapshot.dart deleted file mode 100644 index 2e5bc95..0000000 --- a/packages/gcode_core/lib/src/domain/gcode_load_snapshot.dart +++ /dev/null @@ -1,41 +0,0 @@ -import '../core/gcode_bounds.dart'; -import '../models/gcode_command.dart'; -import '../models/toolpath_segment.dart'; -import '../parser/gcode_parse_result.dart'; -import 'gcode_load_stage.dart'; - -class GcodeLoadSnapshot { - const GcodeLoadSnapshot({ - required this.stage, - required this.commands, - required this.errors, - required this.segments, - required this.linesRead, - this.message = '', - this.diagnosticMessage = '', - this.bounds, - }); - - factory GcodeLoadSnapshot.empty() { - return const GcodeLoadSnapshot( - stage: GcodeLoadStage.idle, - commands: [], - errors: [], - segments: [], - linesRead: 0, - ); - } - - final GcodeLoadStage stage; - final List commands; - final List errors; - final List segments; - final int linesRead; - final String message; - final String diagnosticMessage; - final GcodeBounds? bounds; - - GcodeParseResult toParseResult() { - return GcodeParseResult(commands: commands, errors: errors); - } -} diff --git a/packages/gcode_core/lib/src/domain/gcode_load_stage.dart b/packages/gcode_core/lib/src/domain/gcode_load_stage.dart deleted file mode 100644 index dffe2da..0000000 --- a/packages/gcode_core/lib/src/domain/gcode_load_stage.dart +++ /dev/null @@ -1,7 +0,0 @@ -enum GcodeLoadStage { - idle, - reading, - parsing, - ready, - failed, -} diff --git a/packages/gcode_core/lib/src/domain/parsed_gcode_line.dart b/packages/gcode_core/lib/src/domain/parsed_gcode_line.dart deleted file mode 100644 index 712a52b..0000000 --- a/packages/gcode_core/lib/src/domain/parsed_gcode_line.dart +++ /dev/null @@ -1,51 +0,0 @@ -import '../models/gcode_command.dart'; -import '../parser/gcode_parse_result.dart'; -import 'gcode_line_record.dart'; - -enum ParsedGcodeLineKind { command, error, skipped } - -class ParsedGcodeLine { - const ParsedGcodeLine._({ - required this.kind, - required this.record, - this.command, - this.error, - }); - - factory ParsedGcodeLine.command( - GcodeLineRecord record, - GcodeCommand command, - ) { - return ParsedGcodeLine._( - kind: ParsedGcodeLineKind.command, - record: record, - command: command, - ); - } - - factory ParsedGcodeLine.error( - GcodeLineRecord record, - GcodeParseError error, - ) { - return ParsedGcodeLine._( - kind: ParsedGcodeLineKind.error, - record: record, - error: error, - ); - } - - factory ParsedGcodeLine.skipped(GcodeLineRecord record) { - return ParsedGcodeLine._( - kind: ParsedGcodeLineKind.skipped, - record: record, - ); - } - - final ParsedGcodeLineKind kind; - final GcodeLineRecord record; - final GcodeCommand? command; - final GcodeParseError? error; - - bool get hasCommand => kind == ParsedGcodeLineKind.command; - bool get hasError => kind == ParsedGcodeLineKind.error; -} diff --git a/packages/gcode_core/lib/src/models/gcode_command.dart b/packages/gcode_core/lib/src/models/gcode_command.dart deleted file mode 100644 index 46fe468..0000000 --- a/packages/gcode_core/lib/src/models/gcode_command.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'machine_position.dart'; - -enum GcodeSegmentType { rapid, linear } - -class GcodeCommand { - const GcodeCommand({ - required this.lineNumber, - required this.rawLine, - required this.code, - required this.params, - this.comment = '', - }); - - final int lineNumber; - final String rawLine; - final String code; - final Map params; - final String comment; - - double? get x => params['X']; - double? get y => params['Y']; - double? get feedRate => params['F']; - - MachinePosition toPosition(MachinePosition current) { - return MachinePosition( - x: x ?? current.x, - y: y ?? current.y, - feedRate: feedRate ?? current.feedRate, - ); - } -} diff --git a/packages/gcode_core/lib/src/models/machine_position.dart b/packages/gcode_core/lib/src/models/machine_position.dart deleted file mode 100644 index 057d958..0000000 --- a/packages/gcode_core/lib/src/models/machine_position.dart +++ /dev/null @@ -1,24 +0,0 @@ -class MachinePosition { - const MachinePosition({ - this.x = 0, - this.y = 0, - this.feedRate = 0, - }); - - final double x; - final double y; - final double feedRate; - - MachinePosition copyWith({double? x, double? y, double? feedRate}) { - return MachinePosition( - x: x ?? this.x, - y: y ?? this.y, - feedRate: feedRate ?? this.feedRate, - ); - } - - @override - String toString() { - return 'MachinePosition(x: $x, y: $y, F: $feedRate)'; - } -} diff --git a/packages/gcode_core/lib/src/models/toolpath_segment.dart b/packages/gcode_core/lib/src/models/toolpath_segment.dart deleted file mode 100644 index 00a2722..0000000 --- a/packages/gcode_core/lib/src/models/toolpath_segment.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'gcode_command.dart'; -import 'machine_position.dart'; - -class ToolpathSegment { - const ToolpathSegment({ - required this.start, - required this.end, - required this.command, - required this.type, - }); - - final MachinePosition start; - final MachinePosition end; - final GcodeCommand command; - final GcodeSegmentType type; -} diff --git a/packages/gcode_core/lib/src/parser/gcode_parse_result.dart b/packages/gcode_core/lib/src/parser/gcode_parse_result.dart deleted file mode 100644 index 87b3966..0000000 --- a/packages/gcode_core/lib/src/parser/gcode_parse_result.dart +++ /dev/null @@ -1,28 +0,0 @@ -import '../models/gcode_command.dart'; - -class GcodeParseError { - const GcodeParseError({ - required this.lineNumber, - required this.rawLine, - required this.message, - }); - - final int lineNumber; - final String rawLine; - final String message; - - @override - String toString() => 'Line $lineNumber: $message (raw: "$rawLine")'; -} - -class GcodeParseResult { - const GcodeParseResult({ - required this.commands, - required this.errors, - }); - - final List commands; - final List errors; - - bool get hasErrors => errors.isNotEmpty; -} diff --git a/packages/gcode_core/lib/src/parser/gcode_parser.dart b/packages/gcode_core/lib/src/parser/gcode_parser.dart deleted file mode 100644 index 52e0ada..0000000 --- a/packages/gcode_core/lib/src/parser/gcode_parser.dart +++ /dev/null @@ -1,205 +0,0 @@ -import '../models/gcode_command.dart'; -import '../domain/gcode_line_record.dart'; -import '../domain/parsed_gcode_line.dart'; -import 'gcode_parse_result.dart'; - -class GcodeParser { - static const _supportedCodes = {'G0', 'G00', 'G1', 'G01', 'G90', 'G91'}; - static final _paramPattern = RegExp(r'^([A-Za-z])(-?(?:\d+\.?\d*|\.\d+))$'); - - GcodeParseResult parse(String source) { - final commands = []; - final errors = []; - final lines = source.split('\n'); - - for (var i = 0; i < lines.length; i++) { - final lineNumber = i + 1; - final rawLine = lines[i].trim(); - - if (rawLine.isEmpty) continue; - - final result = parseLine(rawLine, lineNumber); - - result.when( - command: (cmd) => commands.add(cmd), - error: (err) => errors.add(err), - skipped: () {}, - ); - } - - return GcodeParseResult(commands: commands, errors: errors); - } - - ParsedGcodeLine parseRecord(GcodeLineRecord record) { - final result = parseLine(record.rawLine.trim(), record.lineNumber); - return result.when( - command: (cmd) => ParsedGcodeLine.command(record, cmd), - error: (err) => ParsedGcodeLine.error(record, err), - skipped: () => ParsedGcodeLine.skipped(record), - ); - } - - LineParseResult parseLine(String rawLine, int lineNumber) { - var line = rawLine; - - line = _removeParenthesesComments(line); - - final comment = _extractSemicolonComment(line); - line = comment != null - ? line.substring(0, line.indexOf(';')).trim() - : line.trim(); - - if (line.isEmpty) { - return LineParseResult.skipped(); - } - - final tokens = _tokenize(line); - if (tokens.isEmpty) { - return LineParseResult.skipped(); - } - - final commandCode = tokens[0].toUpperCase(); - - final normalized = _normalizeCode(commandCode); - if (!_supportedCodes.contains(normalized)) { - return LineParseResult.error( - GcodeParseError( - lineNumber: lineNumber, - rawLine: rawLine, - message: 'Unsupported code: $commandCode', - ), - ); - } - - final params = {}; - for (var i = 1; i < tokens.length; i++) { - final token = tokens[i]; - final match = _paramPattern.firstMatch(token); - if (match == null) { - return LineParseResult.error( - GcodeParseError( - lineNumber: lineNumber, - rawLine: rawLine, - message: 'Malformed parameter: $token', - ), - ); - } - final key = match.group(1)!.toUpperCase(); - final valueStr = match.group(2); - if (valueStr == null) { - return LineParseResult.error( - GcodeParseError( - lineNumber: lineNumber, - rawLine: rawLine, - message: 'Missing numeric value in: $token', - ), - ); - } - final value = double.tryParse(valueStr); - if (value == null) { - return LineParseResult.error( - GcodeParseError( - lineNumber: lineNumber, - rawLine: rawLine, - message: 'Invalid numeric value: $token', - ), - ); - } - params[key] = value; - } - - return LineParseResult.command( - GcodeCommand( - lineNumber: lineNumber, - rawLine: rawLine, - code: normalized, - params: params, - comment: comment ?? '', - ), - ); - } - - String _removeParenthesesComments(String line) { - final result = StringBuffer(); - var inComment = false; - for (var i = 0; i < line.length; i++) { - final ch = line[i]; - if (ch == '(') { - inComment = true; - } else if (ch == ')') { - inComment = false; - } else if (!inComment) { - result.write(ch); - } - } - return result.toString(); - } - - String? _extractSemicolonComment(String line) { - final index = line.indexOf(';'); - if (index == -1) return null; - return line.substring(index + 1).trim(); - } - - List _tokenize(String line) { - final tokens = []; - final buffer = StringBuffer(); - for (var i = 0; i < line.length; i++) { - final ch = line[i]; - if (ch == ' ' || ch == '\t') { - if (buffer.isNotEmpty) { - tokens.add(buffer.toString()); - buffer.clear(); - } - } else { - buffer.write(ch); - } - } - if (buffer.isNotEmpty) { - tokens.add(buffer.toString()); - } - return tokens; - } - - String _normalizeCode(String code) { - return switch (code) { - 'G0' || 'G00' => 'G0', - 'G1' || 'G01' => 'G1', - _ => code, - }; - } -} - -sealed class LineParseResult { - const LineParseResult(); - - factory LineParseResult.command(GcodeCommand cmd) => _CommandResult(cmd); - factory LineParseResult.error(GcodeParseError err) => _ErrorResult(err); - factory LineParseResult.skipped() => const _SkippedResult(); - - T when({ - required T Function(GcodeCommand) command, - required T Function(GcodeParseError) error, - required T Function() skipped, - }) { - return switch (this) { - _CommandResult(:final cmd) => command(cmd), - _ErrorResult(:final err) => error(err), - _SkippedResult() => skipped(), - }; - } -} - -class _CommandResult extends LineParseResult { - const _CommandResult(this.cmd); - final GcodeCommand cmd; -} - -class _ErrorResult extends LineParseResult { - const _ErrorResult(this.err); - final GcodeParseError err; -} - -class _SkippedResult extends LineParseResult { - const _SkippedResult(); -} diff --git a/packages/gcode_core/lib/src/services/toolpath_builder.dart b/packages/gcode_core/lib/src/services/toolpath_builder.dart deleted file mode 100644 index ad3fc96..0000000 --- a/packages/gcode_core/lib/src/services/toolpath_builder.dart +++ /dev/null @@ -1,107 +0,0 @@ -import '../core/gcode_bounds.dart'; -import '../models/gcode_command.dart'; -import '../models/machine_position.dart'; -import '../models/toolpath_segment.dart'; - -enum CoordinateMode { absolute, relative } - -MachinePosition _applyCommand( - GcodeCommand cmd, MachinePosition current, CoordinateMode mode) { - if (mode == CoordinateMode.absolute) { - return cmd.toPosition(current); - } - return MachinePosition( - x: cmd.x != null ? current.x + cmd.x! : current.x, - y: cmd.y != null ? current.y + cmd.y! : current.y, - feedRate: cmd.feedRate ?? current.feedRate, - ); -} - -class ToolpathBuilder { - static List build(List commands) { - final segments = []; - var current = const MachinePosition(); - var mode = CoordinateMode.absolute; - - for (final cmd in commands) { - if (cmd.code == 'G90') { - mode = CoordinateMode.absolute; - continue; - } - if (cmd.code == 'G91') { - mode = CoordinateMode.relative; - continue; - } - - final next = _applyCommand(cmd, current, mode); - - if (next.x != current.x || next.y != current.y) { - final type = - cmd.code == 'G0' ? GcodeSegmentType.rapid : GcodeSegmentType.linear; - - segments.add( - ToolpathSegment( - start: current, - end: next, - command: cmd, - type: type, - ), - ); - } - - current = next; - } - - return segments; - } -} - -class IncrementalToolpathBuilder { - MachinePosition _current = const MachinePosition(); - GcodeBounds _bounds = GcodeBounds.zero; - CoordinateMode _mode = CoordinateMode.absolute; - - MachinePosition get current => _current; - - GcodeBounds get bounds => _bounds; - - CoordinateMode get coordinateMode => _mode; - - ToolpathSegment? accept(GcodeCommand command) { - if (command.code == 'G90') { - _mode = CoordinateMode.absolute; - return null; - } - if (command.code == 'G91') { - _mode = CoordinateMode.relative; - return null; - } - - final next = _applyCommand(command, _current, _mode); - - if (next.x == _current.x && next.y == _current.y) { - _current = next; - return null; - } - - _bounds = _bounds.expand(_current.x, _current.y).expand(next.x, next.y); - - final segment = ToolpathSegment( - start: _current, - end: next, - command: command, - type: command.code == 'G0' - ? GcodeSegmentType.rapid - : GcodeSegmentType.linear, - ); - - _current = next; - return segment; - } - - void reset() { - _current = const MachinePosition(); - _bounds = GcodeBounds.zero; - _mode = CoordinateMode.absolute; - } -} diff --git a/packages/gcode_core/lib/src/widgets/command_timeline.dart b/packages/gcode_core/lib/src/widgets/command_timeline.dart deleted file mode 100644 index 9b8f78b..0000000 --- a/packages/gcode_core/lib/src/widgets/command_timeline.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../models/gcode_command.dart'; -import '../parser/gcode_parse_result.dart'; - -class CommandTimeline extends StatelessWidget { - const CommandTimeline({ - super.key, - required this.commands, - required this.errors, - this.currentIndex = -1, - this.onTap, - this.maxHeight, - }); - - final List commands; - final List errors; - final int currentIndex; - final ValueChanged? onTap; - final double? maxHeight; - - @override - Widget build(BuildContext context) { - final items = _buildTimelineItems(); - - return Container( - constraints: - maxHeight != null ? BoxConstraints(maxHeight: maxHeight!) : null, - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - child: Row( - children: [ - Text( - '指令列表 (${commands.length})', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - if (errors.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${errors.length} 错误', - style: const TextStyle( - fontSize: 11, - color: Colors.red, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ), - ), - const Divider(height: 1), - Flexible( - child: ListView.builder( - shrinkWrap: true, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - final cmd = item.command; - final error = item.error; - final commandIndex = cmd == null ? -1 : commands.indexOf(cmd); - final isCurrent = - commandIndex >= 0 && commandIndex == currentIndex; - final hasError = error != null; - final code = cmd?.code; - - return InkWell( - onTap: onTap != null && commandIndex >= 0 - ? () => onTap!(commandIndex) - : null, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isCurrent - ? Theme.of(context).colorScheme.primaryContainer - : hasError - ? Colors.red.withValues(alpha: 0.05) - : null, - ), - child: Row( - children: [ - SizedBox( - width: 32, - child: Text( - '${item.lineNumber}', - style: TextStyle( - fontSize: 11, - color: hasError - ? Colors.red.shade500 - : Colors.grey.shade500, - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: hasError - ? Colors.red.withValues(alpha: 0.15) - : code == 'G0' - ? Colors.blue.withValues(alpha: 0.15) - : Colors.green.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - hasError ? 'ERR' : code ?? '', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: hasError - ? Colors.red - : code == 'G0' - ? Colors.blue - : Colors.green, - fontFamily: 'monospace', - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.rawLine, - style: TextStyle( - fontSize: 12, - fontFamily: 'monospace', - color: hasError - ? Colors.red.shade700 - : isCurrent - ? null - : Colors.grey.shade700, - ), - ), - if (hasError) - Text( - error.message, - style: TextStyle( - fontSize: 11, - color: Colors.red.shade600, - ), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ); - } - - List<_TimelineItem> _buildTimelineItems() { - final items = <_TimelineItem>[ - for (final command in commands) _TimelineItem.command(command), - for (final error in errors) _TimelineItem.error(error), - ]; - items.sort((a, b) => a.lineNumber.compareTo(b.lineNumber)); - return items; - } -} - -class _TimelineItem { - const _TimelineItem._({ - required this.lineNumber, - required this.rawLine, - this.command, - this.error, - }); - - factory _TimelineItem.command(GcodeCommand command) => _TimelineItem._( - lineNumber: command.lineNumber, - rawLine: command.rawLine, - command: command, - ); - - factory _TimelineItem.error(GcodeParseError error) => _TimelineItem._( - lineNumber: error.lineNumber, - rawLine: error.rawLine, - error: error, - ); - - final int lineNumber; - final String rawLine; - final GcodeCommand? command; - final GcodeParseError? error; -} diff --git a/packages/gcode_core/lib/src/widgets/gcode_canvas.dart b/packages/gcode_core/lib/src/widgets/gcode_canvas.dart deleted file mode 100644 index e5451e5..0000000 --- a/packages/gcode_core/lib/src/widgets/gcode_canvas.dart +++ /dev/null @@ -1,502 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import '../core/gcode_bounds.dart'; -import '../core/gcode_style.dart'; -import '../models/gcode_command.dart'; -import '../models/toolpath_segment.dart'; - -class GcodeCanvas extends StatelessWidget { - const GcodeCanvas({ - super.key, - required this.segments, - required this.progress, - this.errorCount = 0, - this.commandCount = 0, - this.bounds, - this.style, - this.showLegend = true, - }); - - final List segments; - final double progress; - final int errorCount; - final int commandCount; - final GcodeBounds? bounds; - final GcodeStyle? style; - final bool showLegend; - - @override - Widget build(BuildContext context) { - final effectiveStyle = style ?? GcodeStyle.light(); - - return Container( - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: LayoutBuilder( - builder: (context, constraints) { - Widget content; - - if (segments.isEmpty && commandCount == 0) { - content = _buildEmptyState(); - } else if (segments.isEmpty && commandCount > 0) { - content = _buildNoMovementState(commandCount); - } else if (segments.isNotEmpty && errorCount > 0) { - content = _buildPartialErrorState(constraints, effectiveStyle); - } else { - content = Stack( - children: [ - CustomPaint( - size: Size(constraints.maxWidth, constraints.maxHeight), - painter: _ToolpathPainter( - segments: segments, - progress: progress, - bounds: bounds, - style: effectiveStyle, - ), - ), - if (showLegend) - Positioned( - left: 8, - bottom: 8, - child: _CanvasLegend(style: effectiveStyle), - ), - ], - ); - } - - return content; - }, - ), - ); - } - - Widget _buildEmptyState() { - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.draw_outlined, size: 48, color: Colors.grey), - SizedBox(height: 12), - Text( - '输入并解析 G-code 后显示轨迹', - style: TextStyle(fontSize: 14, color: Colors.grey), - ), - ], - ), - ); - } - - Widget _buildNoMovementState(int cmds) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 48, color: Colors.orange.shade300), - const SizedBox(height: 12), - Text( - '已解析 $cmds 条指令,但未产生运动轨迹', - style: const TextStyle(fontSize: 14, color: Colors.grey), - ), - const SizedBox(height: 4), - Text( - '提示:G1 F1200 仅设置进给率,需配合 X/Y 坐标', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), - ), - ], - ), - ); - } - - Widget _buildPartialErrorState(BoxConstraints constraints, GcodeStyle style) { - return Stack( - children: [ - CustomPaint( - size: Size(constraints.maxWidth, constraints.maxHeight), - painter: _ToolpathPainter( - segments: segments, - progress: progress, - bounds: bounds, - style: style, - ), - ), - if (showLegend) - Positioned( - left: 8, - bottom: 8, - child: _CanvasLegend(style: style), - ), - Positioned( - top: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '$errorCount 个解析错误', - style: const TextStyle( - fontSize: 11, - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ); - } -} - -class _CanvasLegend extends StatelessWidget { - const _CanvasLegend({required this.style}); - - final GcodeStyle style; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _legendRow(style.rapidMovePaint, 'G0 快速移动', isDashed: true), - const SizedBox(height: 4), - _legendRow(style.linearMovePaint, 'G1 线性移动'), - const SizedBox(height: 4), - _legendRow(style.toolHeadPaint, '当前刀头', isCircle: true), - const SizedBox(height: 4), - _legendRow( - Paint() - ..color = const Color(0x99FF9800) - ..strokeWidth = 1.5, - '原点', - isCross: true), - ], - ), - ); - } - - Widget _legendRow(Paint paint, String label, - {bool isDashed = false, bool isCircle = false, bool isCross = false}) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 24, - height: 12, - child: CustomPaint( - painter: _LegendIconPainter( - iconPaint: paint, - isDashed: isDashed, - isCircle: isCircle, - isCross: isCross, - ), - ), - ), - const SizedBox(width: 6), - Text(label, style: const TextStyle(fontSize: 11)), - ], - ); - } -} - -class _LegendIconPainter extends CustomPainter { - _LegendIconPainter({ - required Paint iconPaint, - this.isDashed = false, - this.isCircle = false, - this.isCross = false, - }) : _iconPaint = iconPaint; - - final Paint _iconPaint; - final bool isDashed; - final bool isCircle; - final bool isCross; - - @override - void paint(Canvas canvas, Size size) { - if (isCircle) { - canvas.drawCircle(Offset(size.width / 2, size.height / 2), 4, _iconPaint); - } else if (isCross) { - final cx = size.width / 2; - final cy = size.height / 2; - canvas.drawLine(Offset(cx - 4, cy), Offset(cx + 4, cy), _iconPaint); - canvas.drawLine(Offset(cx, cy - 4), Offset(cx, cy + 4), _iconPaint); - } else if (isDashed) { - const dash = 4.0; - const gap = 3.0; - var dx = 0.0; - while (dx < size.width) { - final end = (dx + dash).clamp(0.0, size.width); - canvas.drawLine(Offset(dx, size.height / 2), - Offset(end, size.height / 2), _iconPaint); - dx = end + gap; - } - } else { - canvas.drawLine( - Offset(0, size.height / 2), - Offset(size.width, size.height / 2), - _iconPaint, - ); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class _ToolpathPainter extends CustomPainter { - _ToolpathPainter({ - required this.segments, - required this.progress, - required this.bounds, - required this.style, - }); - - final List segments; - final double progress; - final GcodeBounds? bounds; - final GcodeStyle style; - - static const _padding = 30.0; - static const _gridSpacing = 20.0; - - @override - void paint(Canvas canvas, Size size) { - if (segments.isEmpty) return; - - final b = bounds ?? _computeBounds(); - final machineRangeX = max(b.maxX - b.minX, 1.0); - final machineRangeY = max(b.maxY - b.minY, 1.0); - final scaleX = (size.width - _padding * 2) / machineRangeX; - final scaleY = (size.height - _padding * 2) / machineRangeY; - final scale = min(scaleX, scaleY); - - final offsetX = - _padding + (size.width - _padding * 2 - machineRangeX * scale) / 2; - final offsetY = - _padding + (size.height - _padding * 2 - machineRangeY * scale) / 2; - - _drawGrid(canvas, size, b, scale, offsetX, offsetY); - _drawFullPath(canvas, b, scale, offsetX, offsetY); - _drawAnimatedPath(canvas, b, scale, offsetX, offsetY); - _drawToolHead(canvas, b, scale, offsetX, offsetY); - _drawOrigin(canvas, b, scale, offsetX, offsetY); - } - - void _drawGrid( - Canvas canvas, - Size size, - GcodeBounds bounds, - double scale, - double offsetX, - double offsetY, - ) { - final gridStep = _gridSpacing / scale; - - var x = (bounds.minX / gridStep).floor() * gridStep; - while (x <= bounds.maxX) { - final sx = offsetX + (x - bounds.minX) * scale; - canvas.drawLine( - Offset(sx, offsetY), - Offset(sx, offsetY + (bounds.maxY - bounds.minY) * scale), - style.gridPaint, - ); - x += gridStep; - } - - var y = (bounds.minY / gridStep).floor() * gridStep; - while (y <= bounds.maxY) { - final sy = offsetY + (bounds.maxY - y - bounds.minY) * scale; - canvas.drawLine( - Offset(offsetX, sy), - Offset(offsetX + (bounds.maxX - bounds.minX) * scale, sy), - style.gridPaint, - ); - y += gridStep; - } - } - - void _drawFullPath( - Canvas canvas, - GcodeBounds bounds, - double scale, - double offsetX, - double offsetY, - ) { - for (final seg in segments) { - final sx = offsetX + (seg.start.x - bounds.minX) * scale; - final sy = offsetY + (bounds.maxY - seg.start.y - bounds.minY) * scale; - final ex = offsetX + (seg.end.x - bounds.minX) * scale; - final ey = offsetY + (bounds.maxY - seg.end.y - bounds.minY) * scale; - - if (seg.type == GcodeSegmentType.rapid) { - _drawDashedLine( - canvas, - Offset(sx, sy), - Offset(ex, ey), - style.rapidMoveBgPaint, - ); - } else { - canvas.drawLine( - Offset(sx, sy), - Offset(ex, ey), - style.linearMoveBgPaint, - ); - } - } - } - - void _drawAnimatedPath( - Canvas canvas, - GcodeBounds bounds, - double scale, - double offsetX, - double offsetY, - ) { - if (progress <= 0 || segments.isEmpty) return; - - final totalSegments = segments.length; - final currentSegFloat = progress * totalSegments; - final currentSegIndex = currentSegFloat.floor().clamp(0, totalSegments - 1); - final localProgress = (currentSegFloat - currentSegIndex).clamp(0.0, 1.0); - - for (var i = 0; i <= currentSegIndex && i < totalSegments; i++) { - final seg = segments[i]; - final isCurrent = i == currentSegIndex; - - var endX = seg.end.x; - var endY = seg.end.y; - - if (isCurrent) { - endX = seg.start.x + (seg.end.x - seg.start.x) * localProgress; - endY = seg.start.y + (seg.end.y - seg.start.y) * localProgress; - } - - final sx = offsetX + (seg.start.x - bounds.minX) * scale; - final sy = offsetY + (bounds.maxY - seg.start.y - bounds.minY) * scale; - final ex = offsetX + (endX - bounds.minX) * scale; - final ey = offsetY + (bounds.maxY - endY - bounds.minY) * scale; - - if (seg.type == GcodeSegmentType.rapid) { - _drawDashedLine( - canvas, - Offset(sx, sy), - Offset(ex, ey), - style.rapidMovePaint, - ); - } else { - canvas.drawLine( - Offset(sx, sy), - Offset(ex, ey), - style.linearMovePaint, - ); - } - } - } - - void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) { - const dashLength = 7.0; - const gapLength = 5.0; - final delta = end - start; - final distance = delta.distance; - if (distance == 0) return; - - final direction = delta / distance; - var current = 0.0; - while (current < distance) { - final next = min(current + dashLength, distance); - canvas.drawLine( - start + direction * current, - start + direction * next, - paint, - ); - current = next + gapLength; - } - } - - void _drawToolHead( - Canvas canvas, - GcodeBounds bounds, - double scale, - double offsetX, - double offsetY, - ) { - if (progress <= 0 || segments.isEmpty) return; - - final totalSegments = segments.length; - final currentSegFloat = progress * totalSegments; - final currentSegIndex = currentSegFloat.floor().clamp(0, totalSegments - 1); - final localProgress = (currentSegFloat - currentSegIndex).clamp(0.0, 1.0); - final seg = segments[currentSegIndex]; - - final toolX = seg.start.x + (seg.end.x - seg.start.x) * localProgress; - final toolY = seg.start.y + (seg.end.y - seg.start.y) * localProgress; - - final sx = offsetX + (toolX - bounds.minX) * scale; - final sy = offsetY + (bounds.maxY - toolY - bounds.minY) * scale; - - canvas.drawCircle(Offset(sx, sy), 10, style.toolHeadGlowPaint); - canvas.drawCircle(Offset(sx, sy), 5, style.toolHeadPaint); - } - - void _drawOrigin( - Canvas canvas, - GcodeBounds bounds, - double scale, - double offsetX, - double offsetY, - ) { - final ox = offsetX + (0 - bounds.minX) * scale; - final oy = offsetY + (bounds.maxY - 0 - bounds.minY) * scale; - - const size = 6; - canvas.drawLine( - Offset(ox - size, oy), Offset(ox + size, oy), style.originPaint); - canvas.drawLine( - Offset(ox, oy - size), Offset(ox, oy + size), style.originPaint); - canvas.drawCircle(Offset(ox, oy), 2, style.originDotPaint); - } - - GcodeBounds _computeBounds() { - var minX = double.infinity; - var maxX = double.negativeInfinity; - var minY = double.infinity; - var maxY = double.negativeInfinity; - - for (final seg in segments) { - minX = min(minX, min(seg.start.x, seg.end.x)); - maxX = max(maxX, max(seg.start.x, seg.end.x)); - minY = min(minY, min(seg.start.y, seg.end.y)); - maxY = max(maxY, max(seg.start.y, seg.end.y)); - } - - return GcodeBounds( - minX: minX, - maxX: maxX, - minY: minY, - maxY: maxY, - ); - } - - @override - bool shouldRepaint(covariant _ToolpathPainter oldDelegate) { - return oldDelegate.progress != progress || - oldDelegate.segments != segments || - oldDelegate.bounds != bounds || - oldDelegate.style != style; - } -} diff --git a/packages/gcode_core/lib/src/widgets/playback_controls.dart b/packages/gcode_core/lib/src/widgets/playback_controls.dart deleted file mode 100644 index 5597d2d..0000000 --- a/packages/gcode_core/lib/src/widgets/playback_controls.dart +++ /dev/null @@ -1,115 +0,0 @@ -import 'package:flutter/material.dart'; - -class PlaybackControls extends StatelessWidget { - const PlaybackControls({ - super.key, - required this.isPlaying, - required this.progress, - required this.speedMultiplier, - required this.onPlay, - required this.onPause, - required this.onReset, - required this.onSeek, - required this.onSpeedChange, - }); - - final bool isPlaying; - final double progress; - final double speedMultiplier; - final VoidCallback onPlay; - final VoidCallback onPause; - final VoidCallback onReset; - final ValueChanged onSeek; - final ValueChanged onSpeedChange; - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Column( - children: [ - Row( - children: [ - IconButton.filled( - onPressed: isPlaying ? onPause : onPlay, - icon: - Icon(isPlaying ? Icons.pause : Icons.play_arrow, size: 20), - style: IconButton.styleFrom( - minimumSize: const Size(36, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - IconButton.filledTonal( - onPressed: onReset, - icon: const Icon(Icons.stop, size: 18), - style: IconButton.styleFrom( - minimumSize: const Size(36, 36), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - const SizedBox(width: 8), - Expanded( - child: SliderTheme( - data: const SliderThemeData( - trackHeight: 4, - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 6), - overlayShape: RoundSliderOverlayShape(overlayRadius: 12), - ), - child: Slider( - value: progress, - onChanged: onSeek, - ), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 50, - child: Text( - '${(progress * 100).toStringAsFixed(0)}%', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), - ), - ), - ], - ), - Row( - children: [ - const Text('速度', style: TextStyle(fontSize: 12)), - const SizedBox(width: 8), - Expanded( - child: SliderTheme( - data: const SliderThemeData( - trackHeight: 2, - thumbShape: RoundSliderThumbShape(enabledThumbRadius: 5), - overlayShape: RoundSliderOverlayShape(overlayRadius: 10), - ), - child: Slider( - value: speedMultiplier, - min: 0.25, - max: 4.0, - divisions: 15, - label: '${speedMultiplier.toStringAsFixed(1)}x', - onChanged: onSpeedChange, - ), - ), - ), - SizedBox( - width: 40, - child: Text( - '${speedMultiplier.toStringAsFixed(1)}x', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/packages/gcode_core/pubspec.yaml b/packages/gcode_core/pubspec.yaml deleted file mode 100644 index 3ac411b..0000000 --- a/packages/gcode_core/pubspec.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: gcode_core -description: G-code parsing, line reading, toolpath building, and Flutter visualization widgets. -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.6.0 <4.0.0' - -resolution: workspace - -dependencies: - flutter: - sdk: flutter - -dev_dependencies: - flutter_test: - sdk: flutter - lints: ^4.0.0 - -flutter: diff --git a/packages/gcode_core/test/application/gcode_readline_pipeline_test.dart b/packages/gcode_core/test/application/gcode_readline_pipeline_test.dart deleted file mode 100644 index 2c822cf..0000000 --- a/packages/gcode_core/test/application/gcode_readline_pipeline_test.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:gcode_core/gcode_core.dart'; - -void main() { - group('GcodeReadlinePipeline', () { - test('reads string source line by line and builds segments', () async { - const source = ''' -G0 X0 Y0 -G1 X10 Y0 -G2 X10 Y10 -G1 X10 Y10 -'''; - - final pipeline = GcodeReadlinePipeline( - options: const GcodeReadlineOptions(snapshotBatchSize: 2), - ); - final snapshots = - await pipeline.load(const StringGcodeLineReader(source)).toList(); - - expect(snapshots.first.stage, GcodeLoadStage.reading); - expect(snapshots.last.stage, GcodeLoadStage.ready); - expect(snapshots.last.linesRead, 4); - expect(snapshots.last.commands, hasLength(3)); - expect(snapshots.last.errors, hasLength(1)); - expect(snapshots.last.segments, hasLength(2)); - expect(snapshots.last.segments.last.end.x, 10); - expect(snapshots.last.segments.last.end.y, 10); - }); - - test('reads gcode from file path', () async { - final file = File('${Directory.systemTemp.path}/gcode_readline_test.nc'); - await file.writeAsString('G0 X0 Y0\nG1 X5 Y5\n'); - addTearDown(() { - if (file.existsSync()) { - file.deleteSync(); - } - }); - - final pipeline = GcodeReadlinePipeline(); - final snapshots = - await pipeline.load(FileGcodeLineReader(file.path)).toList(); - - expect(snapshots.last.stage, GcodeLoadStage.ready); - expect(snapshots.last.linesRead, 2); - expect(snapshots.last.commands, hasLength(2)); - expect(snapshots.last.segments, hasLength(1)); - expect(snapshots.last.segments.single.end.x, 5); - expect(snapshots.last.segments.single.end.y, 5); - }); - - test('normalizes copied file paths before opening', () async { - final tempDirectory = Directory.systemTemp.createTempSync('ff_gcode_'); - final file = File('${tempDirectory.path}/gcode readline copied path.nc'); - await file.writeAsString('G0 X0 Y0\nG1 X3 Y4\n'); - addTearDown(() { - if (tempDirectory.existsSync()) { - tempDirectory.deleteSync(recursive: true); - } - }); - - final pipeline = GcodeReadlinePipeline(); - final quotedPathSnapshots = - await pipeline.load(FileGcodeLineReader('"${file.path}"')).toList(); - final fileUriSnapshots = await pipeline - .load(FileGcodeLineReader(file.uri.toString())) - .toList(); - final escapedPathSnapshots = await pipeline - .load(FileGcodeLineReader(file.path.replaceAll(' ', r'\ '))) - .toList(); - - expect(quotedPathSnapshots.last.stage, GcodeLoadStage.ready); - expect(fileUriSnapshots.last.stage, GcodeLoadStage.ready); - expect(escapedPathSnapshots.last.stage, GcodeLoadStage.ready); - expect(escapedPathSnapshots.last.segments.single.end.x, 3); - expect(escapedPathSnapshots.last.segments.single.end.y, 4); - }); - }); -} diff --git a/packages/gcode_core/test/gcode_parser_test.dart b/packages/gcode_core/test/gcode_parser_test.dart deleted file mode 100644 index 1aac0a0..0000000 --- a/packages/gcode_core/test/gcode_parser_test.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:gcode_core/gcode_core.dart'; - -void main() { - group('GcodeParser', () { - final parser = GcodeParser(); - - test('parses G0 uppercase', () { - final result = parser.parse('G0 X10 Y20'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.code, 'G0'); - expect(result.commands.first.x, 10); - expect(result.commands.first.y, 20); - }); - - test('parses G1 uppercase', () { - final result = parser.parse('G1 X30 Y40 F500'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.code, 'G1'); - expect(result.commands.first.x, 30); - expect(result.commands.first.y, 40); - expect(result.commands.first.feedRate, 500); - }); - - test('parses lowercase', () { - final result = parser.parse('g0 x10 y20'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.code, 'G0'); - expect(result.commands.first.x, 10); - }); - - test('parses G00 and G01 aliases', () { - final r1 = parser.parse('G00 X5 Y5'); - expect(r1.commands.first.code, 'G0'); - - final r2 = parser.parse('G01 X5 Y5'); - expect(r2.commands.first.code, 'G1'); - }); - - test('parses semicolon comments', () { - final result = parser.parse('G1 X10 Y10 ; move to position'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.comment, 'move to position'); - }); - - test('parses parentheses comments', () { - final result = parser.parse('G1 X10 Y10 (comment here)'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.x, 10); - }); - - test('skips empty lines', () { - final result = parser.parse(''' - -G0 X0 Y0 - -G1 X10 Y10 - -'''); - expect(result.commands, hasLength(2)); - expect(result.errors, isEmpty); - }); - - test('rejects unsupported G2', () { - final result = parser.parse('G2 X10 Y10 I5 J5'); - expect(result.errors, hasLength(1)); - expect(result.errors.first.message, contains('Unsupported code')); - }); - - test('rejects malformed X value', () { - final result = parser.parse('G1 Xabc Y10'); - expect(result.errors, hasLength(1)); - expect(result.errors.first.message, contains('Malformed parameter')); - }); - - test('rejects parameter with trailing junk', () { - final result = parser.parse('G1 X10abc Y10'); - expect(result.commands, isEmpty); - expect(result.errors, hasLength(1)); - expect(result.errors.first.message, contains('Malformed parameter')); - }); - - test('handles negative coordinates', () { - final result = parser.parse('G1 X-10.5 Y-20.3'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.x, -10.5); - expect(result.commands.first.y, -20.3); - }); - - test('handles leading decimal coordinates', () { - final result = parser.parse('G1 X.5 Y-.25'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.x, 0.5); - expect(result.commands.first.y, -0.25); - }); - - test('multi-line parse with errors preserves valid commands', () { - final result = parser.parse(''' -G0 X0 Y0 -G2 X10 Y10 -G1 X20 Y20 -'''); - expect(result.commands, hasLength(2)); - expect(result.errors, hasLength(1)); - expect(result.errors.first.lineNumber, 2); - }); - - test('parses G90 as valid command', () { - final result = parser.parse('G90'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.code, 'G90'); - expect(result.errors, isEmpty); - }); - - test('parses G91 as valid command', () { - final result = parser.parse('G91'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.code, 'G91'); - expect(result.errors, isEmpty); - }); - - test('parses G90 with comment only', () { - final result = parser.parse('G90 ; set absolute mode'); - expect(result.commands, hasLength(1)); - expect(result.commands.first.code, 'G90'); - expect(result.commands.first.comment, 'set absolute mode'); - }); - - test('parses sequential G90 and G91', () { - final result = parser.parse('G90\nG91\nG1 X10 Y10'); - expect(result.commands, hasLength(3)); - expect(result.commands[0].code, 'G90'); - expect(result.commands[1].code, 'G91'); - expect(result.commands[2].code, 'G1'); - }); - - test('parseRecord preserves readline metadata', () { - final parsed = parser.parseRecord( - const GcodeLineRecord( - lineNumber: 12, - rawLine: 'G1 X20 Y30', - byteOffset: 128, - ), - ); - - expect(parsed.kind, ParsedGcodeLineKind.command); - expect(parsed.record.lineNumber, 12); - expect(parsed.record.byteOffset, 128); - expect(parsed.command?.lineNumber, 12); - expect(parsed.command?.x, 20); - }); - }); -} diff --git a/packages/gcode_core/test/toolpath_builder_test.dart b/packages/gcode_core/test/toolpath_builder_test.dart deleted file mode 100644 index 2bfa5da..0000000 --- a/packages/gcode_core/test/toolpath_builder_test.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:gcode_core/gcode_core.dart'; - -void main() { - group('ToolpathBuilder', () { - test('builds linear segments from G1 commands', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G1 X10 Y0', - code: 'G1', - params: {'X': 10, 'Y': 0}, - ), - const GcodeCommand( - lineNumber: 2, - rawLine: 'G1 X10 Y10', - code: 'G1', - params: {'X': 10, 'Y': 10}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - - expect(segments, hasLength(2)); - expect(segments[0].type, GcodeSegmentType.linear); - expect(segments[0].start.x, 0); - expect(segments[0].start.y, 0); - expect(segments[0].end.x, 10); - expect(segments[0].end.y, 0); - expect(segments[1].end.x, 10); - expect(segments[1].end.y, 10); - }); - - test('builds rapid segments from G0 commands', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G0 X50 Y50', - code: 'G0', - params: {'X': 50, 'Y': 50}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - - expect(segments, hasLength(1)); - expect(segments[0].type, GcodeSegmentType.rapid); - }); - - test('keeps previous coordinate when X/Y omitted', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G1 X10 Y20', - code: 'G1', - params: {'X': 10, 'Y': 20}, - ), - const GcodeCommand( - lineNumber: 2, - rawLine: 'G1 X30', - code: 'G1', - params: {'X': 30}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - - expect(segments, hasLength(2)); - expect(segments[1].start.y, 20); // kept from previous - expect(segments[1].end.x, 30); - expect(segments[1].end.y, 20); - }); - - test('no segment when no movement', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G1 X0 Y0', - code: 'G1', - params: {'X': 0, 'Y': 0}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - expect(segments, isEmpty); - }); - - test('empty commands returns empty segments', () { - final segments = ToolpathBuilder.build([]); - expect(segments, isEmpty); - }); - - test('G90 sets absolute mode', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G90', - code: 'G90', - params: {}, - ), - const GcodeCommand( - lineNumber: 2, - rawLine: 'G1 X10 Y10', - code: 'G1', - params: {'X': 10, 'Y': 10}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - expect(segments, hasLength(1)); - expect(segments[0].end.x, 10); - expect(segments[0].end.y, 10); - }); - - test('G91 sets relative mode', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G91', - code: 'G91', - params: {}, - ), - const GcodeCommand( - lineNumber: 2, - rawLine: 'G1 X10 Y10', - code: 'G1', - params: {'X': 10, 'Y': 10}, - ), - const GcodeCommand( - lineNumber: 3, - rawLine: 'G1 X10 Y10', - code: 'G1', - params: {'X': 10, 'Y': 10}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - expect(segments, hasLength(2)); - expect(segments[0].end.x, 10); - expect(segments[0].end.y, 10); - expect(segments[1].start.x, 10); - expect(segments[1].start.y, 10); - expect(segments[1].end.x, 20); - expect(segments[1].end.y, 20); - }); - - test('mode changes do not create segments', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G90', - code: 'G90', - params: {}, - ), - const GcodeCommand( - lineNumber: 2, - rawLine: 'G91', - code: 'G91', - params: {}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - expect(segments, isEmpty); - }); - - test('mixed G90/G91 sequence', () { - final commands = [ - const GcodeCommand( - lineNumber: 1, - rawLine: 'G90', - code: 'G90', - params: {}, - ), - const GcodeCommand( - lineNumber: 2, - rawLine: 'G1 X10 Y10', - code: 'G1', - params: {'X': 10, 'Y': 10}, - ), - const GcodeCommand( - lineNumber: 3, - rawLine: 'G91', - code: 'G91', - params: {}, - ), - const GcodeCommand( - lineNumber: 4, - rawLine: 'G1 X10 Y10', - code: 'G1', - params: {'X': 10, 'Y': 10}, - ), - ]; - - final segments = ToolpathBuilder.build(commands); - expect(segments, hasLength(2)); - expect(segments[0].end.x, 10); // absolute: 0 -> 10 - expect(segments[0].end.y, 10); - expect(segments[1].end.x, 20); // relative: 10 + 10 - expect(segments[1].end.y, 20); - }); - }); -} diff --git a/packages/video_player_win/AI_ANALYSIS.md b/packages/video_player_win/AI_ANALYSIS.md new file mode 100644 index 0000000..cd8edd1 --- /dev/null +++ b/packages/video_player_win/AI_ANALYSIS.md @@ -0,0 +1,11 @@ +{ + "schema": "vibecoding.harness.ai_analysis.v2", + "mode": "index", + "node": {"id":"video_player_win","kind":"workspace_package","package":"video_player_win","path":"packages/video_player_win","status":"active"}, + "entrypoints": ["lib/video_player_win.dart"], + "owns": ["windows_video_player_backend"], + "depends": ["flutter","video_player_platform_interface"], + "children": [], + "contracts": {"no_natural_language":true,"index_only":true,"max_index_depth":2,"doc_consumer":"coding_agent","doc_mode":"machine_contract"}, + "validation": ["flutter analyze"] +} diff --git a/packages/video_player_win/CHANGELOG.md b/packages/video_player_win/CHANGELOG.md new file mode 100644 index 0000000..878f431 --- /dev/null +++ b/packages/video_player_win/CHANGELOG.md @@ -0,0 +1,147 @@ +## 3.2.2 + +* Support multiple window (with multi-window-related package) + +## 3.2.1 + +* Fix: low FPS on vp8 / vp9 (.webm) video + +## 3.2.0 + +* support http headers (experimental) +* BREAKING CHANGE: now `WinVideoPlayer` and `VideoPlayer` DO NOT automatically keep aspect ratio, act as `video_player`. +* Fix cmake error in cmake v3.31 or later + +## 3.1.2 + +* Support load from assets. + +## 3.1.1 + +* Fix #44: Fix setLooping(true) will auto play without video frames when play ended + +## 3.1.0 + +* Fix crash issue if Windows not support DirectX 12. + +## 3.0.0 + +* Re-implemented by IMFMediaEngine API of Windows Media Foundation. +* Support .m3u8 now +* Support video scrubbing + +## 2.3.11 + +* Fix @41: can't play video if no audio out device + +## 2.3.10 + +* destroy all old players when hot-restart in debug mode. +* remove black background in WinVideoPlayerWidget. + +## 2.3.9 + +* Fix exception + +## 2.3.8 + +* Fix: unexpected dispose controller when VideoPlayer created without use it, even if it is still referenced. +* Fix: should set value.isInitialized to false while decode error + +## 2.3.7 + +* Fix: crash when open wrong file path or url. + +## 2.3.6 + +* Fix: controller.setPlaybackSpeed() not working + +## 2.3.5 + +* update README: no need to call registerWith() + +## 2.3.4 + +* Fix: getCurrentPosition() shouldn't return 0 during seeking +* support controller.value.isCompleted +* auto call dispose() when controller garbage-collected + +## 2.3.2 + +* Fix: sometimes crash when closing player + +## 2.3.1 + +* Fix: cannot play video path containing non-ASCII characters + +## 2.3.0 +* Fix: get player position directly from windows media foundation, not cached value. +* support Dart 3.0 + +## 2.2.2 + +* Fix: shouldn't auto play when seeking in pause state +* Fix: show the first frame when video loaded and Play() not called +* Fix: prevent video freeze when call Play() twice +* keep screen on (disable screensaver) while playing video. + +## 2.2.0 + +* Enhance performance +* Fix memory leak +* Fix SetVolume() issue + +## 2.0.0 + +* Enable GPU hardware acceleration + +## 1.1.6 + +* Support non-English filename + +## 1.1.5 + +* Fix video skew when width or height is not multiple of 16 + +## 1.1.4 + +* Fix crash when dispose video which has no audio or video stream + +## 1.1.3 + +* support AV1 video +* enhance playback performance + +## 1.1.2 + +* fix compile error + +## 1.1.1 + +* change sdk version limitation + +## 1.1.0 + +* fix crash issue +* fix memory leak + +## 1.0.4 + +* modify README.md + +## 1.0.3 + +* modify README.md + +## 1.0.2 + +* Fix SetLooping() +* Fix bug when play to end + +## 1.0.1 + +* Fix memory leak in cpp code + +## 1.0.0 + +* Initial version diff --git a/packages/video_player_win/LICENSE b/packages/video_player_win/LICENSE new file mode 100644 index 0000000..95e58a5 --- /dev/null +++ b/packages/video_player_win/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright 2022, jakky1 (jakky1@gmail.com) +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/video_player_win/README.md b/packages/video_player_win/README.md new file mode 100644 index 0000000..dfa77aa --- /dev/null +++ b/packages/video_player_win/README.md @@ -0,0 +1,279 @@ +# video_player_win + +[visits-count-image]: https://img.shields.io/badge/dynamic/json?label=Visits%20Count&query=value&url=https://api.countapi.xyz/hit/jakky1_video_player_win/visits + +Flutter video player for Windows, lightweight, using Windows built-in Media Foundation API. +Windows implementation of the [video_player][1] plugin. + +## Platform Support + +This package itself support only Windows. + +But use it with [video_player][1], your app can support Windows / Android / iOS / Web at the same time. + +## Built-in control panel & Fullscreen & Subtitle support + +Please use package [video_player_control_panel][2] instead. + +Which also use this package to play video on Windows. + +## Features & Limitations + +Features: + +- GPU hardware acceleration, low CPU usage *(maybe support 4K 60fps video?)* +- No GPL / LGPL 3rd-party libraries inside. +- Only one dll file (~130 KB) added as a plugin. +- Support Windows / Android / iOS / Web by collaboration with [video_player][1] + +Limitations: +- This package doesn't support HLS (.m3u8) + + +But, since this package use Microsoft Media Foundation API, there are some limtations: + +- Playback will use codecs preloaded in Windows OS. If you want to play some video format that not supported by these preloaded codecs, you need to install 3rd-party codecs exe file, about 18 MB. (see the next section). + +## Supported Formats in Windows (Important !) + +Ref: [Windows preloaded codec list][8] + +This package use Windows built-in Media Foundation API. +So playback video will use the codecs preload in your Windows environment. +All the media format can be played by WMP (Windows Media Player) can also be played by this package. +However, the preloaded codecs in Windows is limited. + +If you have a media file cannot played by this package, ALSO CANNOT played by WMP (Windows Media Player), it means that this media file is not support by codecs preloaded in your Windows. + +In this case, please install ONE of the following codec pack into your Windows: +- [K-Lite codec pack][3] (~18MB) +- [Windows 10 Codec Pack][4] + +You can auto-install codec by the following Dart code: +```dart +import 'dart:io'; + +Process.run('E:\\K-Lite_Codec_Pack_1730_Basic.exe', ['/silent']).then((value) { + if (value.exitCode == 0) log("installation success"); + else log("installation failed"); +}); +``` + +After install the codec pack, most of the media format are supported. + +## Supported AV1 video + +To play AV1 video, +- install codec in [Microsoft Store][7]. +- or download the [AV1 codec installer][6] (only 850 KB) + +You can silently auto-install codec by the following Dart code: +```dart +import 'dart:io'; + +Process.run('powershell', ['Add-AppxPackage', '-Path', 'E:\\av1-video-extension-1-1-52851-0.appx']).then((value) { + if (value.exitCode == 0) log("installation success"); + else log("installation failed"); +}); +``` + +# Problem shootting for building fail + +If you build fail with this package, and the error message has the keyword "**MSB3073**": + +- run "**flutter build windows**" in command line in [**Administrator**] mode + + +# Quick Start + +## Installation + +Add this to your package's `pubspec.yaml` file: + +```yaml +dependencies: + video_player: ^2.5.1 + video_player_win: ^3.0.0 +``` + +# Usage + +## video / audio playback + +Play from network source: +```dart +var controller = VideoPlayerController.network("https://www.your-web.com/sample.mp4"); +controller.initialize().then((value) { + if (controller.value.isInitialized) { + controller.play(); + } else { + log("video file load failed"); + } +}).catchError((e) { + log("controller.initialize() error occurs: $e"); +}); +``` + +Play from file: +```dart +var controller = VideoPlayerController.file(File("E:\\test.mp4")); +``` + +If the file is a video, build a display widget to show video frames: +```dart +Widget build(BuildContext context) { + return VideoPlayer(controller); +} +``` + +# operations + +- Play: `controller.play();` +- Pause: `controller.pause();` +- Seek: `controller.seekTo( Duration(minute: 10, second:30) );` +- set playback speed: (normal speed: 1.0) +`controller.setPlaybackSpeed(1.5);` +- set volume: (max: 1.0 , mute: 0.0) +`controller.setVolume(0.5);` +- set looping: `controller.setLooping(true);` +- free resource: `controller.dispose();` + +# Listen playback events and values +```dart +void onPlaybackEvent() { + final value = controller.value; + // value.isInitialized (bool) + // value.size (Size, video size) + // value.duration (Duration) + // value.isPlaying (bool) + // value.isBuffering (bool) + // value.isCompleted (bool) + // value.position (Duration) +} +controller.addListener(onPlaybackEvent); +... +controller.removeListener(onPlaybackEvent); // remember to removeListener() +``` + +## Release resource + +```dart +controller.dispose(); +``` + +# Keep aspect ratio of VideoPlayer + +```dart +Widget player = Container( + color: Colors.black, + child: Center( + child: AspectRatio( + aspectRatio: controller!.value.aspectRatio, + child: VideoPlayer(controller!), + ), + ), +); +``` + +# standalone mode + +If your app only runs on Windows, and you want to remove library dependencies as many as possible, you can modify `pubspec.yaml` file: + +```yaml +dependencies: + # video_player: ^2.4.7 # mark this line, for Windows only app + video_player_win: +``` + +and modify all the following class name in your code: +```dart +VideoPlayer -> WinVideoPlayer // add "Win" prefix +VideoPlayerController -> WinVideoPlayerController // add "Win" prefix +``` + +just only modify class names. All the properties / method are the same with [video_player][1] + + +# Example + +```dart +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:video_player_win/video_player_win.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({Key? key}) : super(key: key); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + + late VideoPlayerController controller; + + @override + void initState() { + super.initState(); + controller = VideoPlayerController.file(File("E:\\test_youtube.mp4")); + controller.initialize().then((value) { + if (controller.value.isInitialized) { + controller.play(); + setState(() {}); + } else { + log("video file load failed"); + } + }); + } + + @override + void dispose() { + super.dispose(); + controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('video_player_win example app'), + ), + + body: Stack(children: [ + VideoPlayer(controller), + Positioned( + bottom: 0, + child: Column(children: [ + ValueListenableBuilder( + valueListenable: controller, + builder: ((context, value, child) { + int minute = controller.value.position.inMinutes; + int second = controller.value.position.inSeconds % 60; + return Text("$minute:$second", style: Theme.of(context).textTheme.headline6!.copyWith(color: Colors.white, backgroundColor: Colors.black54)); + }), + ), + ElevatedButton(onPressed: () => controller.play(), child: const Text("Play")), + ElevatedButton(onPressed: () => controller.pause(), child: const Text("Pause")), + ElevatedButton(onPressed: () => controller.seekTo(Duration(milliseconds: controller.value.position.inMilliseconds+ 10*1000)), child: const Text("Forward")), + ])), + ]), + ), + ); + } +} +``` +[1]: https://pub.dev/packages/video_player "video_player" +[2]: https://pub.dev/packages/video_player_control_panel "video_player_control_panel" +[3]: https://codecguide.com/ "K-Lite Codec Pack" +[4]: https://www.windows10codecpack.com/ "Windows 10 Codec Pack" +[5]: https://pub.dev/packages/webview_win_floating "webview_win_floating" +[6]: https://av1-video-extension.en.uptodown.com/windows "AV1 codec installer" +[7]: https://apps.microsoft.com/store/detail/av1-video-extension/9MVZQVXJBQ9V?hl=en-us&gl=us "Microsoft Store AV1 codec" +[8]: https://learn.microsoft.com/en-us/windows/win32/medfound/supported-media-formats-in-media-foundation "Windows preloaded codec list" \ No newline at end of file diff --git a/packages/video_player_win/analysis_options.yaml b/packages/video_player_win/analysis_options.yaml new file mode 100644 index 0000000..a5744c1 --- /dev/null +++ b/packages/video_player_win/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/video_player_win/example/README.md b/packages/video_player_win/example/README.md new file mode 100644 index 0000000..a511a4f --- /dev/null +++ b/packages/video_player_win/example/README.md @@ -0,0 +1,16 @@ +# video_player_win_example + +Demonstrates how to use the video_player_win plugin. + +## 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/packages/gcode_core/example/analysis_options.yaml b/packages/video_player_win/example/analysis_options.yaml similarity index 89% rename from packages/gcode_core/example/analysis_options.yaml rename to packages/video_player_win/example/analysis_options.yaml index 0d29021..0a49e8c 100644 --- a/packages/gcode_core/example/analysis_options.yaml +++ b/packages/video_player_win/example/analysis_options.yaml @@ -7,13 +7,18 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - windows/** 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. + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. # # 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 diff --git a/packages/video_player_win/example/lib/main.dart b/packages/video_player_win/example/lib/main.dart new file mode 100644 index 0000000..1eab974 --- /dev/null +++ b/packages/video_player_win/example/lib/main.dart @@ -0,0 +1,164 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:desktop_multi_window/desktop_multi_window.dart'; +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +//typedef VideoPlayerController = WinVideoPlayerController; +//typedef VideoPlayer = WinVideoPlayer; +//typedef VideoPlayerValue = WinVideoPlayerValue; + +Future main(List args) async { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const MyApp()); +} + +Future createNewWindow() async { + final controller = await WindowController.create( + const WindowConfiguration(hiddenAtLaunch: true, arguments: ''), + ); + + await controller.show(); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + VideoPlayerController? controller; + final httpHeaders = { + "User-Agent": "ergerthertherth", + "key3": "value3_ccccc", + }; + + void reload() { + controller?.dispose(); + controller = VideoPlayerController.file(File("D:\\test\\test_4k.mp4")); + //controller = VideoPlayerController.file(File("C:\\Downloads\\FDM\\big-buck-bunny_trailer-.webm")); + //controller = VideoPlayerController.networkUrl(Uri.parse("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8")); + + //controller = VideoPlayerController.networkUrl(Uri.parse("https://media.w3.org/2010/05/sintel/trailer.mp4"),httpHeaders: httpHeaders); + + //controller = WinVideoPlayerController.file(File("E:\\Downloads\\0.FDM\\sample-file-1.flac")); + + controller! + .initialize() + .then((value) { + if (controller!.value.isInitialized) { + controller!.play(); + setState(() {}); + + controller!.addListener(() { + if (controller!.value.isCompleted) { + log("ui: player completed, pos=${controller!.value.position}"); + } + }); + } else { + log("video file load failed"); + } + }) + .catchError((e) { + log("controller.initialize() error occurs: $e"); + }); + setState(() {}); + } + + @override + void initState() { + super.initState(); + reload(); + } + + @override + void dispose() { + super.dispose(); + controller?.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget player = Container( + color: Colors.black, + child: Center( + child: AspectRatio( + aspectRatio: controller!.value.aspectRatio, + child: VideoPlayer(controller!), + ), + ), + ); + + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('video_player_win example app')), + body: Stack( + children: [ + player, + Positioned( + bottom: 0, + child: Column( + children: [ + ValueListenableBuilder( + valueListenable: controller!, + builder: ((context, value, child) { + int minute = value.position.inMinutes; + int second = value.position.inSeconds % 60; + String timeStr = "$minute:$second"; + if (value.isCompleted) timeStr = "$timeStr (completed)"; + return Text( + timeStr, + style: Theme.of(context).textTheme.headlineMedium! + .copyWith( + color: Colors.white, + backgroundColor: Colors.black54, + ), + ); + }), + ), + const ElevatedButton( + onPressed: createNewWindow, + child: Text("New Window"), + ), + ElevatedButton( + onPressed: reload, + child: const Text("Reload"), + ), + ElevatedButton( + onPressed: () => controller?.play(), + child: const Text("Play"), + ), + ElevatedButton( + onPressed: () => controller?.pause(), + child: const Text("Pause"), + ), + ElevatedButton( + onPressed: () => controller?.seekTo( + Duration( + milliseconds: + controller!.value.position.inMilliseconds + + 10 * 1000, + ), + ), + child: const Text("Forward"), + ), + ElevatedButton( + onPressed: () { + int ms = controller!.value.duration.inMilliseconds; + var tt = Duration(milliseconds: ms - 1000); + controller?.seekTo(tt); + }, + child: const Text("End"), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/gcode_core/example/pubspec.lock b/packages/video_player_win/example/pubspec.lock similarity index 67% rename from packages/gcode_core/example/pubspec.lock rename to packages/video_player_win/example/pubspec.lock index 72801cb..29edf15 100644 --- a/packages/gcode_core/example/pubspec.lock +++ b/packages/video_player_win/example/pubspec.lock @@ -41,22 +41,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" - cross_file: + csslib: dependency: transitive description: - name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.5+2" - cupertino_icons: + version: "1.0.2" + desktop_multi_window: dependency: "direct main" description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + name: desktop_multi_window + sha256: "6a82209dfd29279258515157e5c08612542ebf7bfb3b55fb1dfcd32c076eea4d" url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.9" + version: "0.3.1" fake_async: dependency: transitive description: @@ -65,70 +65,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" - file_selector: - dependency: "direct main" - description: - name: file_selector - sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.0" - file_selector_android: - dependency: transitive - description: - name: file_selector_android - sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.5.2+6" - file_selector_ios: - dependency: transitive - description: - name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.5.3+5" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.4" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.7.0" - file_selector_web: - dependency: transitive - description: - name: file_selector_web - sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.5" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -138,10 +74,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 url: "https://pub.flutter-io.cn" source: hosted - version: "6.0.0" + version: "2.0.3" flutter_test: dependency: "direct dev" description: flutter @@ -152,29 +88,14 @@ packages: description: flutter source: sdk version: "0.0.0" - gcode_core: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.1.0" - http: + html: dependency: transitive description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" 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" + version: "0.15.7" leak_tracker: dependency: transitive description: @@ -203,18 +124,18 @@ packages: dependency: transitive description: name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" url: "https://pub.flutter-io.cn" source: hosted - version: "6.1.0" + version: "2.1.1" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -227,10 +148,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.flutter-io.cn" source: hosted - version: "1.18.0" + version: "1.19.0" path: dependency: transitive description: @@ -296,34 +217,73 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.11" - typed_data: + version: "0.7.12" + vector_math: dependency: transitive description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.0" - vector_math: + version: "2.4.2" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "8c837b570dccb9ae6ff73d2e0b03c7e708bfefd3bd1194faa7f3e7f200dfc399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.14.0" + video_player_android: dependency: transitive description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + name: video_player_android + sha256: d27054dea34d748a44f06d433a57005d2aac69944485b0abbaed3303dbcfa3f1 url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.12.2" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "436fd029bd1c1e303b2d95ebd76948893f3c28dab286e7235ba9dd7b22533bf0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + video_player_platform_interface: + dependency: "direct main" + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + video_player_win: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "3.2.2" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.flutter-io.cn" source: hosted - version: "15.2.0" + version: "15.3.0" web: dependency: transitive description: @@ -333,5 +293,5 @@ packages: source: hosted version: "1.1.1" sdks: - dart: ">=3.11.5 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.2" diff --git a/packages/gcode_core/example/pubspec.yaml b/packages/video_player_win/example/pubspec.yaml similarity index 67% rename from packages/gcode_core/example/pubspec.yaml rename to packages/video_player_win/example/pubspec.yaml index 4f1bc4a..fcfd701 100644 --- a/packages/gcode_core/example/pubspec.yaml +++ b/packages/video_player_win/example/pubspec.yaml @@ -1,25 +1,13 @@ -name: example -description: "A new Flutter project." +name: video_player_win_example +description: Demonstrates how to use the video_player_win plugin. + # 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.11.5 + sdk: '>=3.13.0 <4.0.0' + flutter: ">=3.47.2" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions @@ -31,13 +19,16 @@ dependencies: flutter: sdk: flutter + video_player_win: + path: .. + + desktop_multi_window: ^0.3.0 + video_player_platform_interface: ^6.2.1 + video_player: ^2.9.1 + + # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - - gcode_core: - path: ../ - file_selector: ^1.1.0 dev_dependencies: flutter_test: @@ -48,7 +39,7 @@ dev_dependencies: # 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: ^6.0.0 + flutter_lints: ^2.0.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -67,10 +58,10 @@ flutter: # - 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 + # https://flutter.dev/assets-and-images/#resolution-aware # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package + # https://flutter.dev/assets-and-images/#from-packages # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a @@ -90,4 +81,4 @@ flutter: # weight: 700 # # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/video_player_win/example/test/widget_test.dart b/packages/video_player_win/example/test/widget_test.dart new file mode 100644 index 0000000..ce4c36f --- /dev/null +++ b/packages/video_player_win/example/test/widget_test.dart @@ -0,0 +1,27 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:video_player_win_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => + widget is Text && widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/packages/video_player_win/example/windows/CMakeLists.txt b/packages/video_player_win/example/windows/CMakeLists.txt new file mode 100644 index 0000000..a347360 --- /dev/null +++ b/packages/video_player_win/example/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(video_player_win_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "video_player_win_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/video_player_win/example/windows/flutter/CMakeLists.txt b/packages/video_player_win/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/packages/video_player_win/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/video_player_win/example/windows/runner/CMakeLists.txt b/packages/video_player_win/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..17411a8 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/CMakeLists.txt @@ -0,0 +1,39 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/video_player_win/example/windows/runner/Runner.rc b/packages/video_player_win/example/windows/runner/Runner.rc new file mode 100644 index 0000000..6308867 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "org.jakky1" "\0" + VALUE "FileDescription", "video_player_win_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "video_player_win_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 org.jakky1. All rights reserved." "\0" + VALUE "OriginalFilename", "video_player_win_example.exe" "\0" + VALUE "ProductName", "video_player_win_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/video_player_win/example/windows/runner/flutter_window.cpp b/packages/video_player_win/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..a146e0f --- /dev/null +++ b/packages/video_player_win/example/windows/runner/flutter_window.cpp @@ -0,0 +1,69 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" +#include "desktop_multi_window/desktop_multi_window_plugin.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + + DesktopMultiWindowSetWindowCreatedCallback([](void *controller) { + auto *flutter_view_controller = + reinterpret_cast(controller); + auto *registry = flutter_view_controller->engine(); + RegisterPlugins(registry); + }); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/video_player_win/example/windows/runner/flutter_window.h b/packages/video_player_win/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/video_player_win/example/windows/runner/main.cpp b/packages/video_player_win/example/windows/runner/main.cpp new file mode 100644 index 0000000..9fe1b91 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"video_player_win_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/video_player_win/example/windows/runner/resource.h b/packages/video_player_win/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/video_player_win/example/windows/runner/resources/app_icon.ico b/packages/video_player_win/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/packages/video_player_win/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/video_player_win/example/windows/runner/runner.exe.manifest b/packages/video_player_win/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/packages/video_player_win/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/video_player_win/example/windows/runner/utils.cpp b/packages/video_player_win/example/windows/runner/utils.cpp new file mode 100644 index 0000000..f5bf9fa --- /dev/null +++ b/packages/video_player_win/example/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/video_player_win/example/windows/runner/utils.h b/packages/video_player_win/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/video_player_win/example/windows/runner/win32_window.cpp b/packages/video_player_win/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..c10f08d --- /dev/null +++ b/packages/video_player_win/example/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/packages/video_player_win/example/windows/runner/win32_window.h b/packages/video_player_win/example/windows/runner/win32_window.h new file mode 100644 index 0000000..17ba431 --- /dev/null +++ b/packages/video_player_win/example/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/video_player_win/lib/video_player_win.dart b/packages/video_player_win/lib/video_player_win.dart new file mode 100644 index 0000000..5e73bc5 --- /dev/null +++ b/packages/video_player_win/lib/video_player_win.dart @@ -0,0 +1,382 @@ +export 'video_player_win_plugin.dart'; +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; +import 'video_player_win_platform_interface.dart'; + +enum WinDataSourceType { asset, network, file, contentUri } + +@immutable +class WinVideoPlayerValue { + final Duration duration; + final bool isBuffering; + final bool isInitialized; + final bool isLooping; + final bool isPlaying; + final bool isCompleted; + final double playbackSpeed; + final Duration position; + final Size size; + final double volume; + + final String? errorDescription; + bool get hasError => errorDescription != null; + + final int textureId; //for internal use only + + double get aspectRatio => size.isEmpty ? 1 : size.width / size.height; + + const WinVideoPlayerValue({ + this.textureId = -1, + this.duration = Duration.zero, + this.size = Size.zero, + this.position = Duration.zero, + //Caption caption = Caption.none, + //Duration captionOffset = Duration.zero, + //List buffered = const [], + this.isInitialized = false, + this.isPlaying = false, + this.isLooping = false, + this.isBuffering = false, + this.isCompleted = false, + this.volume = 1.0, + this.playbackSpeed = 1.0, + //int rotationCorrection = 0, + this.errorDescription, + }); + + WinVideoPlayerValue copyWith({ + int? textureId, + Duration? duration, + bool? isBuffering, + bool? isInitialized, + bool? isLooping, + bool? isPlaying, + bool? isCompleted, + double? playbackSpeed, + Duration? position, + Size? size, + double? volume, + String? errorDescription, + }) { + return WinVideoPlayerValue( + textureId: textureId ?? this.textureId, + duration: duration ?? this.duration, + isBuffering: isBuffering ?? this.isBuffering, + isInitialized: isInitialized ?? this.isInitialized, + isLooping: isLooping ?? this.isLooping, + isPlaying: isPlaying ?? this.isPlaying, + isCompleted: isCompleted ?? this.isCompleted, + playbackSpeed: playbackSpeed ?? this.playbackSpeed, + position: position ?? this.position, + size: size ?? this.size, + volume: volume ?? this.volume, + errorDescription: errorDescription ?? this.errorDescription, + ); + } +} + +class WinVideoPlayerController extends ValueNotifier { + late final bool _isPluginMode; // true if used by 'video_player' package + int textureId_ = -1; + final String dataSource; + final Map httpHeaders; + late final WinDataSourceType dataSourceType; + bool _isLooping = false; + + // used by flutter official "video_player" package + final _eventStreamController = StreamController(); + + Stream get videoEventStream => _eventStreamController.stream; + + Future get position async { + var pos = await _getCurrentPosition(); + return Duration(milliseconds: pos); + } + + WinVideoPlayerController._( + this.dataSource, + this.dataSourceType, { + bool isPluginMode = false, + this.httpHeaders = const {}, + }) : super(const WinVideoPlayerValue()) { + if (dataSourceType == WinDataSourceType.contentUri) { + throw UnsupportedError( + "VideoPlayerController.contentUri() not supported in Windows", + ); + } + if (dataSourceType == WinDataSourceType.asset) { + throw UnsupportedError( + "VideoPlayerController.asset() not implement yet.", + ); + } + + _isPluginMode = isPluginMode; + //VideoPlayerWinPlatform.instance.registerPlayer(_textureId, this); + } + static final Finalizer _finalizer = Finalizer((textureId) { + log("[video_player_win] gc free a player that didn't dispose() yet !!!!!"); + VideoPlayerWinPlatform.instance.unregisterPlayer(textureId); + VideoPlayerWinPlatform.instance.dispose(textureId); + }); + + static String getAssetPath(String dataSource) { + File file = File(Platform.resolvedExecutable); + return "${file.parent.path}\\data\\flutter_assets\\$dataSource"; + } + + WinVideoPlayerController.file(File file, {bool isPluginMode = false}) + : this._(file.path, WinDataSourceType.file, isPluginMode: isPluginMode); + @Deprecated('Use WinVideoPlayerController.networkUrl instead') + WinVideoPlayerController.network( + String dataSource, { + bool isPluginMode = false, + Map httpHeaders = const {}, + }) : this._( + dataSource, + WinDataSourceType.network, + isPluginMode: isPluginMode, + httpHeaders: httpHeaders, + ); + WinVideoPlayerController.networkUrl( + Uri url, { + bool isPluginMode = false, + Map httpHeaders = const {}, + }) : this._( + url.toString(), + WinDataSourceType.network, + isPluginMode: isPluginMode, + httpHeaders: httpHeaders, + ); + WinVideoPlayerController.asset( + String dataSource, { + String? package, + bool isPluginMode = false, + }) : this._( + getAssetPath(dataSource), + WinDataSourceType.file, + isPluginMode: isPluginMode, + ); + WinVideoPlayerController.contentUri(Uri contentUri) + : this._("", WinDataSourceType.contentUri); + + Timer? _positionTimer; + void _cancelTrackingPosition() => _positionTimer?.cancel(); + void _startTrackingPosition() async { + // NOTE: 'video_player' package already auto get position periodically, + // so do nothing if _isPluginMode = true + if (_isPluginMode) return; + + _positionTimer?.cancel(); + _positionTimer = Timer.periodic(const Duration(milliseconds: 300), ( + Timer timer, + ) async { + if (!value.isInitialized || + !value.isPlaying || + value.isCompleted || + value.hasError) { + timer.cancel(); + return; + } + + //log("[video_player_win] ui: position timer tick"); + await position; // set player's position to value.position + }); + } + + void onPlaybackEvent_(int state) { + switch (state) { + // MediaEventType in win32 api + case 1: // MEBufferingStarted + log("[video_player_win] playback event: buffering start"); + value = value.copyWith(isInitialized: true, isBuffering: true); + _eventStreamController.add( + VideoEvent(eventType: VideoEventType.bufferingStart), + ); + break; + case 2: // MEBufferingStopped + log("[video_player_win] playback event: buffering finish"); + value = value.copyWith(isInitialized: true, isBuffering: false); + _eventStreamController.add( + VideoEvent(eventType: VideoEventType.bufferingEnd), + ); + break; + case 3: // MESessionStarted , occurs when user call play() or seekTo() in playing mode + //log("[video_player_win] playback event: playing"); + value = value.copyWith( + isInitialized: true, + isPlaying: true, + isCompleted: false, + ); + _startTrackingPosition(); + _eventStreamController.add( + VideoEvent(eventType: VideoEventType.isPlayingStateUpdate), + ); + break; + case 4: // MESessionPaused + //log("[video_player_win] playback event: paused"); + value = value.copyWith(isPlaying: false); + _cancelTrackingPosition(); + _eventStreamController.add( + VideoEvent(eventType: VideoEventType.isPlayingStateUpdate), + ); + break; + case 5: // MESessionStopped + log("[video_player_win] playback event: stopped"); + value = value.copyWith(isPlaying: false); + _cancelTrackingPosition(); + _eventStreamController.add( + VideoEvent(eventType: VideoEventType.isPlayingStateUpdate), + ); + break; + case 6: // MESessionEnded + //log("[video_player_win] playback event: play ended"); + if (_isLooping) { + seekTo(Duration.zero); + } else { + _cancelTrackingPosition(); + value = value.copyWith(isCompleted: true, position: value.duration); + _eventStreamController.add( + VideoEvent(eventType: VideoEventType.completed), + ); + } + break; + case 7: // MEError + log("[video_player_win] playback event: error"); + value = value.copyWith( + isInitialized: false, + isPlaying: false, + duration: Duration.zero, + errorDescription: "N/A", + ); + var exp = PlatformException(code: "decode failed", message: "N/A"); + _eventStreamController.addError(exp); + _cancelTrackingPosition(); + break; + } + } + + Future initialize() async { + WinVideoPlayerValue? pv = await VideoPlayerWinPlatform.instance.openVideo( + this, + textureId_, + dataSource, + httpHeaders, + ); + if (pv == null) { + log("[video_player_win] controller intialize (open video) failed"); + value = value.copyWith( + isInitialized: false, + errorDescription: "open file failed", + ); + _eventStreamController.add( + VideoEvent( + eventType: VideoEventType.initialized, + duration: null, + size: null, + ), + ); + return; + } + textureId_ = pv.textureId; + value = pv; + _finalizer.attach(this, textureId_, detach: this); + + _eventStreamController.add( + VideoEvent( + eventType: VideoEventType.initialized, + duration: pv.duration, + size: pv.size, + ), + ); + log("flutter: video player file opened: id=$textureId_"); + } + + Future play() async { + if (!value.isInitialized) throw ArgumentError("video file not opened yet"); + await VideoPlayerWinPlatform.instance.play(textureId_); + } + + Future pause() async { + if (!value.isInitialized) throw ArgumentError("video file not opened yet"); + await VideoPlayerWinPlatform.instance.pause(textureId_); + } + + Future seekTo(Duration time) async { + if (!value.isInitialized) throw ArgumentError("video file not opened yet"); + + await VideoPlayerWinPlatform.instance.seekTo( + textureId_, + time.inMilliseconds, + ); + value = value.copyWith(position: time, isCompleted: false); + } + + Future _getCurrentPosition() async { + if (!value.isInitialized) throw ArgumentError("video file not opened yet"); + if (value.isCompleted) return value.duration.inMilliseconds; + int pos = await VideoPlayerWinPlatform.instance.getCurrentPosition( + textureId_, + ); + + if (textureId_ < 0) return 0; + value = value.copyWith(position: Duration(milliseconds: pos)); + return pos; + } + + Future setPlaybackSpeed(double speed) async { + if (!value.isInitialized) throw ArgumentError("video file not opened yet"); + await VideoPlayerWinPlatform.instance.setPlaybackSpeed(textureId_, speed); + value = value.copyWith(playbackSpeed: speed); + } + + Future setVolume(double volume) async { + if (!value.isInitialized) throw ArgumentError("video file not opened yet"); + await VideoPlayerWinPlatform.instance.setVolume(textureId_, volume); + value = value.copyWith(volume: volume); + } + + Future setLooping(bool looping) async { + _isLooping = looping; + value = value.copyWith(isLooping: looping); + } + + @override + Future dispose() async { + value = value.copyWith(textureId: -1); + + VideoPlayerWinPlatform.instance.unregisterPlayer(textureId_); + await VideoPlayerWinPlatform.instance.dispose(textureId_); + + log("flutter: video player dispose: id=$textureId_"); + textureId_ = -1; + + _finalizer.detach(this); + _cancelTrackingPosition(); + + super.dispose(); + } +} + +class WinVideoPlayer extends StatelessWidget { + final WinVideoPlayerController controller; + final FilterQuality filterQuality; + + const WinVideoPlayer( + this.controller, { + super.key, + this.filterQuality = FilterQuality.low, + }); + + @override + Widget build(BuildContext context) { + return Texture( + textureId: controller.textureId_, + filterQuality: filterQuality, + ); + } +} diff --git a/packages/video_player_win/lib/video_player_win_method_channel.dart b/packages/video_player_win/lib/video_player_win_method_channel.dart new file mode 100644 index 0000000..67164df --- /dev/null +++ b/packages/video_player_win/lib/video_player_win_method_channel.dart @@ -0,0 +1,148 @@ +import 'dart:developer'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'video_player_win.dart'; +import 'video_player_win_platform_interface.dart'; + +/// An implementation of [VideoPlayerWinPlatform] that uses method channels. +class MethodChannelVideoPlayerWin extends VideoPlayerWinPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('video_player_win'); + + final playerMap = >{}; + + MethodChannelVideoPlayerWin() { + assert(() { + // When hot-reload in debugging mode, clear all old players created before hot-reload + methodChannel.invokeMethod('clearAll'); + return true; + }()); + + methodChannel.setMethodCallHandler((call) async { + //log("[videoplayer] native->flutter: $call"); + int? textureId = call.arguments["textureId"]; + assert(textureId != null); + final player = playerMap[textureId]; + if (player == null) { + log("player not found: id = $textureId"); + return; + } + + if (call.method == "OnPlaybackEvent") { + int state = call.arguments["state"]!; + player.target?.onPlaybackEvent_(state); + } else { + assert(false, "unknown call from native: ${call.method}"); + } + }); + } + + @override + void unregisterPlayer(int textureId) { + playerMap.remove(textureId); + } + + @override + WinVideoPlayerController? getPlayerByTextureId(int textureId) { + return playerMap[textureId]?.target; + } + + @override + Future openVideo( + WinVideoPlayerController player, + int textureId, + String path, + Map httpHeaders, + ) async { + var arguments = await methodChannel.invokeMethod('openVideo', { + "textureId": -1, + "path": path, + "httpHeaders": httpHeaders, + }); + if (arguments == null) return null; + if (arguments["result"] == false) return null; + + int width = arguments["videoWidth"]; + int height = arguments["videoHeight"]; + double volume = arguments["volume"]; + int textureId = arguments["textureId"]; + var value = WinVideoPlayerValue( + textureId: textureId, + position: Duration.zero, + duration: Duration(milliseconds: arguments["duration"]), + size: Size(width.toDouble(), height.toDouble()), + isPlaying: false, + isInitialized: true, + volume: volume, + ); + + playerMap[value.textureId] = WeakReference( + player, + ); + return value; + } + + @override + Future play(int textureId) async { + await methodChannel.invokeMethod('play', {"textureId": textureId}); + } + + @override + Future pause(int textureId) async { + await methodChannel.invokeMethod('pause', {"textureId": textureId}); + } + + @override + Future seekTo(int textureId, int ms) async { + // TODO: will auto play after seek, it seems there is no way to seek without playing in windows media foundation API... + await methodChannel.invokeMethod('seekTo', { + "textureId": textureId, + "ms": ms, + }); + } + + @override + Future getCurrentPosition(int textureId) async { + // TODO: sometimes will return 0 when seeking... seems a bug in windows media foundation API... + var value = await methodChannel.invokeMethod('getCurrentPosition', { + "textureId": textureId, + }); + return value ?? -1; + } + + @override + Future getDuration(int textureId) async { + var value = await methodChannel.invokeMethod('getDuration', { + "textureId": textureId, + }); + return value ?? -1; + } + + @override + Future setPlaybackSpeed(int textureId, double speed) async { + await methodChannel.invokeMethod('setPlaybackSpeed', { + "textureId": textureId, + "speed": speed, + }); + } + + @override + Future setVolume(int textureId, double volume) async { + await methodChannel.invokeMethod('setVolume', { + "textureId": textureId, + "volume": volume, + }); + } + + @override + Future dispose(int textureId) async { + await methodChannel.invokeMethod('shutdown', { + "textureId": textureId, + }); + // NOTE: delay some time to wait last callbacks finished + await Future.delayed(const Duration(milliseconds: 100)); + await methodChannel.invokeMethod('dispose', {"textureId": textureId}); + } +} diff --git a/packages/video_player_win/lib/video_player_win_platform_interface.dart b/packages/video_player_win/lib/video_player_win_platform_interface.dart new file mode 100644 index 0000000..31131f4 --- /dev/null +++ b/packages/video_player_win/lib/video_player_win_platform_interface.dart @@ -0,0 +1,82 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'video_player_win.dart'; +import 'video_player_win_method_channel.dart'; + +abstract class VideoPlayerWinPlatform extends PlatformInterface { + /// Constructs a VideoPlayerWinPlatform. + VideoPlayerWinPlatform() : super(token: _token); + + static final Object _token = Object(); + + static VideoPlayerWinPlatform _instance = MethodChannelVideoPlayerWin(); + + /// The default instance of [VideoPlayerWinPlatform] to use. + /// + /// Defaults to [MethodChannelVideoPlayerWin]. + static VideoPlayerWinPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [VideoPlayerWinPlatform] when + /// they register themselves. + static set instance(VideoPlayerWinPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + void registerPlayer(int textureId, WinVideoPlayerController player) { + throw UnimplementedError('registerPlayer() has not been implemented.'); + } + + void unregisterPlayer(int textureId) { + throw UnimplementedError('unregisterPlayer() has not been implemented.'); + } + + WinVideoPlayerController? getPlayerByTextureId(int textureId) { + throw UnimplementedError( + 'getPlayerByTextureId() has not been implemented.', + ); + } + + Future openVideo( + WinVideoPlayerController player, + int textureId, + String path, + Map httpHeaders, + ) { + throw UnimplementedError('openVideo() has not been implemented.'); + } + + Future play(int textureId) { + throw UnimplementedError('play() has not been implemented.'); + } + + Future pause(int textureId) { + throw UnimplementedError('pause() has not been implemented.'); + } + + Future seekTo(int textureId, int ms) { + throw UnimplementedError('seekTo() has not been implemented.'); + } + + Future getCurrentPosition(int textureId) { + throw UnimplementedError('getCurrentPosition() has not been implemented.'); + } + + Future getDuration(int textureId) { + throw UnimplementedError('getDuration() has not been implemented.'); + } + + Future setPlaybackSpeed(int textureId, double speed) { + throw UnimplementedError('setPlaybackSpeed() has not been implemented.'); + } + + Future setVolume(int textureId, double volume) { + // volume: 0.0 ~ 1.0 + throw UnimplementedError('setVolume() has not been implemented.'); + } + + Future dispose(int textureId) { + throw UnimplementedError('destroy() has not been implemented.'); + } +} diff --git a/packages/video_player_win/lib/video_player_win_plugin.dart b/packages/video_player_win/lib/video_player_win_plugin.dart new file mode 100644 index 0000000..6d3f5b9 --- /dev/null +++ b/packages/video_player_win/lib/video_player_win_plugin.dart @@ -0,0 +1,178 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +import 'video_player_win.dart'; +import 'video_player_win_platform_interface.dart'; + +class WindowsVideoPlayer extends VideoPlayerPlatform { + static void registerWith() { + VideoPlayerPlatform.instance = WindowsVideoPlayer(); + } + + final mControllerMap = {}; + + @override + Future init() async { + // do nothing... + } + + /// Clears one video. + @override + Future dispose(int textureId) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.dispose(); + mControllerMap.remove(textureId); + } + + /// Creates an instance of a video player and returns its textureId. + @override + Future create(DataSource dataSource) async { + WinVideoPlayerController? controller; + + if (dataSource.sourceType == DataSourceType.file) { + // dataSource.uri is url encoded and has a file:// scheme. + // But if the dataSource.uri original path contains non-ASCII characters, + // it will cause the IMFSourceResolver API url decoding to fail and cause + // the app to crash. + // + // To avoid this, need to pass dataSource.uri to Uri.parse() and get + // the path from uri.toFilePath(). It removes the file:// scheme and + // url decodes the path. + // + // Without the file:// scheme, the IMFSourceResolver API treats the "%" + // character as a normal string instead of url decoding the path. + var uri = Uri.parse(dataSource.uri!); + controller = WinVideoPlayerController.file( + File(uri.toFilePath()), + isPluginMode: true, + ); + } else if (dataSource.sourceType == DataSourceType.network) { + controller = WinVideoPlayerController.network( + dataSource.uri!, + isPluginMode: true, + httpHeaders: dataSource.httpHeaders, + ); + } else if (dataSource.sourceType == DataSourceType.asset) { + controller = WinVideoPlayerController.asset( + dataSource.asset!, + isPluginMode: true, + ); + } else { + throw UnimplementedError( + 'create() has not been implemented for dataSource type [assets] and [contentUri] in Windows OS', + ); + } + + await controller.initialize(); + if (controller.textureId_ > 0) { + mControllerMap[controller.textureId_] = controller; + return controller.textureId_; + } + return null; + } + + /// Returns a Stream of [VideoEventType]s. + @override + Stream videoEventsFor(int textureId) { + var player = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + if (player != null) { + return player.videoEventStream; + } else { + // send an intialized-failed event + var streamController = StreamController(); + streamController.add( + VideoEvent( + eventType: VideoEventType.initialized, + duration: null, + size: null, + ), + ); + return streamController.stream; + } + } + + /// Sets the looping attribute of the video. + @override + Future setLooping(int textureId, bool looping) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.setLooping(looping); + } + + /// Starts the video playback. + @override + Future play(int textureId) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.play(); + } + + /// Stops the video playback. + @override + Future pause(int textureId) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.pause(); + } + + /// Sets the volume to a range between 0.0 and 1.0. + @override + Future setVolume(int textureId, double volume) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.setVolume(volume); + } + + /// Sets the video position to a [Duration] from the start. + @override + Future seekTo(int textureId, Duration position) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.seekTo(position); + } + + /// Sets the playback speed to a [speed] value indicating the playback rate. + @override + Future setPlaybackSpeed(int textureId, double speed) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + await controller?.setPlaybackSpeed(speed); + } + + /// Gets the video position as [Duration] from the start. + @override + Future getPosition(int textureId) async { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + ); + return await controller?.position ?? const Duration(); + } + + /// Returns a widget displaying the video with a given textureID. + @override + Widget buildView(int textureId) { + var controller = VideoPlayerWinPlatform.instance.getPlayerByTextureId( + textureId, + )!; + return WinVideoPlayer(controller); + } + + /// Sets the audio mode to mix with other sources + @override + Future setMixWithOthers(bool mixWithOthers) async { + // do nothing... not support in Windows OS + } +} diff --git a/packages/video_player_win/pubspec.yaml b/packages/video_player_win/pubspec.yaml new file mode 100644 index 0000000..36856e5 --- /dev/null +++ b/packages/video_player_win/pubspec.yaml @@ -0,0 +1,81 @@ +name: video_player_win +description: Video player for Windows, lightweight, using Windows built-in Media Foundation API. Windows implementation of the video_player plugin. +homepage: https://github.com/jakky1/video_player_win +repository: https://github.com/jakky1/video_player_win +issue_tracker: https://github.com/jakky1/video_player_win/issues +version: 3.2.2 +resolution: workspace + +environment: + sdk: '>=3.11.5 <4.0.0' + flutter: '>=3.0.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + video_player_platform_interface: ^6.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +topics: + - video + - video-player + - audio + - audio-player + +# 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: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + implements: video_player #Jacky + platforms: + windows: + dartPluginClass: WindowsVideoPlayer #Jacky + pluginClass: VideoPlayerWinPluginCApi + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your plugin package, 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 in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/video_player_win/test/video_player_win_method_channel_test.dart b/packages/video_player_win/test/video_player_win_method_channel_test.dart new file mode 100644 index 0000000..dc67b5c --- /dev/null +++ b/packages/video_player_win/test/video_player_win_method_channel_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +//import 'package:video_player_win/video_player_win_method_channel.dart'; + +void main() { + //MethodChannelVideoPlayerWin platform = MethodChannelVideoPlayerWin(); + const MethodChannel channel = MethodChannel('video_player_win'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return '42'; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('getPlatformVersion', () async { + //expect(await platform.getPlatformVersion(), '42'); + }); +} diff --git a/packages/video_player_win/test/video_player_win_test.dart b/packages/video_player_win/test/video_player_win_test.dart new file mode 100644 index 0000000..96e3df0 --- /dev/null +++ b/packages/video_player_win/test/video_player_win_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:video_player_win/video_player_win.dart'; +import 'package:video_player_win/video_player_win_platform_interface.dart'; +import 'package:video_player_win/video_player_win_method_channel.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockVideoPlayerWinPlatform + with MockPlatformInterfaceMixin + implements VideoPlayerWinPlatform { + @override + Future openVideo( + WinVideoPlayerController player, + int playerId, + String path, + Map httpHeaders, + ) { + throw UnimplementedError(); + } + + @override + WinVideoPlayerController? getPlayerByTextureId(int textureId) { + throw UnimplementedError(); + } + + @override + Future getCurrentPosition(int textureId) { + throw UnimplementedError(); + } + + @override + Future getDuration(int textureId) { + throw UnimplementedError(); + } + + @override + Future setVolume(int textureId, double volume) { + throw UnimplementedError(); + } + + @override + Future dispose(int textureId) { + throw UnimplementedError(); + } + + @override + Future pause(int playerId) { + throw UnimplementedError(); + } + + @override + Future play(int playerId) { + throw UnimplementedError(); + } + + @override + void registerPlayer(int playerId, WinVideoPlayerController player) {} + + @override + Future seekTo(int playerId, int ms) { + throw UnimplementedError(); + } + + @override + Future setPlaybackSpeed(int playerId, double rate) { + throw UnimplementedError(); + } + + @override + void unregisterPlayer(int playerId) {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('video_player_win'), + (call) async => null, + ); + final VideoPlayerWinPlatform initialPlatform = + VideoPlayerWinPlatform.instance; + + test('$MethodChannelVideoPlayerWin is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('getPlatformVersion', () async { + //VideoPlayerWin videoPlayerWinPlugin = VideoPlayerWin(); + MockVideoPlayerWinPlatform fakePlatform = MockVideoPlayerWinPlatform(); + VideoPlayerWinPlatform.instance = fakePlatform; + + //expect(await videoPlayerWinPlugin.getPlatformVersion(), '42'); + }); +} diff --git a/packages/video_player_win/windows/CMakeLists.txt b/packages/video_player_win/windows/CMakeLists.txt new file mode 100644 index 0000000..b5f339b --- /dev/null +++ b/packages/video_player_win/windows/CMakeLists.txt @@ -0,0 +1,104 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "video_player_win") +project(${PROJECT_NAME} LANGUAGES CXX) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "video_player_win_plugin") + +######## Jacky { +# download nuget.exe (Microsoft) and Microsoft WebView2 SDK +# ref: https://github.com/jnschulze/flutter-webview-windows/blob/main/windows/CMakeLists.txt + +set(NUGET_URL https://dist.nuget.org/win-x86-commandline/v5.10.0/nuget.exe) +set(NUGET_SHA256 852b71cc8c8c2d40d09ea49d321ff56fd2397b9d6ea9f96e532530307bbbafd3) + +set(WIL_VERSION "1.0.220914.1") + +find_program(NUGET nuget) +if(NOT NUGET) + set(NUGET ${CMAKE_BINARY_DIR}/nuget.exe) + + if (NOT EXISTS ${NUGET}) + message(NOTICE "Nuget is not installed.\nStart downloading nuget. Please wait...") + file(DOWNLOAD ${NUGET_URL} ${NUGET}) + endif() + + file(SHA256 ${NUGET} NUGET_DL_HASH) + if (NOT NUGET_DL_HASH STREQUAL NUGET_SHA256) + message(FATAL_ERROR "Integrity check for ${NUGET} failed.") + endif() +endif() + +set(WIL_STAMP_FILE ${CMAKE_BINARY_DIR}/wil_download.stamp) +add_custom_command( + OUTPUT ${WIL_STAMP_FILE} + COMMAND ${NUGET} install Microsoft.Windows.ImplementationLibrary -Version ${WIL_VERSION} -ExcludeVersion -OutputDirectory ${CMAKE_BINARY_DIR}/packages + COMMAND ${CMAKE_COMMAND} -E touch ${WIL_STAMP_FILE} + DEPENDS ${NUGET} + VERBATIM +) +add_custom_target(${PROJECT_NAME}_DEPENDENCIES_DOWNLOAD ALL + DEPENDS ${WIL_STAMP_FILE} +) + +include_directories("${CMAKE_CURRENT_SOURCE_DIR}/DX11VideoRenderer") +AUX_SOURCE_DIRECTORY(DX11VideoRenderer DX11VideoRenderer_Sources) +######## Jacky } + +# Any new source files that you add to the plugin should be added here. +list(APPEND PLUGIN_SOURCES + "video_player_win_plugin.cpp" + "video_player_win_plugin.h" +) + +# Define the plugin library target. Its name must not be changed (see comment +# on PLUGIN_NAME above). +add_library(${PLUGIN_NAME} SHARED + "include/video_player_win/video_player_win_plugin_c_api.h" + "video_player_win_plugin_c_api.cpp" + ${PLUGIN_SOURCES} + "my_grabber_player.cpp" #Jacky + "my_http_bytestream.cpp" #Jacky + ${DX11VideoRenderer_Sources} #Jacky +) + +# Apply a standard set of build settings that are configured in the +# application-level CMakeLists.txt. This can be removed for plugins that want +# full control over build settings. +apply_standard_settings(${PLUGIN_NAME}) + +# WIL is a header-only dependency. The .targets file is MSBuild metadata and +# must never be passed to target_link_libraries (especially with NMake). +set(WIL_INCLUDE_DIR + "${CMAKE_BINARY_DIR}/packages/Microsoft.Windows.ImplementationLibrary/build/native/include") +target_include_directories(${PLUGIN_NAME} PRIVATE "${WIL_INCLUDE_DIR}") +add_dependencies(${PLUGIN_NAME} ${PROJECT_NAME}_DEPENDENCIES_DOWNLOAD) + + +# Symbols are hidden by default to reduce the chance of accidental conflicts +# between plugins. This should not be removed; any symbols that should be +# exported should be explicitly exported with the FLUTTER_PLUGIN_EXPORT macro. +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) + +# Source include directories and library dependencies. Add any plugin-specific +# dependencies here. +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(video_player_win_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/video_player_win/windows/include/video_player_win/video_player_win_plugin_c_api.h b/packages/video_player_win/windows/include/video_player_win/video_player_win_plugin_c_api.h new file mode 100644 index 0000000..0a0bb63 --- /dev/null +++ b/packages/video_player_win/windows/include/video_player_win/video_player_win_plugin_c_api.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_VIDEO_PLAYER_WIN_PLUGIN_C_API_H_ +#define FLUTTER_PLUGIN_VIDEO_PLAYER_WIN_PLUGIN_C_API_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void VideoPlayerWinPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_VIDEO_PLAYER_WIN_PLUGIN_C_API_H_ diff --git a/packages/video_player_win/windows/my_grabber_player.cpp b/packages/video_player_win/windows/my_grabber_player.cpp new file mode 100644 index 0000000..e129040 --- /dev/null +++ b/packages/video_player_win/windows/my_grabber_player.cpp @@ -0,0 +1,455 @@ +#include "my_grabber_player.h" +#include "my_http_bytestream.h" + +#include + +#include +#include +#include + +#pragma comment(lib, "D3D11") +#pragma comment(lib, "mfplat") + +#define NO_FRAME -2 +#define TAG "[video_player_win][native] " + +#define CHECK_HR(x) if (FAILED(x)) { goto done; } +//#define CHECK_HR(x) if (FAILED(x)) { std::cout << TAG "CHECK_HR() failed: hr=" << hr << ", at " << __FUNCTION__ << "():" << __LINE__ << std::endl; goto done; } // debug + +// -------------------------------------------------------------------------- + +STDMETHODIMP MyPlayer::QueryInterface(REFIID riid, void** ppv) +{ + if (__uuidof(IMFMediaEngineNotify) == riid) + { + *ppv = static_cast(this); + } + else + { + *ppv = nullptr; + return E_NOINTERFACE; + } + AddRef(); + return S_OK; +} + +HRESULT MyPlayer::initD3D11() +{ + const UINT creationFlags = (D3D11_CREATE_DEVICE_VIDEO_SUPPORT + | D3D11_CREATE_DEVICE_BGRA_SUPPORT + | D3D11_CREATE_DEVICE_PREVENT_INTERNAL_THREADING_OPTIMIZATIONS); + + HRESULT hr; + wil::com_ptr pDXGIDevice; + wil::com_ptr pMultithread; + hr = D3D11CreateDevice( + m_adapter.get(), + D3D_DRIVER_TYPE_UNKNOWN, //D3D_DRIVER_TYPE_HARDWARE, //TODO: should use HARDWARE... ?? + NULL, + creationFlags, + NULL, + 0, + D3D11_SDK_VERSION, + &pDX11Device, + NULL, + NULL + ); + CHECK_HR(hr); + + // enable multithread for d3d11 device + CHECK_HR(hr = pDX11Device->QueryInterface(IID_PPV_ARGS(&pMultithread))); + pMultithread->SetMultithreadProtected(TRUE); + + UINT resetToken; + CHECK_HR(hr = MFCreateDXGIDeviceManager(&resetToken, &m_pDXGIManager)); + CHECK_HR(hr = m_pDXGIManager->ResetDevice(pDX11Device.get(), resetToken)); + + CHECK_HR(hr = pDX11Device->QueryInterface(IID_PPV_ARGS(&pDXGIDevice))); + + // Ensure that DXGI does not queue more than one frame at a time. This both reduces + // latency and ensures that the application will only render after each VSync, minimizing + // power consumption. + CHECK_HR(hr = pDXGIDevice->SetMaximumFrameLatency(1)); + +done: + return hr; +} + +HRESULT MyPlayer::initTexture() +{ + HRESULT hr; + D3D11_TEXTURE2D_DESC textureDesc = {}; + + textureDesc.Width = m_VideoWidth; + textureDesc.Height = m_VideoHeight; + textureDesc.MipLevels = 1; + textureDesc.ArraySize = 1; + textureDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + textureDesc.SampleDesc.Count = 1; + textureDesc.SampleDesc.Quality = 0; + textureDesc.CPUAccessFlags = 0; + textureDesc.Usage = D3D11_USAGE_DEFAULT; + textureDesc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + textureDesc.MiscFlags = D3D11_RESOURCE_MISC_SHARED; + CHECK_HR(hr = pDX11Device->CreateTexture2D(&textureDesc, nullptr, &m_pTexture)); + +done: + return hr; +} + +MyPlayer::MyPlayer(IDXGIAdapter* adapter) : + m_adapter(adapter), + m_VideoWidth(0), + m_VideoHeight(0), + m_isShutdown(false) +{ + m_playingEvent = CreateEvent(NULL, TRUE, m_isPlaying, NULL); +} + +HRESULT MyPlayer::EventNotify(DWORD event, DWORD_PTR param1, DWORD param2) +{ + switch (event) { + case MF_MEDIA_ENGINE_EVENT_TIMEUPDATE: + return S_OK; + + case MF_MEDIA_ENGINE_EVENT_LOADEDMETADATA: + m_hasVideo = m_pEngine->HasVideo(); + if (m_hasVideo) + { + m_pEngine->GetNativeVideoSize(&m_VideoWidth, &m_VideoHeight); + m_frameRectDst.right = m_VideoWidth; + m_frameRectDst.bottom = m_VideoHeight; + initTexture(); + } + break; + + case MF_MEDIA_ENGINE_EVENT_CANPLAY: + // notify client code that loading successfully + m_loadCallback(true); + m_loadCallback = NULL; + break; + + case MF_MEDIA_ENGINE_EVENT_FIRSTFRAMEREADY: + updateFrame(); // show first frame when ready + startVideoThread(); + break; + + // when playing / paused / ended, try to pause/resume video thread + case MF_MEDIA_ENGINE_EVENT_PLAY: + case MF_MEDIA_ENGINE_EVENT_PLAYING: + m_isPlaying = TRUE; + m_isEnded = FALSE; + SetEvent(m_playingEvent); + break; + case MF_MEDIA_ENGINE_EVENT_ENDED: + m_isEnded = TRUE; + case MF_MEDIA_ENGINE_EVENT_PAUSE: + ResetEvent(m_playingEvent); + m_isPlaying = FALSE; + break; + + //case MF_MEDIA_ENGINE_EVENT_SEEKING: + case MF_MEDIA_ENGINE_EVENT_SEEKED: + if (!m_isPlaying && m_hasVideo) // video scrubbing in pause state + { + m_seekingToPts = -1; + ResetEvent(m_playingEvent); + updateFrame(); // TODO: seems not scrubbing during pause after seek... + } + break; + + case MF_MEDIA_ENGINE_EVENT_BUFFERINGSTARTED: + case MF_MEDIA_ENGINE_EVENT_BUFFERINGENDED: + break; + + case MF_MEDIA_ENGINE_EVENT_ERROR: + // TODO: pass error reason to client code ? + printErrorMessage(param1); + case MF_MEDIA_ENGINE_EVENT_ABORT: + if (m_loadCallback != NULL) + { + m_loadCallback(false); + m_loadCallback = NULL; + } + break; + } + + //std::cout << "EventNotify(): " << event << std::endl; + OnPlayerEvent(event); + return S_OK; +} + +void MyPlayer::printErrorMessage(DWORD_PTR param1) +{ + char *msg = "Unknown error"; + switch (param1) + { + case MF_MEDIA_ENGINE_ERR_NOERROR: + msg = "no error... ??"; + break; + case MF_MEDIA_ENGINE_ERR_ABORTED : + msg = "aborted"; + break; + case MF_MEDIA_ENGINE_ERR_NETWORK : + msg = "network issue"; + break; + case MF_MEDIA_ENGINE_ERR_DECODE : + msg = "decode error"; + break; + case MF_MEDIA_ENGINE_ERR_SRC_NOT_SUPPORTED : + msg = "file not found / corrupted / not supported"; + break; + case MF_MEDIA_ENGINE_ERR_ENCRYPTED: + msg = "file is encrypted"; + break; + } + std::cout << TAG "player error occurs (MF_MEDIA_ENGINE_EVENT_ERROR) : " << msg << std::endl; +} + +HRESULT MyPlayer::OpenURL(const WCHAR* pszFileName, MyPlayerCallback* playerCallback, HWND hwndVideo, std::vector httpHeaders, std::function loadCallback) +{ + HRESULT hr; + wil::com_ptr pStream; + wil::com_ptr pFactory; // TODO: keep as static member ? + wil::com_ptr pAttributes; + + if (m_isShutdown || m_pEngine) return E_ABORT; + + m_frameCallback = playerCallback; + m_loadCallback = loadCallback; + + CHECK_HR(hr = initD3D11()); + CHECK_HR(hr = CoCreateInstance(CLSID_MFMediaEngineClassFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pFactory))); + + CHECK_HR(hr = MFCreateAttributes(&pAttributes, 3)); + CHECK_HR(hr = pAttributes->SetUnknown(MF_MEDIA_ENGINE_DXGI_MANAGER, (IUnknown*)m_pDXGIManager.get())); + CHECK_HR(hr = pAttributes->SetUnknown(MF_MEDIA_ENGINE_CALLBACK, (IUnknown*)this)); + CHECK_HR(hr = pAttributes->SetUINT32(MF_MEDIA_ENGINE_VIDEO_OUTPUT_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM)); + CHECK_HR(hr = pFactory->CreateInstance(0, pAttributes.get(), &m_pEngine)); + + CHECK_HR(hr = m_pEngine->QueryInterface(IID_PPV_ARGS(&m_pEngineEx))); + + if (!httpHeaders.empty() && wcsncmp(pszFileName, L"http", 4) == 0) { + pStream.attach(new MyHttpByteStream(std::wstring(pszFileName), httpHeaders)); + CHECK_HR(hr = m_pEngineEx->SetSourceFromByteStream(pStream.get(), (BSTR)pszFileName)); + } else { + CHECK_HR(hr = m_pEngine->SetSource((BSTR)pszFileName)); + } + +done: + if (FAILED(hr)) + { + std::cout << TAG "OpenURL() failed: hr=" << hr << std::endl; + m_loadCallback(SUCCEEDED(hr)); + m_loadCallback = NULL; + } + return hr; +} + +MyPlayer::~MyPlayer() +{ + Shutdown(); + CloseHandle(m_playingEvent); + std::cout << TAG "~MyPlayer() destroyed" << std::endl; +} + +DWORD WINAPI MyThreadFunction(LPVOID lpParam) +{ + wil::com_ptr player((MyPlayer*) lpParam); // keep player reference during thread + player->priv__videoThreadFunc(); + return 0; +} + +HRESULT MyPlayer::startVideoThread() +{ + if (!m_hasVideo) return S_OK; // no video track found + if (NULL != m_threadHandle) { + std::cout << "startVideoThread() already running now !!!" << std::endl; + return E_FAIL; + } + + // TODO: should call this->AddRef() before thread start ??? + m_threadHandle = CreateThread(NULL, 0, MyThreadFunction, this, 0, NULL); + if (NULL == m_threadHandle) { + return E_FAIL; + } + SetThreadPriority(m_threadHandle, THREAD_PRIORITY_HIGHEST); + + return S_OK; +} + +void MyPlayer::priv__videoThreadFunc() +{ + wil::com_ptr pDXGIOutput; + + m_adapter->EnumOutputs(0, &pDXGIOutput); + + //std::cout << "priv__videoThreadFunc() start" << std::endl; + do + { + pDXGIOutput->WaitForVBlank(); + + // show frame + if (m_isShutdown) break; + updateFrame(); + + // pause thread if video paused + if (m_isShutdown) break; + if (!m_isPlaying && m_seekingToPts < 0) + { + // suspend thread during video paused, wait until play() or shutdown() + //std::cout << "video thread pause ~~~" << std::endl; + WaitForSingleObject(m_playingEvent, INFINITE); + //std::cout << "video thread resume ~~~" << std::endl; + } + + } while (true); + + //std::cout << "priv__videoThreadFunc() exit" << std::endl; + CloseHandle(m_threadHandle); + m_threadHandle = NULL; +} + +LONGLONG MyPlayer::updateFrame() +{ + HRESULT hr; + LONGLONG pts; + bool bFound; + + do + { + pts = -1; + bFound = false; + + hr = m_pEngine->OnVideoStreamTick(&pts); + //std::cout << "OnVideoStreamTick() hr: " << hr << ", pts : " << pts << std::endl; + if (S_OK == hr) + { + /* + static LONGLONG s_lastPts = 0; + std::cout << "frame diff pts : " << (pts - s_lastPts) / 10000 << std::endl; + s_lastPts = pts; + */ + + hr = m_pEngine->TransferVideoFrame(m_pTexture.get(), &m_frameRectSrc, &m_frameRectDst, NULL); + if (FAILED(hr)) { + std::cout << TAG "TransferVideoFrame failed !!!!!!!!!!!! hr = " << hr << std::endl; + } + else + { + m_frameCallback->OnProcessFrame(m_pTexture.get()); + bFound = true; + } + } + else + { + hr = S_OK; + //std::cout << "OnVideoStreamTick() no new frame !!!!!" << std::endl; + } + + // video scrubbing: if seeking in pause state, loop until next frame found + if (!m_isPlaying && m_seekingToPts >= 0) + { + LONGLONG diffPts = pts - m_seekingToPts; + if (diffPts < 0) diffPts = -diffPts; + if (bFound && diffPts < 10000*100) + { + m_seekingToPts = -1; + ResetEvent(m_playingEvent); // pause video thread again + return -1; + } + else + { + continue; + } + } + } while (false); + + return bFound ? pts : NO_FRAME; +} + +HRESULT MyPlayer::Play(LONGLONG ms) +{ + if (!m_pEngine) return E_FAIL; + return m_pEngine->Play(); +} + +HRESULT MyPlayer::Pause() +{ + if (!m_pEngine) return E_FAIL; + return m_pEngine->Pause(); +} + +LONGLONG MyPlayer::GetDuration() +{ + if (!m_pEngine) return -1; + return (LONGLONG) (m_pEngine->GetDuration() * 1000); +} + +LONGLONG MyPlayer::GetCurrentPosition() +{ + if (!m_pEngine) return -1; + return (LONGLONG) (m_pEngine->GetCurrentTime() * 1000); +} + +HRESULT MyPlayer::Seek(LONGLONG ms) +{ + if (!m_pEngine) return E_FAIL; + + if (!m_isPlaying) + { + // video scrubbing + m_seekingToPts = ms * 10000; + SetEvent(m_playingEvent); + } + //return m_pEngine->SetCurrentTime((double)ms / 1000); + if (m_isEnded) Play(); // Fix issue#44: seek() after ended, will play audio without video frames... + return m_pEngineEx->SetCurrentTimeEx((double)ms / 1000, MF_MEDIA_ENGINE_SEEK_MODE_APPROXIMATE ); +} + +SIZE MyPlayer::GetVideoSize() +{ + SIZE size = {}; + if (!m_pEngine) return size; + size.cx = (LONG) m_VideoWidth; + size.cy = (LONG) m_VideoHeight; + return size; +} + +HRESULT MyPlayer::SetPlaybackSpeed(float speed) +{ + if (!m_pEngine) return E_FAIL; + return m_pEngine->SetPlaybackRate((double)speed); +} + +HRESULT MyPlayer::GetVolume(float* pVol) +{ + if (!m_pEngine) return E_FAIL; + double vol = m_pEngine->GetVolume(); + *pVol = (float)vol; + return S_OK; +} + +HRESULT MyPlayer::SetVolume(float vol) +{ + if (!m_pEngine) return E_FAIL; + return m_pEngine->SetVolume((double)vol); +} + +void MyPlayer::Shutdown() +{ + std::unique_lock guard(m_mutex); + if (m_isShutdown) return; + m_isShutdown = true; + + if (m_pEngine) + { + OnPlayerEvent(MESessionClosed); + SetEvent(m_playingEvent); // resume video thread and close by itself + m_pEngine->Shutdown(); + m_pEngine.reset(); + m_pTexture.reset(); + + this->Release(); // TODO: without this line, ~MyPlayer() not called... who keep this pointer ??? + } +} diff --git a/packages/video_player_win/windows/my_grabber_player.h b/packages/video_player_win/windows/my_grabber_player.h new file mode 100644 index 0000000..cfa8e2f --- /dev/null +++ b/packages/video_player_win/windows/my_grabber_player.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +class MyPlayerCallback : public IUnknown +{ +public: + virtual void OnProcessFrame(ID3D11Texture2D* texture) = 0; +}; + +class MyPlayer : public IMFMediaEngineNotify +{ +public: + STDMETHODIMP QueryInterface(REFIID riid, void** ppv); + inline STDMETHODIMP_(ULONG) AddRef() { + return InterlockedIncrement(&m_cRef); + } + STDMETHODIMP_(ULONG) Release() { + ULONG uCount = InterlockedDecrement(&m_cRef); + if (uCount == 0) delete this; + return uCount; + } +private: + long m_cRef = 1; + +public: + HRESULT OpenURL(const WCHAR* pszFileName, MyPlayerCallback* playerCallback, HWND hwndVideo, std::vector httpHeaders, std::function loadCallback); + HRESULT Play(LONGLONG ms = -1); + HRESULT Pause(); + void Shutdown(); + + LONGLONG GetDuration(); + LONGLONG GetCurrentPosition(); + HRESULT Seek(LONGLONG ms); + SIZE GetVideoSize(); + + HRESULT SetPlaybackSpeed(float fRate); + + HRESULT GetVolume(float* pVol); + HRESULT SetVolume(float vol); + + MyPlayer(IDXGIAdapter* adapter); + virtual ~MyPlayer(); + + void priv__videoThreadFunc(); + +protected: + virtual void OnPlayerEvent(DWORD event) {}; + + DWORD m_VideoWidth; + DWORD m_VideoHeight; + +private: + wil::com_ptr pDX11Device; + HRESULT initD3D11(); + HRESULT initTexture(); + HRESULT EventNotify(DWORD meEvent, DWORD_PTR param1, DWORD param2); + HRESULT startVideoThread(); + LONGLONG updateFrame(); + void printErrorMessage(DWORD_PTR param1); + + wil::com_ptr m_frameCallback; + std::function m_loadCallback; + + wil::com_ptr m_pDXGIManager; + wil::com_ptr m_adapter; + wil::com_ptr m_pEngine; + wil::com_ptr m_pEngineEx; + wil::com_ptr m_pTexture; + + std::mutex m_mutex; + bool m_hasVideo = false; + LONGLONG m_maxFrameInterval = 10; + + BOOL m_isPlaying = FALSE; + BOOL m_isEnded = FALSE; + HANDLE m_playingEvent = NULL; // win32 event + LONGLONG m_seekingToPts = -1; // seeking pts if seeking + bool m_isShutdown; + + MFVideoNormalizedRect m_frameRectSrc = {}; + RECT m_frameRectDst = {}; + HANDLE m_threadHandle = NULL; +}; \ No newline at end of file diff --git a/packages/video_player_win/windows/my_http_bytestream.cpp b/packages/video_player_win/windows/my_http_bytestream.cpp new file mode 100644 index 0000000..75648d0 --- /dev/null +++ b/packages/video_player_win/windows/my_http_bytestream.cpp @@ -0,0 +1,467 @@ +#include "my_http_bytestream.h" + +#include +#include +#include // IMFByteStream +#include // MFCreateAsyncResult +#include + +#pragma comment(lib, "WinHTTP") +#pragma comment(lib, "Mfuuid") // IMFByteStream +#pragma comment(lib, "Mfplat") // MFCreateAsyncResult + +#include +#include +#include + +#include +#include + +class MyHttpConnection +{ +public: + bool open(std::wstring& url, std::vector headers = {}, QWORD startPosition = 0); + int read(BYTE* buf, int size); + void close(); + long getContentLength(); + ~MyHttpConnection(); + +private: + char buf[1024 * 8]; + HINTERNET hSession = NULL; + HINTERNET hConnect = NULL; + HINTERNET hRequest = NULL; +}; + +#define CHECK(result, errMsg) if (!result) { std::cerr << errMsg << GetLastError() << std::endl; close(); return false; } +bool MyHttpConnection::open(std::wstring& url, std::vector headers, QWORD startPosition) +{ + BOOL bResults = FALSE; + URL_COMPONENTS urlComp = { sizeof(URL_COMPONENTS) }; + WCHAR lpszHostName[1024]; + WCHAR lpszUrlPath[1024]; + + assert(hSession == NULL); + + urlComp.lpszHostName = lpszHostName; + urlComp.lpszUrlPath = lpszUrlPath; + urlComp.dwHostNameLength = sizeof(lpszHostName) / sizeof(WCHAR); + urlComp.dwUrlPathLength = sizeof(lpszUrlPath) / sizeof(WCHAR); + CHECK(WinHttpCrackUrl(url.c_str(), 0, 0, &urlComp), "WinHttpCrackUrl failed"); + + hSession = WinHttpOpen(L"A WinHTTP Example Program/1.0", + WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, 0); + CHECK(hSession, "WinHttpOpen failed"); + + hConnect = WinHttpConnect(hSession, urlComp.lpszHostName, + INTERNET_DEFAULT_HTTPS_PORT, 0); + CHECK(hConnect, "WinHttpConnect failed"); + + hRequest = WinHttpOpenRequest(hConnect, L"GET", urlComp.lpszUrlPath, + NULL, WINHTTP_NO_REFERER, + NULL, WINHTTP_FLAG_SECURE); + CHECK(hRequest, "WinHttpOpenRequest failed"); + + if (startPosition > 0) + { + wchar_t rangeHeader[50]; + swprintf(rangeHeader, sizeof(rangeHeader) / sizeof(wchar_t), L"Range: bytes=%d-", (int)startPosition); + WinHttpAddRequestHeaders(hRequest, rangeHeader, (DWORD) wcslen(rangeHeader), (DWORD) WINHTTP_ADDREQ_FLAG_ADD); + } + + //std::wcout << L"start pass headers..." << std::endl; + for (auto header : headers) + { + //std::wcout << L"header: " << header << std::endl; + BOOL b = WinHttpAddRequestHeaders(hRequest, header.c_str(), (DWORD) header.length(), (DWORD) WINHTTP_ADDREQ_FLAG_ADD); + CHECK(b, "WinHttpAddRequestHeaders failed"); + } + + bResults = WinHttpSendRequest(hRequest, + WINHTTP_NO_ADDITIONAL_HEADERS, + 0, WINHTTP_NO_REQUEST_DATA, + 0, 0, 0); + CHECK(bResults, "WinHttpSendRequest failed"); + + bResults = WinHttpReceiveResponse(hRequest, NULL); + CHECK(bResults, "WinHttpReceiveResponse failed"); + + return true; +} + +int MyHttpConnection::read(BYTE* pBuf, int size) +{ + DWORD bytesRead; + + BOOL bResults = WinHttpReadData(hRequest, pBuf, (DWORD) size, &bytesRead); + if (bResults && bytesRead > 0) { + return bytesRead; + } + return -1; +} + +void MyHttpConnection::close() +{ + if (hSession != NULL) + { + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + } + hRequest = hConnect = hSession = NULL; +} + +long MyHttpConnection::getContentLength() +{ + if (!hRequest) return -1; + DWORD contentLength = 0; + DWORD length = sizeof(contentLength); + if (WinHttpQueryHeaders(hRequest, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER, + NULL, &contentLength, &length, NULL)) { + //std::wcout << L"Content Length: " << contentLength << L" bytes" << std::endl; + return contentLength; + } + + return -1; +} + +MyHttpConnection::~MyHttpConnection() +{ + //std::cout << "MyHttpConnection::~MyHttpConnection()" << std::endl; + close(); +} + +// -------------------------------------------------------------------------- + +class MyDataIUnknown : public IUnknown +{ +public: + MyDataIUnknown(ULONG data) : mData(data) {} + long getData() { return mData; } + + STDMETHODIMP QueryInterface(REFIID riid, void** ppv) { + if (riid == IID_IUnknown) { + *ppv = static_cast(this); + AddRef(); + return S_OK; + } + return E_NOINTERFACE; + } + + STDMETHODIMP_(ULONG) AddRef() { + return InterlockedIncrement(&refCount); + } + + STDMETHODIMP_(ULONG) Release() { + ULONG count = InterlockedDecrement(&refCount); + if (count == 0) { + delete this; + } + return count; + } + +private: + ULONG refCount = 1; + ULONG mData; +}; + +STDMETHODIMP MyHttpByteStream::QueryInterface(REFIID riid, void** ppv) { + if (!ppv) + return E_POINTER; + + if (riid == __uuidof(IUnknown) || + riid == __uuidof(IMFByteStream)) + { + *ppv = static_cast(this); + } + else if (riid == __uuidof(IMFByteStreamBuffering)) + { + *ppv = static_cast(this); + } + /* + else if (riid == __uuidof(IMFMediaEventGenerator)) + { + *ppv = static_cast(this); + } + */ + else + { + *ppv = nullptr; + return E_NOINTERFACE; + } + AddRef(); + return S_OK; +} + +HRESULT MyHttpByteStream::GetCapabilities(DWORD* pdwCapabilities) +{ + *pdwCapabilities = MFBYTESTREAM_IS_READABLE + | MFBYTESTREAM_IS_SEEKABLE + | MFBYTESTREAM_IS_REMOTE + | MFBYTESTREAM_HAS_SLOW_SEEK; + return S_OK; +} + +HRESULT MyHttpByteStream::GetLength(QWORD* pqwLength) +{ + if (mFileSize > 0) { + *pqwLength = mFileSize; + //std::cout << "MyHttpByteStream::GetLength end : size = " << mFileSize << std::endl; + return S_OK; + } + + if (!mConnection) openConnection(0); + if (mConnection) + { + mFileSize = mConnection->getContentLength(); + *pqwLength = mFileSize; + //std::cout << "MyHttpByteStream::GetLength end : size = " << mFileSize << std::endl; + return S_OK; + } + return E_FAIL; +} + +HRESULT MyHttpByteStream::IsEndOfStream(BOOL* pfEndOfStream) +{ + QWORD size; + if (!SUCCEEDED(GetLength(&size))) return E_FAIL; + *pfEndOfStream = (mPosition >= size); + return S_OK; +} + +bool MyHttpByteStream::openConnection(QWORD position) +{ + if (!mConnection) delete mConnection; + + mConnection = new MyHttpConnection(); + if (!mConnection->open(mUrl, mHeaders, position)) + { + delete mConnection; + mConnection = NULL; + return false; + } + mPosition = position; + return true; +} + +HRESULT MyHttpByteStream::GetCurrentPosition(QWORD* pqwPosition) +{ + *pqwPosition = mPosition; + return S_OK; +} + +HRESULT MyHttpByteStream::SetCurrentPosition(QWORD qwPosition) +{ + if (mPosition != qwPosition) + { + mPosition = qwPosition; + if (mConnection) + { + delete mConnection; + mConnection = NULL; + } + } + return S_OK; +} + +HRESULT MyHttpByteStream::Seek(MFBYTESTREAM_SEEK_ORIGIN SeekOrigin, + LONGLONG llSeekOffset, + DWORD dwSeekFlags, + QWORD* pqwCurrentPosition) +{ + long pos; + if (msoBegin == SeekOrigin) pos = (long) llSeekOffset; + else pos = (long) mPosition + (long) llSeekOffset; + return SetCurrentPosition(pos); +} + +HRESULT MyHttpByteStream::Read(BYTE* pb, ULONG cb, ULONG* pcbRead) +{ + if (!mConnection) + { + if (!openConnection(mPosition)) return E_FAIL; + } + + int totalLen = 0; + while (cb > 0) + { + int len = mConnection->read(pb, cb); + if (len < 0) break; + mPosition += len; + pb += len; + totalLen += len; + cb -= len; + break; + } + *pcbRead = totalLen; + return S_OK; +} + +HRESULT MyHttpByteStream::BeginRead(BYTE* pb, ULONG cb, IMFAsyncCallback* pCallback, IUnknown* punkState) +{ + if (!pb || cb == 0 || !pCallback) { + return E_POINTER; + } + + auto ret = std::async(std::launch::async, [=]() -> void { + wil::com_ptr pAsyncResult; + wil::com_ptr pData; + + ULONG readLen = 0; + Read(pb, cb, &readLen); + + pData.attach(new MyDataIUnknown(readLen)); + MFCreateAsyncResult(pData.get(), pCallback, punkState, &pAsyncResult); + MFInvokeCallback(pAsyncResult.get()); + }); + return S_OK; +} + +HRESULT MyHttpByteStream::EndRead(IMFAsyncResult* pResult, ULONG* pcbRead) +{ + if (!pResult || !pcbRead) { + return E_POINTER; + } + + wil::com_ptr pData; + pResult->GetObject((IUnknown**)&pData); + if (!pData) return E_FAIL; + *pcbRead = pData->getData(); + return S_OK; +} + +HRESULT MyHttpByteStream::Close() +{ + //std::cout << "MyHttpByteStream::Close" << std::endl; + if (mConnection) + { + delete mConnection; + mConnection = NULL; + + //m_eventQueue->Shutdown(); + //m_eventQueue.reset(); // Release() will be called + + return S_OK; + } + return E_FAIL; +} + +MyHttpByteStream::MyHttpByteStream(const std::wstring& url, const std::vector headers) +{ + std::cout << "MyHttpByteStream::MyHttpByteStream()" << std::endl; + mUrl = url; + mHeaders = headers; + //MFCreateEventQueue(&m_eventQueue); +} + +MyHttpByteStream::~MyHttpByteStream() +{ + std::cout << "MyHttpByteStream::~MyHttpByteStream()" << std::endl; + Close(); +} + +// -------------------------------------------------------------------------- +// IMFByteStreamBuffering implementation +// -------------------------------------------------------------------------- + +HRESULT MyHttpByteStream::EnableBuffering(BOOL fEnable) +{ + // NOTE: without this implementation, buffering event notification still works... why ? + + /* + std::cout << "MyHttpByteStream::EnableBuffering start" << std::endl; + std::lock_guard lock(m_mutex); + + if (fEnable) + { + if (!m_bufferingEnabled) + { + m_bufferingEnabled = true; + StartBuffering(); + } + } + else + { + if (m_bufferingEnabled) + { + m_bufferingEnabled = false; + StopBuffering(); + } + } + return S_OK; + */ + return E_FAIL; +} + +HRESULT MyHttpByteStream::StopBuffering() +{ + // NOTE: without this implementation, buffering event notification still works... why ? + + /* + std::lock_guard lock(m_mutex); + if (m_bufferingInProgress) + { + std::cout << "MyHttpByteStream::StopBuffering start" << std::endl; + m_bufferingInProgress = false; + // Send MEBufferingStopped event + SendEvent(MEBufferingStopped, GUID_NULL, S_OK, nullptr); + } + return S_OK; + */ + return E_FAIL; +} + +HRESULT MyHttpByteStream::SetBufferingParams(MFBYTESTREAM_BUFFERING_PARAMS *pParams) +{ + // ref: https://learn.microsoft.com/en-us/windows/win32/api/mfidl/ns-mfidl-mf_leaky_bucket_pair + return S_OK; +} + +// -------------------------------------------------------------------------- +// IMFMediaEventGenerator implementation +// -------------------------------------------------------------------------- + +// NOTE: without 'IMFMediaEventGenerator' implementation, buffering event notification still works... why ? +#if 0 +void MyHttpByteStream::StartBuffering() { + if (m_bufferingInProgress) + return; + + std::cout << "MyHttpByteStream::StartBuffering start" << std::endl; + m_bufferingInProgress = true; + // Send MEBufferingStarted event + SendEvent(MEBufferingStarted, GUID_NULL, S_OK, nullptr); +} + +void MyHttpByteStream::SendEvent(MediaEventType met, REFGUID guidType, HRESULT hrStatus, const PROPVARIANT* pv) +{ + if (!m_eventQueue) return; + m_eventQueue->QueueEventParamVar(met, guidType, hrStatus, pv); +} + +STDMETHODIMP MyHttpByteStream::BeginGetEvent(IMFAsyncCallback* pCallback, IUnknown* punkState) +{ + if (!m_eventQueue) return MF_E_NOT_INITIALIZED; + return m_eventQueue->BeginGetEvent(pCallback, punkState); +} + +STDMETHODIMP MyHttpByteStream::EndGetEvent(IMFAsyncResult* pResult, IMFMediaEvent** ppEvent) +{ + if (!m_eventQueue) return MF_E_NOT_INITIALIZED; + return m_eventQueue->EndGetEvent(pResult, ppEvent); +} + +STDMETHODIMP MyHttpByteStream::MyHttpByteStream::GetEvent(DWORD dwFlags, IMFMediaEvent** ppEvent) +{ + if (!m_eventQueue) return MF_E_NOT_INITIALIZED; + return m_eventQueue->GetEvent(dwFlags, ppEvent); +} + +STDMETHODIMP MyHttpByteStream::QueueEvent(MediaEventType met, REFGUID guidExtendedType, HRESULT hrStatus, const PROPVARIANT* pvValue) +{ + if (!m_eventQueue) return MF_E_NOT_INITIALIZED; + return m_eventQueue->QueueEventParamVar(met, guidExtendedType, hrStatus, pvValue); +} +#endif \ No newline at end of file diff --git a/packages/video_player_win/windows/my_http_bytestream.h b/packages/video_player_win/windows/my_http_bytestream.h new file mode 100644 index 0000000..38eb09a --- /dev/null +++ b/packages/video_player_win/windows/my_http_bytestream.h @@ -0,0 +1,88 @@ +#pragma once + +#include // IMFByteStream +#include // IMFByteStreamBuffering +#include +#include +#include +#include + +class MyHttpConnection; +class MyHttpByteStream : + public IMFByteStream, + public IMFByteStreamBuffering + //public IMFMediaEventGenerator +{ +public: + MyHttpByteStream(const std::wstring& url, const std::vector headers = {}); + ~MyHttpByteStream(); + + // IMFByteStreamBuffering + std::mutex m_mutex; + bool m_bufferingEnabled = false; + bool m_bufferingInProgress = false; + HRESULT EnableBuffering(BOOL fEnable); + HRESULT SetBufferingParams(MFBYTESTREAM_BUFFERING_PARAMS *pParams); + HRESULT StopBuffering(); + // + + // IMFMediaEventGenerator +#if 0 + wil::com_ptr m_eventQueue; + void SendEvent(MediaEventType met, REFGUID guidType, HRESULT hrStatus, const PROPVARIANT* pv); + void StartBuffering(); + //void StopBuffering(); + HRESULT BeginGetEvent(IMFAsyncCallback *pCallback, IUnknown *punkState); + HRESULT EndGetEvent(IMFAsyncResult *pResult, IMFMediaEvent **ppEvent); + HRESULT GetEvent(DWORD dwFlags, IMFMediaEvent **ppEvent); + HRESULT QueueEvent(MediaEventType met, REFGUID guidExtendedType, HRESULT hrStatus, const PROPVARIANT *pvValue); +#endif + // + + HRESULT GetCapabilities(DWORD* pdwCapabilities); + + HRESULT Read(BYTE* pb, ULONG cb, ULONG* pcbRead); + HRESULT BeginRead(BYTE* pb, ULONG cb, IMFAsyncCallback* pCallback, IUnknown* punkState); + HRESULT EndRead(IMFAsyncResult* pResult, ULONG* pcbRead); + + HRESULT Seek(MFBYTESTREAM_SEEK_ORIGIN SeekOrigin, + LONGLONG llSeekOffset, + DWORD dwSeekFlags, + QWORD* pqwCurrentPosition + ); + HRESULT SetCurrentPosition(QWORD qwPosition); + HRESULT GetCurrentPosition(QWORD* pqwPosition); + HRESULT GetLength(QWORD* pqwLength); + HRESULT IsEndOfStream(BOOL* pfEndOfStream); + HRESULT Close(); + + // invalid operations + HRESULT Flush() { return E_FAIL; } + HRESULT SetLength(QWORD qwLength) { return E_FAIL; } + HRESULT Write(const BYTE* pb, ULONG cb, ULONG* pcbWritten) { return E_FAIL; } + HRESULT BeginWrite(const BYTE* pb, ULONG cb, IMFAsyncCallback* pCallback, IUnknown* punkState) { return E_FAIL; } + HRESULT EndWrite(IMFAsyncResult* pResult, ULONG* pcbWritten) { return E_FAIL; } + + STDMETHODIMP QueryInterface(REFIID riid, void** ppv); + STDMETHODIMP_(ULONG) AddRef() { + return InterlockedIncrement(&refCount); + } + + STDMETHODIMP_(ULONG) Release() { + ULONG count = InterlockedDecrement(&refCount); + if (count == 0) { + delete this; + } + return count; + } + +private: + ULONG refCount = 1; + bool openConnection(QWORD position); + + MyHttpConnection* mConnection = NULL; + std::wstring mUrl; + std::vector mHeaders; + QWORD mPosition = 0; + QWORD mFileSize = 0; +}; diff --git a/packages/video_player_win/windows/video_player_win_plugin.cpp b/packages/video_player_win/windows/video_player_win_plugin.cpp new file mode 100644 index 0000000..dee4bc3 --- /dev/null +++ b/packages/video_player_win/windows/video_player_win_plugin.cpp @@ -0,0 +1,473 @@ +#include "video_player_win_plugin.h" + +// This must be included before many other Windows headers. +#include + +// For getPlatformVersion; remove unless needed for your plugin implementation. +#include + +#include +#include +#include + +#include +#include + +#include "my_grabber_player.h" +#include +#include +#include +#include + +#define WM_FLUTTER_TASK (WM_APP + 8898) + +// Jacky { + +#include +#include + +#include + +std::wstring toWideString(std::string input) { + WCHAR wPath[1024*3]; + auto convResult = MultiByteToWideChar(CP_UTF8, 0, input.c_str(), -1, wPath, sizeof(wPath) / sizeof(WCHAR)); + if (convResult < 0) { + std::cout << "[video_player_win] native convert string to utf16 (WCHAR*) failed: path = " << input << std::endl; + return L""; + } + return std::wstring(wPath); +} + +inline uint64_t getCurrentTime() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +#include +class ScreenOnKeeper { +public: + ScreenOnKeeper() { + m_id = ++g_lastId; + + if (g_handle == 0) { + REASON_CONTEXT ctx; + ctx.Version = POWER_REQUEST_CONTEXT_VERSION; + ctx.Flags = POWER_REQUEST_CONTEXT_SIMPLE_STRING; + ctx.Reason.SimpleReasonString = L"[video_player_win]"; + g_handle = PowerCreateRequest(&ctx); + } + } + + ~ScreenOnKeeper() { + enable(false); + } + + void enable(bool b) { + if (m_isEnabled == b) + return; + m_isEnabled = b; + const std::lock_guard lock(g_keeperMutex); + + bool exists = g_keeperIdSet.find(m_id) != g_keeperIdSet.end(); + + if (b) { + if (!exists) { + if (g_keeperIdSet.empty()) { + PowerSetRequest(g_handle, PowerRequestSystemRequired); + PowerSetRequest(g_handle, PowerRequestDisplayRequired); + //std::cout << "[video_player_win] enable keep screen on" << std::endl; + } + g_keeperIdSet.insert(m_id); + } + } else { + if (exists) { + g_keeperIdSet.erase(m_id); + if (g_keeperIdSet.empty()) { + PowerClearRequest(g_handle, PowerRequestSystemRequired); + PowerClearRequest(g_handle, PowerRequestDisplayRequired); + //std::cout << "[video_player_win] disable keep screen on" << std::endl; + } + } + } + } +private: + bool m_isEnabled = false; + int m_id; + + static int g_lastId; + static std::set g_keeperIdSet; + static std::mutex g_keeperMutex; + static HANDLE g_handle; +}; +int ScreenOnKeeper::g_lastId = 0; +std::set ScreenOnKeeper::g_keeperIdSet; +std::mutex ScreenOnKeeper::g_keeperMutex; +HANDLE ScreenOnKeeper::g_handle = 0; + + +namespace video_player_win { + +class MyPlayerInternal : public MyPlayer, public MyPlayerCallback { +public: + int64_t textureId = -1; + FlutterDesktopGpuSurfaceDescriptor texture_buffer; + HWND mChildHWND = 0; + + MyPlayerInternal(VideoPlayerWinPlugin* plugin) : MyPlayer(plugin->m_dxgiAdapter), m_plugin(plugin) {} + ~MyPlayerInternal() { + keepScreenOn(false); + textureId = -1; + } + + inline STDMETHODIMP QueryInterface(REFIID riid, void** ppv) { + return MyPlayer::QueryInterface(riid, ppv); + } + inline STDMETHODIMP_(ULONG) AddRef() { + return MyPlayer::AddRef(); + } + STDMETHODIMP_(ULONG) Release() { + return MyPlayer::Release(); + } + +private: + VideoPlayerWinPlugin* m_plugin; + bool mTextureInited = false; + HANDLE mSharedTextureHandle = 0; + ScreenOnKeeper m_screenOnKeeper; + + enum PlaybackState { IDLE = 0, BUFFERING_START, BUFFERING_END, START, PAUSE, STOP, END, SESSION_ERROR }; + PlaybackState mPlaybackState = IDLE; + + void keepScreenOn(bool keepOn) { + if (m_VideoWidth <= 0) // if audio-only media + return; + m_screenOnKeeper.enable(keepOn); + } + + void OnPlayerEvent(DWORD event) override + { + switch (event) { + case MF_MEDIA_ENGINE_EVENT_BUFFERINGSTARTED: + mPlaybackState = BUFFERING_START; + break; + case MF_MEDIA_ENGINE_EVENT_BUFFERINGENDED: + mPlaybackState = BUFFERING_END; + break; + case MF_MEDIA_ENGINE_EVENT_PLAY: + mPlaybackState = START; + keepScreenOn(true); + break; + case MF_MEDIA_ENGINE_EVENT_PAUSE: + mPlaybackState = PAUSE; + keepScreenOn(false); + break; + case MF_MEDIA_ENGINE_EVENT_ENDED: + mPlaybackState = END; + keepScreenOn(false); + break; + case MESessionClosed: + mPlaybackState = IDLE; + keepScreenOn(false); + break; + case MF_MEDIA_ENGINE_EVENT_ABORT: + case MF_MEDIA_ENGINE_EVENT_ERROR: + mPlaybackState = SESSION_ERROR; + keepScreenOn(false); + break; + default: + return; + } + + HWND hwnd = m_plugin->m_nativeHWND; + if (hwnd != NULL && IsWindow(hwnd)) + { + // when session closed, player already destroyed, so don't notify flutter + if (mPlaybackState != IDLE) + { + PostMessage(GetParent(hwnd), WM_FLUTTER_TASK, textureId, mPlaybackState); + } + } + } + + void initTexture(ID3D11Texture2D* texture) + { + if (!mTextureInited) + { + HRESULT hr; + D3D11_TEXTURE2D_DESC desc; + texture->GetDesc(&desc); + + wil::com_ptr resource; + texture->QueryInterface(IID_PPV_ARGS(&resource)); + hr = resource->GetSharedHandle(&mSharedTextureHandle); + if (!SUCCEEDED(hr)) { + std::cout << "[video_player_win] native GetSharedHandle failed: " << hr << std::endl; + return; + } + + texture_buffer.struct_size = sizeof(FlutterDesktopGpuSurfaceDescriptor); + texture_buffer.width = desc.Width; + texture_buffer.height = desc.Height; + texture_buffer.format = kFlutterDesktopPixelFormatBGRA8888; //kFlutterDesktopPixelFormatRGBA8888; //or kFlutterDesktopPixelFormatBGRA8888 + texture_buffer.handle = mSharedTextureHandle; + + mTextureInited = true; + } + } + + void OnProcessFrame(ID3D11Texture2D* texture) + { + if (m_plugin->texture_registar_ != NULL && textureId != -1) { + initTexture(texture); + m_plugin->texture_registar_->MarkTextureFrameAvailable(textureId); + } + } +}; + + +void initMediaFoundation() { + static bool isInited = false; + if (isInited) return; + MFStartup(MF_VERSION); //TODO: hint user if startup failed... if it is possible? + isInited = true; +} + +void VideoPlayerWinPlugin::createTexture(MyPlayer* _data) { + auto data = (MyPlayerInternal*)_data; + memset(&data->texture_buffer, 0, sizeof(data->texture_buffer)); + + flutter::TextureVariant* texture = new flutter::TextureVariant(flutter::GpuSurfaceTexture( + kFlutterDesktopGpuSurfaceTypeDxgiSharedHandle, + [=](size_t width, size_t height) -> const FlutterDesktopGpuSurfaceDescriptor* { + return &data->texture_buffer; + })); + data->textureId = texture_registar_->RegisterTexture(texture); +} + +MyPlayer* VideoPlayerWinPlugin::getPlayerById(int64_t textureId, bool autoCreate) { + std::lock_guard lock(m_mapMutex); + MyPlayerInternal* data = (MyPlayerInternal*) playerMap[textureId]; + if (data == NULL && autoCreate) { + initMediaFoundation(); + data = new MyPlayerInternal(this); + createTexture(data); + playerMap[data->textureId] = data; + } + return data; +} + +void VideoPlayerWinPlugin::destroyPlayerById(int64_t textureId, bool toRelease) { + std::lock_guard lock(m_mapMutex); + MyPlayerInternal* data = (MyPlayerInternal*) playerMap[textureId]; + if (data == NULL) return; + playerMap.erase(textureId); + if (data->textureId != -1) { + texture_registar_->UnregisterTexture(data->textureId); + data->textureId = -1; + } + + data->Shutdown(); + if (toRelease) { + data->Release(); + } + //std::cout << "native destroy player id: " << textureId << std::endl; +} + +void VideoPlayerWinPlugin::destroyAllPlayers() { + for(auto iter = playerMap.begin(); iter != playerMap.end(); iter++) { + if (iter->first < 0) continue; + std::cout << "[video_player_win][native] old player found, deleting " << iter->first << std::endl; + iter->second->Shutdown(); + iter->second->Release(); + } + playerMap.clear(); +} + +} +// Jacky } + +namespace video_player_win { + +// static +void VideoPlayerWinPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarWindows *registrar) { + auto channel = + std::make_unique>( + registrar->messenger(), "video_player_win", + &flutter::StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(registrar); + + channel->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto &call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + // Jacky { + plugin->m_registrar = registrar; + plugin->texture_registar_ = registrar->texture_registrar(); + plugin->gMethodChannel = std::move(channel); + plugin->m_nativeHWND = registrar->GetView()->GetNativeWindow(); + plugin->m_dxgiAdapter = registrar->GetView()->GetGraphicsAdapter(); + // Jacky } + + registrar->AddPlugin(std::move(plugin)); +} + +std::optional VideoPlayerWinPlugin::HandleWindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) +{ + std::optional result = std::nullopt; + if (WM_FLUTTER_TASK != message) + { + return result; + } + flutter::EncodableMap arguments; + arguments[flutter::EncodableValue("textureId")] = flutter::EncodableValue((INT64)wParam); + arguments[flutter::EncodableValue("state")] = flutter::EncodableValue((int)lParam); + //call gMethodChannel->InvokeMethod() will crash.................. + gMethodChannel->InvokeMethod("OnPlaybackEvent", std::make_unique(arguments)); + // std::cout << "OnPlaybackEvent textureId: " << wParam << ", state: " << lParam << std::endl; + return 0; +} + +VideoPlayerWinPlugin::VideoPlayerWinPlugin(flutter::PluginRegistrarWindows *registrar) { + window_proc_id = registrar->RegisterTopLevelWindowProcDelegate( + [this](HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) + { + return HandleWindowProc(hWnd, message, wParam, lParam); + }); +} + +VideoPlayerWinPlugin::~VideoPlayerWinPlugin() { + std::cout << "[video_player_win][native] ~VideoPlayerWinPlugin() called\n"; + destroyAllPlayers(); + if(window_proc_id != -1) { + m_registrar->UnregisterTopLevelWindowProcDelegate(window_proc_id); + window_proc_id = -1; + } + texture_registar_ = NULL; + //MFShutdown(); +} + +void VideoPlayerWinPlugin::HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result) { + + if (method_call.method_name().compare("clearAll") == 0) { + // called when hot-restart in debug mode, and clear all the old players which created before hot-restart + destroyAllPlayers(); + result->Success(); + return; + } + + //std::cout << "HandleMethodCall: " << method_call.method_name() << std::endl; + flutter::EncodableMap arguments = std::get(*method_call.arguments()); + + auto textureId = arguments[flutter::EncodableValue("textureId")].LongValue(); + MyPlayerInternal* player; + bool isOpenVideo = method_call.method_name().compare("openVideo") == 0; + if (isOpenVideo) { + player = (MyPlayerInternal*) getPlayerById(-1, true); + } else { + player = (MyPlayerInternal*) getPlayerById(textureId, false); + } + if (player == nullptr) { + result->Success(); + return; + } + + if (isOpenVideo) { + std::vector headerLines; + auto httpHeaders = std::get(arguments[flutter::EncodableValue("httpHeaders")]); + for (auto it = httpHeaders.begin(); it != httpHeaders.end(); it++) { + auto key = std::get(it->first); + auto value = std::get(it->second); + auto line = toWideString(key) + L": " + toWideString(value); + headerLines.push_back(line); + } + + auto path = std::get(arguments[flutter::EncodableValue("path")]); + WCHAR wPath[1024]; + auto convResult = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, wPath, sizeof(wPath) / sizeof(WCHAR)); + if (convResult < 0) { + std::cout << "[video_player_win] native convert path to utf16 (WCHAR*) failed: path = " << path << std::endl; + } + + textureId = player->textureId; + std::shared_ptr> shared_result = std::move(result); + HWND hwnd = GetAncestor(m_nativeHWND, GA_ROOT); + HRESULT hr = player->OpenURL(wPath, player, hwnd, headerLines, [=](bool isSuccess) { + if (isSuccess) { + auto _player = (MyPlayerInternal*) getPlayerById(textureId, false); + if (_player == NULL) { + // the player is disposed between async OpenURL() and callback here + flutter::EncodableMap map; + map[flutter::EncodableValue("result")] = flutter::EncodableValue(false); + shared_result->Success(map); + return; + } + + SIZE videoSize = _player->GetVideoSize(); + flutter::EncodableMap map; + float volume = 1.0f; + _player->GetVolume(&volume); + map[flutter::EncodableValue("result")] = flutter::EncodableValue(true); + map[flutter::EncodableValue("textureId")] = flutter::EncodableValue(_player->textureId); + map[flutter::EncodableValue("duration")] = flutter::EncodableValue((int64_t)_player->GetDuration()); + map[flutter::EncodableValue("videoWidth")] = flutter::EncodableValue(videoSize.cx); + map[flutter::EncodableValue("videoHeight")] = flutter::EncodableValue(videoSize.cy); + map[flutter::EncodableValue("volume")] = flutter::EncodableValue((double)volume); + shared_result->Success(flutter::EncodableValue(map)); + } else { + // TODO: call destroyPlayerById(true) when open video failed here will crash since player->Release() called... how to fix? + destroyPlayerById(player->textureId, true); + + flutter::EncodableMap map; + map[flutter::EncodableValue("result")] = flutter::EncodableValue(false); + shared_result->Success(map); + } + }); + if (FAILED(hr)) { + flutter::EncodableMap map; + map[flutter::EncodableValue("result")] = flutter::EncodableValue(false); + result->Success(map); + } + } else if (method_call.method_name().compare("play") == 0) { + player->Play(); + result->Success(flutter::EncodableValue(true)); + } else if (method_call.method_name().compare("pause") == 0) { + player->Pause(); + result->Success(flutter::EncodableValue(true)); + } else if (method_call.method_name().compare("seekTo") == 0) { + auto ms = std::get(arguments[flutter::EncodableValue("ms")]); + player->Seek(ms); + result->Success(flutter::EncodableValue(true)); + } else if (method_call.method_name().compare("getCurrentPosition") == 0) { + long ms = (long) player->GetCurrentPosition(); + result->Success(flutter::EncodableValue(ms)); + } else if (method_call.method_name().compare("getDuration") == 0) { + long ms = (long) player->GetDuration(); + result->Success(flutter::EncodableValue(ms)); + } else if (method_call.method_name().compare("setPlaybackSpeed") == 0) { + double speed = std::get(arguments[flutter::EncodableValue("speed")]); + player->SetPlaybackSpeed((float)speed); + result->Success(flutter::EncodableValue(true)); + } else if (method_call.method_name().compare("setVolume") == 0) { + double volume = std::get(arguments[flutter::EncodableValue("volume")]); + player->SetVolume((float)volume); + result->Success(flutter::EncodableValue(true)); + } else if (method_call.method_name().compare("shutdown") == 0) { + // NOTE: because m_pSession->BeginGetEvent(this) will keep *this (player), + // so we need to call m_pSession->Shutdown() first + // then client call player->Release() will make refCount = 0 + player->Shutdown(); + result->Success(flutter::EncodableValue(true)); + } else if (method_call.method_name().compare("dispose") == 0) { + destroyPlayerById(textureId, true); + result->Success(flutter::EncodableValue(true)); + } else { + result->NotImplemented(); + } +} + +} // namespace video_player_win diff --git a/packages/video_player_win/windows/video_player_win_plugin.h b/packages/video_player_win/windows/video_player_win_plugin.h new file mode 100644 index 0000000..b09059d --- /dev/null +++ b/packages/video_player_win/windows/video_player_win_plugin.h @@ -0,0 +1,63 @@ +#ifndef FLUTTER_PLUGIN_VIDEO_PLAYER_WIN_PLUGIN_H_ +#define FLUTTER_PLUGIN_VIDEO_PLAYER_WIN_PLUGIN_H_ + +#include +#include + +#include + +// Jacky { +#include +#include +#include +#include "my_grabber_player.h" +// Jacky } + +namespace video_player_win { + +class VideoPlayerWinPlugin : public flutter::Plugin { + public: + static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar); + + VideoPlayerWinPlugin(flutter::PluginRegistrarWindows *registrar); + + virtual ~VideoPlayerWinPlugin(); + + // Disallow copy and assign. + VideoPlayerWinPlugin(const VideoPlayerWinPlugin&) = delete; + VideoPlayerWinPlugin& operator=(const VideoPlayerWinPlugin&) = delete; + + std::optional HandleWindowProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam); + + private: + // The ID of the WindowProc delegate registration. + int window_proc_id = -1; + + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall( + const flutter::MethodCall &method_call, + std::unique_ptr> result); + + + // Jacky { + + void createTexture(MyPlayer* data); + MyPlayer* getPlayerById(int64_t textureId, bool autoCreate = false); + void destroyPlayerById(int64_t textureId, bool toRelease); + void destroyAllPlayers(); + +public: + flutter::PluginRegistrarWindows *m_registrar; + std::unique_ptr> gMethodChannel; + flutter::TextureRegistrar* texture_registar_; + + HWND m_nativeHWND; + std::map playerMap; // textureId -> MyPlayerInternal* + std::mutex m_mapMutex; + IDXGIAdapter* m_dxgiAdapter; + // Jacky } +}; + +} // namespace video_player_win + +#endif // FLUTTER_PLUGIN_VIDEO_PLAYER_WIN_PLUGIN_H_ diff --git a/packages/video_player_win/windows/video_player_win_plugin_c_api.cpp b/packages/video_player_win/windows/video_player_win_plugin_c_api.cpp new file mode 100644 index 0000000..dce7457 --- /dev/null +++ b/packages/video_player_win/windows/video_player_win_plugin_c_api.cpp @@ -0,0 +1,12 @@ +#include "include/video_player_win/video_player_win_plugin_c_api.h" + +#include + +#include "video_player_win_plugin.h" + +void VideoPlayerWinPluginCApiRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + video_player_win::VideoPlayerWinPlugin::RegisterWithRegistrar( + flutter::PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/pubspec.lock b/pubspec.lock index 2b47e02..e5782cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,14 +105,6 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.2" - desktop_multi_window: - dependency: transitive - description: - name: desktop_multi_window - sha256: "60ba38725b8887b60e44d15afdcf0c3813568b5da2ccaf1e7f6fd09a380a6e24" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.3.0" device_info_plus: dependency: transitive description: @@ -259,6 +251,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_gpu: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: transitive description: @@ -307,6 +304,15 @@ packages: description: flutter source: sdk version: "0.0.0" + gcode_core: + dependency: transitive + description: + path: "." + ref: "v0.2.0-dev.1" + resolved-ref: "019678664bb5891c72f904373efad3aea8238fc6" + url: "https://github.com/lizy-coding/gcode_core.git" + source: git + version: "0.2.0-dev.1" glob: dependency: transitive description: @@ -396,10 +402,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -412,10 +418,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.flutter-io.cn" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -657,10 +663,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.11" + version: "0.7.12" two_dimensional_scrollables: dependency: transitive description: @@ -705,10 +711,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "2.4.2" video_player: dependency: transitive description: @@ -789,6 +795,46 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.1.0" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "4de8b3d1ff4ebe1bdb42e68a5e4f809194a3cb0117a8f495f590004f00da3964" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.14.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fe359c7fac1002124b5b9e2ba3a41906bbb9b2d029ccb4a0067404d8f3704730 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.26.1" + webview_windows: + dependency: transitive + description: + name: webview_windows + sha256: "47fcad5875a45db29dbb5c9e6709bf5c88dcc429049872701343f91ed7255730" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" win32: dependency: transitive description: @@ -831,4 +877,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.2" diff --git a/pubspec.yaml b/pubspec.yaml index 31735e7..6be7828 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,6 @@ environment: workspace: - apps/flutter_forge - - packages/gcode_core - - packages/flutter_study_learning - packages/file_picker_bridge - packages/flutter_ioc_core + - packages/desktop_multi_window diff --git a/tool/android_release_local.sh b/tool/android_release_local.sh new file mode 100755 index 0000000..47d4d9a --- /dev/null +++ b/tool/android_release_local.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bash tool/android_release_local.sh [--all-abis | --aab-only] + +By default, builds an Android App Bundle and an arm64-v8a APK. + --all-abis Build split APKs for every Flutter-supported Android ABI. + --aab-only Build only the Android App Bundle. +EOF +} + +apk_mode="arm64" +case "${1:-}" in + "") ;; + --all-abis) apk_mode="all" ;; + --aab-only) apk_mode="none" ;; + -h | --help) + usage + exit 0 + ;; + *) + usage >&2 + exit 64 + ;; +esac + +if [[ $# -gt 1 ]]; then + usage >&2 + exit 64 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +app_root="$repo_root/apps/flutter_forge" +sdk_root="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Library/Android/sdk}}" +output_root="${ANDROID_RELEASE_OUTPUT:-$app_root/build/android-release}" +keystore="$output_root/local-upload-keystore.jks" +store_password="${ANDROID_KEYSTORE_PASSWORD:-local-only-change-me}" +key_alias="${ANDROID_KEY_ALIAS:-local-upload}" +key_password="${ANDROID_KEY_PASSWORD:-$store_password}" + +android_studio_jdk="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +if [[ -x "$android_studio_jdk/bin/keytool" ]]; then + export JAVA_HOME="$android_studio_jdk" + export PATH="$JAVA_HOME/bin:$PATH" +fi + +mkdir -p "$output_root" +if [[ ! -f "$keystore" ]]; then + keytool -genkeypair -v \ + -keystore "$keystore" \ + -storetype JKS \ + -storepass "$store_password" \ + -keypass "$key_password" \ + -alias "$key_alias" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "CN=Flutter Forge Local,O=Flutter Forge,C=CN" +fi + +export ANDROID_KEYSTORE_PATH="$keystore" +export ANDROID_KEYSTORE_PASSWORD="$store_password" +export ANDROID_KEY_ALIAS="$key_alias" +export ANDROID_KEY_PASSWORD="$key_password" + +cd "$repo_root" +bash tool/quality_gate.sh + +cd "$app_root" +flutter build appbundle --release + +bundle="$app_root/build/app/outputs/bundle/release/app-release.aab" +apk_dir="$app_root/build/app/outputs/apk/release" +flutter_apk_dir="$app_root/build/app/outputs/flutter-apk" +apksigner="$sdk_root/build-tools/$(ls -1 "$sdk_root/build-tools" | sort -V | tail -1)/apksigner" +apk_names=( + app-arm64-v8a-release.apk + app-armeabi-v7a-release.apk + app-x86_64-release.apk +) +apks=() + +# Remove stale split APKs so the output directory reflects this invocation. +for apk_name in "${apk_names[@]}"; do + rm -f "$apk_dir/$apk_name" "$flutter_apk_dir/$apk_name" +done + +case "$apk_mode" in + arm64) + flutter build apk --release --target-platform android-arm64 --split-per-abi + apks=("$apk_dir/app-arm64-v8a-release.apk") + ;; + all) + flutter build apk --release --split-per-abi + for apk_name in "${apk_names[@]}"; do + apks+=("$apk_dir/$apk_name") + done + ;; + none) ;; +esac + +[[ -f "$bundle" ]] || { echo "Missing AAB: $bundle" >&2; exit 1; } + +if [[ ${#apks[@]} -gt 0 ]]; then + [[ -x "$apksigner" ]] || { echo "Missing apksigner: $apksigner" >&2; exit 1; } + for apk in "${apks[@]}"; do + [[ -f "$apk" ]] || { echo "Missing APK: $apk" >&2; exit 1; } + "$apksigner" verify --verbose "$apk" + done +fi +jarsigner -verify -verbose -certs "$bundle" >/dev/null + +{ + sha256sum "$bundle" + if [[ ${#apks[@]} -gt 0 ]]; then + sha256sum "${apks[@]}" + fi +} | tee "$output_root/SHA256SUMS" + +echo "Android local release validation passed" +echo "AAB: $bundle" +if [[ ${#apks[@]} -gt 0 ]]; then + printf 'APK: %s\n' "${apks[@]}" +else + echo "APK: skipped" +fi diff --git a/tool/check_generated_docs.sh b/tool/check_generated_docs.sh index 1de4fe8..042463e 100755 --- a/tool/check_generated_docs.sh +++ b/tool/check_generated_docs.sh @@ -19,9 +19,7 @@ echo "--- 2/3 检测漂移 ---" GENERATED_FILES=( "AI_PROJECT_CONTEXT.md" "REFACTOR_PLAN.md" - "lib/AI_MODULE_INDEX.md" - "packages/gcode_core/AI_ANALYSIS.md" - "packages/flutter_study_learning/AI_ANALYSIS.md" + "apps/flutter_forge/lib/AI_MODULE_INDEX.md" "packages/file_picker_bridge/AI_ANALYSIS.md" "packages/flutter_ioc_core/AI_ANALYSIS.md" ) diff --git a/tool/generate_agent_indexes.js b/tool/generate_agent_indexes.js index 2b09ed5..128054a 100644 --- a/tool/generate_agent_indexes.js +++ b/tool/generate_agent_indexes.js @@ -6,12 +6,8 @@ const appRoot = path.join(root, 'apps/flutter_forge'); const contracts = { no_natural_language: true, - index_only: true, - max_index_depth: 2, doc_consumer: 'coding_agent', doc_mode: 'machine_contract', - update_required_on_file_change: true, - import_direction_enforced: true, }; const modules = [ @@ -20,7 +16,7 @@ const modules = [ id: 'tree_state', route: '/tree-state', status: 'recommended', - depends: ['flutter_study_learning', 'module_registry', 'go_router'], + depends: ['shared_learning', 'module_registry', 'go_router'], title: '三棵树与生命周期', subtitle: '理解 Widget/Element/RenderObject 的关系与重建机制', difficulty: 'beginner', @@ -34,7 +30,7 @@ const modules = [ id: 'microtask', route: '/microtask', status: 'recommended', - depends: ['flutter_study_learning', 'module_registry', 'go_router'], + depends: ['shared_learning', 'module_registry', 'go_router'], title: '事件循环与微任务', subtitle: '掌握 Dart 事件循环中微任务队列与事件队列的执行顺序', difficulty: 'beginner', @@ -48,7 +44,7 @@ const modules = [ id: 'debounce_throttle', route: '/debounce-throttle', status: 'ready', - depends: ['flutter_study_learning', 'module_registry'], + depends: ['shared_learning', 'module_registry'], title: '防抖与节流', subtitle: '对比防抖和节流的执行时序,理解适用场景', difficulty: 'beginner', @@ -61,7 +57,7 @@ const modules = [ id: 'stream_subscription', route: '/stream-subscription', status: 'recommended', - depends: ['flutter_study_learning', 'module_registry', 'go_router'], + depends: ['shared_learning', 'module_registry', 'go_router'], title: 'Stream 订阅机制', subtitle: '学习单订阅流与广播流的区别及使用场景', difficulty: 'intermediate', @@ -75,7 +71,7 @@ const modules = [ id: 'isolate_basic', route: '/isolate-basic', status: 'ready', - depends: ['flutter_study_learning', 'module_registry', 'go_router'], + depends: ['shared_learning', 'module_registry', 'go_router'], title: 'Isolate 并发对比', subtitle: '对比主线程与 Isolate 执行耗时计算对 UI 流畅度的影响', difficulty: 'intermediate', @@ -89,7 +85,7 @@ const modules = [ id: 'isolate_task_manager', route: '/isolate-stream', status: 'ready', - depends: ['flutter_study_learning', 'module_registry'], + depends: ['shared_learning', 'module_registry'], title: '多任务 Isolate 管理器', subtitle: '使用 Isolate 并行处理多任务,通过 Stream 实时上报进度', difficulty: 'advanced', @@ -102,7 +98,7 @@ const modules = [ id: 'status_management', route: '/status-management', status: 'recommended', - depends: ['flutter_study_learning', 'provider', 'flutter_riverpod', 'flutter_bloc', 'module_registry', 'go_router'], + depends: ['shared_learning', 'provider', 'flutter_riverpod', 'flutter_bloc', 'module_registry', 'go_router'], title: '状态管理演进', subtitle: '串联 setState、Provider、Riverpod、Bloc,对比不同方案', difficulty: 'intermediate', @@ -117,7 +113,7 @@ const modules = [ id: 'flutter_ioc', route: '/flutter-ioc', status: 'ready', - depends: ['flutter_study_learning', 'flutter_ioc_core', 'provider', 'module_registry'], + depends: ['shared_learning', 'flutter_ioc_core', 'provider', 'module_registry'], title: 'Flutter IoC 容器', subtitle: '自研 IoC 容器实现,支持单例/瞬态/作用域生命周期', difficulty: 'advanced', @@ -130,7 +126,7 @@ const modules = [ id: 'local_persistence', route: '/local-persistence', status: 'ready', - depends: ['flutter_study_learning', 'shared_preferences', 'module_registry'], + depends: ['shared_learning', 'shared_preferences', 'module_registry'], title: '本地持久化', subtitle: '使用 shared_preferences 持久化设置项与计数器,理解异步读取与状态恢复', difficulty: 'intermediate', @@ -143,7 +139,9 @@ const modules = [ id: 'gcode_visualizer', route: '/gcode-visualizer', status: 'ready', - depends: ['flutter_study_learning', 'gcode_core', 'file_picker_bridge', 'module_registry'], + supportedPlatforms: ['macOS'], + supportedPlatformsComment: '// gcode_core v0.2.0-dev.1 validates macOS GPU rendering only.', + depends: ['shared_learning', 'gcode_core', 'file_picker_bridge', 'module_registry'], title: 'G-code 解析与轨迹动画', subtitle: '解析 G-code 指令,绘制刀路轨迹并用动画展示执行过程', difficulty: 'advanced', @@ -156,7 +154,7 @@ const modules = [ id: 'adsorption_line', route: '/adsorption-line', status: 'ready', - depends: ['flutter_study_learning', 'provider', 'module_registry'], + depends: ['shared_learning', 'provider', 'module_registry'], title: '智能吸附线画板', subtitle: '类似设计工具的对齐吸附功能,学习自定义绘制与手势', difficulty: 'advanced', @@ -169,7 +167,7 @@ const modules = [ id: 'download_animation', route: '/download-animation', status: 'ready', - depends: ['flutter_study_learning', 'module_registry', 'go_router'], + depends: ['shared_learning', 'module_registry', 'go_router'], title: '下载飞入动效', subtitle: '三种实现方式对比:Custom View / CustomPaint / Overlay', difficulty: 'intermediate', @@ -183,7 +181,7 @@ const modules = [ id: 'font_picker', route: '/font-picker', status: 'ready', - depends: ['flutter_study_learning', 'file_picker_bridge', 'module_registry', 'go_router'], + depends: ['shared_learning', 'file_picker_bridge', 'module_registry', 'go_router'], title: '字体选择器', subtitle: '命名列表中直观对比不同字体族与字重样式,并通过文件选择器加载本地字体', difficulty: 'intermediate', @@ -197,7 +195,7 @@ const modules = [ id: 'popup_widgets', route: '/popup-widgets', status: 'ready', - depends: ['flutter_study_learning', 'module_registry'], + depends: ['shared_learning', 'module_registry'], title: '弹窗合集', subtitle: '全面展示 Flutter 中的对话框、底部抽屉、菜单等弹窗类型', difficulty: 'beginner', @@ -210,7 +208,7 @@ const modules = [ id: 'popup_list_interaction', route: '/popup-list-interaction', status: 'ready', - depends: ['flutter_study_learning', 'module_registry', 'go_router'], + depends: ['shared_learning', 'module_registry', 'go_router'], title: '弹窗与列表交互', subtitle: 'Flutter 弹窗组件与二维滚动表格的综合演示', difficulty: 'beginner', @@ -224,7 +222,7 @@ const modules = [ id: 'scroll_table', route: '/scroll-table', status: 'ready', - depends: ['flutter_study_learning', 'two_dimensional_scrollables', 'module_registry'], + depends: ['shared_learning', 'two_dimensional_scrollables', 'module_registry'], title: '二维滚动表格', subtitle: '使用 two_dimensional_scrollables 实现固定表头的表格', difficulty: 'beginner', @@ -237,7 +235,7 @@ const modules = [ id: 'overlay_follow_compare', route: '/overlay-compare', status: 'ready', - depends: ['flutter_study_learning', 'module_registry'], + depends: ['shared_learning', 'module_registry'], title: 'Overlay 跟随方案对照组', subtitle: '对比 CompositedTransformFollower 与 markNeedsBuild 两种浮层跟随方案', difficulty: 'intermediate', @@ -250,7 +248,7 @@ const modules = [ id: 'dio_interceptor', route: '/dio-interceptor', status: 'ready', - depends: ['flutter_study_learning', 'dio', 'module_registry', 'go_router'], + depends: ['shared_learning', 'dio', 'module_registry', 'go_router'], title: 'Dio 拦截器链路', subtitle: 'Auth/Error/Retry/Log 拦截器 + 本地 Mock Server 实战', difficulty: 'intermediate', @@ -264,7 +262,7 @@ const modules = [ id: 'usb_detector', route: '/usb-detector', status: 'ready', - depends: ['flutter_study_learning', 'device_info_plus', 'module_registry'], + depends: ['shared_learning', 'device_info_plus', 'module_registry'], title: 'USB 设备检测', subtitle: 'Android USB 设备检测与状态监控', difficulty: 'intermediate', @@ -279,7 +277,7 @@ const modules = [ id: 'file_picker', route: '/file-picker', status: 'ready', - depends: ['flutter_study_learning', 'file_picker_bridge', 'module_registry'], + depends: ['shared_learning', 'file_picker_bridge', 'module_registry'], title: '文件选择器', subtitle: '复用 file_picker_bridge 中台能力,演示扩展过滤、取消分支与平台差异', difficulty: 'intermediate', @@ -293,7 +291,7 @@ const modules = [ id: 'online_video_player', route: '/online-video-player', status: 'ready', - depends: ['flutter_study_learning', 'dio', 'video_player', 'video_player_win', 'module_registry'], + depends: ['shared_learning', 'dio', 'video_player', 'video_player_win', 'module_registry'], title: '在线视频播放', subtitle: '使用 video_player 播放在线 HTTP 视频流并操控播放参数', difficulty: 'intermediate', @@ -302,6 +300,22 @@ const modules = [ entry: 'OnlineVideoPlayerEntry', supportedPlatforms: ['macOS', 'windows'], }, + { + category: 'platform', + id: 'webview', + route: '/webview', + status: 'ready', + depends: ['shared_learning', 'module_registry', 'webview_flutter', 'webview_windows'], + title: '网页容器与跨平台导航', + subtitle: '学习 Android、macOS、Windows 网页导航与生命周期', + difficulty: 'intermediate', + concepts: ['WebView', 'WebView2', '加载进度', '生命周期'], + estimatedMinutes: 30, + entry: 'WebViewEntry', + supportedPlatforms: ['android', 'macOS', 'windows'], + supportedPlatformsComment: '// Android/macOS use webview_flutter; Windows uses WebView2.', + }, + ]; const categoryComments = { @@ -355,12 +369,12 @@ const routeTableImportOrder = [ ]; const categoryMeta = { - basic: [['tree_state', 'microtask', 'debounce_throttle'], ['basic_mechanisms'], ['module_registry', 'flutter_study_learning']], - async: [['stream_subscription', 'isolate_basic', 'isolate_task_manager'], ['async_concurrency'], ['module_registry', 'flutter_study_learning']], + basic: [['tree_state', 'microtask', 'debounce_throttle'], ['basic_mechanisms'], ['module_registry', 'shared_learning']], + async: [['stream_subscription', 'isolate_basic', 'isolate_task_manager'], ['async_concurrency'], ['module_registry', 'shared_learning']], state: [['status_management', 'flutter_ioc', 'local_persistence'], ['state_management'], ['provider', 'flutter_riverpod', 'flutter_bloc', 'flutter_ioc_core', 'shared_preferences']], - ui: [['gcode_visualizer', 'adsorption_line', 'download_animation', 'font_picker'], ['ui_animation_custom_paint'], ['provider', 'gcode_core', 'file_picker_bridge', 'flutter_study_learning', 'module_registry']], - popup_table: [['popup_widgets', 'popup_list_interaction', 'scroll_table', 'overlay_follow_compare'], ['popup_overlay_table'], ['module_registry', 'flutter_study_learning', 'two_dimensional_scrollables']], - platform: [['dio_interceptor', 'usb_detector', 'file_picker', 'online_video_player'], ['network_platform'], ['dio', 'usb_serial', 'device_info_plus', 'video_player', 'video_player_win', 'flutter_study_learning', 'file_picker_bridge']], + ui: [['gcode_visualizer', 'adsorption_line', 'download_animation', 'font_picker'], ['ui_animation_custom_paint'], ['provider', 'gcode_core', 'file_picker_bridge', 'shared_learning', 'module_registry']], + popup_table: [['popup_widgets', 'popup_list_interaction', 'scroll_table', 'overlay_follow_compare'], ['popup_overlay_table'], ['module_registry', 'shared_learning', 'two_dimensional_scrollables']], + platform: [['dio_interceptor', 'usb_detector', 'file_picker', 'online_video_player', 'webview'], ['network_platform'], ['dio', 'device_info_plus', 'video_player', 'video_player_win', 'shared_learning', 'file_picker_bridge', 'webview_flutter', 'webview_windows']], }; const flutterGuardDependency = { @@ -373,26 +387,6 @@ const flutterGuardDependency = { }; const workspacePackages = [ - { - name: 'gcode_core', - kind: 'flutter_package', - path: 'packages/gcode_core', - entrypoints: ['lib/gcode_core.dart'], - owns: ['gcode_parsing', 'line_reading', 'toolpath_building', 'flutter_visualization_widgets'], - depends: ['flutter_sdk'], - validation: ['flutter pub get', 'flutter analyze', 'flutter test'], - test_status: 'configured', - }, - { - name: 'flutter_study_learning', - kind: 'flutter_package', - path: 'packages/flutter_study_learning', - entrypoints: ['lib/flutter_study_learning.dart'], - owns: ['learning_scaffold_widgets', 'teaching_ui_components'], - depends: ['flutter_sdk'], - validation: ['flutter pub get', 'flutter analyze', 'flutter test'], - test_status: 'configured', - }, { name: 'file_picker_bridge', kind: 'flutter_bridge_package', @@ -496,8 +490,6 @@ function writeSchema() { node_required_keys: ['id', 'kind', 'package', 'path', 'status'], contracts_required: { no_natural_language: true, - index_only: true, - max_index_depth: 2, doc_consumer: 'coding_agent', doc_mode: 'machine_contract', }, @@ -548,6 +540,7 @@ function writeProjectContext() { path: packagePath, entrypoint: entrypoints[0], })), + external_packages: [{ name: 'gcode_core', source: 'git', url: 'https://github.com/lizy-coding/gcode_core.git', ref: 'v0.2.0-dev.1', entrypoint: 'lib/gcode_core.dart', flutter_min: '3.47.2', supported_platforms: ['macOS'], requires: ['impeller', 'flutter_gpu'], macos_deployment_target_min: '12.0' }], external_tools: [flutterGuardDependency], layers: [ { @@ -579,7 +572,7 @@ function writeProjectContext() { required_files: ['module_entry.dart', 'AI_ANALYSIS.md'], required_registration: 'lib/app/router/app_route_table.dart', required_metadata: ['category', 'difficulty', 'concepts', 'estimatedMinutes', 'status', 'subtitle'], - required_learning_dependency: 'flutter_study_learning', + required_learning_dependency: 'shared_learning', route_path_style: 'kebab_case', directory_style: 'snake_case', }, @@ -633,6 +626,8 @@ function writeRefactorPlan() { 'host_bootstrap_boundary', 'workspace_package_import', 'agent_takeover_ready', + 'pc_window_lifecycle_baseline', + 'pc_build_matrix', ], dependency_migration: { layout: 'pub_workspace', @@ -659,9 +654,15 @@ function writeRefactorPlan() { { id: 'platform_plugin_audit', priority: 5, - status: 'pending', + status: 'completed', targets: ['desktop_multi_window', 'file_picker_bridge', 'usb_android_method_channel', 'device_info_plus'], - acceptance: ['android_support_matrix', 'unsupported_fallbacks'], + acceptance: ['android_support_matrix', 'unsupported_fallbacks', 'android_file_selector_mapping'], + evidence: [ + 'desktop_multi_window is gated out of Android navigation', + 'file_picker_bridge selects file_selector on Android', + 'usb_detector uses the Android usb_detector/usb MethodChannel', + 'device_info_plus is registered in GeneratedPluginRegistrant.java', + ], }, { id: 'usb_platform_boundary', @@ -681,21 +682,87 @@ function writeRefactorPlan() { { id: 'android_host', priority: 8, - status: 'blocked_by_dependencies', - depends_on: ['module_platform_contract', 'platform_plugin_audit', 'mobile_layout_baseline'], - acceptance: ['android_directory', 'manifest_capabilities', 'debug_apk', 'emulator_smoke'], + status: 'completed', + depends_on: ['module_platform_contract', 'platform_plugin_audit'], + acceptance: ['android_directory', 'manifest_capabilities', 'debug_apk', 'emulator_smoke', 'single_window_navigation'], + evidence: [ + 'Android host directory and USB host manifest feature exist', + 'debug APK builds and installs on API 35 emulator', + 'MainActivity reaches Fully drawn with a live process and no fatal log', + 'singleTop Activity and in-app NavigationPolicy keep Android single-window behavior', + ], + }, + { + id: 'android_compatibility_plan', + priority: 9, + status: 'planned', + depends_on: ['platform_plugin_audit', 'usb_platform_boundary', 'mobile_layout_baseline', 'android_host'], + phases: [ + 'android_host_and_manifest', + 'platform_capability_fallbacks', + 'mobile_navigation_and_layout', + 'module_matrix_and_unavailable_states', + 'emulator_smoke_and_release_candidate', + ], + acceptance: [ + 'flutter_build_apk_debug', + 'android_emulator_smoke', + 'single_window_in_app_navigation', + 'unsupported_capability_state_visible', + 'no_android_analyzer_or_test_regressions', + ], + }, + { + id: 'android_usb_permission_boundary', + priority: 10, + status: 'completed', + depends_on: ['android_host'], + targets: [ + 'apps/flutter_forge/android/app/src/main/kotlin', + 'apps/flutter_forge/android/app/src/main/AndroidManifest.xml', + 'apps/flutter_forge/lib/modules/platform/usb_detector', + 'apps/flutter_forge/test/modules/platform/usb_detector', + ], + acceptance: [ + 'usb_permission_denied_is_observable', + 'device_enumeration_falls_back_without_crash', + 'android_usb_channel_contract_tested', + ], + evidence: [ + 'current Android MainActivity reports permission-safe USB enumeration', + 'USB service preserves devices when optional fields are unavailable', + 'Android USB service tests pass and APK builds successfully', + ], + }, + { + id: 'module_scaffold_generation', + priority: 11, + status: 'completed', + targets: ['tool/module_scaffold.dart', 'tool/module_scaffold_test.dart'], + acceptance: [ + 'preview_does_not_write_formal_module', + 'apply_generates_module_entry_and_learning_page', + 'generated_analysis_contract_is_valid', + 'invalid_module_arguments_fail_with_usage_code', + 'route_registration_remains_explicit', + ], + evidence: [ + 'module_scaffold_test passes preview/apply and contract assertions', + 'dart analyze passes for scaffold CLI and acceptance test', + 'route registration remains outside scaffold automatic writes', + ], }, { id: 'pc_window_lifecycle_baseline', priority: 3, - status: 'pending', + status: 'completed', targets: ['desktop_multi_window', 'lib/shared/multi_window', 'lib/app/category_navigation'], acceptance: ['three_category_windows', 'close_reopen', 'no_black_surface', 'no_invalid_engine_handle'], }, { id: 'pc_build_matrix', priority: 4, - status: 'blocked_by_host', + status: 'completed', targets: ['macos', 'windows'], acceptance: ['macos_release_build', 'windows_release_build', 'pc_quality_gate'], }, @@ -809,7 +876,13 @@ function writeRouteTable() { if (m.supportedPlatformsComment) { lines.push(` ${m.supportedPlatformsComment}`); } - lines.push(` supportedPlatforms: {${m.supportedPlatforms.map((platform) => `TargetPlatform.${platform}`).join(', ')}},`); + const platforms = m.supportedPlatforms.map((platform) => `TargetPlatform.${platform}`); + const inlinePlatforms = ` supportedPlatforms: {${platforms.join(', ')}},`; + if (inlinePlatforms.length <= 80) { + lines.push(inlinePlatforms); + } else { + lines.push(' supportedPlatforms: {', ...platforms.map((platform) => ` ${platform},`), ' },'); + } } lines.push(` builder: (context) => const ${m.entry}(),`); if (m.subRoutesExpander) { @@ -852,8 +925,8 @@ function writeRootIndexes() { entrypoints: ['lib/main.dart', 'lib/app/app_bootstrap.dart', 'lib/app/app.dart', 'lib/app/router/app_route_table.dart'], owns: ['app_shell', 'module_registry', 'shared_capabilities', 'learning_modules', 'host_integrations'], depends: [ - 'packages/gcode_core', - 'packages/flutter_study_learning', + 'git:https://github.com/lizy-coding/gcode_core.git#v0.2.0-dev.1', + 'packages/shared_learning', 'packages/file_picker_bridge', 'packages/flutter_ioc_core', `git:${flutterGuardDependency.url}#${flutterGuardDependency.ref}`, @@ -941,7 +1014,7 @@ function writeModuleIndexes() { kind: 'modules_index', entrypoints: ['basic', 'async', 'state', 'ui', 'popup_table', 'platform'], owns: ['learning_module_categories', 'route_registered_modules'], - depends: ['module_registry', 'flutter_study_learning'], + depends: ['module_registry', 'shared_learning'], children: ['basic/AI_ANALYSIS.md', 'async/AI_ANALYSIS.md', 'state/AI_ANALYSIS.md', 'ui/AI_ANALYSIS.md', 'popup_table/AI_ANALYSIS.md', 'platform/AI_ANALYSIS.md'], }); for (const [category, [children, owns, depends]] of Object.entries(categoryMeta)) { diff --git a/tool/module_scaffold.dart b/tool/module_scaffold.dart new file mode 100644 index 0000000..37c923b --- /dev/null +++ b/tool/module_scaffold.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; +import 'dart:io'; + +const _categories = {'basic', 'async', 'state', 'ui', 'popupTable', 'platform'}; +const _difficulties = {'beginner', 'intermediate', 'advanced'}; +const _platforms = {'android', 'iOS', 'macOS', 'windows', 'linux', 'fuchsia'}; + +Future main(List args) async { + try { + final options = _parse(args); + if (options.containsKey('help')) { + stdout.write(_usage); + return; + } + final spec = _spec(options); + final files = _files(spec); + final output = Directory( + options['output'] ?? 'build/module_scaffold/${spec['name']}', + ); + + if (options['apply'] != 'true') { + stdout.writeln( + jsonEncode({ + 'mode': 'preview', + 'output': output.path, + 'files': files.keys.toList(), + }), + ); + return; + } + if (output.existsSync() && options['force'] != 'true') { + throw ArgumentError( + 'output exists; pass --force to replace it: ${output.path}', + ); + } + for (final entry in files.entries) { + final file = File('${output.path}/${entry.key}') + ..createSync(recursive: true); + file.writeAsStringSync(entry.value); + } + stdout.writeln( + jsonEncode({ + 'mode': 'applied', + 'output': output.path, + 'files': files.keys.toList(), + }), + ); + } on ArgumentError catch (error) { + stderr.writeln('module_scaffold: ${error.message}'); + stderr.writeln(_usage); + exitCode = 64; + } +} + +Map _parse(List args) { + final result = {}; + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--help' || arg == '-h') { + result['help'] = 'true'; + } else if (arg == '--apply' || arg == '--force') { + result[arg.substring(2)] = 'true'; + } else if (arg.startsWith('--') && i + 1 < args.length) { + result[arg.substring(2)] = args[++i]; + } else { + throw ArgumentError('unknown or incomplete argument: $arg'); + } + } + return result; +} + +Map _spec(Map options) { + String required(String name) { + final value = options[name]; + if (value == null || value.trim().isEmpty) { + throw ArgumentError('--$name is required'); + } + return value.trim(); + } + + final category = required('category'); + final name = required('name'); + final title = required('title'); + final subtitle = required('subtitle'); + final difficulty = options['difficulty'] ?? 'beginner'; + final minutes = int.tryParse(options['minutes'] ?? '15'); + if (!_categories.contains(category)) + throw ArgumentError('invalid category: $category'); + if (!RegExp(r'^[a-z][a-z0-9_]*$').hasMatch(name)) + throw ArgumentError('name must be snake_case: $name'); + if (!_difficulties.contains(difficulty)) + throw ArgumentError('invalid difficulty: $difficulty'); + if (minutes == null || minutes <= 0) + throw ArgumentError('minutes must be a positive integer'); + final platforms = (options['platforms'] ?? '') + .split(',') + .where((item) => item.isNotEmpty) + .toSet(); + if (platforms.any((platform) => !_platforms.contains(platform))) + throw ArgumentError('invalid platform in: $platforms'); + final concepts = (options['concepts'] ?? 'Flutter,实践') + .split(',') + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(); + if (concepts.isEmpty) throw ArgumentError('concepts must not be empty'); + return { + 'category': category, + 'name': name, + 'title': title, + 'subtitle': subtitle, + 'difficulty': difficulty, + 'minutes': '$minutes', + 'concepts': jsonEncode(concepts), + 'platforms': jsonEncode(platforms.toList()), + }; +} + +Map _files(Map spec) { + final platforms = (jsonDecode(spec['platforms']!) as List).cast(); + final concepts = (jsonDecode(spec['concepts']!) as List) + .map((item) => "'$item'") + .join(', '); + final root = 'lib/modules/${spec['category']}/${spec['name']}'; + final testRoot = 'test/modules/${spec['category']}/${spec['name']}'; + final className = _className(spec['name']!); + return { + '$root/module_entry.dart': + '''import 'package:flutter/material.dart'; + +import 'module_root.dart'; + +class ${_className(spec['name']!)}Entry extends StatelessWidget { + const ${_className(spec['name']!)}Entry({super.key}); + + @override + Widget build(BuildContext context) => const ${_className(spec['name']!)}Page(); +} +''', + '$root/module_root.dart': + '''import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/shared/learning/learning_scaffold.dart'; + +class ${_className(spec['name']!)}Page extends StatelessWidget { + const ${_className(spec['name']!)}Page({super.key}); + + @override + Widget build(BuildContext context) { + return const LearningScaffold( + title: '${spec['title']}', + interactiveDemo: _InteractiveDemo(), + sections: [ + LearningObjectives(objectives: ['理解 ${spec['title']} 的核心机制']), + ConceptChips(concepts: [$concepts]), + ExerciseCard(task: '补充一个可验证的交互练习。'), + ], + ); + } +} + +class _InteractiveDemo extends StatelessWidget { + const _InteractiveDemo(); + + @override + Widget build(BuildContext context) => const SizedBox( + height: 160, + child: Center(child: Text('在这里实现 ${spec['title']} 的交互演示')), + ); +} +''', + '$testRoot/${spec['name']}_test.dart': + '''import 'package:flutter/material.dart'; +import 'package:flutter_forge_app/modules/${spec['category']}/${spec['name']}/module_entry.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('${spec['title']} renders in a compact viewport', (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(const MaterialApp(home: ${className}Entry())); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }); +} +''', + '$root/AI_ANALYSIS.md': jsonEncode({ + 'schema': 'vibecoding.harness.ai_analysis.v2', + 'mode': 'module_contract', + 'node': { + 'id': 'flutter_forge_app.modules.${spec['category']}.${spec['name']}', + 'kind': 'learning_module', + 'package': 'flutter_forge_app', + 'path': root, + 'status': 'pending', + }, + 'route': '/${spec['name']!.replaceAll('_', '-')}', + 'category': spec['category'], + 'entrypoints': ['module_entry.dart', 'module_root.dart'], + 'owns': ['module_entry', 'module_ui', 'module_docs'], + 'depends': ['shared_learning', 'module_registry'], + 'children': [], + 'analysis_parent': 'lib/modules/AI_ANALYSIS.md', + 'contracts': { + 'no_natural_language': true, + 'index_only': true, + 'max_index_depth': 2, + 'doc_consumer': 'coding_agent', + 'doc_mode': 'machine_contract', + 'update_required_on_file_change': true, + 'import_direction_enforced': true, + }, + 'validation': ['flutter analyze', 'flutter test'], + }), + 'module_spec.json': jsonEncode({ + 'category': spec['category'], + 'name': spec['name'], + 'title': spec['title'], + 'subtitle': spec['subtitle'], + 'difficulty': spec['difficulty'], + 'estimatedMinutes': int.parse(spec['minutes']!), + 'concepts': jsonDecode(spec['concepts']!), + 'supportedPlatforms': platforms, + 'routeRegistration': 'explicit_review_required', + }), + }; +} + +String _className(String value) => value + .split('_') + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(); + +const _usage = '''Usage: dart run tool/module_scaffold.dart [options] + +Required: --category --name --title --subtitle +Optional: --difficulty beginner|intermediate|advanced --minutes 15 + --concepts a,b,c --platforms android,iOS,macOS,windows + --output path --apply --force + +Default mode is preview. --apply writes the candidate files and never edits +the route table; route registration remains an Agent Hub frozen-task decision. +'''; diff --git a/tool/module_scaffold_test.dart b/tool/module_scaffold_test.dart new file mode 100644 index 0000000..c6a58e6 --- /dev/null +++ b/tool/module_scaffold_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; +import 'dart:io'; + +Future main() async { + final temp = await Directory.systemTemp.createTemp( + 'flutterforge_scaffold_test_', + ); + try { + final output = Directory('${temp.path}/module'); + final args = [ + 'run', + 'tool/module_scaffold.dart', + '--category', + 'async', + '--name', + 'cancellation_timeout', + '--title', + '任务取消与超时', + '--subtitle', + '学习异步任务控制', + '--concepts', + 'Future,取消,超时', + '--platforms', + 'android,macOS', + '--output', + output.path, + '--apply', + ]; + + final result = await Process.run(Platform.resolvedExecutable, args); + _check( + result.exitCode == 0, + 'apply exited ${result.exitCode}: ${result.stderr}', + ); + final files = [ + 'lib/modules/async/cancellation_timeout/module_entry.dart', + 'lib/modules/async/cancellation_timeout/module_root.dart', + 'lib/modules/async/cancellation_timeout/AI_ANALYSIS.md', + 'test/modules/async/cancellation_timeout/cancellation_timeout_test.dart', + 'module_spec.json', + ]; + for (final relative in files) { + _check( + File('${output.path}/$relative').existsSync(), + 'missing $relative', + ); + } + final contract = + jsonDecode( + File( + '${output.path}/lib/modules/async/cancellation_timeout/AI_ANALYSIS.md', + ).readAsStringSync(), + ) + as Map; + _check(contract['mode'] == 'module_contract', 'invalid module contract'); + _check(contract['route'] == '/cancellation-timeout', 'invalid route'); + _check( + File( + '${output.path}/lib/modules/async/cancellation_timeout/module_root.dart', + ).readAsStringSync().contains('LearningScaffold'), + 'teaching scaffold missing', + ); + final generatedTest = File( + '${output.path}/test/modules/async/cancellation_timeout/cancellation_timeout_test.dart', + ).readAsStringSync(); + _check(generatedTest.contains('setSurfaceSize'), 'compact test missing'); + final spec = + jsonDecode(File('${output.path}/module_spec.json').readAsStringSync()) + as Map; + _check(spec['estimatedMinutes'] == 15, 'minutes must be numeric'); + _check(spec['concepts'] is List, 'concepts must be an array'); + + final previewOutput = Directory('${temp.path}/preview'); + final previewArgs = args + .where((arg) => arg != '--apply') + .map((arg) => arg == output.path ? previewOutput.path : arg) + .toList(); + final preview = await Process.run(Platform.resolvedExecutable, previewArgs); + _check(preview.exitCode == 0, 'preview exited ${preview.exitCode}'); + _check( + !Directory('${previewOutput.path}/lib').existsSync(), + 'preview wrote files', + ); + stdout.writeln('module_scaffold_test: PASS'); + } finally { + await temp.delete(recursive: true); + } +} + +void _check(bool condition, String message) { + if (!condition) throw StateError(message); +} diff --git a/tool/test_agent_tools.sh b/tool/test_agent_tools.sh index de59a4f..b2cc9a9 100755 --- a/tool/test_agent_tools.sh +++ b/tool/test_agent_tools.sh @@ -32,7 +32,7 @@ run_test "generator runs" bash tool/generate_harness_ai_analysis.sh run_test "generator deterministic (2nd run)" bash -c 'bash tool/generate_harness_ai_analysis.sh >/dev/null 2>&1' run_test "AI_PROJECT_CONTEXT valid JSON" node -e "JSON.parse(require('fs').readFileSync('AI_PROJECT_CONTEXT.md','utf8'))" run_test "REFACTOR_PLAN valid JSON" node -e "JSON.parse(require('fs').readFileSync('REFACTOR_PLAN.md','utf8'))" -run_test "AI_MODULE_INDEX valid JSON" node -e "JSON.parse(require('fs').readFileSync('lib/AI_MODULE_INDEX.md','utf8'))" +run_test "AI_MODULE_INDEX valid JSON" node -e "JSON.parse(require('fs').readFileSync('apps/flutter_forge/lib/AI_MODULE_INDEX.md','utf8'))" run_test "AI_ANALYSIS_SCHEMA valid JSON" node -e "JSON.parse(require('fs').readFileSync('AI_ANALYSIS_SCHEMA.json','utf8'))" echo "" @@ -46,15 +46,15 @@ run_test "validator detects JSON error" bash -c ' rm -f tool/.test-validator.js tool/.tmp_bad_schema.json ' run_test "validator detects unregistered module" bash -c ' - mkdir -p lib/modules/basic/__unregistered_test_xyz__ - touch lib/modules/basic/__unregistered_test_xyz__/module_entry.dart + mkdir -p apps/flutter_forge/lib/modules/basic/__unregistered_test_xyz__ + touch apps/flutter_forge/lib/modules/basic/__unregistered_test_xyz__/module_entry.dart node tool/validate_agent_docs.js 2>&1 | grep -q "unregistered_module" || exit 1 - rm -rf lib/modules/basic/__unregistered_test_xyz__ + rm -rf apps/flutter_forge/lib/modules/basic/__unregistered_test_xyz__ ' echo "" echo "--- Workspace Packages ---" -for pkg in gcode_core flutter_study_learning file_picker_bridge flutter_ioc_core; do +for pkg in file_picker_bridge flutter_ioc_core; do run_test "${pkg} contract valid" node -e "JSON.parse(require('fs').readFileSync('packages/${pkg}/AI_ANALYSIS.md','utf8'))" run_test "${pkg} manifest has workspace resolution" grep -q 'resolution: workspace' "packages/${pkg}/pubspec.yaml" done diff --git a/tool/test_all.sh b/tool/test_all.sh index 87d960f..8f3e829 100755 --- a/tool/test_all.sh +++ b/tool/test_all.sh @@ -43,8 +43,6 @@ echo "" run_tests "apps/flutter_forge" "flutter_forge_app" "flutter test" # Workspace packages -run_tests "packages/gcode_core" "gcode_core" "flutter test" -run_tests "packages/flutter_study_learning" "flutter_study_learning" "flutter test" run_tests "packages/file_picker_bridge" "file_picker_bridge" "flutter test" run_tests "packages/flutter_ioc_core" "flutter_ioc_core" "flutter test" diff --git a/tool/validate_agent_docs.js b/tool/validate_agent_docs.js index 0d71c6c..c29b24c 100644 --- a/tool/validate_agent_docs.js +++ b/tool/validate_agent_docs.js @@ -8,8 +8,6 @@ const documents = new Map(); const VALID_CATEGORIES = ['basic', 'async', 'state', 'ui', 'popup_table', 'platform']; const workspacePackages = [ - ['gcode_core', 'packages/gcode_core'], - ['flutter_study_learning', 'packages/flutter_study_learning'], ['file_picker_bridge', 'packages/file_picker_bridge'], ['flutter_ioc_core', 'packages/flutter_ioc_core'], ]; @@ -205,7 +203,7 @@ if (moduleIndex) { failures.push(`${mod.path}:missing_module_entry`); } - // Check at least one .dart file in the module imports flutter_study_learning + // Check at least one .dart file in the module imports the shared learning UI. const modDir = resolveProjectPath(mod.path); if (fs.existsSync(modDir)) { let hasTeachingDep = false; @@ -217,7 +215,7 @@ if (moduleIndex) { walk(abs); } else if (entry.name.endsWith('.dart')) { const content = fs.readFileSync(abs, 'utf8'); - if (/flutter_study_learning/.test(content)) { + if (/shared\/learning\/learning_scaffold/.test(content)) { hasTeachingDep = true; } } @@ -225,7 +223,7 @@ if (moduleIndex) { } walk(modDir); if (!hasTeachingDep) { - failures.push(`${mod.path}:missing_teaching_dependency — no file imports flutter_study_learning`); + failures.push(`${mod.path}:missing_teaching_dependency — no file imports shared learning UI`); } } } diff --git a/tool/windows_acceptance.ps1 b/tool/windows_acceptance.ps1 new file mode 100644 index 0000000..8250542 --- /dev/null +++ b/tool/windows_acceptance.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Stop' + +# Run from the repository root on Windows. The patched Flutter tool snapshot +# may be supplied by the caller; otherwise use the installed Flutter tool. +$appDir = Join-Path $PSScriptRoot '..\apps\flutter_forge' +$generator = 'NMake Makefiles' +$env:FLUTTER_WINDOWS_CMAKE_GENERATOR = $generator +$env:CMAKE_GENERATOR = $generator + +function Invoke-Flutter { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) + $snapshot = $env:FLUTTER_TOOL_SNAPSHOT + if ($snapshot -and (Test-Path -LiteralPath $snapshot)) { + $flutterRoot = Split-Path (Split-Path $snapshot -Parent) -Parent + $packages = Join-Path $flutterRoot 'packages\flutter_tools\.dart_tool\package_config.json' + & dart --packages=$packages $snapshot @Arguments + } else { + & flutter @Arguments + } + if ($LASTEXITCODE -ne 0) { throw "Flutter command failed with exit code $LASTEXITCODE" } +} + +# Do not terminate processes by executable name: they may belong to another +# checkout or to the user. This harness does not start a child process that it +# needs to clean up, so no process termination is required here. +$existingProcesses = Get-Process -Name 'flutter','dart','flutter_tester' -ErrorAction SilentlyContinue | + Select-Object Id, ProcessName, StartTime +if ($existingProcesses) { + Write-Host 'Existing Flutter-related processes (left untouched):' + $existingProcesses | Format-Table -AutoSize | Out-String | Write-Host +} else { + Write-Host 'No existing Flutter-related processes found.' +} + +Push-Location $appDir +try { + Invoke-Flutter clean + Invoke-Flutter pub get + Invoke-Flutter build windows --release + Invoke-Flutter test integration_test/windows_acceptance_test.dart -d windows +} +finally { + Pop-Location +}