From 09e3c204b9888fda670c8f8d25bd30e52df36af7 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 11 Oct 2026 18:30:31 -0700 Subject: [PATCH 1/7] Release v0.3.1: recover Google authorization and build official Snaps locally --- .github/workflows/flutter-linux.yml | 163 +----- .gitignore | 3 + android/app/build.gradle.kts | 2 +- docs/android_release.md | 1 + docs/beta_snap_release.md | 330 +++++------- docs/desktop_oauth_release_checklist.md | 99 ++-- docs/development.md | 72 +-- docs/snap_runtime_libraries.md | 54 ++ lib/l10n/app_ar.arb | 16 +- lib/l10n/app_de.arb | 16 +- lib/l10n/app_en.arb | 16 +- lib/l10n/app_es.arb | 16 +- lib/l10n/app_et.arb | 16 +- lib/l10n/app_fa.arb | 16 +- lib/l10n/app_fi.arb | 16 +- lib/l10n/app_fr.arb | 16 +- lib/l10n/app_hi.arb | 16 +- lib/l10n/app_it.arb | 16 +- lib/l10n/app_ja.arb | 16 +- lib/l10n/app_ko.arb | 16 +- lib/l10n/app_pl.arb | 16 +- lib/l10n/app_pt_PT.arb | 16 +- lib/l10n/app_ru.arb | 16 +- lib/l10n/app_vi.arb | 16 +- lib/l10n/app_zh.arb | 16 +- lib/l10n/app_zh_Hans.arb | 16 +- lib/l10n/app_zh_Hant.arb | 16 +- lib/l10n/generated/app_localizations.dart | 42 ++ lib/l10n/generated/app_localizations_ar.dart | 26 + lib/l10n/generated/app_localizations_de.dart | 27 + lib/l10n/generated/app_localizations_en.dart | 26 + lib/l10n/generated/app_localizations_es.dart | 26 + lib/l10n/generated/app_localizations_et.dart | 26 + lib/l10n/generated/app_localizations_fa.dart | 26 + lib/l10n/generated/app_localizations_fi.dart | 26 + lib/l10n/generated/app_localizations_fr.dart | 27 + lib/l10n/generated/app_localizations_hi.dart | 26 + lib/l10n/generated/app_localizations_it.dart | 26 + lib/l10n/generated/app_localizations_ja.dart | 25 + lib/l10n/generated/app_localizations_ko.dart | 25 + lib/l10n/generated/app_localizations_pl.dart | 26 + lib/l10n/generated/app_localizations_pt.dart | 52 ++ lib/l10n/generated/app_localizations_ru.dart | 27 + lib/l10n/generated/app_localizations_vi.dart | 26 + lib/l10n/generated/app_localizations_zh.dart | 72 +++ lib/src/app/app_bootstrap.dart | 12 + lib/src/app/app_router.dart | 1 + lib/src/app/busymax_app.dart | 20 + lib/src/app/windows/windows_busymax_app.dart | 17 +- lib/src/core/auth/oauth_models.dart | 56 ++- .../core/http/request_dispatch_exception.dart | 2 + .../features/auth/data/auth_repository.dart | 8 +- .../registration_setup_dialog.dart | 26 +- .../desktop_notification_service.dart | 38 +- .../presentation/schedule_sidebar.dart | 67 +-- .../presentation/schedule_workspace.dart | 22 +- .../presentation/settings_screen.dart | 129 ++++- .../sync/account_recovery_feedback.dart | 61 +++ lib/src/features/sync/sync_auth_error.dart | 90 +++- lib/src/features/sync/sync_engine.dart | 6 +- .../sync_failure_notification_policy.dart | 15 +- lib/src/google_tasks/oauth/oauth_service.dart | 57 ++- lib/src/l10n/oauth_error_description.dart | 40 ++ lib/src/platform/common/desktop_services.dart | 25 +- .../windows_registration_setup_dialog.dart | 26 +- lib/src/ui/windows/windows_settings_page.dart | 98 +++- linux/io.busystack.busymax.metainfo.xml | 3 + pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- test/app/caldav_release_metadata_test.dart | 8 +- .../auth/authorization_transition_test.dart | 232 ++++++++- .../registration_setup_controller_test.dart | 23 + .../auth/presentation/auth_routing_test.dart | 184 ++++++- .../registration_setup_dialog_test.dart | 43 +- .../desktop_notification_service_test.dart | 35 ++ ...edule_sidebar_collection_actions_test.dart | 10 +- .../presentation/settings_screen_test.dart | 76 ++- ...sync_failure_notification_policy_test.dart | 121 +++++ .../oauth/token_exchange_test.dart | 2 +- test/support/desktop_connection_fixture.dart | 21 +- test/support/desktop_registration_config.dart | 5 +- test/tool/flutter_linux_workflow_test.dart | 475 +++++------------- test/tool/local_snap_release_test.py | 256 ++++++++++ tool/README.md | 21 +- tool/check_desktop_oauth_config.dart | 87 +++- tool/release/build_snap_local.py | 318 ++++++++++++ tool/release/build_snap_local.sh | 4 + tool/release/check_linux_native.sh | 58 +++ tool/release/verify_snap_runtime.sh | 116 +++++ windows/runner/Runner.rc | 4 +- 90 files changed, 3515 insertions(+), 992 deletions(-) create mode 100644 docs/snap_runtime_libraries.md create mode 100644 lib/src/features/sync/account_recovery_feedback.dart create mode 100644 test/tool/local_snap_release_test.py create mode 100644 tool/release/build_snap_local.py create mode 100755 tool/release/build_snap_local.sh create mode 100755 tool/release/check_linux_native.sh create mode 100755 tool/release/verify_snap_runtime.sh diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index 87628cb2..68dcdaa6 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -1,13 +1,6 @@ name: Flutter Linux on: - workflow_dispatch: - inputs: - production_release: - description: 'Build an official production Snap (main only)' - type: boolean - required: true - default: false pull_request: branches: [main] push: @@ -22,12 +15,6 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Require main for manual runs - if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main' - run: | - echo '::error::Manual Linux builds must run from main.' - exit 1 - - name: Check out repository uses: actions/checkout@v7 with: @@ -133,60 +120,10 @@ jobs: integration_test/gtk_header_icons_integration_test.dart -d linux - name: Test native clock patterns, weekday locale, and GTK time picker - run: | - mkdir -p build/linux/native-tests - g++ -std=c++17 -Wall -Wextra -Werror \ - windows/runner/tests/short_time_pattern_test.cpp \ - -o build/linux/native-tests/short_time_pattern_test - build/linux/native-tests/short_time_pattern_test - g++ -std=c++17 -Wall -Wextra -Werror \ - linux/runner/tests/first_weekday_test.cc \ - -o build/linux/native-tests/first_weekday_test - build/linux/native-tests/first_weekday_test - mkdir -p build/linux/native-tests/weekday-schemas - glib-compile-schemas --strict \ - --targetdir=build/linux/native-tests/weekday-schemas \ - linux/runner/tests/schemas - g++ -std=c++17 -Wall -Wextra -Werror \ - linux/runner/tests/first_weekday_preference_test.cc \ - linux/runner/first_weekday_preference.cc \ - -o build/linux/native-tests/first_weekday_preference_test \ - $(pkg-config --cflags --libs gio-2.0) - build/linux/native-tests/first_weekday_preference_test \ - build/linux/native-tests/weekday-schemas - LC_ALL=C.UTF-8 MALLOC_PERTURB_=165 \ - build/linux/native-tests/first_weekday_test - g++ -std=c++17 -Wall -Wextra -Werror \ - -fsanitize=address -fno-omit-frame-pointer \ - linux/runner/tests/first_weekday_test.cc \ - -o build/linux/native-tests/first_weekday_test_asan - ASAN_OPTIONS=detect_leaks=0 \ - build/linux/native-tests/first_weekday_test_asan - g++ -std=c++17 -Wall -Wextra -Werror \ - linux/runner/tests/time_picker_test.cc \ - -o build/linux/native-tests/time_picker_test \ - $(pkg-config --cflags --libs gtk+-3.0) - xvfb-run -a build/linux/native-tests/time_picker_test - g++ -std=c++17 -Wall -Wextra -Werror \ - linux/runner/tests/gtk_window_preferences_test.cc \ - linux/runner/gtk_window_preferences.cc \ - -o build/linux/native-tests/gtk_window_preferences_test \ - $(pkg-config --cflags --libs gtk+-3.0) - xvfb-run -a build/linux/native-tests/gtk_window_preferences_test - g++ -std=c++17 -Wall -Wextra -Werror \ - linux/runner/tests/gtk_header_icons_test.cc \ - linux/runner/gtk_header_icons.cc \ - -o build/linux/native-tests/gtk_header_icons_test \ - $(pkg-config --cflags --libs gtk+-3.0) - XDG_CONFIG_HOME="$RUNNER_TEMP/busymax-gtk-config" \ - xvfb-run -a sh -c 'build/linux/native-tests/gtk_header_icons_test' - g++ -std=c++17 -Wall -Wextra -Werror \ - linux/runner/tests/gtk_search_entry_theme_test.cc \ - linux/runner/gtk_search_entry_theme.cc \ - -o build/linux/native-tests/gtk_search_entry_theme_test \ - $(pkg-config --cflags --libs gtk+-3.0) - xvfb-run -a env GDK_BACKEND=x11 \ - build/linux/native-tests/gtk_search_entry_theme_test + run: BUSYMAX_GTK_CONFIG="$RUNNER_TEMP/busymax-gtk-config" bash tool/release/check_linux_native.sh + + - name: Test local release tooling + run: python3 -m unittest discover -s test/tool -p '*_test.py' - name: Test native registration helper teardown run: python3 test/tool/registration_native_helper_test.py @@ -203,78 +140,10 @@ jobs: test -f build/linux/x64/release/bundle/data/flutter_assets/docs/google_setup.md test -f build/linux/x64/release/bundle/data/flutter_assets/docs/microsoft_setup.md - - name: Validate production registrations and protected originals - if: github.event_name == 'workflow_dispatch' && inputs.production_release && github.ref == 'refs/heads/main' - env: - GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} - GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} - GOOGLE_OAUTH_PROJECT_ID: ${{ vars.GOOGLE_OAUTH_PROJECT_ID }} - MICROSOFT_OAUTH_CLIENT_ID: ${{ vars.MICROSOFT_OAUTH_CLIENT_ID }} - MICROSOFT_OAUTH_AUTHORITY_TENANT: ${{ vars.MICROSOFT_OAUTH_AUTHORITY_TENANT }} - BUSYMAX_GOOGLE_OAUTH_CLIENT_ID: ${{ vars.BUSYMAX_GOOGLE_OAUTH_CLIENT_ID }} - BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET }} - BUSYMAX_GOOGLE_OAUTH_PROJECT_ID: ${{ vars.BUSYMAX_GOOGLE_OAUTH_PROJECT_ID }} - BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID: ${{ vars.BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID }} - BUSYMAX_PRIVACY_POLICY_URL: https://busystack.org/privacy-busymax - run: '"$BUSYMAX_DART_EXECUTABLE" run tool/check_desktop_oauth_config.dart' - - - name: Build official Linux release with explicit active registrations - if: github.event_name == 'workflow_dispatch' && inputs.production_release && github.ref == 'refs/heads/main' - env: - GOOGLE_OAUTH_CLIENT_ID: ${{ vars.GOOGLE_OAUTH_CLIENT_ID }} - GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }} - GOOGLE_OAUTH_PROJECT_ID: ${{ vars.GOOGLE_OAUTH_PROJECT_ID }} - MICROSOFT_OAUTH_CLIENT_ID: ${{ vars.MICROSOFT_OAUTH_CLIENT_ID }} - MICROSOFT_OAUTH_AUTHORITY_TENANT: ${{ vars.MICROSOFT_OAUTH_AUTHORITY_TENANT }} - BUSYMAX_GOOGLE_OAUTH_CLIENT_ID: ${{ vars.BUSYMAX_GOOGLE_OAUTH_CLIENT_ID }} - BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET }} - BUSYMAX_GOOGLE_OAUTH_PROJECT_ID: ${{ vars.BUSYMAX_GOOGLE_OAUTH_PROJECT_ID }} - BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID: ${{ vars.BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID }} - BUSYMAX_PRIVACY_POLICY_URL: https://busystack.org/privacy-busymax - run: | - registration_file="$(mktemp "$RUNNER_TEMP/busymax-oauth.XXXXXX.json")" - trap 'rm -f "$registration_file"' EXIT - python3 - "$registration_file" <<'PYCONFIG' - import json, os, sys - keys = [ - 'GOOGLE_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_SECRET', - 'GOOGLE_OAUTH_PROJECT_ID', 'MICROSOFT_OAUTH_CLIENT_ID', - 'MICROSOFT_OAUTH_AUTHORITY_TENANT', - 'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID', 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET', - 'BUSYMAX_GOOGLE_OAUTH_PROJECT_ID', 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID', - 'BUSYMAX_PRIVACY_POLICY_URL', - ] - with open(sys.argv[1], 'w') as output: - json.dump({key: os.environ[key] for key in keys if os.environ.get(key)}, output) - PYCONFIG - "$BUSYMAX_FLUTTER_EXECUTABLE" build linux --release \ - -t lib/main_linux.dart --dart-define-from-file="$registration_file" - binary=build/linux/x64/release/bundle/lib/libapp.so - for key in GOOGLE_OAUTH_CLIENT_ID MICROSOFT_OAUTH_CLIENT_ID \ - BUSYMAX_GOOGLE_OAUTH_CLIENT_ID BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET \ - BUSYMAX_GOOGLE_OAUTH_PROJECT_ID BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID; do - grep -aFq -- "${!key}" "$binary" - done - if [ -n "$GOOGLE_OAUTH_CLIENT_SECRET" ]; then - grep -aFq -- "$GOOGLE_OAUTH_CLIENT_SECRET" "$binary" - fi - - name: Build strict Snap from the release bundle id: snapcraft uses: snapcore/action-build@v1 - - name: Build deterministic sibling-layout source archive - run: python3 tool/package_source.py - - - name: Upload pinned source artifact - if: github.event_name == 'push' - uses: actions/upload-artifact@v7 - with: - name: busymax-source - path: dist/source/ - if-no-files-found: error - retention-days: 7 - - name: Install strict Snap run: >- sudo snap install --dangerous @@ -286,27 +155,3 @@ jobs: grep -Eq '^[[:space:]]*confinement:[[:space:]]+strict$' # shellcheck disable=SC2016 snap run --shell busymax -c 'test -x "$SNAP/busymax"' - - - name: Label non-production Snap artifact - if: github.event_name != 'workflow_dispatch' || !inputs.production_release - env: - SNAP_ARTIFACT: ${{ steps.snapcraft.outputs.snap }} - run: cp -- "$SNAP_ARTIFACT" build/busymax-snap-ci-non-production.snap - - - name: Upload non-production Snap artifact - if: github.event_name != 'workflow_dispatch' || !inputs.production_release - uses: actions/upload-artifact@v7 - with: - name: busymax-snap-ci-non-production - path: build/busymax-snap-ci-non-production.snap - if-no-files-found: error - retention-days: 7 - - - name: Upload Snap artifact - if: github.event_name == 'workflow_dispatch' && inputs.production_release && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v7 - with: - name: busymax-snap - path: ${{ steps.snapcraft.outputs.snap }} - if-no-files-found: error - retention-days: 7 diff --git a/.gitignore b/.gitignore index 9f124303..7f9f0044 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ config/windows_store.local.json *.pem *.key *.crt + +# Local public release settings; downloaded Google JSON stays outside Git. +config/release.local.json diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6e6e0303..ea0d351d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -65,7 +65,7 @@ android { // (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` // flag during build. - versionCode = 5 + versionCode = 6 versionName = flutter.versionName manifestPlaceholders["busymaxMsalRedirectHost"] = applicationId!! manifestPlaceholders["busymaxMsalSignatureHash"] = msalSignatureHash diff --git a/docs/android_release.md b/docs/android_release.md index 323625fd..1ea8265f 100644 --- a/docs/android_release.md +++ b/docs/android_release.md @@ -8,6 +8,7 @@ outside logs and shell history. Do not replace an established upload identity. For v0.3.0 the public version is `0.3.0` and the package code is `5`, advancing the v0.2.4 source and retained test-signed artifact code `4`. During preparation on 2026-10-11, the owner confirmed `4` is the highest distributed base code. +For v0.3.1, code `6` is confirmed available by the owner on 2026-10-11. For subsequent releases, verify the distribution ledger and account for ABI offsets if split APKs were distributed before choosing the next code. diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 21322beb..213a2e23 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -1,239 +1,161 @@ -# Snap beta release - -The canonical Snap build packages an existing Flutter Linux release bundle with -`snap/snapcraft.yaml`. Snapcraft does not run `flutter build` or read the -local Dart-defines file, so build the configured Flutter bundle first. - -## Prepare - -Use Linux amd64 with the [BusyMax development toolchain](development.md), -snapd, Snapcraft with LXD, `libhandy-1-dev`, and `unsquashfs` from -`squashfs-tools`. Publishing also requires authorization for the BusyMax Snap -Store listing. - -Confirm that source versions agree: - -```bash -grep -nE '^version:|_amd64.snap`. Use that exact -artifact for every inspection, installation, checksum, and upload. If BusyMax -reports that a provider is not configured, the compiled bundle is missing its -Dart defines and must be rebuilt. - -The Flutter Linux workflow performs source validation, builds -`lib/main_linux.dart`, packages a strict Snap, and installs and verifies it. -Pull requests and pushes to `main` need no production OAuth credentials. -They upload the verified `busymax-snap-ci-non-production` -artifact, containing `busymax-snap-ci-non-production.snap`, for -seven days. This unconfigured package keeps custom registration available and -managed connections unavailable. It is not an official release and must not -be published. - -For an official production artifact, configure the actual managed Google and -Microsoft registrations plus the protected originals in GitHub Actions, then -explicitly request the production path from `main`: +The workspace must not exist. It contains independent `busymax/`, +`busystack_contacts/`, `busystack_dav/` and `busystack_graph/` Git checkouts. +Dependencies are materialized at the exact manifest revisions. Existing sibling +repositories and earlier worktrees remain untouched. Failed workspaces are +retained for inspection; choose a new path for another attempt. No reset, +forced worktree removal, or destructive cleanup is used. + +The command checks all five version files before compiling; it never rewrites +them. v0.3.1 uses Android code **6**, confirmed available by the owner on +2026-10-11. It resolves the lockfile, generates/rechecks localization and Drift, +runs formatting, analysis, platform boundaries, full Flutter/tool/native tests, +and compiles a fresh configured bundle. It verifies the actual managed Google +configuration in `libapp.so`, runs canonical Snapcraft packaging with +`--use-lxd`, and checks the package's version, strict confinement, launchers and +assets. An old bundle cannot enter the new checkout. The recipe remains +`base: core24`, `grade: devel`, `confinement: strict`. + +It then checks ELF resolution, X11/Wayland startup, restart and confined network +access in a fresh Ubuntu 24.04 VM. Runtime failures stop the build. A successful +command prints the exact Snap path last and writes its SHA-256 file, redacted +build log, runtime evidence and `artifacts/manifest.json`. The manifest records +`status: candidate` and `release_ready: false` until interactive/provider +acceptance is completed. Compilation and packing alone never establish release +readiness. Failed builds emit no success manifest. Nothing is installed on the +owner's desktop or uploaded automatically. + +## Inspect, verify and test upgrade + +Use the exact printed path throughout. In its artifacts directory: ```bash -gh workflow run flutter-linux.yml --ref main -f production_release=true +sha256sum --check busymax_0.3.1_amd64.snap.sha256 +unsquashfs -cat busymax_0.3.1_amd64.snap meta/snap.yaml ``` -Manual Linux runs from other branches fail. The production path runs the CI -checks, strictly validates the release configuration, verifies that the -configured registrations reached the binary, and builds, installs, and -verifies the strict Snap before uploading the established `busymax-snap` artifact. -Missing, invalid, or synthetic production registrations fail the run. The -artifact is retained for seven days; the workflow does not publish to the -Snap Store. A manual run without `production_release` selected remains -non-production CI. Managed Microsoft connections always use `common`, and -the app registration must support both personal and organizational accounts. - -### Local scaffold helper +Confirm `0.3.1`, `core24`, `devel`, `strict`, the BusyMax launcher, its autostart +entry, desktop file, icon and AppStream history. Investigate library warnings +using [the library investigation record](snap_runtime_libraries.md); do not add +broad linter suppression. The VM evidence must show no unresolved runtime ELF +dependencies. Headless startup does not prove interactive native integration. -For quick local scaffold testing, quit every BusyMax window and tray process, -then run: +On a separate test machine/user session, first install the previous v0.3.0 +package and prepare test accounts, cached tasks/events, disabled collection +preferences, contacts settings, conflicts and pending offline work. Back up +that test state. Quit BusyMax and upgrade using the exact candidate: ```bash -./tool/build_install_snap_local.sh \ - --dart-define-from-file .snap-local/busymax-dart-defines.json -``` - -The helper repacks `/snap/busymax/current`, or the directory passed with -`--scaffold`, around a newly built bundle. It is not the canonical Snapcraft -build and must not be published. Leave `--root` at its safe default. The helper -does not purge application data. Use `--skip-tests` only for a repeat build of -the same validated revision. **`--no-run` still installs the package; it only -suppresses launch.** - -## Inspect and install the exact artifact - -Set the actual artifact path, then record its metadata and checksum: - -```bash -SNAP_FILE=./busymax_RELEASE_VERSION_amd64.snap -unsquashfs -cat "$SNAP_FILE" meta/snap.yaml | - sed -n '/^name:/p;/^version:/p;/^grade:/p;/^confinement:/p' -sha256sum "$SNAP_FILE" -``` - -Confirm that the package is strict and contains one top-level launcher: - -```bash -unsquashfs -ll "$SNAP_FILE" | - sed -nE 's#^.*squashfs-root/meta/gui/([^/]+\.desktop)$#\1#p' -``` - -The launcher output must contain only `busymax.desktop`. -`share/applications/io.busystack.busymax.desktop` is the expected internal -desktop file. - -Quit other BusyMax processes before installing: - -```bash -sudo snap install --dangerous "$SNAP_FILE" +sudo snap install --dangerous /absolute/path/busymax_0.3.1_amd64.snap snap connections busymax snap run busymax ``` -`--dangerous` bypasses Store signature checking, not strict confinement. The -installation command does not request a purge, but preserve a backup of release -test data before any package operation. - -## Release verification +Do not purge data. Verify that the previous account remains visible and its +local content can be inspected. Its Google failure must offer **Reconnect +Google account**, select that account, and authorize through the current client. +Verify same-subject replacement, wrong-account rejection, cancellation, offline +failure and callback failure. An unverifiable legacy identity retains its local +account and offers a separate connection; pending operations must stay with the +original account. Confirm no duplicate account after successful verified +replacement, sync resumes, and restart uses the new issuing registration. -Consolidate results in the release record. At minimum verify: +Test real Calendar/Tasks changes, read-only Contacts, Microsoft Calendar/To Do, +Nextcloud and iCloud, GTK windows and file pickers, browser loopback callbacks, +tray, Secret portal storage and notifications on X11 and Wayland sessions. +Record version/revision/checksum and results without tokens or personal content. +Review the [Google production prerequisites](desktop_oauth_release_checklist.md). -- the installed revision, strict confinement, single desktop launcher, tray - actions, notifications, startup, and restart; -- Google and Microsoft browser sign-in and Apple/Nextcloud connection, - reconnect, revocation, read-only permissions, and synchronization; -- event and provider-supported task create/edit/complete/delete, recurrence, - alarms, imports/exports, WebCal, conflict behavior, and offline recovery; -- external location/link opening as described in the - [privacy and data map](privacy_data_map.md), without an embedded map or - mapping credential; -- the strict-Snap Secret portal credential store across quit and desktop - restart, followed by account removal; -- network, DNS, and platform TLS failures; -- UI, themes, scaling, localization, calendar views, and tray Agenda targeting; - and -- upgrade of a copy of data from the last release plus the supported migration - fixture through the complete current migration chain, preserving credentials, - provider state, cached content, pending work, conflicts, and supplemental - location ownership. +## Owner-performed upload -Do not use production personal data for release testing. - -## Publish an authorized beta - -Authenticate and upload the verified artifact once: +After interactive acceptance and provider prerequisite review, the owner uploads +only the verified checksum. The build command never publishes: ```bash snapcraft login snapcraft whoami -snapcraft upload --release=beta "$SNAP_FILE" +snapcraft upload --release=beta /absolute/path/busymax_0.3.1_amd64.snap snapcraft revisions busymax --arch amd64 snapcraft status busymax --arch amd64 ``` -Record the numeric Store revision printed for the verified checksum. It is an -immutable upload identifier, not the application version. Do not re-upload -while review or release is pending. - -The `busymax-dbus` session D-Bus slot can require manual review. If an older -obsolete revision blocks the candidate, reject only that obsolete revision; -otherwise wait or contact the -[Store reviewers](https://forum.snapcraft.io/c/store-requests/19). -`resource-not-ready` or inconsistent-state errors do not prove a release. -Check the publisher dashboard before retrying. - -If review completes without automatic release, release the exact reviewed -revision: - -```bash -snapcraft release busymax STORE_REVISION beta -snapcraft status busymax --arch amd64 -``` - -The recipe uses `grade: devel`, so it can release only to beta or edge. A -candidate or stable release requires changing the grade, rebuilding, and -repeating artifact verification. - -## Verify the Store revision - -Prefer a separate test machine: - -```bash -sudo snap install busymax --beta -snap info busymax -snap run busymax -``` - -For an existing beta-tracking install: - -```bash -sudo snap refresh busymax --channel=beta -snap info busymax -``` - -Repeat the installed-package checks against the Store-delivered revision. -Record its revision, channel, checksum, test machine, and results without -account identities, credentials, DAV paths, or calendar/task content. - -Official references: -[Snapcraft build environments](https://documentation.ubuntu.com/snapcraft/stable/reference/build-environment-options/), -[upload](https://documentation.ubuntu.com/snapcraft/stable/reference/commands/upload/), -and -[revision management](https://documentation.ubuntu.com/snapcraft/stable/how-to/publishing/manage-revisions-and-releases/). +Record the immutable numeric Store revision. The `busymax-dbus` slot may require +manual Store review; wait or contact reviewers rather than repeatedly uploading. +`grade: devel` supports beta/edge only. If review completes without release, the +owner may release that exact reviewed revision with +`snapcraft release busymax STORE_REVISION beta`. Verify the Store-delivered +revision on a separate test machine. These instructions authorize no automatic +upload, release or merge. + +## Development scaffold helper + +`tool/build_install_snap_local.sh` remains development-only: it repacks an +installed/supplied scaffold and installs it on the local machine. It is not the +production packager and its output must not be published. `--no-run` still +installs that development package. Use the dedicated release command above for +official artifacts. diff --git a/docs/desktop_oauth_release_checklist.md b/docs/desktop_oauth_release_checklist.md index 28f7fd8f..d739dc1d 100644 --- a/docs/desktop_oauth_release_checklist.md +++ b/docs/desktop_oauth_release_checklist.md @@ -1,39 +1,80 @@ -# Desktop OAuth release checklist +# Desktop OAuth release prerequisites -Official desktop releases support explicitly active BusyMax-managed registrations and optional organization/custom registrations. Shared-client retirement is not a prerequisite or a scheduled action. +v0.3.1 uses the owner's new Google Desktop client as the BusyMax-managed +registration for locally built Linux releases. The download has a valid +`installed` object and project ID `busymax`. Its actual fields are read by the +[local release command](beta_snap_release.md); no credential JSON is committed. +Original Google credentials are not required, restored, or fetched from GitHub. -## Google external prerequisites +## Google Console evidence -- The owner must supply a real production Desktop OAuth client ID/secret and its actual project ID through `BUSYMAX_GOOGLE_OAUTH_CLIENT_ID`, `BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET`, and `BUSYMAX_GOOGLE_OAUTH_PROJECT_ID`. -- Configure branding, support/contact details, Calendar and Tasks APIs, and the existing five scopes. Complete In production publishing and any applicable branding/domain and scope approval/verification. Publishing alone is not verification; tests cannot establish either. -- Optional contacts lookup also needs People API and `contacts.readonly` as - described in [Google setup](google_setup.md); it remains separate from initial - Calendar/Tasks authorization. Do not request contacts write access. -- BusyStack-owned production branding uses `https://busystack.org/privacy-busymax`. Other project owners must satisfy their own domain and policy obligations. +On 2026-10-11 the owner reported **External / In production; verification +complete** for the new project. This is owner-reported Console evidence, not an +inference from the download or a successful login. This task has not changed +the cloud project. Before distribution record/check in that exact project's +Google Auth Platform and API Library: -## Microsoft external prerequisites +- Calendar API (`calendar-json.googleapis.com`) and Tasks API + (`tasks.googleapis.com`) enabled. +- The existing required scopes: `openid`, `email`, `profile`, + `https://www.googleapis.com/auth/calendar` and + `https://www.googleapis.com/auth/tasks`. +- Optional read-only contacts: People API (`people.googleapis.com`) enabled and + `https://www.googleapis.com/auth/contacts.readonly` listed/approved when + applicable. Contacts consent remains separate; no write access is requested. +- Audience External for a public BusyMax registration, publishing In production, + actual verification status and scope approval for the scopes above. Internal + Workspace registrations remain a separate custom setup choice. +- BusyMax branding/logo, user-support and developer-contact emails, application + homepage, verified policy domain and + `https://busystack.org/privacy-busymax`, plus required consent-screen + disclosures. Confirm that the supplied Desktop client belongs to this project. -- The owner must supply a real public app ID through `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID`. The managed registration must support personal and organizational Microsoft accounts; its authority is always `common` and is not configurable. Custom registrations retain their own audience and tenant selections. -- Configure Mobile and desktop applications with `http://localhost`, existing mandatory delegated permissions, and applicable administrator consent. Optional shared-calendar/category consent stays optional. No client secret is used. -- Optional contacts lookup uses delegated `Contacts.Read`, including Android; - see [Microsoft setup](microsoft_setup.md). Preserve existing grants accurately. +A read-only `gcloud services list --enabled --project=busymax` check on +2026-10-11 verified Calendar and Tasks APIs enabled. People API was absent at +that check; enabling it requires owner authorization. Individual approved scopes +and branding/support details cannot be read from a Desktop JSON and still +require Console evidence and real provider round trips. Google's [audience guidance](https://support.google.com/cloud/answer/15549945?hl=en) +describes Testing's test-user restrictions and seven-day authorizations. +[Consent configuration](https://developers.google.com/workspace/guides/configure-oauth-consent) +and [sensitive-scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification) +explain the additional approval requirements. Publishing alone is not verification. -## Preserve existing registrations and data +## Microsoft and other providers -- Retain protected `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `MICROSOFT_OAUTH_CLIENT_ID`, and original Microsoft authority separately. Supply `GOOGLE_OAUTH_PROJECT_ID` only when known truthfully; unknown legacy project metadata is omitted rather than fabricated. -- Do not overwrite original values with a new production registration. They resolve only eligible accounts actually issued under them. They never enable Connect with BusyMax or act as an implicit fallback. -- Account envelopes persist the issuing registration, origin, audience, secret where applicable, and tokens. After restart/build changes, refresh, ordinary Reconnect, and Microsoft optional consent use that account's binding. Active registrations do not receive retirement notices. -- Explicit Replace registration retains identity checks, transactional rollback, generations, secure storage, local data, and session reconciliation. Audit unresolved provenance separately; preserve unknown/corrupt records for repair. -- Do not retire/delete any Cloud project, Entra registration, or native Android configuration without separate owner authorization. Android GIS/MSAL and signing/distribution identities remain unchanged. +Supply the established `MICROSOFT_OAUTH_CLIENT_ID` in ignored local public settings; +no Microsoft secret or new app is needed. Preserve its existing public-client +redirect and permissions. Managed `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID` remains +optional until a real application is supplied. An explicitly managed Microsoft +client uses `common`; custom clients retain their own audience/tenant choices. +Verify expired Microsoft grants receive a clear reconnect instruction. Optional +contacts retain `Contacts.Read`. Nextcloud, iCloud and read-only contacts flows +must pass their existing regressions; none use release OAuth secrets. -## Build and acceptance gates +## Existing accounts and build gates -- `dart run tool/check_desktop_oauth_config.dart` validates environment values; `--config ` validates a define file. Missing production registrations fail official release validation and are reported as separate external prerequisites. Syntactic validity does not mean provider approval. -- Routine Linux pull-request and main-push CI requires no production registrations. It builds, installs, and verifies an unconfigured strict Snap and labels its artifact as non-production; it must not be published. Unconfigured validation builds display the recommended action as unavailable and retain custom setup. -- Official Linux packaging requires a manual `Flutter Linux` dispatch on `main` with `production_release` selected. It validates and injects active and original values separately, rejects missing/invalid or synthetic registrations, and uploads the production Snap only after installation and verification. CI success does not prove production readiness or provider approval. -- Windows official configuration validates the same prerequisites; synthetic CI values are restricted to explicitly nonproduction CI packages. `-Ci -Unconfigured -Stage WindowsCompile` exercises the unavailable state only. -- Run locked dependency resolution, localization generation, formatting, analysis, full tests, platform boundaries, Linux release build and diff checks. Run Windows release/native/MSIX validation on Windows and affected Android regressions. -- Inspect actual production Settings dialogs, native menus, all instruction pages and errors in light/dark, narrow, enlarged-text, long-label and keyboard cases. Use isolated synthetic configurations; owner-authorized accounts are required for real provider authorization. -- Review cancellation, invalid/cancelled replacement, expiry, disposal/stale work, duplicate submits, wrong account, missing scopes, secure-storage failure, restart/reconnect and optional-consent evidence before publication. +The new Google client must only populate `BUSYMAX_GOOGLE_OAUTH_*`. Account-bound +credentials record their issuing registration and authorization generation. +For an unavailable legacy client, synchronization marks the existing account +reconnect-required without opening a browser. The user initiates a targeted +replacement flow; verified Google subjects must match. Unverifiable identities +retain their accounts and pending work and offer a separate connection, never +an email-based merge. Failed/cancelled attempts preserve the prior state. -Enable recommended Google and Microsoft connections only after the owner supplies production configuration and completes the respective external prerequisites. +`tool/check_desktop_oauth_config.dart` validates environment values or `--config` +JSON. Managed Google ID/secret/real project metadata and fixed privacy policy +are required. Original Google credentials and managed Microsoft are optional. +Supplied incomplete/invalid optional values and known CI fixtures are rejected; +values are never printed. Syntax cannot prove external Console approval. + +Linux CI validates source without credentials or artifact uploads. Official +Linux packaging uses `tool/release/build_snap_local.sh`, locked dependencies, +exact source/version gates, native tests, compiled configuration inspection and +fresh isolated Snap checks. No new GitHub OAuth variable/secret is needed. +Windows and Android keep their existing CI/toolchain and native architectures. +No cloud registration, Android signing identity, or Store record is changed. + +Complete interactive upgrade, wrong-account/cancellation, permissions, +network/rate-limit, browser callback, restart, secret storage and actual +Calendar/Tasks/contacts acceptance before publishing. Keep cloud prerequisites +and real provider evidence separate from automated build/test results. diff --git a/docs/development.md b/docs/development.md index 65f7a646..b7fc0b3c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -33,20 +33,13 @@ registrations in [`BuildConfig`](../lib/src/config/build_config.dart): `common` for personal and organizational Microsoft accounts. Custom registrations retain their own audience and tenant selections. -Protected `GOOGLE_OAUTH_*` and `MICROSOFT_OAUTH_*` originals serve eligible -existing account bindings. Keep them separate from active registrations; they -do not enable new managed connections or provide an implicit fallback. You can -build and run offline tests without provider configuration; unconfigured -desktop builds keep custom registration setup available. - -Use the [Google](google_setup.md) and [Microsoft](microsoft_setup.md) registration -guides for custom setup. Official packages must pass -`dart run tool/check_desktop_oauth_config.dart --config `; -the [desktop OAuth release checklist](desktop_oauth_release_checklist.md) owns the -complete configuration and provider approval requirements. - -Apple iCloud Calendar and Nextcloud do not use compile-time OAuth client -credentials. +For local Linux releases, select the new external Google Desktop OAuth JSON. +The release tool derives managed `BUSYMAX_GOOGLE_OAUTH_*` values automatically, +including project metadata. Original Google credentials are not needed. +Use ignored local public settings for the established Microsoft client ID; +managed Microsoft remains optional. Custom provider registrations remain usable +in unconfigured development builds. See the [local Snap release procedure](beta_snap_release.md) +and [provider prerequisites](desktop_oauth_release_checklist.md). ## Linux development @@ -156,39 +149,24 @@ depending on Fluent UI. Normal `flutter test` skips credential-gated provider tests. See [Live-provider tests](#live-provider-tests) for their setup and safety requirements. -The Linux and Windows workflows run for pull requests targeting `main` and -pushes to `main`; both also support manual dispatch. Routine Linux CI requires -no production OAuth configuration: it builds, installs, and verifies an -unconfigured strict Snap with custom registration available and managed -connections unavailable. Its verified artifact is explicitly labeled -`busymax-snap-ci-non-production` and must not be published. -Manual Linux runs are restricted to `main`; selecting `production_release` -validates production registrations and builds a verified production Snap, -uploaded under the established `busymax-snap` artifact name. -Without that selection, a manual run remains non-production CI. -A newer run for the same workflow and ref cancels the superseded run. Windows -test reports are retained for every run, while the unsigned CI MSIX and package -evidence are retained for seven days only after a successful `main` push or manual run. -The workflows do not deploy Windows packages. - -Release PRs originate from the existing release branch for that version. -Start each release branch from current `main`. Before final review, fetch and -merge any later `origin/main` commits into the release branch with a normal -merge commit. A squash of a feature containing main changes preserves its -source but does not preserve that ancestry. -Open `release/v0.3.0` into `main` with the title `Release/v0.3.0`. -`tool/check_release_pull_request.py` checks the title and branch (including -intentional lowercase naming), matching application/Snap/AppStream/Windows -resource versions, Android's increasing version code, and current target -ancestry. CI fetches the actual `refs/pull//head` and target branch; -the synthetic PR merge checkout does not prove release-branch ancestry. -The owner reviews and manually merges the open release PR. -Historically, v0.2.4 feature/fix PRs were squashed into `Release/v0.2.4`, -followed by a squash of that release PR into `main`. - -For release artifacts, follow [Snap beta release](beta_snap_release.md) or -[Windows packaging](windows_packaging.md) and the -[Windows release checklist](windows_release_checklist.md). +Linux GitHub Actions verifies source, locked dependencies, generated files, +formatting, analysis, platform boundaries, Flutter/native tests and an +unconfigured strict Snap packaging/install smoke test. It has no production +dispatch, OAuth credentials or artifact uploads. Windows and Android CI remain +independent. The official Linux build is `tool/release/build_snap_local.sh`; +`tool/build_install_snap_local.sh` only repacks a development scaffold. + +## Release branches + +Start `release/v0.3.1` from current `main`, commit reviewed changes, and open it +into `main` with title `release/v0.3.1`. Fetch before final review and incorporate +later `origin/main` commits with a normal merge. The existing +`tool/check_release_pull_request.py` validates the actual PR head, release +metadata and current-main ancestry. A synthetic PR merge does not establish +that ancestry. Preserve worktrees and uncommitted work; do not rewrite history +or repeatedly squash feature branches into long-lived branches. The owner +reviews and merges the release PR. Official artifact production follows +[Local Snap beta release](beta_snap_release.md). ## Live-provider tests diff --git a/docs/snap_runtime_libraries.md b/docs/snap_runtime_libraries.md new file mode 100644 index 00000000..8c64a099 --- /dev/null +++ b/docs/snap_runtime_libraries.md @@ -0,0 +1,54 @@ +# core24 runtime library investigation + +Checked 2026-10-11 with Snapcraft 9.1.4, core24 revision 2124, +GNOME 46-2404 revision 168 and mesa-2404 revision 1839. The installed BusyMax +v0.3.0 revision x3 was used only to establish runtime provenance; it is not +v0.3.1 acceptance evidence. The local release command repeats resolution in a +fresh Ubuntu 24.04 VM against the exact new package and its checksum. + +Inside `snap run --shell busymax`, `ldd` on the runner, Flutter engine and +libgweather resolved the reported libraries as follows: + +| Libraries | Runtime supplier | Observed path prefix | +|---|---|---| +| `libX11.so.6`, `libXdamage.so.1`, `libXext.so.6`, `libXfixes.so.3` | mesa-2404, through GNOME extension GPU content plug | `$SNAP/gpu-2404/usr/lib/x86_64-linux-gnu/` | +| `libwayland-client.so.0`, `libwayland-cursor.so.0`, `libwayland-egl.so.1` | same GPU content mount | same | +| `libxcb-shm.so.0`, `libxcb.so.1`, `libxml2.so.2` | same GPU content mount | same | +| GTK, GLib and ordinary desktop integration libraries | GNOME content runtime and some BusyMax staged package dependencies | `$SNAP/gnome-platform/usr/lib/x86_64-linux-gnu/` and `$SNAP/usr/lib/x86_64-linux-gnu/` | +| Base system dependencies such as `libcap.so.2`, `liblz4.so.1`, `libbsd.so.0` | core24 runtime | `/usr/lib/x86_64-linux-gnu/` inside the confined namespace | + +`snap connections busymax` confirmed connected GNOME, GPU, GTK themes, desktop, +X11, Wayland, OpenGL and network/network-bind plugs. The GPU libraries above +are not supplied by core24 alone. Testing only host `ldd` or only unpacked +BusyMax files omits these content mounts and gives incomplete results. Fresh +runtime checks must include the extension's command chain, connected content +snaps and strict confinement. + +The recipe explicitly stages fonts-noto-core, libhandy-1-0, libgweather-4-0t64, +liblzma5 and libsecret-1-0 with their package dependencies. libhandy is used by +`linux/runner/my_application.cc` (`hdy_init`, native windows and preference +rows). libgweather is dynamically opened by +`lib/src/core/time/linux_gweather_location_source.dart`; its `Locations.bin` +is required. Fonts are loaded by Fontconfig rather than an ELF dependency. +Secret-storage plugins require libsecret. These must not be removed merely +because an ELF linter labels them unused. The pre-existing exclusion of +`libdartjni.so` remains: the Linux runner does not register/use Android JNI and +there is no JVM in the package. + +[Canonical's library-linter guidance](https://ubuntu.com/docs/snapcraft/9/how-to/debugging/use-the-library-linter/) +requires staging packages for actually missing dependencies and explains why +dynamically loaded libraries may appear unused. No broad `lint.ignore` entries +have been added. If a new package cannot resolve a dependency after content +mounts are active, treat that as a packaging defect and fail the release. +Record remaining warnings with their exact runtime supplier and test evidence. + +`tool/release/verify_snap_runtime.sh` creates a fresh tagged Ubuntu 24.04 LXD VM, +installs the exact candidate, checks all packaged ELF libraries in the confined +runtime, then starts/restarts under X11 and starts under headless Wayland. It +also checks confined DNS/TCP reachability. The VM is removed only when its +ownership tag matches this invocation. Existing instances are never reused. +Manual file-picker, tray, notification, browser callback, Secret portal and +real provider tests remain necessary; headless startup cannot establish those. +The core24 recipe always packages in Snapcraft's isolated LXD environment, +without destructive builds on the Ubuntu 26.04 host. See +[Canonical's build environment guidance](https://documentation.ubuntu.com/snapcraft/stable/reference/build-environment-options/). diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 3ea67585..e3ef90d0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "في Audience، اختر Publish app وأكد In production. لا تترك الاستخدام العادي في Testing: تنتهي تفويضات Calendar/Tasks ورموز تحديثها بعد سبعة أيام. النشر والتحقق منفصلان. قد يُعفى الاستخدام الشخصي لأقل من 100 مستخدم من التحقق مع تحذيرات وحد للمستخدمين؛ وقد يتطلب التوزيع الأوسع اعتماد العلامة والنطاقات. قد تنتهي التفويضات الإنتاجية أو تُلغى أيضًا.", "registrationGuideWorkspacePermissions": "راجع هذه النطاقات مع المسؤول. لا تحتاج تطبيقات Internal إلى قائمة نطاقات في شاشة الموافقة. عند طلبها، افتح Data Access → Add or remove scopes وأضف القيم ثم Update وSave. قد تظل قيود المسؤول تمنع التفويض.", "registrationGuideMicrosoftOptionalPermissions": "تُطلب أذونات التقويمات المشتركة والفئات بشكل منفصل عند تفعيل هذه الميزات الاختيارية؛ لا تضفها إلى الإعداد الإلزامي أعلاه.", - "registrationGuideWorkspaceAudience": "انتقل إلى Google Auth Platform → Branding. إن لم يبدأ الإعداد، اختر Get started. أدخل BusyMax في App name واختر بريدك في User support email ثم Next. في Audience، اختر Internal. اختر Next وأدخل بريدك ضمن Contact Information ثم Next. وافق على User Data Policy ثم اختر Continue وCreate. إذا كان الإعداد موجودًا، راجع Branding وAudience.\n\nيسمح Internal فقط بحسابات المؤسسة الأم للمشروع، مع الخضوع لقيود المسؤول. لا حاجة لقائمة مستخدمي اختبار. لا يستطيع BusyMax التحقق من إعدادات وحدة التحكم عبر ملف JSON." + "registrationGuideWorkspaceAudience": "انتقل إلى Google Auth Platform → Branding. إن لم يبدأ الإعداد، اختر Get started. أدخل BusyMax في App name واختر بريدك في User support email ثم Next. في Audience، اختر Internal. اختر Next وأدخل بريدك ضمن Contact Information ثم Next. وافق على User Data Policy ثم اختر Continue وCreate. إذا كان الإعداد موجودًا، راجع Branding وAudience.\n\nيسمح Internal فقط بحسابات المؤسسة الأم للمشروع، مع الخضوع لقيود المسؤول. لا حاجة لقائمة مستخدمي اختبار. لا يستطيع BusyMax التحقق من إعدادات وحدة التحكم عبر ملف JSON.", + "accountReconnectRequired": "أعد ربط هذا الحساب لاستئناف المزامنة.", + "googleReconnectRequired": "أعد ربط حساب Google لاستئناف مزامنة التقويمات والمهام.", + "reconnectGoogleAccount": "إعادة ربط حساب Google", + "reconnectAccount": "إعادة ربط هذا الحساب", + "reconnectSelectedAccount": "إعادة ربط ⁨{account}⁩. سيتم الاحتفاظ بالتقويمات والمهام والتغييرات المحلية المعلقة.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "تعذر التحقق من هوية اتصال Google القديم هذا. تم الاحتفاظ بالتقويمات والمهام والتغييرات المعلقة. أضف اتصال Google منفصلًا للمزامنة بأمان. ستبقى التغييرات المعلقة مع الحساب الأصلي.", + "addSeparateGoogleAccount": "إضافة اتصال Google منفصل" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c32c2f02..cd2eec61 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1345,5 +1345,19 @@ "registrationGuideGooglePublishing": "Wählen Sie unter Audience Publish app und bestätigen Sie In production. Lassen Sie die normale Nutzung nicht in Testing: Diese Kalender-/Aufgabenautorisierungen und Aktualisierungstokens laufen nach sieben Tagen ab. Veröffentlichung und Verifizierung sind getrennt. Persönliche Nutzung mit weniger als 100 Nutzern kann von der Verifizierung befreit sein, mit Warnungen und Nutzerlimit; größere Verbreitung kann Marken- und Berechtigungsprüfungen erfordern. Produktionsautorisierungen können weiterhin ablaufen oder widerrufen werden.", "registrationGuideWorkspacePermissions": "Prüfen Sie diese Berechtigungen mit Ihrem Administrator. Interne Apps benötigen keine Auflistung auf dem Zustimmungsbildschirm. Falls angefordert, öffnen Sie Data Access → Add or remove scopes, fügen Sie die Werte hinzu und wählen Sie Update und Save. Administratorkontrollen können die Autorisierung weiterhin einschränken.", "registrationGuideMicrosoftOptionalPermissions": "Berechtigungen für freigegebene Kalender und Kategorien werden separat beim Aktivieren dieser optionalen Funktionen angefordert. Fügen Sie sie nicht zur erforderlichen Einrichtung hinzu.", - "registrationGuideWorkspaceAudience": "Öffnen Sie Google Auth Platform → Branding. Wählen Sie bei einer neuen Einrichtung Get started. Geben Sie BusyMax als App name ein, wählen Sie Ihre eigene User support email und anschließend Next. Wählen Sie unter Audience Internal. Wählen Sie Next, geben Sie unter Contact Information Ihre E-Mail-Adresse ein und wählen Sie Next. Akzeptieren Sie die User Data Policy und wählen Sie Continue und Create. Prüfen Sie bei einer vorhandenen Einrichtung Branding und Audience.\n\nInternal erlaubt nur Konten der übergeordneten Projektorganisation und unterliegt Administratorkontrollen. Eine Testnutzerliste ist nicht nötig. BusyMax kann diese Konsoleneinstellungen nicht anhand der JSON prüfen." + "registrationGuideWorkspaceAudience": "Öffnen Sie Google Auth Platform → Branding. Wählen Sie bei einer neuen Einrichtung Get started. Geben Sie BusyMax als App name ein, wählen Sie Ihre eigene User support email und anschließend Next. Wählen Sie unter Audience Internal. Wählen Sie Next, geben Sie unter Contact Information Ihre E-Mail-Adresse ein und wählen Sie Next. Akzeptieren Sie die User Data Policy und wählen Sie Continue und Create. Prüfen Sie bei einer vorhandenen Einrichtung Branding und Audience.\n\nInternal erlaubt nur Konten der übergeordneten Projektorganisation und unterliegt Administratorkontrollen. Eine Testnutzerliste ist nicht nötig. BusyMax kann diese Konsoleneinstellungen nicht anhand der JSON prüfen.", + "accountReconnectRequired": "Dieses Konto muss erneut verbunden werden, um die Synchronisierung fortzusetzen.", + "googleReconnectRequired": "Verbinden Sie Ihr Google-Konto erneut, um Kalender und Aufgaben weiter zu synchronisieren.", + "reconnectGoogleAccount": "Google-Konto erneut verbinden", + "reconnectAccount": "Dieses Konto erneut verbinden", + "reconnectSelectedAccount": "{account} erneut verbinden. Kalender, Aufgaben und ausstehende lokale Änderungen bleiben erhalten.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Die Identität dieser älteren Google-Verbindung konnte nicht bestätigt werden. Kalender, Aufgaben und ausstehende Änderungen bleiben erhalten. Fügen Sie eine separate Google-Verbindung hinzu. Ausstehende Änderungen bleiben beim ursprünglichen Konto.", + "addSeparateGoogleAccount": "Separate Google-Verbindung hinzufügen" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bae0f81f..26dc79a4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1977,5 +1977,19 @@ "contactsUseForAttendees": "Use for attendee suggestions", "contactsServerUrlLabel": "HTTPS server URL", "contactsUsernameLabel": "Username", - "contactsPasswordLabel": "Password" + "contactsPasswordLabel": "Password", + "accountReconnectRequired": "This account needs to be reconnected to resume synchronization.", + "googleReconnectRequired": "Your Google account needs to be reconnected to resume calendar and task synchronization.", + "reconnectGoogleAccount": "Reconnect Google account", + "reconnectAccount": "Reconnect this account", + "reconnectSelectedAccount": "Reconnect {account}. Your calendars, tasks and pending local changes will be preserved.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "The identity of this older Google connection could not be verified. Its calendars, tasks and pending changes are preserved. Add a separate Google connection to synchronize safely. Pending changes will remain with the original account.", + "addSeparateGoogleAccount": "Add a separate Google connection" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 04fc1e29..3a8c93d6 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1345,5 +1345,19 @@ "registrationGuideGooglePublishing": "En Audience, selecciona Publish app y confirma In production. No dejes el uso normal en Testing: estas autorizaciones de Calendar/Tasks y sus tokens de actualización caducan a los siete días. Publicar y verificar son procesos separados. El uso personal con menos de 100 usuarios puede estar exento de verificación, con avisos y un límite de usuarios; una distribución mayor puede necesitar aprobación de marca y ámbitos. Las autorizaciones de producción también pueden caducar o revocarse.", "registrationGuideWorkspacePermissions": "Revisa estos ámbitos con el administrador. Las aplicaciones Internal no necesitan listarlos en la pantalla de consentimiento. Si se solicita, abre Data Access → Add or remove scopes, añade estos valores y selecciona Update y Save. Los controles administrativos pueden restringir la autorización.", "registrationGuideMicrosoftOptionalPermissions": "Los permisos de calendarios compartidos y categorías se solicitan por separado al activar esas funciones opcionales; no los añadas a la configuración obligatoria anterior.", - "registrationGuideWorkspaceAudience": "Ve a Google Auth Platform → Branding. Si aún no has iniciado la configuración, selecciona Get started. Introduce BusyMax en App name, elige tu propio User support email y selecciona Next. En Audience, elige Internal. Selecciona Next, introduce tu correo en Contact Information y selecciona Next. Acepta User Data Policy y selecciona Continue y Create. Si ya está configurado, revisa Branding y Audience.\n\nInternal solo admite cuentas de la organización matriz del proyecto, sujetas a los controles del administrador. No necesita lista de usuarios de prueba. BusyMax no puede verificar estos ajustes de la consola a partir del JSON." + "registrationGuideWorkspaceAudience": "Ve a Google Auth Platform → Branding. Si aún no has iniciado la configuración, selecciona Get started. Introduce BusyMax en App name, elige tu propio User support email y selecciona Next. En Audience, elige Internal. Selecciona Next, introduce tu correo en Contact Information y selecciona Next. Acepta User Data Policy y selecciona Continue y Create. Si ya está configurado, revisa Branding y Audience.\n\nInternal solo admite cuentas de la organización matriz del proyecto, sujetas a los controles del administrador. No necesita lista de usuarios de prueba. BusyMax no puede verificar estos ajustes de la consola a partir del JSON.", + "accountReconnectRequired": "Vuelve a conectar esta cuenta para reanudar la sincronización.", + "googleReconnectRequired": "Vuelve a conectar tu cuenta de Google para reanudar la sincronización de calendarios y tareas.", + "reconnectGoogleAccount": "Volver a conectar cuenta de Google", + "reconnectAccount": "Volver a conectar esta cuenta", + "reconnectSelectedAccount": "Volver a conectar {account}. Se conservarán tus calendarios, tareas y cambios locales pendientes.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "No se pudo verificar la identidad de esta conexión antigua de Google. Se conservan los calendarios, tareas y cambios pendientes. Añade una conexión de Google separada para sincronizar con seguridad. Los cambios pendientes seguirán en la cuenta original.", + "addSeparateGoogleAccount": "Añadir conexión de Google separada" } diff --git a/lib/l10n/app_et.arb b/lib/l10n/app_et.arb index bf827c88..889ffb13 100644 --- a/lib/l10n/app_et.arb +++ b/lib/l10n/app_et.arb @@ -1460,5 +1460,19 @@ "registrationGuideGooglePublishing": "Vali jaotises Audience Publish app ja kinnita In production. Ära jäta tavakasutust olekusse Testing: need Calendar/Tasksi volitused ja värskendustõendid aeguvad seitsme päevaga. Avaldamine ja kinnitamine on eraldi. Alla 100 kasutajaga isiklik kasutus võib kinnitamisest vabastatud olla, hoiatuste ja kasutajapiiranguga; laiem levitamine võib nõuda brändi ja õiguste kinnitamist. Ka tootmisvolitused võivad aeguda või tühistatud saada.", "registrationGuideWorkspacePermissions": "Vaata need õigused administraatoriga üle. Internal-rakendused ei pea neid nõusolekukuval loetlema. Kui seda nõutakse, ava Data Access → Add or remove scopes, lisa väärtused ning vali Update ja Save. Administraatori piirangud võivad volitust siiski takistada.", "registrationGuideMicrosoftOptionalPermissions": "Jagatud kalendrite ja kategooriate õigusi küsitakse eraldi vastavate valikuliste funktsioonide lubamisel; ära lisa neid ülaltoodud kohustuslikku seadistusse.", - "registrationGuideWorkspaceAudience": "Ava Google Auth Platform → Branding. Kui seadistust pole alustatud, vali Get started. Sisesta väljale App name BusyMax, vali oma User support email ja vali Next. Vali jaotises Audience Internal. Vali Next, sisesta oma e-post jaotises Contact Information ning vali Next. Nõustu User Data Policyga ja vali Continue ning Create. Olemasoleva seadistuse puhul vaata üle Branding ja Audience.\n\nInternal lubab vaid projekti emaorganisatsiooni kontosid administraatori piirangute kohaselt. Testkasutajate loendit pole vaja. BusyMax ei saa neid konsoolisätteid JSON-faili põhjal kontrollida." + "registrationGuideWorkspaceAudience": "Ava Google Auth Platform → Branding. Kui seadistust pole alustatud, vali Get started. Sisesta väljale App name BusyMax, vali oma User support email ja vali Next. Vali jaotises Audience Internal. Vali Next, sisesta oma e-post jaotises Contact Information ning vali Next. Nõustu User Data Policyga ja vali Continue ning Create. Olemasoleva seadistuse puhul vaata üle Branding ja Audience.\n\nInternal lubab vaid projekti emaorganisatsiooni kontosid administraatori piirangute kohaselt. Testkasutajate loendit pole vaja. BusyMax ei saa neid konsoolisätteid JSON-faili põhjal kontrollida.", + "accountReconnectRequired": "Sünkroonimise jätkamiseks ühenda see konto uuesti.", + "googleReconnectRequired": "Kalendrite ja ülesannete sünkroonimise jätkamiseks ühenda Google’i konto uuesti.", + "reconnectGoogleAccount": "Ühenda Google’i konto uuesti", + "reconnectAccount": "Ühenda see konto uuesti", + "reconnectSelectedAccount": "Ühenda {account} uuesti. Kalendrid, ülesanded ja ootel kohalikud muudatused säilitatakse.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Selle vana Google’i ühenduse identiteeti ei saanud kinnitada. Kalendrid, ülesanded ja ootel muudatused säilitatakse. Lisa turvaliseks sünkroonimiseks eraldi Google’i ühendus. Ootel muudatused jäävad algse konto juurde.", + "addSeparateGoogleAccount": "Lisa eraldi Google’i ühendus" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 4e9b7e12..d2f71cb0 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1356,5 +1356,19 @@ "registrationGuideGooglePublishing": "در Audience گزینهٔ Publish app را بزنید و In production را تأیید کنید. استفادهٔ معمول را در Testing نگذارید: مجوزهای Calendar/Tasks و توکن‌های تازه‌سازی آن‌ها پس از هفت روز منقضی می‌شوند. انتشار و تأیید جدا هستند. استفادهٔ شخصی با کمتر از 100 کاربر ممکن است با هشدار و سقف کاربران از تأیید معاف باشد؛ توزیع گسترده‌تر ممکن است به تأیید برند و محدوده‌ها نیاز داشته باشد. مجوزهای تولید هم می‌توانند منقضی یا لغو شوند.", "registrationGuideWorkspacePermissions": "این محدوده‌ها را با مدیر بررسی کنید. برنامه‌های Internal به فهرست محدوده‌ها در صفحهٔ رضایت نیاز ندارند. اگر درخواست شد، Data Access → Add or remove scopes را باز کنید، مقادیر را اضافه کنید و Update و Save را بزنید. محدودیت‌های مدیر همچنان می‌توانند مجوز را محدود کنند.", "registrationGuideMicrosoftOptionalPermissions": "مجوزهای تقویم مشترک و دسته‌بندی جداگانه هنگام فعال کردن این ویژگی‌های اختیاری درخواست می‌شوند؛ آن‌ها را به تنظیمات الزامی بالا اضافه نکنید.", - "registrationGuideWorkspaceAudience": "به Google Auth Platform → Branding بروید. اگر راه‌اندازی شروع نشده، Get started را بزنید. در App name مقدار BusyMax را وارد کنید، User support email خود را انتخاب کنید و Next را بزنید. در Audience گزینهٔ Internal را انتخاب کنید. Next را بزنید، ایمیل خود را در Contact Information وارد کنید و Next را بزنید. User Data Policy را بپذیرید و Continue و Create را بزنید. اگر قبلاً تنظیم شده، Branding و Audience را بررسی کنید.\n\nInternal فقط حساب‌های سازمان مادر پروژه را با محدودیت‌های مدیر می‌پذیرد. فهرست کاربران آزمایشی لازم نیست. BusyMax نمی‌تواند این تنظیمات کنسول را از JSON دانلودشده تأیید کند." + "registrationGuideWorkspaceAudience": "به Google Auth Platform → Branding بروید. اگر راه‌اندازی شروع نشده، Get started را بزنید. در App name مقدار BusyMax را وارد کنید، User support email خود را انتخاب کنید و Next را بزنید. در Audience گزینهٔ Internal را انتخاب کنید. Next را بزنید، ایمیل خود را در Contact Information وارد کنید و Next را بزنید. User Data Policy را بپذیرید و Continue و Create را بزنید. اگر قبلاً تنظیم شده، Branding و Audience را بررسی کنید.\n\nInternal فقط حساب‌های سازمان مادر پروژه را با محدودیت‌های مدیر می‌پذیرد. فهرست کاربران آزمایشی لازم نیست. BusyMax نمی‌تواند این تنظیمات کنسول را از JSON دانلودشده تأیید کند.", + "accountReconnectRequired": "برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.", + "googleReconnectRequired": "برای ادامهٔ همگام‌سازی تقویم‌ها و کارها، حساب Google خود را دوباره متصل کنید.", + "reconnectGoogleAccount": "اتصال دوبارهٔ حساب Google", + "reconnectAccount": "اتصال دوبارهٔ این حساب", + "reconnectSelectedAccount": "اتصال دوبارهٔ ⁨{account}⁩. تقویم‌ها، کارها و تغییرات محلی در انتظار حفظ می‌شوند.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "هویت این اتصال قدیمی Google تأیید نشد. تقویم‌ها، کارها و تغییرات در انتظار حفظ شده‌اند. برای همگام‌سازی امن، یک اتصال Google جداگانه اضافه کنید. تغییرات در انتظار در حساب اصلی باقی می‌مانند.", + "addSeparateGoogleAccount": "افزودن اتصال Google جداگانه" } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 64edb880..16d7d619 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "Valitse Audience-kohdassa Publish app ja vahvista In production. Älä jätä normaalia käyttöä Testing-tilaan: nämä Calendar/Tasks-valtuutukset ja päivitystunnukset vanhenevat seitsemässä päivässä. Julkaiseminen ja vahvistaminen ovat eri asioita. Alle 100 käyttäjän henkilökohtainen käyttö voi olla vapautettu vahvistuksesta varoituksin ja käyttäjärajoin; laajempi jakelu voi edellyttää brändin ja käyttöoikeuksien hyväksyntää. Tuotantovaltuutukset voivat silti vanhentua tai tulla perutuiksi.", "registrationGuideWorkspacePermissions": "Tarkista nämä käyttöoikeudet ylläpitäjän kanssa. Internal-sovellusten ei tarvitse luetella niitä suostumusnäytössä. Jos luetteloa pyydetään, avaa Data Access → Add or remove scopes, lisää arvot ja valitse Update ja Save. Ylläpitäjän rajoitukset voivat estää valtuutuksen.", "registrationGuideMicrosoftOptionalPermissions": "Jaettujen kalenterien ja luokkien käyttöoikeuksia pyydetään erikseen valinnaisten toimintojen käyttöönotossa; älä lisää niitä yllä olevaan pakolliseen määritykseen.", - "registrationGuideWorkspaceAudience": "Avaa Google Auth Platform → Branding. Aloita uusi määritys valitsemalla Get started. Anna App name -kenttään BusyMax, valitse oma User support email ja valitse Next. Valitse Audience-kohdassa Internal. Valitse Next, anna sähköpostiosoitteesi Contact Information -kohdassa ja valitse Next. Hyväksy User Data Policy ja valitse Continue ja Create. Tarkista aiemmassa määrityksessä Branding ja Audience.\n\nInternal sallii vain projektin emo-organisaation tilit ylläpitäjän rajoitusten mukaisesti. Testikäyttäjäluetteloa ei tarvita. BusyMax ei voi vahvistaa näitä konsoliasetuksia JSON-tiedostosta." + "registrationGuideWorkspaceAudience": "Avaa Google Auth Platform → Branding. Aloita uusi määritys valitsemalla Get started. Anna App name -kenttään BusyMax, valitse oma User support email ja valitse Next. Valitse Audience-kohdassa Internal. Valitse Next, anna sähköpostiosoitteesi Contact Information -kohdassa ja valitse Next. Hyväksy User Data Policy ja valitse Continue ja Create. Tarkista aiemmassa määrityksessä Branding ja Audience.\n\nInternal sallii vain projektin emo-organisaation tilit ylläpitäjän rajoitusten mukaisesti. Testikäyttäjäluetteloa ei tarvita. BusyMax ei voi vahvistaa näitä konsoliasetuksia JSON-tiedostosta.", + "accountReconnectRequired": "Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.", + "googleReconnectRequired": "Yhdistä Google-tilisi uudelleen jatkaaksesi kalenterien ja tehtävien synkronointia.", + "reconnectGoogleAccount": "Yhdistä Google-tili uudelleen", + "reconnectAccount": "Yhdistä tämä tili uudelleen", + "reconnectSelectedAccount": "Yhdistä {account} uudelleen. Kalenterit, tehtävät ja odottavat paikalliset muutokset säilytetään.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Tämän vanhan Google-yhteyden henkilöllisyyttä ei voitu vahvistaa. Kalenterit, tehtävät ja odottavat muutokset säilytetään. Lisää erillinen Google-yhteys turvallista synkronointia varten. Odottavat muutokset jäävät alkuperäiseen tiliin.", + "addSeparateGoogleAccount": "Lisää erillinen Google-yhteys" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 97ecd228..209afdd7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1345,5 +1345,19 @@ "registrationGuideGooglePublishing": "Dans Audience, sélectionnez Publish app et confirmez In production. Ne laissez pas l’usage normal en Testing : ces autorisations Calendar/Tasks et jetons d’actualisation expirent après sept jours. Publication et validation sont distinctes. Un usage personnel avec moins de 100 utilisateurs peut être exempté de validation, avec avertissements et plafond d’utilisateurs ; une diffusion plus large peut nécessiter la validation de marque et des portées. Les autorisations de production peuvent toujours expirer ou être révoquées.", "registrationGuideWorkspacePermissions": "Examinez ces portées avec votre administrateur. Les applications Internal n’ont pas à les lister sur l’écran de consentement. Si demandé, ouvrez Data Access → Add or remove scopes, ajoutez ces valeurs, puis Update et Save. Les contrôles administratifs peuvent encore limiter l’autorisation.", "registrationGuideMicrosoftOptionalPermissions": "Les autorisations de calendriers partagés et de catégories sont demandées séparément lors de l’activation de ces fonctions facultatives ; ne les ajoutez pas à la configuration obligatoire ci-dessus.", - "registrationGuideWorkspaceAudience": "Allez dans Google Auth Platform → Branding. Pour une nouvelle configuration, sélectionnez Get started. Saisissez BusyMax dans App name, choisissez votre propre User support email et sélectionnez Next. Dans Audience, choisissez Internal. Sélectionnez Next, saisissez votre adresse e-mail dans Contact Information et sélectionnez Next. Acceptez User Data Policy, puis sélectionnez Continue et Create. Si la configuration existe, vérifiez Branding et Audience.\n\nInternal autorise uniquement les comptes de l’organisation parente du projet, sous réserve des contrôles administratifs. Aucune liste de testeurs n’est nécessaire. BusyMax ne peut pas vérifier ces réglages à partir du JSON." + "registrationGuideWorkspaceAudience": "Allez dans Google Auth Platform → Branding. Pour une nouvelle configuration, sélectionnez Get started. Saisissez BusyMax dans App name, choisissez votre propre User support email et sélectionnez Next. Dans Audience, choisissez Internal. Sélectionnez Next, saisissez votre adresse e-mail dans Contact Information et sélectionnez Next. Acceptez User Data Policy, puis sélectionnez Continue et Create. Si la configuration existe, vérifiez Branding et Audience.\n\nInternal autorise uniquement les comptes de l’organisation parente du projet, sous réserve des contrôles administratifs. Aucune liste de testeurs n’est nécessaire. BusyMax ne peut pas vérifier ces réglages à partir du JSON.", + "accountReconnectRequired": "Reconnectez ce compte pour reprendre la synchronisation.", + "googleReconnectRequired": "Reconnectez votre compte Google pour reprendre la synchronisation des calendriers et des tâches.", + "reconnectGoogleAccount": "Reconnecter le compte Google", + "reconnectAccount": "Reconnecter ce compte", + "reconnectSelectedAccount": "Reconnecter {account}. Vos calendriers, tâches et modifications locales en attente seront conservés.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "L’identité de cette ancienne connexion Google n’a pas pu être vérifiée. Les calendriers, tâches et modifications en attente sont conservés. Ajoutez une connexion Google distincte pour synchroniser en toute sécurité. Les modifications en attente restent dans le compte d’origine.", + "addSeparateGoogleAccount": "Ajouter une connexion Google distincte" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index e14aa8c6..c6d3f0b4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "Audience में Publish app चुनकर In production की पुष्टि करें। सामान्य इस्तेमाल को Testing में न छोड़ें: ये Calendar/Tasks प्राधिकरण और रीफ़्रेश टोकन सात दिन में समाप्त होते हैं। प्रकाशन और सत्यापन अलग हैं। 100 से कम उपयोगकर्ताओं वाला निजी इस्तेमाल चेतावनी और उपयोगकर्ता सीमा के साथ सत्यापन से मुक्त हो सकता है; बड़े वितरण में ब्रैंड और स्कोप स्वीकृति चाहिए हो सकती है। प्रोडक्शन प्राधिकरण भी समाप्त या रद्द हो सकते हैं।", "registrationGuideWorkspacePermissions": "व्यवस्थापक के साथ ये स्कोप देखें। Internal ऐप को सहमति स्क्रीन पर स्कोप सूची नहीं चाहिए। माँगे जाने पर Data Access → Add or remove scopes खोलें, मान जोड़ें, फिर Update और Save चुनें। व्यवस्थापक के नियंत्रण प्राधिकरण सीमित कर सकते हैं।", "registrationGuideMicrosoftOptionalPermissions": "साझा कैलेंडर और श्रेणी अनुमतियाँ इन वैकल्पिक सुविधाओं को चालू करते समय अलग से माँगी जाती हैं; इन्हें ऊपर के अनिवार्य सेटअप में न जोड़ें।", - "registrationGuideWorkspaceAudience": "Google Auth Platform → Branding में जाएँ। सेटअप शुरू नहीं हुआ है तो Get started चुनें। App name में BusyMax दर्ज करें, अपना User support email चुनें और Next चुनें। Audience में Internal चुनें। Next चुनें, Contact Information में अपना ईमेल दर्ज करें और Next चुनें। User Data Policy स्वीकार करें और Continue तथा Create चुनें। पहले से सेट है तो Branding और Audience जाँचें।\n\nInternal सिर्फ़ प्रोजेक्ट के मूल संगठन के खातों को अनुमति देता है और व्यवस्थापक के नियंत्रण लागू रहते हैं। परीक्षण उपयोगकर्ताओं की सूची नहीं चाहिए। BusyMax डाउनलोड किए JSON से ये कंसोल सेटिंग सत्यापित नहीं कर सकता।" + "registrationGuideWorkspaceAudience": "Google Auth Platform → Branding में जाएँ। सेटअप शुरू नहीं हुआ है तो Get started चुनें। App name में BusyMax दर्ज करें, अपना User support email चुनें और Next चुनें। Audience में Internal चुनें। Next चुनें, Contact Information में अपना ईमेल दर्ज करें और Next चुनें। User Data Policy स्वीकार करें और Continue तथा Create चुनें। पहले से सेट है तो Branding और Audience जाँचें।\n\nInternal सिर्फ़ प्रोजेक्ट के मूल संगठन के खातों को अनुमति देता है और व्यवस्थापक के नियंत्रण लागू रहते हैं। परीक्षण उपयोगकर्ताओं की सूची नहीं चाहिए। BusyMax डाउनलोड किए JSON से ये कंसोल सेटिंग सत्यापित नहीं कर सकता।", + "accountReconnectRequired": "सिंक फिर से शुरू करने के लिए यह खाता दोबारा कनेक्ट करें।", + "googleReconnectRequired": "कैलेंडर और कार्यों का सिंक फिर से शुरू करने के लिए अपना Google खाता दोबारा कनेक्ट करें।", + "reconnectGoogleAccount": "Google खाता दोबारा कनेक्ट करें", + "reconnectAccount": "यह खाता दोबारा कनेक्ट करें", + "reconnectSelectedAccount": "{account} को दोबारा कनेक्ट करें। कैलेंडर, कार्य और लंबित स्थानीय बदलाव सुरक्षित रहेंगे।", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "इस पुराने Google कनेक्शन की पहचान सत्यापित नहीं हो सकी। कैलेंडर, कार्य और लंबित बदलाव सुरक्षित हैं। सुरक्षित सिंक के लिए अलग Google कनेक्शन जोड़ें। लंबित बदलाव मूल खाते में रहेंगे।", + "addSeparateGoogleAccount": "अलग Google कनेक्शन जोड़ें" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index a3d8d497..21bb79c1 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "In Audience scegli Publish app e conferma In production. Non lasciare l’uso normale in Testing: queste autorizzazioni Calendar/Tasks e i token di aggiornamento scadono dopo sette giorni. Pubblicazione e verifica sono separate. L’uso personale con meno di 100 utenti può essere esente da verifica, con avvisi e un limite utenti; una distribuzione più ampia può richiedere approvazione del marchio e degli ambiti. Le autorizzazioni in produzione possono comunque scadere o essere revocate.", "registrationGuideWorkspacePermissions": "Esamina questi ambiti con l’amministratore. Le app Internal non devono elencarli nella schermata di consenso. Se richiesto, apri Data Access → Add or remove scopes, aggiungi i valori, poi Update e Save. I controlli amministrativi possono ancora limitare l’autorizzazione.", "registrationGuideMicrosoftOptionalPermissions": "I permessi per calendari condivisi e categorie sono richiesti separatamente quando si attivano queste funzioni facoltative; non aggiungerli alla configurazione obbligatoria.", - "registrationGuideWorkspaceAudience": "Vai a Google Auth Platform → Branding. Se la configurazione non è iniziata, seleziona Get started. Inserisci BusyMax in App name, scegli la tua User support email e seleziona Next. In Audience scegli Internal. Seleziona Next, inserisci la tua email in Contact Information e seleziona Next. Accetta User Data Policy e seleziona Continue e Create. Se è già configurato, controlla Branding e Audience.\n\nInternal consente solo account dell’organizzazione principale del progetto, soggetti ai controlli amministrativi. Non serve un elenco di utenti di test. BusyMax non può verificare queste impostazioni dal JSON." + "registrationGuideWorkspaceAudience": "Vai a Google Auth Platform → Branding. Se la configurazione non è iniziata, seleziona Get started. Inserisci BusyMax in App name, scegli la tua User support email e seleziona Next. In Audience scegli Internal. Seleziona Next, inserisci la tua email in Contact Information e seleziona Next. Accetta User Data Policy e seleziona Continue e Create. Se è già configurato, controlla Branding e Audience.\n\nInternal consente solo account dell’organizzazione principale del progetto, soggetti ai controlli amministrativi. Non serve un elenco di utenti di test. BusyMax non può verificare queste impostazioni dal JSON.", + "accountReconnectRequired": "Ricollega questo account per riprendere la sincronizzazione.", + "googleReconnectRequired": "Ricollega il tuo account Google per riprendere la sincronizzazione di calendari e attività.", + "reconnectGoogleAccount": "Ricollega account Google", + "reconnectAccount": "Ricollega questo account", + "reconnectSelectedAccount": "Ricollega {account}. Calendari, attività e modifiche locali in sospeso verranno conservati.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Non è stato possibile verificare l’identità di questa vecchia connessione Google. Calendari, attività e modifiche in sospeso sono conservati. Aggiungi una connessione Google separata per sincronizzare in sicurezza. Le modifiche in sospeso rimarranno nell’account originale.", + "addSeparateGoogleAccount": "Aggiungi connessione Google separata" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 423eed24..5d53ff2b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "Audience で Publish app を選び、In production を確認します。通常の使用を Testing のままにしないでください。この Calendar/Tasks の認可と更新トークンは 7 日で期限切れになります。公開と確認は別です。100 人未満の個人利用は警告と人数制限付きで確認を免除される場合があります。広い配布にはブランドとスコープの承認が必要な場合があります。本番の認可も期限切れや取り消しになることがあります。", "registrationGuideWorkspacePermissions": "管理者とこれらのスコープを確認します。Internal アプリは同意画面へのスコープの記載は不要です。求められた場合は Data Access → Add or remove scopes で値を追加し、Update、Save を選びます。管理者の制限で認可が制限される場合があります。", "registrationGuideMicrosoftOptionalPermissions": "共有カレンダーとカテゴリの権限は、これらの任意機能を有効にするときに別途求められます。上の必須設定に追加しないでください。", - "registrationGuideWorkspaceAudience": "Google Auth Platform → Branding に移動します。未設定の場合は Get started を選択します。App name に BusyMax を入力し、ご自身の User support email を選んで Next を選択します。Audience ではInternal を選びます。Next を選び、Contact Information にメールアドレスを入力して Next を選択します。User Data Policy に同意し、Continue、Create の順に選択します。設定済みの場合は Branding と Audience を確認します。\n\nInternal はプロジェクトの親組織のアカウントのみ利用でき、管理者の制限も適用されます。テストユーザーの一覧は不要です。BusyMax はダウンロードした JSON からこれらの設定を確認できません。" + "registrationGuideWorkspaceAudience": "Google Auth Platform → Branding に移動します。未設定の場合は Get started を選択します。App name に BusyMax を入力し、ご自身の User support email を選んで Next を選択します。Audience ではInternal を選びます。Next を選び、Contact Information にメールアドレスを入力して Next を選択します。User Data Policy に同意し、Continue、Create の順に選択します。設定済みの場合は Branding と Audience を確認します。\n\nInternal はプロジェクトの親組織のアカウントのみ利用でき、管理者の制限も適用されます。テストユーザーの一覧は不要です。BusyMax はダウンロードした JSON からこれらの設定を確認できません。", + "accountReconnectRequired": "同期を再開するには、このアカウントを再接続してください。", + "googleReconnectRequired": "カレンダーとタスクの同期を再開するには、Google アカウントを再接続してください。", + "reconnectGoogleAccount": "Google アカウントを再接続", + "reconnectAccount": "このアカウントを再接続", + "reconnectSelectedAccount": "{account} を再接続します。カレンダー、タスク、保留中のローカル変更は保持されます。", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "この古い Google 接続のアカウントを確認できませんでした。カレンダー、タスク、保留中の変更は保持されています。安全に同期するには、別の Google 接続を追加してください。保留中の変更は元のアカウントに残ります。", + "addSeparateGoogleAccount": "別の Google 接続を追加" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 8547247c..d53385ee 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "Audience에서 Publish app을 선택하고 In production을 확인하세요. 일반 사용을 Testing으로 두지 마세요. 이 Calendar/Tasks 승인과 갱신 토큰은 7일 후 만료됩니다. 게시와 인증은 별개입니다. 100명 미만의 개인 사용은 경고와 사용자 제한을 적용하여 인증 면제가 가능하며, 광범위 배포는 브랜드와 범위 승인이 필요할 수 있습니다. 프로덕션 승인도 만료되거나 취소될 수 있습니다.", "registrationGuideWorkspacePermissions": "이 범위를 관리자와 검토하세요. Internal 앱은 동의 화면에 범위를 나열할 필요가 없습니다. 요청받으면 Data Access → Add or remove scopes에서 값을 추가하고 Update와 Save를 선택하세요. 관리자 제어로 승인이 제한될 수 있습니다.", "registrationGuideMicrosoftOptionalPermissions": "공유 캘린더와 범주 권한은 선택 기능을 활성화할 때 별도로 요청됩니다. 위의 필수 설정에 추가하지 마세요.", - "registrationGuideWorkspaceAudience": "Google Auth Platform → Branding으로 이동하세요. 아직 설정하지 않았다면 Get started를 선택하세요. App name에 BusyMax를 입력하고 본인 User support email을 선택한 뒤 Next를 선택하세요. Audience에서 Internal을 선택하세요. Next를 선택하고 Contact Information에 이메일을 입력한 뒤 Next를 선택하세요. User Data Policy에 동의하고 Continue와 Create를 선택하세요. 이미 설정했다면 Branding과 Audience를 확인하세요.\n\nInternal은 프로젝트의 상위 조직 계정만 허용하며 관리자 제어도 적용됩니다. 테스트 사용자 목록은 필요 없습니다. BusyMax는 다운로드한 JSON으로 이러한 콘솔 설정을 확인할 수 없습니다." + "registrationGuideWorkspaceAudience": "Google Auth Platform → Branding으로 이동하세요. 아직 설정하지 않았다면 Get started를 선택하세요. App name에 BusyMax를 입력하고 본인 User support email을 선택한 뒤 Next를 선택하세요. Audience에서 Internal을 선택하세요. Next를 선택하고 Contact Information에 이메일을 입력한 뒤 Next를 선택하세요. User Data Policy에 동의하고 Continue와 Create를 선택하세요. 이미 설정했다면 Branding과 Audience를 확인하세요.\n\nInternal은 프로젝트의 상위 조직 계정만 허용하며 관리자 제어도 적용됩니다. 테스트 사용자 목록은 필요 없습니다. BusyMax는 다운로드한 JSON으로 이러한 콘솔 설정을 확인할 수 없습니다.", + "accountReconnectRequired": "동기화를 재개하려면 이 계정을 다시 연결하세요.", + "googleReconnectRequired": "캘린더와 할 일 동기화를 재개하려면 Google 계정을 다시 연결하세요.", + "reconnectGoogleAccount": "Google 계정 다시 연결", + "reconnectAccount": "이 계정 다시 연결", + "reconnectSelectedAccount": "{account} 계정을 다시 연결합니다. 캘린더, 할 일 및 대기 중인 로컬 변경 사항은 유지됩니다.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "이 오래된 Google 연결의 계정 신원을 확인할 수 없습니다. 캘린더, 할 일 및 대기 중인 변경 사항은 유지됩니다. 안전한 동기화를 위해 별도의 Google 연결을 추가하세요. 대기 중인 변경 사항은 원래 계정에 남습니다.", + "addSeparateGoogleAccount": "별도의 Google 연결 추가" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 191e8e41..f6a4543e 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1963,5 +1963,19 @@ "registrationGuideGooglePublishing": "W Audience wybierz Publish app i potwierdź In production. Nie zostawiaj zwykłego użytku w Testing: te autoryzacje Calendar/Tasks i tokeny odświeżania wygasają po siedmiu dniach. Publikacja i weryfikacja są odrębne. Użytek osobisty z mniej niż 100 użytkownikami może być zwolniony z weryfikacji, z ostrzeżeniami i limitem; szersza dystrybucja może wymagać zatwierdzenia marki i zakresów. Autoryzacje produkcyjne nadal mogą wygasnąć lub zostać cofnięte.", "registrationGuideWorkspacePermissions": "Omów te zakresy z administratorem. Aplikacje Internal nie muszą ich wyświetlać na ekranie zgody. Na żądanie otwórz Data Access → Add or remove scopes, dodaj wartości, potem Update i Save. Ograniczenia administratora mogą nadal blokować autoryzację.", "registrationGuideMicrosoftOptionalPermissions": "Uprawnienia do udostępnionych kalendarzy i kategorii są żądane osobno przy włączaniu opcjonalnych funkcji; nie dodawaj ich do wymaganej konfiguracji powyżej.", - "registrationGuideWorkspaceAudience": "Przejdź do Google Auth Platform → Branding. Jeśli konfiguracja nie została rozpoczęta, wybierz Get started. Wpisz BusyMax w App name, wybierz własny User support email i wybierz Next. W Audience wybierz Internal. Wybierz Next, wpisz swój e-mail w Contact Information i wybierz Next. Zaakceptuj User Data Policy, a następnie wybierz Continue i Create. Jeśli konfiguracja już istnieje, sprawdź Branding i Audience.\n\nInternal dopuszcza tylko konta organizacji nadrzędnej projektu, z ograniczeniami administratora. Lista testerów nie jest potrzebna. BusyMax nie potwierdzi tych ustawień konsoli na podstawie JSON." + "registrationGuideWorkspaceAudience": "Przejdź do Google Auth Platform → Branding. Jeśli konfiguracja nie została rozpoczęta, wybierz Get started. Wpisz BusyMax w App name, wybierz własny User support email i wybierz Next. W Audience wybierz Internal. Wybierz Next, wpisz swój e-mail w Contact Information i wybierz Next. Zaakceptuj User Data Policy, a następnie wybierz Continue i Create. Jeśli konfiguracja już istnieje, sprawdź Branding i Audience.\n\nInternal dopuszcza tylko konta organizacji nadrzędnej projektu, z ograniczeniami administratora. Lista testerów nie jest potrzebna. BusyMax nie potwierdzi tych ustawień konsoli na podstawie JSON.", + "accountReconnectRequired": "Połącz ponownie to konto, aby wznowić synchronizację.", + "googleReconnectRequired": "Połącz ponownie konto Google, aby wznowić synchronizację kalendarzy i zadań.", + "reconnectGoogleAccount": "Połącz ponownie konto Google", + "reconnectAccount": "Połącz ponownie to konto", + "reconnectSelectedAccount": "Połącz ponownie {account}. Kalendarze, zadania i oczekujące zmiany lokalne zostaną zachowane.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Nie można zweryfikować tożsamości tego starszego połączenia Google. Kalendarze, zadania i oczekujące zmiany są zachowane. Dodaj osobne połączenie Google, aby bezpiecznie synchronizować. Oczekujące zmiany pozostaną na pierwotnym koncie.", + "addSeparateGoogleAccount": "Dodaj osobne połączenie Google" } diff --git a/lib/l10n/app_pt_PT.arb b/lib/l10n/app_pt_PT.arb index 15c14946..cfb5f04c 100644 --- a/lib/l10n/app_pt_PT.arb +++ b/lib/l10n/app_pt_PT.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "Em Audience, selecione Publish app e confirme In production. Não mantenha a utilização normal em Testing: estas autorizações Calendar/Tasks e tokens de atualização expiram após sete dias. Publicação e verificação são processos separados. O uso pessoal com menos de 100 utilizadores pode estar isento de verificação, com avisos e um limite de utilizadores; uma distribuição maior pode exigir aprovação da marca e dos âmbitos. As autorizações de produção podem ainda expirar ou ser revogadas.", "registrationGuideWorkspacePermissions": "Reveja estes âmbitos com o administrador. As aplicações Internal não precisam de os listar no ecrã de consentimento. Se solicitado, abra Data Access → Add or remove scopes, adicione os valores, depois Update e Save. Os controlos administrativos podem restringir a autorização.", "registrationGuideMicrosoftOptionalPermissions": "As permissões de calendários partilhados e categorias são pedidas separadamente ao ativar essas funções opcionais; não as adicione à configuração obrigatória acima.", - "registrationGuideWorkspaceAudience": "Aceda a Google Auth Platform → Branding. Se a configuração ainda não começou, selecione Get started. Introduza BusyMax em App name, escolha o seu User support email e selecione Next. Em Audience, escolha Internal. Selecione Next, introduza o seu email em Contact Information e selecione Next. Aceite User Data Policy e selecione Continue e Create. Se já estiver configurado, reveja Branding e Audience.\n\nInternal permite apenas contas da organização principal do projeto, sujeitas aos controlos administrativos. Não é necessária uma lista de utilizadores de teste. O BusyMax não verifica estas definições a partir do JSON." + "registrationGuideWorkspaceAudience": "Aceda a Google Auth Platform → Branding. Se a configuração ainda não começou, selecione Get started. Introduza BusyMax em App name, escolha o seu User support email e selecione Next. Em Audience, escolha Internal. Selecione Next, introduza o seu email em Contact Information e selecione Next. Aceite User Data Policy e selecione Continue e Create. Se já estiver configurado, reveja Branding e Audience.\n\nInternal permite apenas contas da organização principal do projeto, sujeitas aos controlos administrativos. Não é necessária uma lista de utilizadores de teste. O BusyMax não verifica estas definições a partir do JSON.", + "accountReconnectRequired": "Volte a ligar esta conta para retomar a sincronização.", + "googleReconnectRequired": "Volte a ligar a sua conta Google para retomar a sincronização de calendários e tarefas.", + "reconnectGoogleAccount": "Voltar a ligar conta Google", + "reconnectAccount": "Voltar a ligar esta conta", + "reconnectSelectedAccount": "Voltar a ligar {account}. Os calendários, tarefas e alterações locais pendentes serão preservados.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Não foi possível verificar a identidade desta ligação antiga ao Google. Os calendários, tarefas e alterações pendentes são preservados. Adicione uma ligação Google separada para sincronizar em segurança. As alterações pendentes permanecerão na conta original.", + "addSeparateGoogleAccount": "Adicionar ligação Google separada" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index f35edf91..4edfd4da 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "В Audience выберите Publish app и подтвердите In production. Не оставляйте обычное использование в Testing: эти авторизации Calendar/Tasks и токены обновления истекают через семь дней. Публикация и проверка — отдельные процессы. Личное использование менее чем 100 пользователями может не требовать проверки, с предупреждениями и лимитом; широкое распространение может требовать одобрения бренда и разрешений. Производственные авторизации тоже могут истекать или отзываться.", "registrationGuideWorkspacePermissions": "Согласуйте эти разрешения с администратором. Приложения Internal не обязаны перечислять их на экране согласия. По запросу откройте Data Access → Add or remove scopes, добавьте значения, затем Update и Save. Ограничения администратора могут препятствовать авторизации.", "registrationGuideMicrosoftOptionalPermissions": "Разрешения для общих календарей и категорий запрашиваются отдельно при включении этих дополнительных функций; не добавляйте их к обязательной настройке выше.", - "registrationGuideWorkspaceAudience": "Откройте Google Auth Platform → Branding. Для новой настройки выберите Get started. Введите BusyMax в App name, выберите свой User support email и нажмите Next. В Audience выберите Internal. Нажмите Next, введите свой адрес в Contact Information и нажмите Next. Примите User Data Policy и выберите Continue и Create. Если настройка уже есть, проверьте Branding и Audience.\n\nInternal допускает только аккаунты родительской организации проекта с учётом ограничений администратора. Список тестовых пользователей не нужен. BusyMax не может проверить эти настройки консоли по JSON." + "registrationGuideWorkspaceAudience": "Откройте Google Auth Platform → Branding. Для новой настройки выберите Get started. Введите BusyMax в App name, выберите свой User support email и нажмите Next. В Audience выберите Internal. Нажмите Next, введите свой адрес в Contact Information и нажмите Next. Примите User Data Policy и выберите Continue и Create. Если настройка уже есть, проверьте Branding и Audience.\n\nInternal допускает только аккаунты родительской организации проекта с учётом ограничений администратора. Список тестовых пользователей не нужен. BusyMax не может проверить эти настройки консоли по JSON.", + "accountReconnectRequired": "Подключите этот аккаунт заново, чтобы возобновить синхронизацию.", + "googleReconnectRequired": "Подключите аккаунт Google заново, чтобы возобновить синхронизацию календарей и задач.", + "reconnectGoogleAccount": "Подключить аккаунт Google заново", + "reconnectAccount": "Подключить этот аккаунт заново", + "reconnectSelectedAccount": "Подключить {account} заново. Календари, задачи и ожидающие локальные изменения будут сохранены.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Не удалось подтвердить личность владельца этого старого подключения Google. Календари, задачи и ожидающие изменения сохранены. Добавьте отдельное подключение Google для безопасной синхронизации. Ожидающие изменения останутся в исходном аккаунте.", + "addSeparateGoogleAccount": "Добавить отдельное подключение Google" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index b908ff2f..74720c74 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "Trong Audience, chọn Publish app và xác nhận In production. Không để sử dụng thông thường ở Testing: các ủy quyền Calendar/Tasks và mã làm mới này hết hạn sau bảy ngày. Xuất bản và xác minh là hai việc riêng. Sử dụng cá nhân dưới 100 người có thể được miễn xác minh, với cảnh báo và giới hạn người dùng; phân phối rộng hơn có thể cần phê duyệt thương hiệu và phạm vi. Ủy quyền sản xuất vẫn có thể hết hạn hoặc bị thu hồi.", "registrationGuideWorkspacePermissions": "Xem các phạm vi này với quản trị viên. Ứng dụng Internal không cần liệt kê phạm vi trên màn hình đồng ý. Nếu được yêu cầu, mở Data Access → Add or remove scopes, thêm giá trị rồi Update và Save. Kiểm soát quản trị vẫn có thể hạn chế ủy quyền.", "registrationGuideMicrosoftOptionalPermissions": "Quyền lịch dùng chung và danh mục được yêu cầu riêng khi bật các tính năng tùy chọn; không thêm chúng vào thiết lập bắt buộc ở trên.", - "registrationGuideWorkspaceAudience": "Vào Google Auth Platform → Branding. Nếu chưa bắt đầu thiết lập, chọn Get started. Nhập BusyMax vào App name, chọn User support email của bạn rồi chọn Next. Trong Audience, chọn Internal. Chọn Next, nhập email vào Contact Information rồi chọn Next. Chấp nhận User Data Policy rồi chọn Continue và Create. Nếu đã thiết lập, kiểm tra Branding và Audience.\n\nInternal chỉ cho phép tài khoản thuộc tổ chức mẹ của dự án, chịu kiểm soát của quản trị viên. Không cần danh sách người dùng thử. BusyMax không thể xác minh các thiết lập này từ JSON tải xuống." + "registrationGuideWorkspaceAudience": "Vào Google Auth Platform → Branding. Nếu chưa bắt đầu thiết lập, chọn Get started. Nhập BusyMax vào App name, chọn User support email của bạn rồi chọn Next. Trong Audience, chọn Internal. Chọn Next, nhập email vào Contact Information rồi chọn Next. Chấp nhận User Data Policy rồi chọn Continue và Create. Nếu đã thiết lập, kiểm tra Branding và Audience.\n\nInternal chỉ cho phép tài khoản thuộc tổ chức mẹ của dự án, chịu kiểm soát của quản trị viên. Không cần danh sách người dùng thử. BusyMax không thể xác minh các thiết lập này từ JSON tải xuống.", + "accountReconnectRequired": "Kết nối lại tài khoản này để tiếp tục đồng bộ.", + "googleReconnectRequired": "Kết nối lại tài khoản Google để tiếp tục đồng bộ lịch và công việc.", + "reconnectGoogleAccount": "Kết nối lại tài khoản Google", + "reconnectAccount": "Kết nối lại tài khoản này", + "reconnectSelectedAccount": "Kết nối lại {account}. Lịch, công việc và thay đổi cục bộ đang chờ sẽ được giữ lại.", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "Không thể xác minh danh tính của kết nối Google cũ này. Lịch, công việc và thay đổi đang chờ được giữ lại. Thêm một kết nối Google riêng để đồng bộ an toàn. Thay đổi đang chờ vẫn thuộc tài khoản gốc.", + "addSeparateGoogleAccount": "Thêm kết nối Google riêng" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0ed139c9..f7cecdc5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "在 Audience 中选择 Publish app 并确认 In production。日常使用不要停留在 Testing:这些 Calendar/Tasks 授权和刷新令牌会在七天后过期。发布与验证是两回事。少于 100 人的个人使用可能免于验证,但仍有未验证警告和人数限制;更广泛分发可能需要品牌和权限范围批准。生产授权仍可能过期或被撤销。", "registrationGuideWorkspacePermissions": "与管理员检查以下权限范围。Internal 应用不需要在同意屏幕中列出权限范围。如管理员要求,请打开 Data Access → Add or remove scopes,添加这些值,然后选择 Update 和 Save。管理员控制仍可能限制授权。", "registrationGuideMicrosoftOptionalPermissions": "共享日历和类别权限会在启用相应可选功能时单独请求;不要将其添加到上述必需设置中。", - "registrationGuideWorkspaceAudience": "进入 Google Auth Platform → Branding。如果尚未开始设置,选择 Get started。在 App name 中输入 BusyMax,选择您自己的 User support email,然后选择 Next。在 Audience 中,请选择 Internal。选择 Next,在 Contact Information 中输入您的邮箱,再选择 Next。接受 User Data Policy,然后选择 Continue 和 Create。如果已完成设置,请检查 Branding 和 Audience。\n\nInternal 仅允许项目父组织中的帐号,并受管理员控制。不需要测试用户列表。BusyMax 无法从下载的 JSON 验证这些控制台设置。" + "registrationGuideWorkspaceAudience": "进入 Google Auth Platform → Branding。如果尚未开始设置,选择 Get started。在 App name 中输入 BusyMax,选择您自己的 User support email,然后选择 Next。在 Audience 中,请选择 Internal。选择 Next,在 Contact Information 中输入您的邮箱,再选择 Next。接受 User Data Policy,然后选择 Continue 和 Create。如果已完成设置,请检查 Branding 和 Audience。\n\nInternal 仅允许项目父组织中的帐号,并受管理员控制。不需要测试用户列表。BusyMax 无法从下载的 JSON 验证这些控制台设置。", + "accountReconnectRequired": "请重新连接此账号以恢复同步。", + "googleReconnectRequired": "请重新连接 Google 账号以恢复日历和任务同步。", + "reconnectGoogleAccount": "重新连接 Google 账号", + "reconnectAccount": "重新连接此账号", + "reconnectSelectedAccount": "重新连接 {account}。日历、任务和待处理的本地更改将保留。", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "无法验证此旧 Google 连接的账号身份。日历、任务和待处理的更改已保留。请添加独立的 Google 连接以安全同步。待处理的更改将保留在原账号中。", + "addSeparateGoogleAccount": "添加独立的 Google 连接" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index abcc8f16..253a2dd3 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "在 Audience 中选择 Publish app 并确认 In production。日常使用不要停留在 Testing:这些 Calendar/Tasks 授权和刷新令牌会在七天后过期。发布与验证是两回事。少于 100 人的个人使用可能免于验证,但仍有未验证警告和人数限制;更广泛分发可能需要品牌和权限范围批准。生产授权仍可能过期或被撤销。", "registrationGuideWorkspacePermissions": "与管理员检查以下权限范围。Internal 应用不需要在同意屏幕中列出权限范围。如管理员要求,请打开 Data Access → Add or remove scopes,添加这些值,然后选择 Update 和 Save。管理员控制仍可能限制授权。", "registrationGuideMicrosoftOptionalPermissions": "共享日历和类别权限会在启用相应可选功能时单独请求;不要将其添加到上述必需设置中。", - "registrationGuideWorkspaceAudience": "进入 Google Auth Platform → Branding。如果尚未开始设置,选择 Get started。在 App name 中输入 BusyMax,选择您自己的 User support email,然后选择 Next。在 Audience 中,请选择 Internal。选择 Next,在 Contact Information 中输入您的邮箱,再选择 Next。接受 User Data Policy,然后选择 Continue 和 Create。如果已完成设置,请检查 Branding 和 Audience。\n\nInternal 仅允许项目父组织中的帐号,并受管理员控制。不需要测试用户列表。BusyMax 无法从下载的 JSON 验证这些控制台设置。" + "registrationGuideWorkspaceAudience": "进入 Google Auth Platform → Branding。如果尚未开始设置,选择 Get started。在 App name 中输入 BusyMax,选择您自己的 User support email,然后选择 Next。在 Audience 中,请选择 Internal。选择 Next,在 Contact Information 中输入您的邮箱,再选择 Next。接受 User Data Policy,然后选择 Continue 和 Create。如果已完成设置,请检查 Branding 和 Audience。\n\nInternal 仅允许项目父组织中的帐号,并受管理员控制。不需要测试用户列表。BusyMax 无法从下载的 JSON 验证这些控制台设置。", + "accountReconnectRequired": "请重新连接此账号以恢复同步。", + "googleReconnectRequired": "请重新连接 Google 账号以恢复日历和任务同步。", + "reconnectGoogleAccount": "重新连接 Google 账号", + "reconnectAccount": "重新连接此账号", + "reconnectSelectedAccount": "重新连接 {account}。日历、任务和待处理的本地更改将保留。", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "无法验证此旧 Google 连接的账号身份。日历、任务和待处理的更改已保留。请添加独立的 Google 连接以安全同步。待处理的更改将保留在原账号中。", + "addSeparateGoogleAccount": "添加独立的 Google 连接" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 23c7179c..d7bf73f3 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1282,5 +1282,19 @@ "registrationGuideGooglePublishing": "在 Audience 中選擇 Publish app 並確認 In production。日常使用不要停留在 Testing:這些 Calendar/Tasks 授權和更新權杖會在七天後到期。發佈與驗證是兩回事。少於 100 人的個人使用可能免於驗證,但仍有未驗證警告和人數限制;更廣泛散布可能需要品牌和權限範圍核准。正式授權仍可能到期或遭撤銷。", "registrationGuideWorkspacePermissions": "與管理員檢查以下權限範圍。Internal 應用程式不需要在同意畫面中列出權限範圍。如管理員要求,請開啟 Data Access → Add or remove scopes,新增這些值,然後選擇 Update 和 Save。管理員控制仍可能限制授權。", "registrationGuideMicrosoftOptionalPermissions": "共用日曆和類別權限會在啟用相應選用功能時個別要求;不要將其加入上述必要設定。", - "registrationGuideWorkspaceAudience": "進入 Google Auth Platform → Branding。如果尚未開始設定,選擇 Get started。在 App name 中輸入 BusyMax,選擇您自己的 User support email,然後選擇 Next。在 Audience 中,請選擇 Internal。選擇 Next,在 Contact Information 中輸入您的電子郵件,再選擇 Next。接受 User Data Policy,然後選擇 Continue 和 Create。如果已完成設定,請檢查 Branding 和 Audience。\n\nInternal 僅允許專案父組織中的帳號,並受管理員控制。不需要測試使用者清單。BusyMax 無法從下載的 JSON 驗證這些主控台設定。" + "registrationGuideWorkspaceAudience": "進入 Google Auth Platform → Branding。如果尚未開始設定,選擇 Get started。在 App name 中輸入 BusyMax,選擇您自己的 User support email,然後選擇 Next。在 Audience 中,請選擇 Internal。選擇 Next,在 Contact Information 中輸入您的電子郵件,再選擇 Next。接受 User Data Policy,然後選擇 Continue 和 Create。如果已完成設定,請檢查 Branding 和 Audience。\n\nInternal 僅允許專案父組織中的帳號,並受管理員控制。不需要測試使用者清單。BusyMax 無法從下載的 JSON 驗證這些主控台設定。", + "accountReconnectRequired": "請重新連接此帳號以恢復同步。", + "googleReconnectRequired": "請重新連接 Google 帳號以恢復行事曆和待辦事項同步。", + "reconnectGoogleAccount": "重新連接 Google 帳號", + "reconnectAccount": "重新連接此帳號", + "reconnectSelectedAccount": "重新連接 {account}。行事曆、待辦事項和待處理的本機變更將保留。", + "@reconnectSelectedAccount": { + "placeholders": { + "account": { + "type": "String" + } + } + }, + "googleIdentityUnverified": "無法驗證此舊 Google 連線的帳號身分。行事曆、待辦事項和待處理的變更已保留。請新增獨立的 Google 連線以安全同步。待處理的變更將保留在原帳號中。", + "addSeparateGoogleAccount": "新增獨立的 Google 連線" } diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 2c9c3ab7..62f99895 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -5993,6 +5993,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Password'** String get contactsPasswordLabel; + + /// No description provided for @accountReconnectRequired. + /// + /// In en, this message translates to: + /// **'This account needs to be reconnected to resume synchronization.'** + String get accountReconnectRequired; + + /// No description provided for @googleReconnectRequired. + /// + /// In en, this message translates to: + /// **'Your Google account needs to be reconnected to resume calendar and task synchronization.'** + String get googleReconnectRequired; + + /// No description provided for @reconnectGoogleAccount. + /// + /// In en, this message translates to: + /// **'Reconnect Google account'** + String get reconnectGoogleAccount; + + /// No description provided for @reconnectAccount. + /// + /// In en, this message translates to: + /// **'Reconnect this account'** + String get reconnectAccount; + + /// No description provided for @reconnectSelectedAccount. + /// + /// In en, this message translates to: + /// **'Reconnect {account}. Your calendars, tasks and pending local changes will be preserved.'** + String reconnectSelectedAccount(String account); + + /// No description provided for @googleIdentityUnverified. + /// + /// In en, this message translates to: + /// **'The identity of this older Google connection could not be verified. Its calendars, tasks and pending changes are preserved. Add a separate Google connection to synchronize safely. Pending changes will remain with the original account.'** + String get googleIdentityUnverified; + + /// No description provided for @addSeparateGoogleAccount. + /// + /// In en, this message translates to: + /// **'Add a separate Google connection'** + String get addSeparateGoogleAccount; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 4fde87e0..5b7084ff 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -3620,4 +3620,30 @@ class AppLocalizationsAr extends AppLocalizations { @override String get contactsPasswordLabel => 'كلمة المرور'; + + @override + String get accountReconnectRequired => + 'أعد ربط هذا الحساب لاستئناف المزامنة.'; + + @override + String get googleReconnectRequired => + 'أعد ربط حساب Google لاستئناف مزامنة التقويمات والمهام.'; + + @override + String get reconnectGoogleAccount => 'إعادة ربط حساب Google'; + + @override + String get reconnectAccount => 'إعادة ربط هذا الحساب'; + + @override + String reconnectSelectedAccount(String account) { + return 'إعادة ربط ⁨$account⁩. سيتم الاحتفاظ بالتقويمات والمهام والتغييرات المحلية المعلقة.'; + } + + @override + String get googleIdentityUnverified => + 'تعذر التحقق من هوية اتصال Google القديم هذا. تم الاحتفاظ بالتقويمات والمهام والتغييرات المعلقة. أضف اتصال Google منفصلًا للمزامنة بأمان. ستبقى التغييرات المعلقة مع الحساب الأصلي.'; + + @override + String get addSeparateGoogleAccount => 'إضافة اتصال Google منفصل'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index c3572e6e..339c6098 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -3570,4 +3570,31 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contactsPasswordLabel => 'Passwort'; + + @override + String get accountReconnectRequired => + 'Dieses Konto muss erneut verbunden werden, um die Synchronisierung fortzusetzen.'; + + @override + String get googleReconnectRequired => + 'Verbinden Sie Ihr Google-Konto erneut, um Kalender und Aufgaben weiter zu synchronisieren.'; + + @override + String get reconnectGoogleAccount => 'Google-Konto erneut verbinden'; + + @override + String get reconnectAccount => 'Dieses Konto erneut verbinden'; + + @override + String reconnectSelectedAccount(String account) { + return '$account erneut verbinden. Kalender, Aufgaben und ausstehende lokale Änderungen bleiben erhalten.'; + } + + @override + String get googleIdentityUnverified => + 'Die Identität dieser älteren Google-Verbindung konnte nicht bestätigt werden. Kalender, Aufgaben und ausstehende Änderungen bleiben erhalten. Fügen Sie eine separate Google-Verbindung hinzu. Ausstehende Änderungen bleiben beim ursprünglichen Konto.'; + + @override + String get addSeparateGoogleAccount => + 'Separate Google-Verbindung hinzufügen'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e052357c..9b70309e 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -3592,4 +3592,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactsPasswordLabel => 'Password'; + + @override + String get accountReconnectRequired => + 'This account needs to be reconnected to resume synchronization.'; + + @override + String get googleReconnectRequired => + 'Your Google account needs to be reconnected to resume calendar and task synchronization.'; + + @override + String get reconnectGoogleAccount => 'Reconnect Google account'; + + @override + String get reconnectAccount => 'Reconnect this account'; + + @override + String reconnectSelectedAccount(String account) { + return 'Reconnect $account. Your calendars, tasks and pending local changes will be preserved.'; + } + + @override + String get googleIdentityUnverified => + 'The identity of this older Google connection could not be verified. Its calendars, tasks and pending changes are preserved. Add a separate Google connection to synchronize safely. Pending changes will remain with the original account.'; + + @override + String get addSeparateGoogleAccount => 'Add a separate Google connection'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 3c04f147..4d7115a4 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -3577,4 +3577,30 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contactsPasswordLabel => 'Contraseña'; + + @override + String get accountReconnectRequired => + 'Vuelve a conectar esta cuenta para reanudar la sincronización.'; + + @override + String get googleReconnectRequired => + 'Vuelve a conectar tu cuenta de Google para reanudar la sincronización de calendarios y tareas.'; + + @override + String get reconnectGoogleAccount => 'Volver a conectar cuenta de Google'; + + @override + String get reconnectAccount => 'Volver a conectar esta cuenta'; + + @override + String reconnectSelectedAccount(String account) { + return 'Volver a conectar $account. Se conservarán tus calendarios, tareas y cambios locales pendientes.'; + } + + @override + String get googleIdentityUnverified => + 'No se pudo verificar la identidad de esta conexión antigua de Google. Se conservan los calendarios, tareas y cambios pendientes. Añade una conexión de Google separada para sincronizar con seguridad. Los cambios pendientes seguirán en la cuenta original.'; + + @override + String get addSeparateGoogleAccount => 'Añadir conexión de Google separada'; } diff --git a/lib/l10n/generated/app_localizations_et.dart b/lib/l10n/generated/app_localizations_et.dart index a1096e94..b451d802 100644 --- a/lib/l10n/generated/app_localizations_et.dart +++ b/lib/l10n/generated/app_localizations_et.dart @@ -3544,4 +3544,30 @@ class AppLocalizationsEt extends AppLocalizations { @override String get contactsPasswordLabel => 'Parool'; + + @override + String get accountReconnectRequired => + 'Sünkroonimise jätkamiseks ühenda see konto uuesti.'; + + @override + String get googleReconnectRequired => + 'Kalendrite ja ülesannete sünkroonimise jätkamiseks ühenda Google’i konto uuesti.'; + + @override + String get reconnectGoogleAccount => 'Ühenda Google’i konto uuesti'; + + @override + String get reconnectAccount => 'Ühenda see konto uuesti'; + + @override + String reconnectSelectedAccount(String account) { + return 'Ühenda $account uuesti. Kalendrid, ülesanded ja ootel kohalikud muudatused säilitatakse.'; + } + + @override + String get googleIdentityUnverified => + 'Selle vana Google’i ühenduse identiteeti ei saanud kinnitada. Kalendrid, ülesanded ja ootel muudatused säilitatakse. Lisa turvaliseks sünkroonimiseks eraldi Google’i ühendus. Ootel muudatused jäävad algse konto juurde.'; + + @override + String get addSeparateGoogleAccount => 'Lisa eraldi Google’i ühendus'; } diff --git a/lib/l10n/generated/app_localizations_fa.dart b/lib/l10n/generated/app_localizations_fa.dart index 96be11d2..02e2c5ca 100644 --- a/lib/l10n/generated/app_localizations_fa.dart +++ b/lib/l10n/generated/app_localizations_fa.dart @@ -3599,4 +3599,30 @@ class AppLocalizationsFa extends AppLocalizations { @override String get contactsPasswordLabel => 'گذرواژه'; + + @override + String get accountReconnectRequired => + 'برای ادامهٔ همگام‌سازی، این حساب را دوباره متصل کنید.'; + + @override + String get googleReconnectRequired => + 'برای ادامهٔ همگام‌سازی تقویم‌ها و کارها، حساب Google خود را دوباره متصل کنید.'; + + @override + String get reconnectGoogleAccount => 'اتصال دوبارهٔ حساب Google'; + + @override + String get reconnectAccount => 'اتصال دوبارهٔ این حساب'; + + @override + String reconnectSelectedAccount(String account) { + return 'اتصال دوبارهٔ ⁨$account⁩. تقویم‌ها، کارها و تغییرات محلی در انتظار حفظ می‌شوند.'; + } + + @override + String get googleIdentityUnverified => + 'هویت این اتصال قدیمی Google تأیید نشد. تقویم‌ها، کارها و تغییرات در انتظار حفظ شده‌اند. برای همگام‌سازی امن، یک اتصال Google جداگانه اضافه کنید. تغییرات در انتظار در حساب اصلی باقی می‌مانند.'; + + @override + String get addSeparateGoogleAccount => 'افزودن اتصال Google جداگانه'; } diff --git a/lib/l10n/generated/app_localizations_fi.dart b/lib/l10n/generated/app_localizations_fi.dart index 17c79ce8..c1507c84 100644 --- a/lib/l10n/generated/app_localizations_fi.dart +++ b/lib/l10n/generated/app_localizations_fi.dart @@ -3595,4 +3595,30 @@ class AppLocalizationsFi extends AppLocalizations { @override String get contactsPasswordLabel => 'Salasana'; + + @override + String get accountReconnectRequired => + 'Yhdistä tämä tili uudelleen jatkaaksesi synkronointia.'; + + @override + String get googleReconnectRequired => + 'Yhdistä Google-tilisi uudelleen jatkaaksesi kalenterien ja tehtävien synkronointia.'; + + @override + String get reconnectGoogleAccount => 'Yhdistä Google-tili uudelleen'; + + @override + String get reconnectAccount => 'Yhdistä tämä tili uudelleen'; + + @override + String reconnectSelectedAccount(String account) { + return 'Yhdistä $account uudelleen. Kalenterit, tehtävät ja odottavat paikalliset muutokset säilytetään.'; + } + + @override + String get googleIdentityUnverified => + 'Tämän vanhan Google-yhteyden henkilöllisyyttä ei voitu vahvistaa. Kalenterit, tehtävät ja odottavat muutokset säilytetään. Lisää erillinen Google-yhteys turvallista synkronointia varten. Odottavat muutokset jäävät alkuperäiseen tiliin.'; + + @override + String get addSeparateGoogleAccount => 'Lisää erillinen Google-yhteys'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 20944867..8cf7c074 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -3582,4 +3582,31 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contactsPasswordLabel => 'Mot de passe'; + + @override + String get accountReconnectRequired => + 'Reconnectez ce compte pour reprendre la synchronisation.'; + + @override + String get googleReconnectRequired => + 'Reconnectez votre compte Google pour reprendre la synchronisation des calendriers et des tâches.'; + + @override + String get reconnectGoogleAccount => 'Reconnecter le compte Google'; + + @override + String get reconnectAccount => 'Reconnecter ce compte'; + + @override + String reconnectSelectedAccount(String account) { + return 'Reconnecter $account. Vos calendriers, tâches et modifications locales en attente seront conservés.'; + } + + @override + String get googleIdentityUnverified => + 'L’identité de cette ancienne connexion Google n’a pas pu être vérifiée. Les calendriers, tâches et modifications en attente sont conservés. Ajoutez une connexion Google distincte pour synchroniser en toute sécurité. Les modifications en attente restent dans le compte d’origine.'; + + @override + String get addSeparateGoogleAccount => + 'Ajouter une connexion Google distincte'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 23c4d454..5f31072d 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -3565,4 +3565,30 @@ class AppLocalizationsHi extends AppLocalizations { @override String get contactsPasswordLabel => 'पासवर्ड'; + + @override + String get accountReconnectRequired => + 'सिंक फिर से शुरू करने के लिए यह खाता दोबारा कनेक्ट करें।'; + + @override + String get googleReconnectRequired => + 'कैलेंडर और कार्यों का सिंक फिर से शुरू करने के लिए अपना Google खाता दोबारा कनेक्ट करें।'; + + @override + String get reconnectGoogleAccount => 'Google खाता दोबारा कनेक्ट करें'; + + @override + String get reconnectAccount => 'यह खाता दोबारा कनेक्ट करें'; + + @override + String reconnectSelectedAccount(String account) { + return '$account को दोबारा कनेक्ट करें। कैलेंडर, कार्य और लंबित स्थानीय बदलाव सुरक्षित रहेंगे।'; + } + + @override + String get googleIdentityUnverified => + 'इस पुराने Google कनेक्शन की पहचान सत्यापित नहीं हो सकी। कैलेंडर, कार्य और लंबित बदलाव सुरक्षित हैं। सुरक्षित सिंक के लिए अलग Google कनेक्शन जोड़ें। लंबित बदलाव मूल खाते में रहेंगे।'; + + @override + String get addSeparateGoogleAccount => 'अलग Google कनेक्शन जोड़ें'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index ff09eb1b..f6761f95 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -3591,4 +3591,30 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contactsPasswordLabel => 'Parola d’accesso'; + + @override + String get accountReconnectRequired => + 'Ricollega questo account per riprendere la sincronizzazione.'; + + @override + String get googleReconnectRequired => + 'Ricollega il tuo account Google per riprendere la sincronizzazione di calendari e attività.'; + + @override + String get reconnectGoogleAccount => 'Ricollega account Google'; + + @override + String get reconnectAccount => 'Ricollega questo account'; + + @override + String reconnectSelectedAccount(String account) { + return 'Ricollega $account. Calendari, attività e modifiche locali in sospeso verranno conservati.'; + } + + @override + String get googleIdentityUnverified => + 'Non è stato possibile verificare l’identità di questa vecchia connessione Google. Calendari, attività e modifiche in sospeso sono conservati. Aggiungi una connessione Google separata per sincronizzare in sicurezza. Le modifiche in sospeso rimarranno nell’account originale.'; + + @override + String get addSeparateGoogleAccount => 'Aggiungi connessione Google separata'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index e48858b9..cee642ae 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -3482,4 +3482,29 @@ class AppLocalizationsJa extends AppLocalizations { @override String get contactsPasswordLabel => 'パスワード'; + + @override + String get accountReconnectRequired => '同期を再開するには、このアカウントを再接続してください。'; + + @override + String get googleReconnectRequired => + 'カレンダーとタスクの同期を再開するには、Google アカウントを再接続してください。'; + + @override + String get reconnectGoogleAccount => 'Google アカウントを再接続'; + + @override + String get reconnectAccount => 'このアカウントを再接続'; + + @override + String reconnectSelectedAccount(String account) { + return '$account を再接続します。カレンダー、タスク、保留中のローカル変更は保持されます。'; + } + + @override + String get googleIdentityUnverified => + 'この古い Google 接続のアカウントを確認できませんでした。カレンダー、タスク、保留中の変更は保持されています。安全に同期するには、別の Google 接続を追加してください。保留中の変更は元のアカウントに残ります。'; + + @override + String get addSeparateGoogleAccount => '別の Google 接続を追加'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index ce1aa62f..e4657194 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -3485,4 +3485,29 @@ class AppLocalizationsKo extends AppLocalizations { @override String get contactsPasswordLabel => '비밀번호'; + + @override + String get accountReconnectRequired => '동기화를 재개하려면 이 계정을 다시 연결하세요.'; + + @override + String get googleReconnectRequired => + '캘린더와 할 일 동기화를 재개하려면 Google 계정을 다시 연결하세요.'; + + @override + String get reconnectGoogleAccount => 'Google 계정 다시 연결'; + + @override + String get reconnectAccount => '이 계정 다시 연결'; + + @override + String reconnectSelectedAccount(String account) { + return '$account 계정을 다시 연결합니다. 캘린더, 할 일 및 대기 중인 로컬 변경 사항은 유지됩니다.'; + } + + @override + String get googleIdentityUnverified => + '이 오래된 Google 연결의 계정 신원을 확인할 수 없습니다. 캘린더, 할 일 및 대기 중인 변경 사항은 유지됩니다. 안전한 동기화를 위해 별도의 Google 연결을 추가하세요. 대기 중인 변경 사항은 원래 계정에 남습니다.'; + + @override + String get addSeparateGoogleAccount => '별도의 Google 연결 추가'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 4b462cef..7968dd5b 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -3744,4 +3744,30 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contactsPasswordLabel => 'Hasło'; + + @override + String get accountReconnectRequired => + 'Połącz ponownie to konto, aby wznowić synchronizację.'; + + @override + String get googleReconnectRequired => + 'Połącz ponownie konto Google, aby wznowić synchronizację kalendarzy i zadań.'; + + @override + String get reconnectGoogleAccount => 'Połącz ponownie konto Google'; + + @override + String get reconnectAccount => 'Połącz ponownie to konto'; + + @override + String reconnectSelectedAccount(String account) { + return 'Połącz ponownie $account. Kalendarze, zadania i oczekujące zmiany lokalne zostaną zachowane.'; + } + + @override + String get googleIdentityUnverified => + 'Nie można zweryfikować tożsamości tego starszego połączenia Google. Kalendarze, zadania i oczekujące zmiany są zachowane. Dodaj osobne połączenie Google, aby bezpiecznie synchronizować. Oczekujące zmiany pozostaną na pierwotnym koncie.'; + + @override + String get addSeparateGoogleAccount => 'Dodaj osobne połączenie Google'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index a99ec166..5bf8633c 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -3592,6 +3592,32 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contactsPasswordLabel => 'Password'; + + @override + String get accountReconnectRequired => + 'This account needs to be reconnected to resume synchronization.'; + + @override + String get googleReconnectRequired => + 'Your Google account needs to be reconnected to resume calendar and task synchronization.'; + + @override + String get reconnectGoogleAccount => 'Reconnect Google account'; + + @override + String get reconnectAccount => 'Reconnect this account'; + + @override + String reconnectSelectedAccount(String account) { + return 'Reconnect $account. Your calendars, tasks and pending local changes will be preserved.'; + } + + @override + String get googleIdentityUnverified => + 'The identity of this older Google connection could not be verified. Its calendars, tasks and pending changes are preserved. Add a separate Google connection to synchronize safely. Pending changes will remain with the original account.'; + + @override + String get addSeparateGoogleAccount => 'Add a separate Google connection'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -7176,4 +7202,30 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get contactsPasswordLabel => 'Palavra-passe'; + + @override + String get accountReconnectRequired => + 'Volte a ligar esta conta para retomar a sincronização.'; + + @override + String get googleReconnectRequired => + 'Volte a ligar a sua conta Google para retomar a sincronização de calendários e tarefas.'; + + @override + String get reconnectGoogleAccount => 'Voltar a ligar conta Google'; + + @override + String get reconnectAccount => 'Voltar a ligar esta conta'; + + @override + String reconnectSelectedAccount(String account) { + return 'Voltar a ligar $account. Os calendários, tarefas e alterações locais pendentes serão preservados.'; + } + + @override + String get googleIdentityUnverified => + 'Não foi possível verificar a identidade desta ligação antiga ao Google. Os calendários, tarefas e alterações pendentes são preservados. Adicione uma ligação Google separada para sincronizar em segurança. As alterações pendentes permanecerão na conta original.'; + + @override + String get addSeparateGoogleAccount => 'Adicionar ligação Google separada'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index 6195525c..311e6ac5 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -3603,4 +3603,31 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contactsPasswordLabel => 'Пароль'; + + @override + String get accountReconnectRequired => + 'Подключите этот аккаунт заново, чтобы возобновить синхронизацию.'; + + @override + String get googleReconnectRequired => + 'Подключите аккаунт Google заново, чтобы возобновить синхронизацию календарей и задач.'; + + @override + String get reconnectGoogleAccount => 'Подключить аккаунт Google заново'; + + @override + String get reconnectAccount => 'Подключить этот аккаунт заново'; + + @override + String reconnectSelectedAccount(String account) { + return 'Подключить $account заново. Календари, задачи и ожидающие локальные изменения будут сохранены.'; + } + + @override + String get googleIdentityUnverified => + 'Не удалось подтвердить личность владельца этого старого подключения Google. Календари, задачи и ожидающие изменения сохранены. Добавьте отдельное подключение Google для безопасной синхронизации. Ожидающие изменения останутся в исходном аккаунте.'; + + @override + String get addSeparateGoogleAccount => + 'Добавить отдельное подключение Google'; } diff --git a/lib/l10n/generated/app_localizations_vi.dart b/lib/l10n/generated/app_localizations_vi.dart index da26ba04..eb83a942 100644 --- a/lib/l10n/generated/app_localizations_vi.dart +++ b/lib/l10n/generated/app_localizations_vi.dart @@ -3559,4 +3559,30 @@ class AppLocalizationsVi extends AppLocalizations { @override String get contactsPasswordLabel => 'Mật khẩu'; + + @override + String get accountReconnectRequired => + 'Kết nối lại tài khoản này để tiếp tục đồng bộ.'; + + @override + String get googleReconnectRequired => + 'Kết nối lại tài khoản Google để tiếp tục đồng bộ lịch và công việc.'; + + @override + String get reconnectGoogleAccount => 'Kết nối lại tài khoản Google'; + + @override + String get reconnectAccount => 'Kết nối lại tài khoản này'; + + @override + String reconnectSelectedAccount(String account) { + return 'Kết nối lại $account. Lịch, công việc và thay đổi cục bộ đang chờ sẽ được giữ lại.'; + } + + @override + String get googleIdentityUnverified => + 'Không thể xác minh danh tính của kết nối Google cũ này. Lịch, công việc và thay đổi đang chờ được giữ lại. Thêm một kết nối Google riêng để đồng bộ an toàn. Thay đổi đang chờ vẫn thuộc tài khoản gốc.'; + + @override + String get addSeparateGoogleAccount => 'Thêm kết nối Google riêng'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 7099ff1f..b93b8852 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -3430,6 +3430,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contactsPasswordLabel => '密码'; + + @override + String get accountReconnectRequired => '请重新连接此账号以恢复同步。'; + + @override + String get googleReconnectRequired => '请重新连接 Google 账号以恢复日历和任务同步。'; + + @override + String get reconnectGoogleAccount => '重新连接 Google 账号'; + + @override + String get reconnectAccount => '重新连接此账号'; + + @override + String reconnectSelectedAccount(String account) { + return '重新连接 $account。日历、任务和待处理的本地更改将保留。'; + } + + @override + String get googleIdentityUnverified => + '无法验证此旧 Google 连接的账号身份。日历、任务和待处理的更改已保留。请添加独立的 Google 连接以安全同步。待处理的更改将保留在原账号中。'; + + @override + String get addSeparateGoogleAccount => '添加独立的 Google 连接'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6853,6 +6877,30 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get contactsPasswordLabel => '密码'; + + @override + String get accountReconnectRequired => '请重新连接此账号以恢复同步。'; + + @override + String get googleReconnectRequired => '请重新连接 Google 账号以恢复日历和任务同步。'; + + @override + String get reconnectGoogleAccount => '重新连接 Google 账号'; + + @override + String get reconnectAccount => '重新连接此账号'; + + @override + String reconnectSelectedAccount(String account) { + return '重新连接 $account。日历、任务和待处理的本地更改将保留。'; + } + + @override + String get googleIdentityUnverified => + '无法验证此旧 Google 连接的账号身份。日历、任务和待处理的更改已保留。请添加独立的 Google 连接以安全同步。待处理的更改将保留在原账号中。'; + + @override + String get addSeparateGoogleAccount => '添加独立的 Google 连接'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -10277,4 +10325,28 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get contactsPasswordLabel => '密碼'; + + @override + String get accountReconnectRequired => '請重新連接此帳號以恢復同步。'; + + @override + String get googleReconnectRequired => '請重新連接 Google 帳號以恢復行事曆和待辦事項同步。'; + + @override + String get reconnectGoogleAccount => '重新連接 Google 帳號'; + + @override + String get reconnectAccount => '重新連接此帳號'; + + @override + String reconnectSelectedAccount(String account) { + return '重新連接 $account。行事曆、待辦事項和待處理的本機變更將保留。'; + } + + @override + String get googleIdentityUnverified => + '無法驗證此舊 Google 連線的帳號身分。行事曆、待辦事項和待處理的變更已保留。請新增獨立的 Google 連線以安全同步。待處理的變更將保留在原帳號中。'; + + @override + String get addSeparateGoogleAccount => '新增獨立的 Google 連線'; } diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index b476f134..784e0db7 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -418,6 +418,15 @@ final desktopNotificationServiceProvider = Provider( cancellationQueue: ref.watch(notificationCancellationQueueProvider), settings: settings, locale: settings.locale, + onReconnectActivated: (id) async { + await ref.read(desktopWindowServiceProvider).showWindow(); + ref + .read(desktopNavigationServiceProvider) + .open( + DesktopNavigationDestination.settings, + reconnectAccountId: id, + ); + }, onDestinationActivated: (destination) async { await ref.read(desktopWindowServiceProvider).showWindow(); ref.read(desktopNavigationServiceProvider).open(destination); @@ -1782,6 +1791,9 @@ Future _runAccountSyncIfEligible( .generation(accountId); try { await synchronize(ref.read(accountSyncOperationsProvider)); + ref + .read(desktopNotificationServiceProvider) + .clearAccountSyncFailure(accountId); } on Object catch (error) { throw AuthorizationScopedFailure( accountId: accountId, diff --git a/lib/src/app/app_router.dart b/lib/src/app/app_router.dart index c50fe6a1..d8cf02a1 100644 --- a/lib/src/app/app_router.dart +++ b/lib/src/app/app_router.dart @@ -72,6 +72,7 @@ final appRouterProvider = Provider((ref) { pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, child: SettingsScreen( + reconnectAccountId: state.uri.queryParameters['reconnect'], initialPage: settingsPageFromRouteValue( state.uri.queryParameters['page'], ), diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index b9f66ef7..bba4c67e 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -154,6 +154,17 @@ class _BusyMaxAppState extends ConsumerState { } Future _handleExternalCalendarOpen(DesktopActivation request) async { + if (request.kind == DesktopActivationKind.notification && + request.isValid && + request.notificationDestination != null) { + _handleNavigationRequest( + DesktopNavigationRequest( + request.notificationDestination!, + reconnectAccountId: request.payload?['accountId'], + ), + ); + return; + } if (request.kind != DesktopActivationKind.webCal && request.kind != DesktopActivationKind.icsFile) { return; @@ -220,6 +231,15 @@ class _BusyMaxAppState extends ConsumerState { void _handleNavigationRequest(DesktopNavigationRequest request) { if (!mounted) return; final router = ref.read(appRouterProvider); + if (request.reconnectAccountId case final id?) { + router.go( + Uri( + path: '/settings', + queryParameters: {'page': 'accounts', 'reconnect': id}, + ).toString(), + ); + return; + } router.go(switch (request.destination) { DesktopNavigationDestination.schedule => '/schedule', DesktopNavigationDestination.tasks => '/tasks', diff --git a/lib/src/app/windows/windows_busymax_app.dart b/lib/src/app/windows/windows_busymax_app.dart index 2fab597c..bb1f98d5 100644 --- a/lib/src/app/windows/windows_busymax_app.dart +++ b/lib/src/app/windows/windows_busymax_app.dart @@ -50,6 +50,7 @@ final windowsAppRouter = GoRouter( destination: WindowsWorkspaceDestination.settings, child: WindowsSettingsPage( initialPage: state.uri.queryParameters['page'], + reconnectAccountId: state.uri.queryParameters['reconnect'], ), ), ), @@ -129,7 +130,12 @@ class _WindowsBusyMaxAppState extends ConsumerState case DesktopActivationKind.notification: final destination = activation.notificationDestination; if (destination != null) { - _handleNavigation(DesktopNavigationRequest(destination)); + _handleNavigation( + DesktopNavigationRequest( + destination, + reconnectAccountId: activation.payload?['accountId'], + ), + ); return; } final scheduleId = activation.payload?['notificationScheduleId']; @@ -223,6 +229,15 @@ class _WindowsBusyMaxAppState extends ConsumerState } void _handleNavigation(DesktopNavigationRequest request) { + if (request.reconnectAccountId case final id?) { + windowsAppRouter.go( + Uri( + path: '/settings', + queryParameters: {'page': 'accounts', 'reconnect': id}, + ).toString(), + ); + return; + } windowsAppRouter.go(switch (request.destination) { DesktopNavigationDestination.schedule => '/schedule', DesktopNavigationDestination.tasks => '/tasks', diff --git a/lib/src/core/auth/oauth_models.dart b/lib/src/core/auth/oauth_models.dart index c5b0e5c3..b01e6203 100644 --- a/lib/src/core/auth/oauth_models.dart +++ b/lib/src/core/auth/oauth_models.dart @@ -123,6 +123,9 @@ class OAuthCallbackResult { } enum OAuthFailureKind { + registrationUnavailable, + identityUnverified, + clientRejected, configuration, wrongAccount, permission, @@ -144,6 +147,8 @@ class OAuthException implements Exception { final String code; final String message; OAuthFailureKind get classification => switch (code) { + 'OAuthRegistrationUnresolved' => OAuthFailureKind.registrationUnavailable, + 'OAuthIdentityUnverified' => OAuthFailureKind.identityUnverified, 'OAuthWrongAccount' => OAuthFailureKind.wrongAccount, 'OAuthSignInCancelled' => OAuthFailureKind.cancelled, 'OAuthStaleAuthorization' => OAuthFailureKind.stale, @@ -154,7 +159,6 @@ class OAuthException implements Exception { 'MicrosoftOAuthMissingRefreshToken' => OAuthFailureKind.permission, 'OAuthCallbackTimeout' || 'OAuthRequestTimeout' => OAuthFailureKind.timeout, 'OAuthSetupRequired' || - 'OAuthRegistrationUnresolved' || 'OAuthConfigurationRejected' || 'OAuthConfigurationExpired' || 'OAuthWrongClientType' || @@ -165,6 +169,16 @@ class OAuthException implements Exception { _ => OAuthFailureKind.temporary, }; + /// Only failures of an existing authorization can require reconnection. + bool get requiresReconnect => + code == 'OAuthRegistrationUnresolved' || + const { + 'OAuthMissingToken', + 'OAuthMissingRefreshToken', + 'MicrosoftOAuthMissingToken', + 'MicrosoftOAuthMissingRefreshToken', + }.contains(code); + @override String toString() => '$code: $message'; } @@ -190,13 +204,18 @@ class OAuthRefreshException extends OAuthException { return switch (oauthError) { 'invalid_grant' => OAuthFailureKind.expiredGrant, 'invalid_client' || - 'unauthorized_client' || - 'invalid_scope' => OAuthFailureKind.configuration, + 'unauthorized_client' => OAuthFailureKind.clientRejected, + 'invalid_scope' => OAuthFailureKind.permission, 'access_denied' => OAuthFailureKind.permission, _ => OAuthFailureKind.temporary, }; } + @override + bool get requiresReconnect => + classification == OAuthFailureKind.expiredGrant || + classification == OAuthFailureKind.clientRejected; + /// The structured OAuth `error` value returned by the provider. final String? oauthError; @@ -204,6 +223,21 @@ class OAuthRefreshException extends OAuthException { final String? oauthErrorDescription; } +/// The retiring registration cannot establish a usable authorization. Keep the +/// structured provider failure for redacted diagnostics, never its UI text. +class OAuthRegistrationUnavailableException extends OAuthException { + const OAuthRegistrationUnavailableException({this.cause}) + : super( + 'OAuthRegistrationUnresolved', + 'Your Google account needs to be reconnected to resume calendar and task synchronization.', + ); + final OAuthException? cause; + @override + String toString() => + '$code: registration unavailable' + '${cause == null ? '' : ' (${cause!.classification.name})'}'; +} + /// A failed candidate authorization never describes the existing refresh grant. /// Only allowlisted outcomes affect recovery; raw provider text is not retained. class OAuthAuthorizationException extends OAuthException { @@ -212,10 +246,12 @@ class OAuthAuthorizationException extends OAuthException { super.message, { required this.kind, this.statusCode, + this.providerError, this.retryAfter, }); final OAuthFailureKind kind; final int? statusCode; + final String? providerError; final Duration? retryAfter; @override OAuthFailureKind get classification => kind; @@ -259,6 +295,20 @@ OAuthAuthorizationException authorizationOutcomeFailure({ code, '$message${statusCode == null ? '' : ' (HTTP $statusCode)'}', kind: kind, + providerError: + const { + 'invalid_client', + 'unauthorized_client', + 'invalid_scope', + 'invalid_grant', + 'access_denied', + 'consent_required', + 'interaction_required', + 'login_required', + 'invalid_request', + }.contains(providerError) + ? providerError + : null, statusCode: statusCode, retryAfter: retryAfter, ); diff --git a/lib/src/core/http/request_dispatch_exception.dart b/lib/src/core/http/request_dispatch_exception.dart index 12a43702..5b0fa951 100644 --- a/lib/src/core/http/request_dispatch_exception.dart +++ b/lib/src/core/http/request_dispatch_exception.dart @@ -63,6 +63,8 @@ final class AuthorizationScopedOAuthException extends OAuthException { final OAuthException cause; @override OAuthFailureKind get classification => cause.classification; + @override + bool get requiresReconnect => cause.requiresReconnect; } int? failureAuthorizationGeneration(Object error, String id) { diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index 43ebf08b..59df2a16 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -680,7 +680,7 @@ class AuthSessionController extends StateNotifier { await _onSignedIn(accountId, initial); } on Object catch (error) { _logger.warning('Signed-in sync failed: initial=$initial error=$error'); - if (!isMissingOAuthTokenError(error)) { + if (!isOAuthReconnectRequiredError(error)) { return; } try { @@ -719,6 +719,12 @@ String authErrorMessage(Object error) { } return googleSignInCallbackNotReceivedMessage; } + if (error.requiresReconnect) { + return accountReconnectRequiredSyncMessage; + } + if (error is OAuthRefreshException) { + return syncTemporarilyUnavailableMessage; + } return error.message; } if (error is PlatformException) { diff --git a/lib/src/features/auth/presentation/registration_setup_dialog.dart b/lib/src/features/auth/presentation/registration_setup_dialog.dart index ff9a11df..d2727ebc 100644 --- a/lib/src/features/auth/presentation/registration_setup_dialog.dart +++ b/lib/src/features/auth/presentation/registration_setup_dialog.dart @@ -17,8 +17,9 @@ import '../../../app/busymax_design.dart'; Future showRegistrationSetup( BuildContext context, WidgetRef ref, - BusyProvider provider, -) { + BusyProvider provider, { + String? accountLabel, +}) { final controller = RegistrationSetupController( ref.read(registrationStagingProvider), provider, @@ -26,15 +27,23 @@ Future showRegistrationSetup( ); return showBusyMaxModalDialog( context, - builder: (_) => - _RegistrationDialog(provider: provider, controller: controller), + builder: (_) => _RegistrationDialog( + provider: provider, + controller: controller, + accountLabel: accountLabel, + ), ); } enum _SetupPage { methods, configuration, instructions } class _RegistrationDialog extends StatefulWidget { - const _RegistrationDialog({required this.provider, required this.controller}); + const _RegistrationDialog({ + required this.provider, + required this.controller, + this.accountLabel, + }); + final String? accountLabel; final BusyProvider provider; final RegistrationSetupController controller; @override @@ -235,6 +244,13 @@ class _RegistrationDialogState extends State<_RegistrationDialog> { } List _methods(AppLocalizations l10n) => [ + if (widget.accountLabel case final label?) ...[ + Text( + l10n.reconnectSelectedAccount(label), + key: const ValueKey('registration-reconnect-account'), + ), + const SizedBox(height: 12), + ], Text(l10n.registrationMethodsIntroduction), const SizedBox(height: BusyMaxSpacing.md), Align( diff --git a/lib/src/features/notifications/desktop_notification_service.dart b/lib/src/features/notifications/desktop_notification_service.dart index 40ae9405..aae0e60d 100644 --- a/lib/src/features/notifications/desktop_notification_service.dart +++ b/lib/src/features/notifications/desktop_notification_service.dart @@ -1,3 +1,6 @@ +import '../../core/auth/oauth_models.dart'; +import '../../core/http/request_dispatch_exception.dart'; + import 'dart:async'; import 'dart:ui'; @@ -45,6 +48,7 @@ class DesktopNotificationService { required AppSettings settings, NotificationCancellationQueue? cancellationQueue, Locale? locale, + this.onReconnectActivated, Duration syncFailureDebounce = const Duration(minutes: 5), Duration reminderFailureRetryDelay = const Duration(minutes: 1), DateTime Function()? now, @@ -72,15 +76,26 @@ class DesktopNotificationService { final Future Function(DesktopNavigationDestination destination)? _onDestinationActivated; + final Future Function(String accountId)? onReconnectActivated; + final Set _reconnectFailures = {}; + DateTime? _lastSyncFailureAt; String? _lastSyncFailureBody; Future notifySyncFailure(Object error) async { + final accountId = switch (error) { + AuthorizationScopedFailure(:final accountId) => accountId, + AuthorizationScopedOAuthException(:final accountId) => accountId, + _ => null, + }; final disposition = syncFailureNotificationDisposition(error); final message = switch (disposition) { SyncFailureNotificationDisposition.suppressed => null, SyncFailureNotificationDisposition.reconnectRequired => - _strings.reconnectRequired, + resolveEffectiveSyncFailure(error) + is OAuthRegistrationUnavailableException + ? _strings.googleReconnectRequired + : _strings.reconnectRequired, SyncFailureNotificationDisposition.permissionChanged => _strings.permissionChanged, SyncFailureNotificationDisposition.unsupportedProvider => @@ -91,11 +106,16 @@ class DesktopNotificationService { if (message == null || !_settings.notifySyncFailures || _isQuietHours()) { return; } + if (disposition == SyncFailureNotificationDisposition.reconnectRequired && + !_reconnectFailures.add(accountId ?? 'unknown')) { + return; + } final body = _showsNotificationDetails ? _strings.syncFailureBody(message) : _strings.syncFailureBody(_strings.detailsHidden); final now = _now(); - if (_lastSyncFailureBody == body && + if (disposition != SyncFailureNotificationDisposition.reconnectRequired && + _lastSyncFailureBody == body && _lastSyncFailureAt != null && now.difference(_lastSyncFailureAt!) < _syncFailureDebounce) { return; @@ -107,10 +127,19 @@ class DesktopNotificationService { body, BusyMaxNotificationCategory.networkError, stableId: 'sync-failure', - payload: const {'notificationRoute': 'sync-failure'}, + payload: { + 'notificationRoute': 'sync-failure', + if (accountId != null) 'accountId': accountId, + }, + onActivated: accountId == null || onReconnectActivated == null + ? null + : () => onReconnectActivated!(accountId), ); } + void clearAccountSyncFailure(String accountId) => + _reconnectFailures.remove(accountId); + Future notifyConflict(String summary) async { if (!_settings.notifyConflicts || _isQuietHours()) { return; @@ -366,6 +395,7 @@ class NotificationStrings { required this.taskReminderTitle, required this.detailsHidden, required this.reconnectRequired, + required this.googleReconnectRequired, required this.permissionChanged, required this.unsupportedProvider, required this.temporarilyUnavailable, @@ -402,6 +432,7 @@ class NotificationStrings { taskReminderTitle: l10n.taskReminderNotificationTitle, detailsHidden: l10n.notificationDetailsHidden, reconnectRequired: l10n.davReauthenticationRequired, + googleReconnectRequired: l10n.googleReconnectRequired, permissionChanged: l10n.davPermissionChanged, unsupportedProvider: l10n.davUnsupportedServer, temporarilyUnavailable: l10n.davTemporarilyUnavailable, @@ -423,6 +454,7 @@ class NotificationStrings { final String taskReminderTitle; final String detailsHidden; final String reconnectRequired; + final String googleReconnectRequired; final String permissionChanged; final String unsupportedProvider; final String temporarilyUnavailable; diff --git a/lib/src/features/schedule/presentation/schedule_sidebar.dart b/lib/src/features/schedule/presentation/schedule_sidebar.dart index c52da2b5..fb1145db 100644 --- a/lib/src/features/schedule/presentation/schedule_sidebar.dart +++ b/lib/src/features/schedule/presentation/schedule_sidebar.dart @@ -1,3 +1,5 @@ +import '../../sync/account_recovery_feedback.dart'; +import '../../../l10n/oauth_error_description.dart'; import '../../../core/http/request_dispatch_exception.dart'; import 'dart:async'; @@ -32,7 +34,6 @@ import '../../calendar/data/calendar_repository.dart'; import '../../connectivity/network_connectivity_service.dart'; import '../../calendar/presentation/calendar_color_dialog.dart'; import '../../sync/sync_auth_error.dart'; -import '../../sync/sync_failure_notification_policy.dart'; import '../../task_lists/data/task_lists_repository.dart'; import '../../tasks/domain/task_capabilities.dart'; @@ -1350,37 +1351,7 @@ Future _handleRefreshFailure( String accountId, Object error, ) async { - try { - if (syncFailureNotificationDisposition(error) == - SyncFailureNotificationDisposition.reconnectRequired) { - await ref - .read(authRepositoryProvider) - .markReconnectRequired( - accountId, - authorizationGeneration: failureAuthorizationGeneration( - error, - accountId, - ), - ); - } - } on Object { - // Keep the original refresh failure visible even if cleanup fails. - } - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.refreshFailed( - syncFailureMessage( - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), - ), - ), - ), - ); + await showAccountSyncFailure(context, ref, accountId, error); } Future _manualRefreshAccountIsEligible( @@ -1395,12 +1366,11 @@ Future _manualRefreshAccountIsEligible( return true; } if (account?.needsReconnect == true && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.refreshFailed(accountReconnectRequiredSyncMessage), - ), - ), + await showAccountSyncFailure( + context, + ref, + accountId, + const AccountNotSyncEligibleException(needsReconnect: true), ); } return false; @@ -1585,7 +1555,8 @@ Future _createAccountCalendar( _showCalendarMutationFailure( context, context.l10n.calendarCreateFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1617,7 +1588,8 @@ Future _createAccountTaskList( SnackBar( content: Text( context.l10n.taskListCreateFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1690,7 +1662,8 @@ Future _changeCalendarColor( _showCalendarMutationFailure( context, context.l10n.calendarUpdateFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1739,7 +1712,8 @@ Future _renameCalendar( _showCalendarMutationFailure( context, context.l10n.calendarUpdateFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1796,7 +1770,8 @@ Future _deleteCalendar( isDavCollectionPendingChangesError(error) ? context.l10n.calendarPendingChangesPreventRemoval : context.l10n.calendarDeleteFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1844,7 +1819,8 @@ Future _renameTaskList( SnackBar( content: Text( context.l10n.taskListRenameFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1893,7 +1869,8 @@ Future _deleteTaskList( isDavCollectionPendingChangesError(error) ? context.l10n.taskListPendingChangesPreventRemoval : context.l10n.taskListDeleteFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, diff --git a/lib/src/features/schedule/presentation/schedule_workspace.dart b/lib/src/features/schedule/presentation/schedule_workspace.dart index a53c5615..9a64aaea 100644 --- a/lib/src/features/schedule/presentation/schedule_workspace.dart +++ b/lib/src/features/schedule/presentation/schedule_workspace.dart @@ -1,3 +1,6 @@ +import '../../sync/account_recovery_feedback.dart'; +import '../../../l10n/oauth_error_description.dart'; + import 'dart:async'; import 'package:flutter/material.dart'; @@ -2248,18 +2251,7 @@ class _ScheduleWorkspaceState extends ConsumerState { if (!mounted) { return false; } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.refreshFailed( - syncFailureMessage( - error, - networkUnavailableMessage: context.l10n.networkOfflineTryAgain, - ), - ), - ), - ), - ); + await showAccountSyncFailure(context, ref, accountId, error); return false; } } @@ -2697,7 +2689,8 @@ class _ScheduleWorkspaceState extends ConsumerState { SnackBar( content: Text( context.l10n.syncFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -2731,7 +2724,8 @@ class _ScheduleWorkspaceState extends ConsumerState { SnackBar( content: Text( context.l10n.refreshFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 24e5f10b..9c1a22a7 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -72,7 +72,13 @@ final _settingsLogger = RedactingLogger(Logger('SettingsScreen')); const _systemLocaleTag = 'system'; class SettingsScreen extends ConsumerStatefulWidget { - const SettingsScreen({super.key, this.initialPage = SettingsPage.system}); + const SettingsScreen({ + super.key, + this.initialPage = SettingsPage.system, + this.reconnectAccountId, + }); + + final String? reconnectAccountId; final SettingsPage initialPage; @@ -91,9 +97,31 @@ class _SettingsScreenState extends ConsumerState { final _busyContactAccountIds = {}; late final LaunchAtLoginRefreshObserver _autostartRefresh; + String? _handledRecovery; + Future _openRequestedRecovery() async { + final id = widget.reconnectAccountId; + if (id == null || id == _handledRecovery) return; + _handledRecovery = id; + final account = await ref.read(accountsRepositoryProvider).accountById(id); + if (mounted && account != null) { + await _connectAccount(account.provider, reconnecting: account); + } + if (!mounted) return; + final router = GoRouter.maybeOf(context); + if (router == null) return; + final uri = GoRouterState.of(context).uri; + if (uri.path == '/settings' && uri.queryParameters['reconnect'] == id) { + final query = {...uri.queryParameters}..remove('reconnect'); + unawaited(router.replace(uri.replace(queryParameters: query).toString())); + } + } + @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _openRequestedRecovery(), + ); _autostartRefresh = LaunchAtLoginRefreshObserver( () => ref.invalidate(launchAtLoginStateProvider), ); @@ -111,6 +139,12 @@ class _SettingsScreenState extends ConsumerState { @override void didUpdateWidget(covariant SettingsScreen oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.reconnectAccountId != oldWidget.reconnectAccountId) { + if (widget.reconnectAccountId == null) _handledRecovery = null; + WidgetsBinding.instance.addPostFrameCallback( + (_) => _openRequestedRecovery(), + ); + } if (oldWidget.initialPage != widget.initialPage) { _page = widget.initialPage; } @@ -869,6 +903,7 @@ class _SettingsScreenState extends ConsumerState { _authorizationCancellation?.isCancelled == false) { return; } + var addSeparate = false; final ownedCancellation = AuthorizationCancellation(); _authorizationCancellation = ownedCancellation; try { @@ -890,10 +925,30 @@ class _SettingsScreenState extends ConsumerState { AuthorizationRequest? request; if (provider == BusyProvider.google || provider == BusyProvider.microsoft) { - if (reconnecting != null && !replaceRegistration) { + final summary = reconnecting == null + ? null + : ref + .read(registrationSummariesProvider) + .valueOrNull?[reconnecting.id]; + final googleRecovery = + provider == BusyProvider.google && + reconnecting != null && + (reconnecting.needsReconnect || + summary == null || + summary.origin == RegistrationOrigin.retiringShared); + if (reconnecting != null && !replaceRegistration && !googleRecovery) { request = AuthorizationRequest.reconnect(reconnecting.id); } else { - final handle = await showRegistrationSetup(context, ref, provider); + final handle = await showRegistrationSetup( + context, + ref, + provider, + accountLabel: reconnecting == null + ? null + : reconnecting.email ?? + reconnecting.displayName ?? + reconnecting.id, + ); if (handle == null || !mounted) return; request = reconnecting == null ? AuthorizationRequest.newConnection(handle) @@ -960,7 +1015,39 @@ class _SettingsScreenState extends ConsumerState { (error is DavException && error.kind == DavErrorKind.cancelled)) { return; } - if (mounted) { + if (mounted && + error is OAuthException && + error.classification == OAuthFailureKind.identityUnverified) { + addSeparate = + await showBusyMaxModalDialog( + context, + builder: (dialogContext) => BusyMaxDialogShell( + title: context.l10n.reconnectGoogleAccount, + maxWidth: 560, + actions: [ + BusyMaxPushButton.standard( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(context.l10n.cancel), + ), + BusyMaxPushButton.suggested( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(context.l10n.addSeparateGoogleAccount), + ), + ], + children: [ + Text( + context.l10n.reconnectSelectedAccount( + reconnecting!.email ?? + reconnecting.displayName ?? + reconnecting.id, + ), + ), + Text(context.l10n.googleIdentityUnverified), + ], + ), + ) ?? + false; + } else if (mounted) { _showMessage(context, _accountConnectionErrorMessage(context, error)); } } finally { @@ -979,6 +1066,7 @@ class _SettingsScreenState extends ConsumerState { if (mounted) setState(() => _connectingProvider = null); } } + if (addSeparate && mounted) await _connectAccount(BusyProvider.google); } void _cancelAccountConnection() { @@ -1002,7 +1090,8 @@ class _SettingsScreenState extends ConsumerState { _showMessage( context, context.l10n.syncFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1101,7 +1190,8 @@ class _SettingsScreenState extends ConsumerState { _showMessage( context, context.l10n.syncFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1132,7 +1222,8 @@ class _SettingsScreenState extends ConsumerState { _showMessage( context, context.l10n.syncFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -1227,7 +1318,8 @@ class _SettingsScreenState extends ConsumerState { _showMessage( context, context.l10n.calendarUpdateFailed( - syncFailureMessage( + localizedSyncFailureMessage( + context.l10n, error, networkUnavailableMessage: context.l10n.networkOfflineTryAgain, ), @@ -2854,7 +2946,9 @@ class _AccountManagementCard extends StatelessWidget { if (account.hasConnectionIssue) BusyMaxActionRow( title: account.needsReconnect - ? accountReconnectRequiredActionLabel + ? (account.provider == BusyProvider.google + ? l10n.reconnectGoogleAccount + : l10n.reconnectAccount) : _accountConnectionIssueMessage(context, account), subtitle: _accountConnectionIssueMessage(context, account), leading: Icon( @@ -3211,7 +3305,9 @@ String _accountConnectionIssueMessage( AccountEntity account, ) => switch (account.connectionState) { AccountConnectionState.reauthenticationRequired => - context.l10n.davReauthenticationRequired, + account.provider == BusyProvider.google + ? context.l10n.googleReconnectRequired + : context.l10n.accountReconnectRequired, AccountConnectionState.temporarilyUnavailable => context.l10n.davTemporarilyUnavailable, AccountConnectionState.permissionChanged => context.l10n.davPermissionChanged, @@ -3322,7 +3418,18 @@ class _RegistrationAccountCard extends ConsumerWidget { : l10n.registrationReplace, onTap: onMigrate, ), - BusyMaxActionRow(title: l10n.connectAccountAction, onTap: onReconnect), + BusyMaxActionRow( + key: ValueKey('reconnect-account-${account.id}'), + title: + account.needsReconnect || + summary == null || + summary.origin == RegistrationOrigin.retiringShared + ? (account.provider == BusyProvider.google + ? l10n.reconnectGoogleAccount + : l10n.reconnectAccount) + : l10n.connectAccountAction, + onTap: onReconnect, + ), ], ); } diff --git a/lib/src/features/sync/account_recovery_feedback.dart b/lib/src/features/sync/account_recovery_feedback.dart new file mode 100644 index 00000000..044b557e --- /dev/null +++ b/lib/src/features/sync/account_recovery_feedback.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/app_bootstrap.dart'; +import '../../core/http/request_dispatch_exception.dart'; +import '../../l10n/l10n.dart'; +import '../../l10n/oauth_error_description.dart'; +import '../../providers/busy_provider.dart'; +import 'sync_failure_notification_policy.dart'; + +/// A deliberate recovery action selects the actual account and opens its flow. +Future showAccountSyncFailure( + BuildContext context, + WidgetRef ref, + String accountId, + Object error, +) async { + final reconnect = + syncFailureNotificationDisposition(error) == + SyncFailureNotificationDisposition.reconnectRequired; + if (reconnect) { + await ref + .read(authRepositoryProvider) + .markReconnectRequired( + accountId, + authorizationGeneration: failureAuthorizationGeneration( + error, + accountId, + ), + ); + } + final account = await ref + .read(accountsRepositoryProvider) + .accountById(accountId); + if (!context.mounted) return; + final l10n = context.l10n; + final google = account?.provider == BusyProvider.google; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + reconnect && google + ? l10n.googleReconnectRequired + : localizedSyncFailureMessage(l10n, error), + ), + action: !reconnect || account == null + ? null + : SnackBarAction( + label: google + ? l10n.reconnectGoogleAccount + : l10n.reconnectAccount, + onPressed: () => context.go( + Uri( + path: '/settings', + queryParameters: {'page': 'accounts', 'reconnect': accountId}, + ).toString(), + ), + ), + ), + ); +} diff --git a/lib/src/features/sync/sync_auth_error.dart b/lib/src/features/sync/sync_auth_error.dart index 4cf24796..a520da5e 100644 --- a/lib/src/features/sync/sync_auth_error.dart +++ b/lib/src/features/sync/sync_auth_error.dart @@ -1,3 +1,13 @@ +import '../../google_calendar/google_calendar_errors.dart'; +import '../../microsoft_calendar/microsoft_calendar_errors.dart'; +import '../../microsoft_todo/api/microsoft_todo_api_error.dart'; +import '../tasks/domain/task_remote_error.dart'; + +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + import 'account_sync_operations.dart'; import 'domain_sync_schedule.dart'; import '../../core/logging/redacting_logger.dart'; @@ -36,6 +46,12 @@ bool isMissingOAuthTokenError(Object error) { effectiveError.code == 'android/auth-interaction-required'); } +bool isOAuthReconnectRequiredError(Object error) { + final effective = resolveEffectiveSyncFailure(error); + return effective is OAuthException && effective.requiresReconnect || + isMissingOAuthTokenError(effective); +} + String syncFailureMessage( Object error, { String networkUnavailableMessage = 'No network connection is available.', @@ -55,16 +71,78 @@ String syncFailureMessage( ? accountReconnectRequiredSyncMessage : syncTemporarilyUnavailableMessage; } - if (isMissingOAuthTokenError(effectiveError) || - (effectiveError is OAuthRefreshException && - (effectiveError.statusCode == 400 || - effectiveError.statusCode == 401) && - effectiveError.oauthError == 'invalid_grant')) { + if (isOAuthReconnectRequiredError(effectiveError)) { return accountReconnectRequiredSyncMessage; } - if (effectiveError is OAuthRefreshException || + if (effectiveError is OAuthException || effectiveError is RequestNotDispatchedException) { + return effectiveError is OAuthException && + effectiveError.classification == OAuthFailureKind.permission + ? 'Account permissions need to be updated to resume synchronization.' + : syncTemporarilyUnavailableMessage; + } + final status = switch (effectiveError) { + GoogleCalendarApiError(:final statusCode) => statusCode, + MicrosoftCalendarApiError(:final statusCode) => statusCode, + MicrosoftTodoApiError(:final statusCode) => statusCode, + TaskRemoteError(:final statusCode) => statusCode, + _ => null, + }; + if (status != null) { + if (status == 401) return accountReconnectRequiredSyncMessage; + if (status == 403) { + return 'Account permissions need to be updated to resume synchronization.'; + } return syncTemporarilyUnavailableMessage; } return redactForLog(effectiveError); } + +/// Diagnostics use typed outcomes and allowlisted codes, never provider bodies. +String syncFailureDiagnostic(Object failure) { + var error = resolveEffectiveSyncFailure(failure); + if (error is OAuthRegistrationUnavailableException) { + return 'OAuth.registrationUnavailable${error.cause == null ? '' : ': ${syncFailureDiagnostic(error.cause!)}'}'; + } + if (error is OAuthRefreshException) { + const safeCodes = { + 'invalid_client', + 'unauthorized_client', + 'invalid_grant', + 'invalid_scope', + 'access_denied', + 'temporarily_unavailable', + 'server_error', + 'invalid_request', + }; + final provider = safeCodes.contains(error.oauthError) + ? error.oauthError + : 'unrecognized'; + return 'OAuth.${error.classification.name}: HTTP ${error.statusCode}; $provider'; + } + if (error is OAuthAuthorizationException) { + final provider = + const { + 'invalid_client', + 'unauthorized_client', + 'invalid_scope', + 'invalid_grant', + 'access_denied', + 'consent_required', + 'interaction_required', + 'login_required', + 'invalid_request', + }.contains(error.providerError) + ? error.providerError + : 'unrecognized'; + return 'OAuth.${error.classification.name}; $provider${error.statusCode == null ? '' : '; HTTP ${error.statusCode}'}'; + } + if (error is OAuthException) return 'OAuth.${error.classification.name}'; + if (error is NetworkUnavailableException || + error is SocketException || + error is http.ClientException) { + return 'Network failure'; + } + if (error is TimeoutException) return 'Network timeout'; + return redactForLog(error); +} diff --git a/lib/src/features/sync/sync_engine.dart b/lib/src/features/sync/sync_engine.dart index 04462957..7cffdf04 100644 --- a/lib/src/features/sync/sync_engine.dart +++ b/lib/src/features/sync/sync_engine.dart @@ -1,3 +1,5 @@ +import 'sync_auth_error.dart'; + import 'package:drift/drift.dart'; import 'package:uuid/uuid.dart'; @@ -163,8 +165,8 @@ class SyncEngine { status: 'failed', taskListsSeen: taskListsSeen, tasksSeen: tasksSeen, - errorCode: error.runtimeType.toString(), - errorMessage: error.toString(), + errorCode: syncFailureDiagnostic(error), + errorMessage: syncFailureDiagnostic(error), ); rethrow; } diff --git a/lib/src/features/sync/sync_failure_notification_policy.dart b/lib/src/features/sync/sync_failure_notification_policy.dart index 5dd9cc45..a54a757a 100644 --- a/lib/src/features/sync/sync_failure_notification_policy.dart +++ b/lib/src/features/sync/sync_failure_notification_policy.dart @@ -75,17 +75,16 @@ SyncFailureNotificationDisposition syncFailureNotificationDisposition( if (effectiveError is MicrosoftTodoApiError) { return _httpDisposition(effectiveError.statusCode); } + if (isOAuthReconnectRequiredError(effectiveError)) { + return SyncFailureNotificationDisposition.reconnectRequired; + } + if (effectiveError is OAuthException && + effectiveError.classification == OAuthFailureKind.permission) { + return SyncFailureNotificationDisposition.permissionChanged; + } if (effectiveError is OAuthRefreshException) { - if ((effectiveError.statusCode == 400 || - effectiveError.statusCode == 401) && - effectiveError.oauthError == 'invalid_grant') { - return SyncFailureNotificationDisposition.reconnectRequired; - } return _oauthRefreshHttpDisposition(effectiveError.statusCode); } - if (isMissingOAuthTokenError(effectiveError)) { - return SyncFailureNotificationDisposition.reconnectRequired; - } return SyncFailureNotificationDisposition.temporarilyUnavailable; } diff --git a/lib/src/google_tasks/oauth/oauth_service.dart b/lib/src/google_tasks/oauth/oauth_service.dart index 0a15aec6..e6239a63 100644 --- a/lib/src/google_tasks/oauth/oauth_service.dart +++ b/lib/src/google_tasks/oauth/oauth_service.dart @@ -106,6 +106,8 @@ class OAuthService final Duration _authorizationRevocationTimeout; final RedactingLogger _logger = RedactingLogger(Logger('OAuthService')); final Map _credentialGenerations = {}; + final Map + _unavailableRegistrations = {}; @override Future get activeAccountId => _tokenStore.readActiveAccountId(); @@ -144,6 +146,20 @@ class OAuthService _logger.warning( 'Google userinfo request failed with HTTP ${response.statusCode}.', ); + if (response.statusCode == 429 || response.statusCode >= 500) { + throw OAuthAuthorizationException( + 'OAuthIdentityRequestFailed', + 'Account identity verification is temporarily unavailable. Try again.', + kind: response.statusCode == 429 + ? OAuthFailureKind.throttled + : OAuthFailureKind.temporary, + statusCode: response.statusCode, + retryAfter: parseHttpRetryAfter( + response.headers['retry-after'], + now: _nowUtc(), + ), + ); + } return null; } final decoded = decodeOAuthProviderObject(response.body); @@ -244,14 +260,19 @@ class OAuthService ); } attempt.check(); + final previousRecord = target == null + ? null + : await attempt.wait(persistence.readCurrentCredential(target)); + final previousScopes = previousRecord is OAuthSecretRecord + ? previousRecord.tokenSet.scopes + : const {}; final requestedScopes = { ...googleBusyMaxOAuthScope.split(' '), ...optionalScopes, - if (previous != null) - for (final scope in previous.tokenSet.scopes) - if (scope == googleContactsReadScope || - scope == googleContactsWriteScope) - googleContactsReadScope, + for (final scope in previousScopes) + if (scope == googleContactsReadScope || + scope == googleContactsWriteScope) + googleContactsReadScope, }; final result = await _loopbackFlow.start( attempt: attempt, @@ -488,8 +509,8 @@ class OAuthService } } throw const OAuthException( - 'OAuthRegistrationUnresolved', - 'This account lacks a verified Google identity. Its data and credentials are preserved; restore its original configuration before reconnecting.', + 'OAuthIdentityUnverified', + 'The identity of this older Google connection could not be verified. Its calendars, tasks and pending changes are preserved. Add a separate Google connection to synchronize safely; existing pending changes will remain with this account.', ); } @@ -520,12 +541,17 @@ class OAuthService persistence == null || !await persistence.wasPreexisting(id) || _config.googleOAuthClientId.trim().isEmpty) { - throw const OAuthException( - 'OAuthRegistrationUnresolved', - 'The original Google registration is unavailable or unidentified. Account data is preserved; configure this account explicitly.', - ); + throw const OAuthRegistrationUnavailableException(); + } + final localAccount = await (persistence.database.select( + persistence.database.accounts, + )..where((row) => row.id.equals(id))).getSingleOrNull(); + if (localAccount?.authState == 'reauth_required') { + throw const OAuthRegistrationUnavailableException(); } final expected = await persistence.generation(id); + final unavailable = _unavailableRegistrations[id]; + if (unavailable != null && unavailable.$1 == expected) throw unavailable.$2; final registration = GoogleDesktopRegistration( clientId: _config.googleOAuthClientId.trim(), clientSecret: _config.googleOAuthClientSecret.trim().isEmpty @@ -541,11 +567,12 @@ class OAuthService try { tokens = await refreshToken(record.tokenSet, registration: registration); } on OAuthRefreshException catch (failure) { - if (failure.statusCode == 400 || failure.statusCode == 401) { - throw const OAuthException( - 'OAuthRegistrationUnresolved', - 'The original Google registration could not be established. Account data and credentials are preserved; configure this account explicitly.', + if (failure.requiresReconnect) { + final unavailable = OAuthRegistrationUnavailableException( + cause: failure, ); + _unavailableRegistrations[id] = (expected, unavailable); + throw unavailable; } await persistence.recordTokenRequestCooldown( id, diff --git a/lib/src/l10n/oauth_error_description.dart b/lib/src/l10n/oauth_error_description.dart index bea9f416..a915e675 100644 --- a/lib/src/l10n/oauth_error_description.dart +++ b/lib/src/l10n/oauth_error_description.dart @@ -1,3 +1,6 @@ +import '../features/sync/sync_failure_notification_policy.dart'; +import '../core/http/request_dispatch_exception.dart'; +import '../features/sync/sync_auth_error.dart'; import '../../l10n/generated/app_localizations.dart'; import '../core/auth/oauth_models.dart'; import '../core/secrets/secret_store.dart'; @@ -24,6 +27,10 @@ String localizedOAuthFailure( OAuthFailureKind? kind, String fallback, ) => switch (kind) { + OAuthFailureKind.registrationUnavailable || + OAuthFailureKind.expiredGrant => l10n.accountReconnectRequired, + OAuthFailureKind.identityUnverified => l10n.googleIdentityUnverified, + OAuthFailureKind.clientRejected => l10n.oauthRegistrationRejected, OAuthFailureKind.configuration => l10n.oauthRegistrationRejected, OAuthFailureKind.authorizationCode => l10n.oauthAuthorizationCodeUnusable, OAuthFailureKind.permission => l10n.oauthPermissionRefused, @@ -39,3 +46,36 @@ String localizedAccountRemovalError(AppLocalizations l10n, Object error) => error is AccountRemovalPersistenceException ? l10n.oauthRevokedRemovalIncomplete : l10n.removeAccountFailed; + +String localizedSyncFailureMessage( + AppLocalizations l10n, + Object error, { + String? networkUnavailableMessage, +}) { + final effective = resolveEffectiveSyncFailure(error); + if (isOAuthReconnectRequiredError(effective) || + effective is AccountNotSyncEligibleException && + effective.needsReconnect) { + return l10n.accountReconnectRequired; + } + final disposition = syncFailureNotificationDisposition(effective); + if (disposition == SyncFailureNotificationDisposition.reconnectRequired) { + return l10n.accountReconnectRequired; + } + if (disposition == SyncFailureNotificationDisposition.permissionChanged && + effective is! OAuthException) { + return l10n.davPermissionChanged; + } + if (effective is OAuthException) { + return localizedOAuthFailure( + l10n, + effective.classification, + l10n.oauthProviderTemporaryFailure, + ); + } + return syncFailureMessage( + error, + networkUnavailableMessage: + networkUnavailableMessage ?? l10n.networkOfflineTryAgain, + ); +} diff --git a/lib/src/platform/common/desktop_services.dart b/lib/src/platform/common/desktop_services.dart index 9668d6c0..d666ba99 100644 --- a/lib/src/platform/common/desktop_services.dart +++ b/lib/src/platform/common/desktop_services.dart @@ -176,7 +176,10 @@ final class DesktopActivation { case DesktopActivationKind.notification: if (payload?.containsKey('notificationRoute') ?? false) { return const {'default', 'open'}.contains(action) && - payload!.length == 1 && + (payload!.length == 1 || + payload!.length == 2 && + payload!['notificationRoute'] == 'sync-failure' && + (payload!['accountId']?.isNotEmpty ?? false)) && notificationDestination != null; } const permittedActions = {'default', 'open', 'snooze', 'dismiss'}; @@ -235,7 +238,8 @@ enum DesktopNavigationDestination { schedule, tasks, settings } @immutable final class DesktopNavigationRequest { - const DesktopNavigationRequest(this.destination); + const DesktopNavigationRequest(this.destination, {this.reconnectAccountId}); + final String? reconnectAccountId; final DesktopNavigationDestination destination; } @@ -243,7 +247,10 @@ final class DesktopNavigationRequest { abstract interface class DesktopNavigationService { Stream get requests; - void open(DesktopNavigationDestination destination); + void open( + DesktopNavigationDestination destination, { + String? reconnectAccountId, + }); } final class QueuedDesktopNavigationService implements DesktopNavigationService { @@ -255,8 +262,16 @@ final class QueuedDesktopNavigationService implements DesktopNavigationService { Stream get requests => _requests.stream; @override - void open(DesktopNavigationDestination destination) { - _requests.add(DesktopNavigationRequest(destination)); + void open( + DesktopNavigationDestination destination, { + String? reconnectAccountId, + }) { + _requests.add( + DesktopNavigationRequest( + destination, + reconnectAccountId: reconnectAccountId, + ), + ); } Future dispose() => _requests.close(); diff --git a/lib/src/ui/windows/windows_registration_setup_dialog.dart b/lib/src/ui/windows/windows_registration_setup_dialog.dart index 71265134..58b3c746 100644 --- a/lib/src/ui/windows/windows_registration_setup_dialog.dart +++ b/lib/src/ui/windows/windows_registration_setup_dialog.dart @@ -14,8 +14,9 @@ import '../../../l10n/generated/app_localizations.dart'; Future showWindowsRegistrationSetup( BuildContext context, WidgetRef ref, - BusyProvider provider, -) { + BusyProvider provider, { + String? accountLabel, +}) { final controller = RegistrationSetupController( ref.read(registrationStagingProvider), provider, @@ -23,15 +24,23 @@ Future showWindowsRegistrationSetup( ); return showDialog( context: context, - builder: (_) => - _RegistrationDialog(provider: provider, controller: controller), + builder: (_) => _RegistrationDialog( + provider: provider, + controller: controller, + accountLabel: accountLabel, + ), ); } enum _SetupPage { methods, configuration, instructions } class _RegistrationDialog extends StatefulWidget { - const _RegistrationDialog({required this.provider, required this.controller}); + const _RegistrationDialog({ + required this.provider, + required this.controller, + this.accountLabel, + }); + final String? accountLabel; final BusyProvider provider; final RegistrationSetupController controller; @override @@ -220,6 +229,13 @@ class _RegistrationDialogState extends State<_RegistrationDialog> { } List _methods(AppLocalizations l10n) => [ + if (widget.accountLabel case final label?) ...[ + Text( + l10n.reconnectSelectedAccount(label), + key: const ValueKey('registration-reconnect-account'), + ), + const SizedBox(height: 12), + ], Text(l10n.registrationMethodsIntroduction), const SizedBox(height: 12), Align( diff --git a/lib/src/ui/windows/windows_settings_page.dart b/lib/src/ui/windows/windows_settings_page.dart index 1d8f8035..9a75c7a0 100644 --- a/lib/src/ui/windows/windows_settings_page.dart +++ b/lib/src/ui/windows/windows_settings_page.dart @@ -53,7 +53,13 @@ const _apacheLicenseUrl = 'https://www.apache.org/licenses/LICENSE-2.0'; const _systemLocaleTag = 'system'; class WindowsSettingsPage extends ConsumerStatefulWidget { - const WindowsSettingsPage({super.key, this.initialPage}); + const WindowsSettingsPage({ + super.key, + this.initialPage, + this.reconnectAccountId, + }); + + final String? reconnectAccountId; final String? initialPage; @@ -678,7 +684,12 @@ class _WindowsSettingsPageState extends ConsumerState { for (var index = 0; index < values.length; index++) ...[ if (values[index].provider == BusyProvider.google || values[index].provider == BusyProvider.microsoft) - _WindowsRegistrationCard(account: values[index]), + _WindowsRegistrationCard( + account: values[index], + startRecovery: + widget.reconnectAccountId == values[index].id, + onAddSeparate: () => _setup(BusyProvider.google), + ), ListTile( leading: Icon(windowsBusyMaxGlyph(BusyMaxGlyph.account)), title: Semantics( @@ -1783,8 +1794,14 @@ Future showWindowsLicensesDialog(BuildContext context) async { } class _WindowsRegistrationCard extends ConsumerStatefulWidget { - const _WindowsRegistrationCard({required this.account}); + const _WindowsRegistrationCard({ + required this.account, + this.startRecovery = false, + required this.onAddSeparate, + }); final AccountEntity account; + final bool startRecovery; + final VoidCallback onAddSeparate; @override ConsumerState<_WindowsRegistrationCard> createState() => _WindowsRegistrationCardState(); @@ -1793,6 +1810,7 @@ class _WindowsRegistrationCard extends ConsumerStatefulWidget { class _WindowsRegistrationCardState extends ConsumerState<_WindowsRegistrationCard> { bool connecting = false; + bool recoveryStarted = false; AuthorizationCancellation? _authorizationCancellation; @override void dispose() { @@ -1800,6 +1818,17 @@ class _WindowsRegistrationCardState super.dispose(); } + void _clearRequestedRecovery() { + if (!mounted) return; + final router = GoRouter.maybeOf(context); + if (router == null) return; + final uri = GoRouterState.of(context).uri; + if (uri.queryParameters['reconnect'] == account.id) { + final query = {...uri.queryParameters}..remove('reconnect'); + unawaited(router.replace(uri.replace(queryParameters: query).toString())); + } + } + AccountEntity get account => widget.account; @override Widget build(BuildContext context) { @@ -1814,11 +1843,16 @@ class _WindowsRegistrationCardState _authorizationCancellation = cancellation; try { AuthorizationRequest request; - if (replace) { + if (replace || + account.provider == BusyProvider.google && + (account.needsReconnect || + summary == null || + summary.origin == RegistrationOrigin.retiringShared)) { final handle = await showWindowsRegistrationSetup( context, ref, account.provider, + accountLabel: account.email ?? account.displayName ?? account.id, ); if (handle == null) return; request = AuthorizationRequest.replace(account.id, handle); @@ -1839,7 +1873,30 @@ class _WindowsRegistrationCardState error.classification == OAuthFailureKind.cancelled) { return; } - if (context.mounted) { + if (context.mounted && + error is OAuthException && + error.classification == OAuthFailureKind.identityUnverified) { + final addSeparate = await showDialog( + context: context, + builder: (dialogContext) => ContentDialog( + title: Text(l10n.reconnectGoogleAccount), + content: Text( + '${l10n.reconnectSelectedAccount(account.email ?? account.displayName ?? account.id)}\n\n${l10n.googleIdentityUnverified}', + ), + actions: [ + Button( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.addSeparateGoogleAccount), + ), + ], + ), + ); + if (addSeparate == true && mounted) widget.onAddSeparate(); + } else if (context.mounted) { await displayInfoBar( context, builder: (_, close) => InfoBar( @@ -1858,15 +1915,34 @@ class _WindowsRegistrationCardState cancellation.cancel(); if (identical(_authorizationCancellation, cancellation)) { _authorizationCancellation = null; - if (mounted) setState(() => connecting = false); + if (mounted) { + setState(() => connecting = false); + _clearRequestedRecovery(); + } } } } + if (!widget.startRecovery) recoveryStarted = false; + if (widget.startRecovery && !recoveryStarted) { + recoveryStarted = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(connect(false)); + }); + } return Card( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (account.needsReconnect) + InfoBar( + severity: InfoBarSeverity.warning, + title: Text( + account.provider == BusyProvider.google + ? l10n.googleReconnectRequired + : l10n.accountReconnectRequired, + ), + ), Text( summary == null ? l10n.registrationUnresolved @@ -1902,7 +1978,15 @@ class _WindowsRegistrationCardState ), Button( onPressed: connecting ? null : () => connect(false), - child: Text(l10n.connectAccountAction), + child: Text( + account.needsReconnect || + summary == null || + summary.origin == RegistrationOrigin.retiringShared + ? (account.provider == BusyProvider.google + ? l10n.reconnectGoogleAccount + : l10n.reconnectAccount) + : l10n.connectAccountAction, + ), ), ], ), diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 1aa08248..33d73846 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -96,6 +96,9 @@ + +

Recover Google account authorization while preserving local data, and build official Linux Snaps in an isolated local workspace.

+

Adds synchronized read-only contacts and attendee suggestions on Linux, Windows, and Android using shared contacts, DAV, and Graph integrations.

diff --git a/pubspec.yaml b/pubspec.yaml index f9393789..734bd77a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.3.0 +version: 0.3.1 environment: sdk: ^3.13.4 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 9c1daf43..741fc116 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.3.0" +version: "0.3.1" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager. diff --git a/test/app/caldav_release_metadata_test.dart b/test/app/caldav_release_metadata_test.dart index 12bea3a5..82cdec13 100644 --- a/test/app/caldav_release_metadata_test.dart +++ b/test/app/caldav_release_metadata_test.dart @@ -27,12 +27,12 @@ void main() { RegExp(r' seedLegacy({bool verified = true}) async { + h.staging.dispose(); + await h.db.close(); + h = Harness(buildConfig: syntheticDesktopConfig(originalGoogle: false)); + await h.accounts.upsertSignedInAccount( + id: 'legacy-local', + provider: BusyProvider.google, + providerAccountId: verified ? 'subject' : 'legacy-local', + email: 'previous@example.invalid', + tasksEnabled: false, + grantedScopes: googleBusyMaxOAuthScope, + ); + await h.secrets.saveCredential( + 'legacy-local', + OAuthSecretRecord(provider: BusyProvider.google, tokenSet: tokens()), + ); + await h.db.customStatement( + "INSERT INTO task_lists (account_id,id,title,raw_json,created_local_at_utc,updated_local_at_utc) VALUES ('legacy-local','cached-list','Cached tasks','{}','2026-10-01T00:00:00Z','2026-10-01T00:00:00Z')", + ); + await h.db.customStatement( + "INSERT INTO tasks (account_id,task_list_id,id,title,raw_json,local_dirty,created_local_at_utc,updated_local_at_utc) VALUES ('legacy-local','cached-list','cached-task','Offline task','{}',1,'2026-10-01T00:00:00Z','2026-10-01T00:00:00Z')", + ); + await h.db.customStatement( + "INSERT INTO calendar_sources (id,account_id,provider,provider_calendar_id,summary,selected,created_at_local,updated_at_local) VALUES ('cached-calendar','legacy-local','google','primary','Cached calendar',0,1,1)", + ); + await h.db.customStatement( + "INSERT INTO calendar_events (id,account_id,calendar_source_id,provider,provider_calendar_id,provider_event_id,title,created_at_local,updated_at_local) VALUES ('cached-event','legacy-local','cached-calendar','google','primary','event','Offline event',1,1)", + ); + await h.db.customStatement( + "INSERT INTO pending_ops (id,account_id,entity_type,operation,request_json,conflict_state,created_at_utc,updated_at_utc) VALUES ('pending-event','legacy-local','event','update','{}','unresolved','2026-10-01T00:00:00Z','2026-10-01T00:00:00Z')", + ); + } + + Future> localData() async => [ + ...await h.db.select(h.db.taskLists).get(), + ...await h.db.select(h.db.tasks).get(), + ...await h.db.select(h.db.calendarSources).get(), + ...await h.db.select(h.db.calendarEvents).get(), + ...await h.db.select(h.db.pendingOps).get(), + ]; + test('unavailable original preserves local data and reauthorizes with managed issuer across restart', () async { + await seedLegacy(); + final data = await localData(); + await expectLater( + h.google.validTokenForAccount('legacy-local'), + throwsA(isA()), + ); + expect(h.requests, isEmpty); + await h.repository.markReconnectRequired('legacy-local'); + expect( + (await h.accounts.listVisibleAccounts()).single.needsReconnect, + true, + ); + expect(await localData(), data); + await h.repository.signIn( + request: AuthorizationRequest.replace( + 'legacy-local', + h.staging.stageBusyMax(BusyProvider.google), + ), + ); + final accepted = await h.secrets.readCredential( + 'legacy-local', + ) as GoogleDesktopCredential; + expect(accepted.registration.origin, RegistrationOrigin.busyMaxManaged); + expect( + accepted.registration.clientId, + h.buildConfig.busyMaxGoogleOAuthClientId, + ); + expect(accepted.subject, 'subject'); + expect(h.flow.clients, [h.buildConfig.busyMaxGoogleOAuthClientId]); + expect( + h.requests + .where((r) => r.method == 'POST') + .every( + (r) => + Uri.splitQueryString(r.body)['grant_type'] == + 'authorization_code', + ), + true, + ); + expect( + (await h.accounts.listVisibleAccounts()).single.id, + 'legacy-local', + ); + expect( + (await h.accounts.accountById('legacy-local'))!.tasksEnabled, + false, + ); + expect( + (await h.accounts.accountById('legacy-local'))!.needsReconnect, + false, + ); + expect(await localData(), data); + await h.secrets.saveCredential( + 'legacy-local', + SecretRecord.fromJson(jsonDecode(jsonEncode(accepted.toJson()))), + ); + final restarted = OAuthService( + config: h.buildConfig, + httpClient: h.client, + tokenStore: h.secrets, + loopbackFlow: h.flow, + registrations: h.staging, + persistence: AuthorizationPersistence( + database: h.db, + secrets: h.secrets, + ), + ); + await restarted.refreshTokenForAccount('legacy-local'); + expect( + Uri.splitQueryString(h.requests.last.body)['client_id'], + h.buildConfig.busyMaxGoogleOAuthClientId, + ); + expect(await localData(), data); + }); + for (final mode in ['wrong-account', 'cancel', 'missing-scope']) { + test( + '$mode leaves old account, pending operations and credentials intact', + () async { + await seedLegacy(); + await h.repository.markReconnectRequired('legacy-local'); + final data = await localData(); + final previous = (await h.secrets.readCredential('legacy-local'))! + .toJson(); + h.mode = mode; + await expectLater( + h.repository.signIn( + request: AuthorizationRequest.replace( + 'legacy-local', + h.staging.stageBusyMax(BusyProvider.google), + ), + ), + throwsA(isA()), + ); + expect( + (await h.secrets.readCredential('legacy-local'))!.toJson(), + previous, + ); + expect(await localData(), data); + expect( + (await h.accounts.listVisibleAccounts()).single.needsReconnect, + true, + ); + }, + ); + } + test('unverified legacy identity never refreshes old client or rebinds local work', () async { + await seedLegacy(verified: false); + final data = await localData(); + h.beforeRequest = (r) async => + r.method == 'GET' ? http.Response('{}', 401) : null; + await expectLater( + h.repository.signIn( + request: AuthorizationRequest.replace( + 'legacy-local', + h.staging.stageBusyMax(BusyProvider.google), + ), + ), + throwsA( + isA().having( + (e) => e.classification, + 'kind', + OAuthFailureKind.identityUnverified, + ), + ), + ); + expect(h.flow.clients, isEmpty); + expect(h.requests.every((r) => r.method == 'GET'), true); + expect( + (await h.accounts.listVisibleAccounts()).single.id, + 'legacy-local', + ); + expect(await localData(), data); + }); + for (final status in [429, 500]) { + test( + 'legacy identity HTTP $status remains a temporary provider failure', + () async { + await seedLegacy(verified: false); + h.beforeRequest = (r) async => http.Response('{}', status); + await expectLater( + h.repository.signIn( + request: AuthorizationRequest.replace( + 'legacy-local', + h.staging.stageBusyMax(BusyProvider.google), + ), + ), + throwsA( + isA() + .having((e) => e.statusCode, 'status', status) + .having((e) => e.requiresReconnect, 'reconnect', false), + ), + ); + expect(h.flow.clients, isEmpty); + }, + ); + } + test('rejected legacy binding preserves structured cause and avoids repeated refresh', () async { + const id = 'rejected-legacy'; + await h.accounts.upsertSignedInAccount( + id: id, + provider: BusyProvider.google, + providerAccountId: 'subject', + grantedScopes: googleBusyMaxOAuthScope, + ); + await h.db + .into(h.db.oAuthTransitionAccounts) + .insert(OAuthTransitionAccountsCompanion.insert(accountId: id)); + await h.secrets.saveCredential( + id, + OAuthSecretRecord(provider: BusyProvider.google, tokenSet: tokens()), + ); + h.beforeRequest = (r) async => + http.Response('{"error":"invalid_client"}', 401); + for (var i = 0; i < 2; i++) { + await expectLater( + h.google.boundCredentialForAccount(id), + throwsA( + isA().having( + (e) => e.cause?.classification, + 'cause', + OAuthFailureKind.clientRejected, + ), + ), + ); + } + expect(h.requests, hasLength(1)); + }); + }); for (final origin in [ RegistrationOrigin.busyMaxManaged, RegistrationOrigin.userProvided, @@ -849,7 +1079,7 @@ void main() { isA().having( (error) => error.classification, 'kind', - OAuthFailureKind.configuration, + OAuthFailureKind.registrationUnavailable, ), ), ); diff --git a/test/core/auth/registration_setup_controller_test.dart b/test/core/auth/registration_setup_controller_test.dart index 93432622..d4b9f27f 100644 --- a/test/core/auth/registration_setup_controller_test.dart +++ b/test/core/auth/registration_setup_controller_test.dart @@ -19,6 +19,29 @@ const clientId = '11223344-5566-7788-99aa-bbccddeeff00'; const tenantId = 'aabbccdd-1122-3344-5566-77889900aabb'; void main() { + test( + 'Google managed setup needs neither original Google nor managed Microsoft', + () { + final staging = RegistrationStaging( + syntheticDesktopConfig(originalGoogle: false), + ); + final controller = RegistrationSetupController( + staging, + BusyProvider.google, + ); + addTearDown(() { + controller.dispose(); + staging.dispose(); + }); + final handle = controller.connectWithBusyMax()!; + expect(handle.summary.origin, RegistrationOrigin.busyMaxManaged); + expect( + staging.consume(handle).clientId, + staging.config.busyMaxGoogleOAuthClientId, + ); + }, + ); + for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { test( '$provider shared connection is explicit, validated and single use', diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 151cb7e0..e8abb12b 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -50,7 +50,6 @@ import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/features/schedule/presentation/schedule_workspace.dart'; import 'package:busymax/src/features/settings/presentation/settings_screen.dart'; -import 'package:busymax/src/features/sync/sync_auth_error.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; @@ -111,6 +110,144 @@ void main() { await database.close(); }); + for (final mode in ['wrong-account', 'cancel', 'unverified']) { + testWidgets( + 'targeted Google recovery $mode retains the old account and offers safe recovery', + (tester) async { + await _insertAccount( + database, + id: 'legacy-local', + provider: BusyProvider.google, + authState: accountAuthStateReauthRequired, + ); + final fixture = DesktopConnectionFixture( + database, + _nativeReader, + originalGoogle: false, + ); + addTearDown(fixture.staging.dispose); + await database.customStatement( + "UPDATE accounts SET provider_account_id = ? WHERE id = 'legacy-local'", + [mode == 'unverified' ? 'legacy-local' : 'fixture-user'], + ); + await fixture.secrets.saveCredential( + 'legacy-local', + OAuthSecretRecord( + provider: BusyProvider.google, + tokenSet: _tokenSet().copyWith(accessToken: 'synthetic-old'), + ), + ); + final prior = (await fixture.secrets.readCredential('legacy-local'))! + .toJson(); + fixture.googleSubject = mode == 'wrong-account' + ? 'different-user' + : 'fixture-user'; + fixture.flow.cancelAttempt = mode == 'cancel'; + fixture.legacyAccessUnavailable = mode == 'unverified'; + await _pumpApp( + tester, + database: database, + oAuth: fixture.google, + secrets: fixture.secrets, + persistence: fixture.persistence, + staging: fixture.staging, + onSignedIn: (_, _) async {}, + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(ScheduleWorkspace)), + ); + container + .read(appRouterProvider) + .go('/settings?page=accounts&reconnect=legacy-local'); + for ( + var i = 0; + i < 20 && + find + .byKey(const ValueKey('registration-methods-dialog')) + .evaluate() + .isEmpty; + i++ + ) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pumpAndSettle(); + } + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('registration-busymax'))); + for (var i = 0; i < 20; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pumpAndSettle(); + } + expect( + (await tester.runAsync( + () => fixture.secrets.readCredential('legacy-local'), + ))!.toJson(), + prior, + ); + final accounts = (await tester.runAsync( + () => database.select(database.accounts).get(), + ))!; + expect(accounts, hasLength(1)); + expect(accounts.single.authState, accountAuthStateReauthRequired); + expect( + find.textContaining('OAuthRegistrationUnresolved'), + findsNothing, + ); + if (mode == 'wrong-account') { + expect( + find.textContaining('Authorize the account selected'), + findsOneWidget, + ); + } else if (mode == 'cancel') { + container + .read(appRouterProvider) + .go('/settings?page=accounts&reconnect=legacy-local'); + for ( + var i = 0; + i < 20 && + find + .byKey(const ValueKey('registration-methods-dialog')) + .evaluate() + .isEmpty; + i++ + ) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pumpAndSettle(); + } + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('registration-close'))); + await tester.pumpAndSettle(); + } else if (mode == 'unverified') { + expect(find.textContaining('could not be verified'), findsOneWidget); + await tester.tap(find.text('Add a separate Google connection')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('registration-methods-dialog')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsNothing, + ); + await tester.tap(find.byKey(const ValueKey('registration-close'))); + await tester.pumpAndSettle(); + } + await _disposeApp(tester); + }, + ); + } for (final choice in [ (BusyProvider.google, DesktopConnectionMethod.busyMax), (BusyProvider.google, DesktopConnectionMethod.googleWorkspace), @@ -608,11 +745,19 @@ void main() { provider: BusyProvider.google, authState: accountAuthStateReauthRequired, ); + final fixture = DesktopConnectionFixture(database, _nativeReader); + addTearDown(fixture.staging.dispose); + await database.customStatement( + "UPDATE accounts SET provider_account_id = 'fixture-user' WHERE id = 'account-1'", + ); final calls = <({String accountId, bool initial})>[]; await _pumpApp( tester, database: database, - oAuth: oAuth, + oAuth: fixture.google, + secrets: fixture.secrets, + persistence: fixture.persistence, + staging: fixture.staging, onSignedIn: (accountId, initial) async => calls.add((accountId: accountId, initial: initial)), ); @@ -623,8 +768,32 @@ void main() { ); final original = container.read(authSessionControllerProvider); calls.clear(); - await tester.tap(find.text(accountReconnectRequiredActionLabel)); + final action = find.byKey(const ValueKey('reconnect-account-account-1')); + await tester.ensureVisible(action); + await tester.tap(action); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('registration-close'))); + await tester.pumpAndSettle(); + expect( + (await tester.runAsync(() => database.select(database.accounts).get()))! + .singleWhere((r) => r.id == 'account-1') + .authState, + accountAuthStateReauthRequired, + ); + await tester.tap(action); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('registration-busymax'))); + for (var i = 0; i < 40; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pumpAndSettle(); + if (calls.isNotEmpty) break; + } final rows = (await tester.runAsync( () => database.select(database.accounts).get(), ))!; @@ -1483,7 +1652,13 @@ void main() { tester.element(find.byType(SettingsScreen)), ); - await tester.tap(find.text(accountReconnectRequiredActionLabel)); + final action = find.byKey( + const ValueKey('reconnect-account-google:reconnect'), + ); + await tester.ensureVisible(action); + await tester.tap(action); + await tester.pumpAndSettle(); + await _validateAndAuthorizeGoogleSetup(tester); await tester.pump(); final sessionWhileReconnecting = container.read( @@ -1781,6 +1956,7 @@ Future _authorizeGoogleSetup(WidgetTester tester) async { } Future _validateAndAuthorizeGoogleSetup(WidgetTester tester) async { + await tester.ensureVisible(find.byKey(const ValueKey('registration-custom'))); await tester.tap(find.byKey(const ValueKey('registration-custom'))); await tester.pumpAndSettle(); await tester.runAsync(() async { diff --git a/test/features/auth/presentation/registration_setup_dialog_test.dart b/test/features/auth/presentation/registration_setup_dialog_test.dart index 57e2e6c4..62dbc0c9 100644 --- a/test/features/auth/presentation/registration_setup_dialog_test.dart +++ b/test/features/auth/presentation/registration_setup_dialog_test.dart @@ -97,6 +97,34 @@ void main() { addTearDown(() => staging.dispose()); }); + testWidgets( + 'reconnection identifies the existing account and supports keyboard cancellation', + (tester) async { + staging.dispose(); + staging = RegistrationStaging( + syntheticDesktopConfig(originalGoogle: false), + ); + harness = _Harness(windows, staging); + await harness.open( + tester, + BusyProvider.google, + methods: true, + accountLabel: 'existing@example.invalid', + ); + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsOneWidget, + ); + expect( + find.textContaining('existing@example.invalid'), + findsOneWidget, + ); + expect(_callback(tester, 'busymax'), isNotNull); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(harness.results, [null]); + }, + ); for (final provider in [BusyProvider.google, BusyProvider.microsoft]) { testWidgets( '$provider methods expose unavailable shared and usable custom paths', @@ -778,6 +806,7 @@ class _Harness { WidgetTester tester, BusyProvider provider, { bool methods = false, + String? accountLabel, }) async { tester.view.physicalSize = const Size(1100, 950); tester.view.devicePixelRatio = 1; @@ -787,8 +816,18 @@ class _Harness { builder: (context, ref, _) { void open() { final result = windows - ? showWindowsRegistrationSetup(context, ref, provider) - : showRegistrationSetup(context, ref, provider); + ? showWindowsRegistrationSetup( + context, + ref, + provider, + accountLabel: accountLabel, + ) + : showRegistrationSetup( + context, + ref, + provider, + accountLabel: accountLabel, + ); unawaited(result.then(results.add)); } diff --git a/test/features/notifications/desktop_notification_service_test.dart b/test/features/notifications/desktop_notification_service_test.dart index e6910a47..ef77c793 100644 --- a/test/features/notifications/desktop_notification_service_test.dart +++ b/test/features/notifications/desktop_notification_service_test.dart @@ -11,6 +11,41 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; void main() { + test( + 'unchanged registration failure is suppressed until successful recovery', + () async { + final backend = _FakeNotificationBackend(); + var now = DateTime(2026); + final selected = []; + final service = DesktopNotificationService( + backend: backend, + settings: AppSettings.defaults(), + now: () => now, + onReconnectActivated: (id) async => selected.add(id), + ); + const failure = AuthorizationScopedFailure( + accountId: 'legacy-local', + generation: 0, + cause: OAuthRegistrationUnavailableException(), + ); + await service.notifySyncFailure(failure); + now = now.add(const Duration(days: 1)); + await service.notifySyncFailure(failure); + expect(backend.notifications, hasLength(1)); + expect(backend.notifications.single.payload, { + 'notificationRoute': 'sync-failure', + 'accountId': 'legacy-local', + }); + await backend.notifications.single.onAction?.call( + 'default', + backend.notifications.single.payload, + ); + expect(selected, ['legacy-local']); + service.clearAccountSyncFailure('legacy-local'); + await service.notifySyncFailure(failure); + expect(backend.notifications, hasLength(2)); + }, + ); test('unknown sync failure uses a controlled generic message', () async { final backend = _FakeNotificationBackend(); final service = DesktopNotificationService( diff --git a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart index 02270ab9..b9484071 100644 --- a/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart +++ b/test/features/schedule/presentation/schedule_sidebar_collection_actions_test.dart @@ -149,9 +149,12 @@ void main() { expect(auth.markedAccountIds, ['account']); expect(accounts.account.needsReconnect, isTrue); expect( - find.text('Refresh failed: This account needs to be reconnected.'), + find.text( + 'Your Google account needs to be reconnected to resume calendar and task synchronization.', + ), findsOneWidget, ); + expect(find.text('Reconnect Google account'), findsOneWidget); menu = tester.widget>( find.byKey(const ValueKey(('calendar-options', 'calendar'))), ); @@ -198,9 +201,12 @@ void main() { expect(auth.markedAccountIds, ['account']); expect(accounts.account.needsReconnect, isTrue); expect( - find.text('Refresh failed: This account needs to be reconnected.'), + find.text( + 'Your Google account needs to be reconnected to resume calendar and task synchronization.', + ), findsOneWidget, ); + expect(find.text('Reconnect Google account'), findsOneWidget); menu = tester.widget>( find.byKey(const ValueKey(('task-list-options', 'account', 'tasks'))), ); diff --git a/test/features/settings/presentation/settings_screen_test.dart b/test/features/settings/presentation/settings_screen_test.dart index 3501e92e..6dd4fe5a 100644 --- a/test/features/settings/presentation/settings_screen_test.dart +++ b/test/features/settings/presentation/settings_screen_test.dart @@ -1,3 +1,4 @@ +import '../../../support/desktop_registration_config.dart'; import '../../../support/contacts_fixture.dart'; import '../../../platform/contacts_settings_routes_test.dart' show complete, settle; @@ -206,7 +207,7 @@ void main() { 'Settings renders typed exchange recovery and enables stored reconnect retry $kind shared=$shared', (tester) async { await tester.runAsync(() async { - final h = Harness(); + final h = Harness(buildConfig: syntheticDesktopConfig()); addTearDown(() async { h.staging.dispose(); await h.db.close(); @@ -230,6 +231,8 @@ void main() { container, logicalSize: const Size(1200, 1200), ); + await container.read(registrationSummariesProvider.future); + await tester.pump(); h.beforeRequest = (r) async => http.Response( jsonEncode({ 'error': kind, @@ -237,8 +240,11 @@ void main() { }), kind == 'server_error' ? 503 : 400, ); - await tester.ensureVisible(find.text('Connect').last); - await tester.tap(find.text('Connect').last); + await _startStoredGoogleReconnect( + tester, + observing, + replacement: shared, + ); await observing.finished; await tester.pumpAndSettle(); final message = kind == 'invalid_client' @@ -254,8 +260,11 @@ void main() { ); expect((await h.summary('opaque')).showRetirementNotice, shared); h.beforeRequest = null; - await tester.ensureVisible(find.text('Connect').last); - await tester.tap(find.text('Connect').last); + await _startStoredGoogleReconnect( + tester, + observing, + replacement: shared, + ); await observing.finished; await tester.pumpAndSettle(); expect( @@ -1326,9 +1335,11 @@ void main() { await _pumpSettings(tester, container); - expect(find.text(accountReconnectRequiredActionLabel), findsOneWidget); + expect(find.text('Reconnect Google account'), findsWidgets); expect( - find.text('Reconnect this account to resume synchronization.'), + find.text( + 'Your Google account needs to be reconnected to resume calendar and task synchronization.', + ), findsOneWidget, ); expect(find.text('New task list'), findsNothing); @@ -2235,6 +2246,9 @@ ProviderContainer _container({ ], if (productionHarness != null) ...[ databaseProvider.overrideWithValue(productionHarness.db), + registrationStagingProvider.overrideWithValue( + productionHarness.staging, + ), authorizationPersistenceProvider.overrideWithValue( productionHarness.persistence, ), @@ -2670,8 +2684,10 @@ class _ObservedAuthRepository extends AuthRepository { authorizationPersistence: h.persistence, ); Future? finished; + Completer started = Completer(); @override Future signIn({AuthorizationRequest? request}) { + if (!started.isCompleted) started.complete(); final operation = super.signIn(request: request); finished = operation.then( (_) {}, @@ -2691,7 +2707,7 @@ void registerSettingsAuthorizationCancellationRegressions() { // The production database/transport futures run in the real I/O zone. // Rendering pumps cannot be used as a substitute for their barriers. await tester.runAsync(() async { - final h = Harness(); + final h = Harness(buildConfig: syntheticDesktopConfig()); addTearDown(() async { h.staging.dispose(); await h.db.close(); @@ -2741,9 +2757,11 @@ void registerSettingsAuthorizationCancellationRegressions() { return null; }; } - final reconnect = find.text('Connect'); - await tester.ensureVisible(reconnect.last); - await tester.tap(reconnect.last); + await _startStoredGoogleReconnect( + tester, + observing, + replacement: shared, + ); if (pause == 'browser') { await h.flow.started.future; } else if (pause != 'binding') { @@ -2777,8 +2795,11 @@ void registerSettingsAuthorizationCancellationRegressions() { } h.beforeRequest = null; h.flow.barrier = null; - await tester.ensureVisible(find.text('Connect').last); - await tester.tap(find.text('Connect').last); + await _startStoredGoogleReconnect( + tester, + observing, + replacement: shared, + ); await observing.finished; await tester.pumpAndSettle(); expect( @@ -2787,7 +2808,7 @@ void registerSettingsAuthorizationCancellationRegressions() { ) as GoogleDesktopCredential).tokenSet.accessToken, 'candidate-access', ); - expect((await h.summary('opaque')).showRetirementNotice, shared); + expect((await h.summary('opaque')).showRetirementNotice, false); await tester.pumpWidget(const SizedBox()); }); }, @@ -2796,3 +2817,30 @@ void registerSettingsAuthorizationCancellationRegressions() { } } } + +Future _startStoredGoogleReconnect( + WidgetTester tester, + _ObservedAuthRepository observing, { + required bool replacement, +}) async { + observing.started = Completer(); + final action = find + .text(replacement ? 'Reconnect Google account' : 'Connect') + .last; + await tester.ensureVisible(action); + await tester.tap(action); + if (replacement) { + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('registration-reconnect-account')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('registration-busymax'))); + // Start the reverse animation before advancing its frame timestamp. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + await tester.pump(); + await observing.started.future.timeout(const Duration(seconds: 3)); + await tester.pump(); +} diff --git a/test/features/sync/sync_failure_notification_policy_test.dart b/test/features/sync/sync_failure_notification_policy_test.dart index 52e95e1e..a1aab1e6 100644 --- a/test/features/sync/sync_failure_notification_policy_test.dart +++ b/test/features/sync/sync_failure_notification_policy_test.dart @@ -16,6 +16,127 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; void main() { + test( + 'calendar and task provider payloads stay out of ordinary sync errors', + () { + for (final failure in [ + const GoogleCalendarApiError( + statusCode: 500, + code: 'raw-code', + message: 'private provider response', + ), + const MicrosoftTodoApiError( + statusCode: 403, + code: 'raw-code', + message: 'private provider response', + ), + const TaskRemoteError( + statusCode: 401, + message: 'private provider response', + ), + ]) { + expect( + syncFailureMessage(failure), + isNot(contains('private provider response')), + ); + expect( + syncFailureMessage(failure), + isNot(contains(failure.runtimeType.toString())), + ); + } + }, + ); + test('diagnostics retain distinct safe structured outcomes', () { + const failure = OAuthRegistrationUnavailableException( + cause: OAuthRefreshException( + 'OAuthRefreshFailed', + 'secret provider body', + statusCode: 401, + oauthError: 'invalid_client', + ), + ); + final diagnostic = syncFailureDiagnostic(failure); + expect(diagnostic, contains('registrationUnavailable')); + expect(diagnostic, contains('clientRejected')); + expect(diagnostic, contains('HTTP 401')); + expect(diagnostic, isNot(contains('secret provider body'))); + expect( + syncFailureDiagnostic( + const OAuthRefreshException( + 'OAuthRefreshFailed', + 'raw', + statusCode: 400, + oauthError: 'invalid_grant', + ), + ), + contains('expiredGrant'), + ); + expect( + syncFailureDiagnostic(const OAuthException('OAuthWrongAccount', 'raw')), + contains('wrongAccount'), + ); + expect( + syncFailureDiagnostic(const SocketException('secret host')), + 'Network failure', + ); + }); + test( + 'structured registration and provider failures drive consistent recovery', + () { + for (final failure in [ + const OAuthException('OAuthRegistrationUnresolved', 'Internal details'), + const OAuthRegistrationUnavailableException(), + const OAuthRefreshException( + 'OAuthRefreshFailed', + 'Internal details', + statusCode: 401, + oauthError: 'invalid_client', + ), + const OAuthRefreshException( + 'MicrosoftOAuthRefreshFailed', + 'Internal details', + statusCode: 400, + oauthError: 'invalid_grant', + ), + ]) { + expect( + syncFailureNotificationDisposition(failure), + SyncFailureNotificationDisposition.reconnectRequired, + ); + expect( + syncFailureMessage(failure), + accountReconnectRequiredSyncMessage, + ); + expect(syncFailureMessage(failure), isNot(contains(failure.code))); + } + for (final status in [400, 429, 500]) { + final failure = OAuthRefreshException( + 'OAuthRefreshFailed', + 'raw response', + statusCode: status, + oauthError: status == 400 ? 'invalid_request' : 'invalid_grant', + ); + expect(isOAuthReconnectRequiredError(failure), false); + } + expect( + syncFailureNotificationDisposition( + const OAuthRefreshException( + 'OAuthRefreshFailed', + 'raw response', + statusCode: 400, + oauthError: 'invalid_scope', + ), + ), + SyncFailureNotificationDisposition.permissionChanged, + ); + expect( + isOAuthReconnectRequiredError( + const OAuthException('OAuthCallbackStateMismatch', 'Callback failed'), + ), + false, + ); + }, + ); group('sync failure notification policy', () { test('suppresses transport failures', () { final failures = [ diff --git a/test/google_tasks/oauth/token_exchange_test.dart b/test/google_tasks/oauth/token_exchange_test.dart index 38fa9d67..219a3828 100644 --- a/test/google_tasks/oauth/token_exchange_test.dart +++ b/test/google_tasks/oauth/token_exchange_test.dart @@ -22,7 +22,7 @@ import 'package:busymax/src/providers/busy_provider.dart'; void main() { for (final scenario in [ - (400, 'invalid_client', OAuthFailureKind.configuration), + (400, 'invalid_client', OAuthFailureKind.clientRejected), (429, 'invalid_grant', OAuthFailureKind.throttled), (503, 'invalid_grant', OAuthFailureKind.temporary), ]) { diff --git a/test/support/desktop_connection_fixture.dart b/test/support/desktop_connection_fixture.dart index c6beaa1e..7b0a0aba 100644 --- a/test/support/desktop_connection_fixture.dart +++ b/test/support/desktop_connection_fixture.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:busymax/src/core/auth/authorization_attempt.dart'; +import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/core/auth/authorization_persistence.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; import 'package:busymax/src/core/auth/registration_file_reader.dart'; @@ -21,7 +22,11 @@ import 'desktop_registration_config.dart'; // Production services and persistence with synthetic provider replies only. // Used through real Settings callers; this never opens a live authorization. class DesktopConnectionFixture { - DesktopConnectionFixture(this.database, RegistrationFileReader reader) { + DesktopConnectionFixture( + this.database, + RegistrationFileReader reader, { + bool originalGoogle = true, + }) : config = syntheticDesktopConfig(originalGoogle: originalGoogle) { persistence = AuthorizationPersistence( database: database, secrets: secrets, @@ -35,11 +40,15 @@ class DesktopConnectionFixture { ); final httpClient = MockClient((request) async { if (request.method == 'GET') { + if (legacyAccessUnavailable && + request.headers['Authorization'] == 'Bearer synthetic-old') { + return http.Response('{}', 401); + } return http.Response( jsonEncode( request.url.host == 'graph.microsoft.com' ? {'id': 'fixture-user', 'displayName': 'Synthetic account'} - : {'sub': 'fixture-user', 'email': 'fixture@example.invalid'}, + : {'sub': googleSubject, 'email': 'fixture@example.invalid'}, ), 200, ); @@ -78,7 +87,9 @@ class DesktopConnectionFixture { ); } final AppDatabase database; - final config = syntheticDesktopConfig(); + final BuildConfig config; + String googleSubject = 'fixture-user'; + bool legacyAccessUnavailable = false; final secrets = InMemorySecretStore(); final flow = _FixtureBrowser(); late final AuthorizationPersistence persistence; @@ -90,6 +101,7 @@ class DesktopConnectionFixture { class _FixtureBrowser extends OAuthLoopbackFlow { _FixtureBrowser() : super(authorizationLauncher: (_) async => false); final clients = []; + bool cancelAttempt = false; String scope = ''; @override Future start({ @@ -106,6 +118,9 @@ class _FixtureBrowser extends OAuthLoopbackFlow { AuthorizationAttempt? attempt, }) async { attempt?.check(); + if (cancelAttempt) { + throw const OAuthException('OAuthSignInCancelled', 'Sign-in cancelled.'); + } clients.add(clientId); this.scope = scope; return OAuthLoopbackResult( diff --git a/test/support/desktop_registration_config.dart b/test/support/desktop_registration_config.dart index 58eed6a0..a1d4cd9d 100644 --- a/test/support/desktop_registration_config.dart +++ b/test/support/desktop_registration_config.dart @@ -3,10 +3,13 @@ import 'package:busymax/src/config/build_config.dart'; // Synthetic values used only by tests. Never include these in official packages. BuildConfig syntheticDesktopConfig({ bool managed = true, + bool originalGoogle = true, bool later = false, String originalMicrosoftAuthority = 'common', }) => BuildConfig( - googleOAuthClientId: 'original.apps.googleusercontent.com', + googleOAuthClientId: originalGoogle + ? 'original.apps.googleusercontent.com' + : '', googleOAuthClientSecret: '', microsoftOAuthClientId: '22222222-2222-2222-2222-222222222222', microsoftOAuthAuthorityTenant: originalMicrosoftAuthority, diff --git a/test/tool/flutter_linux_workflow_test.dart b/test/tool/flutter_linux_workflow_test.dart index cdbb787f..1c9e2474 100644 --- a/test/tool/flutter_linux_workflow_test.dart +++ b/test/tool/flutter_linux_workflow_test.dart @@ -3,356 +3,151 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -const _productionCondition = - "if: github.event_name == 'workflow_dispatch' && " - "inputs.production_release && github.ref == 'refs/heads/main'"; -const _ciCondition = - "if: github.event_name != 'workflow_dispatch' || !inputs.production_release"; - void main() { - test('Linux builds installable strict Snaps with the pinned SDK', () { + test('Linux CI verifies source and a smoke Snap without release credentials or uploads', () { final workflow = File('.github/workflows/flutter-linux.yml') .readAsStringSync(); - - expect(workflow, contains('runs-on: ubuntu-24.04')); - expect(workflow, contains("flutter-version: '3.47.5'")); - expect(workflow, contains('Verify pinned Flutter and bundled Dart')); - expect(workflow, contains(r'command -v flutter')); + for (final required in [ + 'pull_request:', + 'push:', + 'branches: [main]', + 'cancel-in-progress: true', + 'runs-on: ubuntu-24.04', + "flutter-version: '3.47.5'", + 'tool/verify_flutter_sdk.dart', + '--expected-flutter 3.47.5', + '--expected-dart 3.13.4', + 'tool/check_release_pull_request.py', + 'tool/checkout_shared_dependencies.py', + r'"$BUSYMAX_FLUTTER_EXECUTABLE" pub get --enforce-lockfile', + r'"$BUSYMAX_FLUTTER_EXECUTABLE" gen-l10n', + 'run build_runner build', + 'git diff --exit-code', + '--set-exit-if-changed', + r'"$BUSYMAX_FLUTTER_EXECUTABLE" analyze', + 'tool/check_platform_boundaries.dart', + r'"$BUSYMAX_FLUTTER_EXECUTABLE" test', + 'gtk_header_icons_integration_test.dart', + 'tool/release/check_linux_native.sh', + 'registration_native_helper_test.py', + 'busymax_registration_reader_tests', + 'uses: snapcore/action-build@v1', + 'sudo snap install --dangerous', + 'snap info --verbose busymax', + ]) { + expect(workflow, contains(required)); + } + for (final removed in [ + 'workflow_dispatch', + 'production_release', + 'secrets.', + 'vars.', + '--dart-define', + 'upload-artifact', + 'busymax-snap-ci-non-production', + 'busymax-source', + 'tool/package_source.py', + 'snapcraft upload', + ]) { + expect(workflow, isNot(contains(removed))); + } + expect(File('tool/package_source.py').existsSync(), isTrue); expect( workflow, contains(r'dart_executable="$flutter_bin/cache/dart-sdk/bin/dart"'), ); - expect(workflow, contains('tool/verify_flutter_sdk.dart')); - expect(workflow, contains('--expected-flutter 3.47.5')); - expect(workflow, contains('--expected-dart 3.13.4')); expect(workflow, isNot(contains(r'command -v dart'))); - expect( - workflow, - contains( - r"printf 'BUSYMAX_FLUTTER_EXECUTABLE=%s\n' " - r'"$flutter_executable" >> "$GITHUB_ENV"', - ), - ); - expect( - workflow, - contains( - r"printf 'BUSYMAX_DART_EXECUTABLE=%s\n' " - r'"$dart_executable" >> "$GITHUB_ENV"', - ), - ); - expect( - workflow, - contains(r'"$BUSYMAX_FLUTTER_EXECUTABLE" pub get --enforce-lockfile'), + }); + + test('production validator requires managed Google but no original or managed Microsoft', () async { + final directory = Directory.systemTemp.createTempSync( + 'busymax-config-check-', ); - expect(workflow, contains(r'"$BUSYMAX_FLUTTER_EXECUTABLE" gen-l10n')); + addTearDown(() => directory.deleteSync(recursive: true)); + final file = File('${directory.path}/defines.json'); + Future check(Map values) async { + file.writeAsStringSync(jsonEncode(values)); + return Process.run(_hostDartExecutable(), [ + '--packages=.dart_tool/package_config.json', + 'tool/check_desktop_oauth_config.dart', + '--config', + file.path, + ]); + } + + final google = { + 'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID': + 'release-fixture.apps.googleusercontent.com', + 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': 'fixture-secret-never-print', + 'BUSYMAX_GOOGLE_OAUTH_PROJECT_ID': 'fixture-project', + 'BUSYMAX_PRIVACY_POLICY_URL': 'https://busystack.org/privacy-busymax', + }; + final complete = await check(google); + expect(complete.exitCode, 0); + expect(complete.stdout, contains('still require owner review')); expect( - workflow, - contains(r'"$BUSYMAX_DART_EXECUTABLE" run build_runner build'), + '${complete.stdout}${complete.stderr}', + isNot(contains('fixture-secret-never-print')), ); - expect(workflow, contains(r'"$BUSYMAX_DART_EXECUTABLE" format')); + expect((await check({})).exitCode, 1); + for (final key in google.keys) { + expect((await check({...google, key: ''})).exitCode, 1); + } + for (final key in [ + 'MICROSOFT_OAUTH_CLIENT_ID', + 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID', + ]) { + expect( + (await check({...google, key: '01234567-89ab-cdef-0123-456789abcdef'})) + .exitCode, + 0, + ); + expect((await check({...google, key: ''})).exitCode, 1); + expect((await check({...google, key: false})).exitCode, 1); + } expect( - workflow, - contains( - r'"$BUSYMAX_DART_EXECUTABLE" run ' - 'tool/check_platform_boundaries.dart', - ), + (await check({ + ...google, + 'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID': + 'busymax-ci-managed.apps.googleusercontent.com', + })).exitCode, + 1, ); - expect(workflow, contains(r'"$BUSYMAX_FLUTTER_EXECUTABLE" analyze')); - expect(workflow, contains(r'"$BUSYMAX_FLUTTER_EXECUTABLE" test')); - expect(workflow, contains(r'"$BUSYMAX_FLUTTER_EXECUTABLE" build linux')); expect( - workflow, - isNot( - matches(RegExp(r'^\s+(?:run:\s*)?(?:dart|flutter)\s', multiLine: true)), - ), + (await check({ + ...google, + 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': ' synthetic-ci-public-secret ', + })).exitCode, + 1, ); - expect(workflow, contains('uses: snapcore/action-build@v1')); - expect(workflow, contains('uses: actions/upload-artifact@v7')); expect( - workflow, - contains('Validate production registrations and protected originals'), + (await check({ + ...google, + 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID': + '44444444-4444-4444-4444-444444444444', + })).exitCode, + 1, ); expect( - workflow, - contains( - 'Build official Linux release with explicit active registrations', - ), + (await check({...google, 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': false})) + .exitCode, + 1, ); - expect(workflow, contains('tool/check_desktop_oauth_config.dart')); - expect(workflow, contains('BUSYMAX_GOOGLE_OAUTH_PROJECT_ID')); - expect(workflow, contains('BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID')); - expect(workflow, contains('MICROSOFT_OAUTH_AUTHORITY_TENANT')); - expect(workflow, contains('https://busystack.org/privacy-busymax')); - expect(workflow, contains('for key in GOOGLE_OAUTH_CLIENT_ID')); - expect(workflow, contains(r'grep -aFq -- "${!key}"')); - expect(workflow, contains(r'grep -aFq -- "$GOOGLE_OAUTH_CLIENT_SECRET"')); - expect(workflow, contains('name: busymax-snap\n')); - expect(workflow, contains(r'path: ${{ steps.snapcraft.outputs.snap }}')); - expect(workflow, contains('sudo snap install --dangerous')); - expect(workflow, contains('snap info --verbose busymax')); - expect(workflow, contains(r'confinement:[[:space:]]+strict')); - expect(workflow, contains(r'test -x "$SNAP/busymax"')); - expect(workflow, isNot(contains('SNAP_CONFINEMENT'))); - expect(workflow, isNot(contains('busymax-linux-x64-release-bundle'))); - expect(workflow, isNot(contains('Build Linux debug'))); - expect(workflow, isNot(contains('zip -r'))); - }); - - test( - 'Linux CI has main-only triggers, cancellation, and short Snap retention', - () { - final workflow = File('.github/workflows/flutter-linux.yml') - .readAsStringSync(); - final triggers = workflow.substring( - workflow.indexOf('on:'), - workflow.indexOf('\nconcurrency:'), - ); - final concurrency = workflow.substring( - workflow.indexOf('concurrency:'), - workflow.indexOf('\njobs:'), - ); - - expect( - _triggerBlock(triggers, 'pull_request'), - 'pull_request:\n branches: [main]', - ); - expect(_triggerBlock(triggers, 'push'), 'push:\n branches: [main]'); - final manual = _triggerBlock(triggers, 'workflow_dispatch'); - expect(manual, contains('production_release:')); - expect(manual, contains('type: boolean')); - expect(manual, contains('required: true')); - expect(manual, contains('default: false')); - expect(triggers, isNot(contains('Release/**'))); - expect( - concurrency, - contains(r'group: ${{ github.workflow }}-${{ github.ref }}'), - ); - expect(concurrency, contains('cancel-in-progress: true')); - for (final name in [ - 'Upload non-production Snap artifact', - 'Upload Snap artifact', - ]) { - final upload = _stepBlock(workflow, name); - expect(upload, contains('retention-days: 7')); - expect(upload, contains('if-no-files-found: error')); - expect( - workflow.indexOf('- name: $name'), - greaterThan(workflow.indexOf('- name: Verify installed strict Snap')), - reason: 'Only verified artifacts may be uploaded.', - ); - } - }, - ); - - test( - 'routine Linux CI needs no production credentials and verifies its Snap', - () { - final workflow = File('.github/workflows/flutter-linux.yml') - .readAsStringSync(); - - for (final name in [ - 'Resolve dependencies', - 'Generate localizations', - 'Generate Drift code', - 'Check formatting', - 'Analyze', - 'Check platform boundaries', - 'Test', - 'Test GTK header-icon bridge', - 'Test native clock patterns, weekday locale, and GTK time picker', - 'Test native registration helper teardown', - 'Test native registration configuration handles', - 'Build unconfigured release with usable custom registration paths', - 'Build strict Snap from the release bundle', - 'Install strict Snap', - 'Verify installed strict Snap', - ]) { - final step = _stepBlock(workflow, name); - expect(step, contains('- name: $name')); - expect( - step, - isNot(contains('\n if:')), - reason: '$name must run for both pull requests and main pushes.', - ); - expect(step, isNot(contains('secrets.'))); - expect(step, isNot(contains('vars.'))); - } - final unconfigured = _stepBlock( - workflow, - 'Build unconfigured release with usable custom registration paths', - ); - expect(unconfigured, isNot(contains('--dart-define'))); - expect(unconfigured, contains('docs/google_setup.md')); - expect(unconfigured, contains('docs/microsoft_setup.md')); - final label = _stepBlock(workflow, 'Label non-production Snap artifact'); - final upload = _stepBlock( - workflow, - 'Upload non-production Snap artifact', - ); - expect(label, contains(_ciCondition)); - expect(upload, contains(_ciCondition)); - expect(label, contains('build/busymax-snap-ci-non-production.snap')); - expect( - upload, - contains('path: build/busymax-snap-ci-non-production.snap'), - ); - expect(upload, contains('name: busymax-snap-ci-non-production')); - expect(workflow, isNot(contains('snapcraft upload'))); - }, - ); - - test( - 'production validation, build and artifact require explicit main dispatch', - () { - final workflow = File('.github/workflows/flutter-linux.yml') - .readAsStringSync(); - final guard = _stepBlock(workflow, 'Require main for manual runs'); - expect( - guard, - contains( - "if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main'", - ), - ); - expect(guard, contains('::error::')); - expect(guard, contains('exit 1')); - expect( - workflow.indexOf('- name: Require main for manual runs'), - lessThan(workflow.indexOf('- name: Check out repository')), - ); - const validation = - 'Validate production registrations and protected originals'; - const build = - 'Build official Linux release with explicit active registrations'; - for (final name in [validation, build, 'Upload Snap artifact']) { - expect(_stepBlock(workflow, name), contains(_productionCondition)); - } - for (final step in workflow.split(RegExp(r'(?= - name:)'))) { - if (step.contains('secrets.') || step.contains('vars.')) { - expect(step, contains(_productionCondition)); - } - } - expect( - _stepBlock(workflow, validation), - contains('tool/check_desktop_oauth_config.dart'), - ); - expect(_stepBlock(workflow, build), contains('--dart-define-from-file=')); - expect( - workflow.indexOf('- name: $validation'), - lessThan(workflow.indexOf('- name: $build')), - ); - expect( - workflow.indexOf('- name: $build'), - lessThan(workflow.indexOf('- name: Build strict Snap')), - ); - }, - ); - - test( - 'official release checks report prerequisites without exposing credentials', - () async { - final temporary = Directory.systemTemp.createTempSync( - 'busymax-release-check.', - ); - addTearDown(() => temporary.deleteSync(recursive: true)); - final configuration = File('${temporary.path}/registration.json'); - Future check(Map values) { - configuration.writeAsStringSync(jsonEncode(values)); - return Process.run(_hostDartExecutable(), [ - '--packages=.dart_tool/package_config.json', - 'tool/check_desktop_oauth_config.dart', - '--config', - configuration.path, - ]); - } - - final absent = await check({}); - expect(absent.exitCode, 1); - expect(absent.stderr, contains('External prerequisite: Google')); - expect(absent.stderr, contains('External prerequisite: Microsoft')); - expect(absent.stderr, contains('Protected original registration IDs')); - - // Test data only: syntax cannot establish a real provider registration. - final values = { - 'GOOGLE_OAUTH_CLIENT_ID': 'fixture-original.apps.googleusercontent.com', - 'MICROSOFT_OAUTH_CLIENT_ID': '11111111-1111-1111-1111-111111111111', - 'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID': - 'fixture-active.apps.googleusercontent.com', - 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': - 'fixture-value-must-not-be-printed', - 'BUSYMAX_GOOGLE_OAUTH_PROJECT_ID': 'fixture-active', - 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID': - '33333333-3333-3333-3333-333333333333', - 'BUSYMAX_PRIVACY_POLICY_URL': 'https://busystack.org/privacy-busymax', - }; - final complete = await check(values); - expect(complete.exitCode, 0); - expect(complete.stdout, contains('syntactically complete')); - expect(complete.stdout, contains('still require owner review')); - expect( - '${complete.stdout}${complete.stderr}', - isNot(contains(values['BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET'])), - ); - - for (final invalid in ['', 'not-a-client-id']) { - final invalidMicrosoft = await check({ - ...values, - 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID': invalid, - }); - expect(invalidMicrosoft.exitCode, 1); - expect( - invalidMicrosoft.stderr, - contains('External prerequisite: Microsoft'), - ); - } - final originalOnly = await check({ - 'GOOGLE_OAUTH_CLIENT_ID': values['GOOGLE_OAUTH_CLIENT_ID'], - 'GOOGLE_OAUTH_CLIENT_SECRET': 'fixture-original-secret', - 'MICROSOFT_OAUTH_CLIENT_ID': values['MICROSOFT_OAUTH_CLIENT_ID'], - 'BUSYMAX_PRIVACY_POLICY_URL': values['BUSYMAX_PRIVACY_POLICY_URL'], - }); - expect(originalOnly.exitCode, 1); - expect(originalOnly.stderr, contains('External prerequisite: Google')); - expect(originalOnly.stderr, contains('External prerequisite: Microsoft')); - final ci = await check({ - ...values, - 'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID': - 'busymax-ci-managed.apps.googleusercontent.com', - }); - expect(ci.exitCode, 1); - expect(ci.stderr, contains('Synthetic CI')); - final microsoftCi = await check({ - ...values, - 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID': - '44444444-4444-4444-4444-444444444444', - }); - expect(microsoftCi.exitCode, 1); - expect(microsoftCi.stderr, contains('Synthetic CI')); - final paddedCi = await check({ - ...values, - 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': ' synthetic-ci-public-secret ', - }); - expect(paddedCi.exitCode, 1); - expect(paddedCi.stderr, contains('Synthetic CI')); - final wrongType = await check({ - ...values, - 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': false, - }); - expect(wrongType.exitCode, 1); - expect(wrongType.stderr, contains('External prerequisite: Google')); - - configuration.writeAsStringSync('{"secret":"must-not-be-printed"'); - final malformed = await Process.run(_hostDartExecutable(), [ - '--packages=.dart_tool/package_config.json', - 'tool/check_desktop_oauth_config.dart', - '--config', - configuration.path, - ]); - expect(malformed.exitCode, 1); - expect(malformed.stderr, contains('Cannot read a JSON object')); - expect(malformed.stderr, isNot(contains('must-not-be-printed'))); - }, - timeout: const Timeout(Duration(minutes: 2)), - ); + file.writeAsStringSync('{"secret":"must-not-print"'); + final malformed = await Process.run(_hostDartExecutable(), [ + '--packages=.dart_tool/package_config.json', + 'tool/check_desktop_oauth_config.dart', + '--config', + file.path, + ]); + expect(malformed.exitCode, 1); + expect(malformed.stderr, isNot(contains('must-not-print'))); + final environment = await Process.run(_hostDartExecutable(), [ + '--packages=.dart_tool/package_config.json', + 'tool/check_desktop_oauth_config.dart', + ], environment: google.cast()); + expect(environment.exitCode, 0); + }, timeout: const Timeout(Duration(minutes: 2))); } String _hostDartExecutable() { @@ -364,21 +159,5 @@ String _hostDartExecutable() { if (candidate.existsSync()) return candidate.path; directory = directory.parent; } - throw StateError('Cannot locate the test runner’s bundled Dart SDK.'); -} - -String _stepBlock(String workflow, String name) { - final start = workflow.indexOf('- name: $name'); - expect(start, isNonNegative, reason: 'Missing workflow step: $name'); - final end = workflow.indexOf('\n - name:', start + 1); - return end == -1 ? workflow.substring(start) : workflow.substring(start, end); -} - -String _triggerBlock(String triggers, String event) { - final start = triggers.indexOf(' $event:'); - expect(start, isNonNegative, reason: 'Missing workflow trigger: $event'); - final nextEvent = RegExp(r'\n [a-z_]+:') - .firstMatch(triggers.substring(start + 1)); - final end = nextEvent == null ? null : start + 1 + nextEvent.start; - return triggers.substring(start, end).trim(); + throw StateError('Cannot locate bundled Dart SDK.'); } diff --git a/test/tool/local_snap_release_test.py b/test/tool/local_snap_release_test.py new file mode 100644 index 00000000..bd02b283 --- /dev/null +++ b/test/tool/local_snap_release_test.py @@ -0,0 +1,256 @@ +"""Contracts for the production entry point; all credentials here are synthetic.""" +import argparse +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('local_release', ROOT / 'tool/release/build_snap_local.py') +release = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(release) + + +class LocalReleaseTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='busymax-local-release-test-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / 'source' + self.source.mkdir() + self.credentials = self.root / 'desktop.json' + self.installed = {'client_id': 'release-fixture.apps.googleusercontent.com', + 'project_id': 'release-fixture', 'client_secret': 'fixture-secret-never-print'} + self.write_google() + + def write_google(self, value=None): + self.credentials.write_text(json.dumps(value if value is not None else {'installed': self.installed})) + + def config(self): + return release.google_configuration(self.credentials, self.source) + + def source_files(self): + for name in ['pubspec.yaml', 'snap/snapcraft.yaml', 'linux/io.busystack.busymax.metainfo.xml', + 'windows/runner/Runner.rc', 'android/app/build.gradle.kts', + 'tool/check_release_pull_request.py', 'tool/shared_dependencies.json']: + target = self.source / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / name, target) + + def source_runner(self, args, cwd, **kwargs): + if args[1:3] == ['rev-parse', 'HEAD']: + return 'a' * 40 + if args[1:3] == ['branch', '--show-current']: + return 'release/v0.3.1' + return '' + + def bundle(self, root): + for name in ['busymax', 'lib/libapp.so', 'lib/libflutter_linux_gtk.so', 'data/icudtl.dat', + 'data/flutter_assets/docs/google_setup.md', 'data/flutter_assets/docs/microsoft_setup.md', + 'data/flutter_assets/assets/branding/busymax-logo.svg']: + p = root / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes((' '.join(self.config().values())).encode() if name == 'lib/libapp.so' else b'fixture') + (root / 'data/flutter_assets/version.json').write_text('{"app_name":"busymax","version":"0.3.1"}') + os.chmod(root / 'busymax', 0o755) + + def test_valid_desktop_derives_only_managed_fields(self): + values = self.config() + self.assertEqual(values['BUSYMAX_GOOGLE_OAUTH_PROJECT_ID'], self.installed['project_id']) + self.assertNotIn('GOOGLE_OAUTH_CLIENT_ID', values) + self.assertEqual(values['BUSYMAX_GOOGLE_OAUTH_CLIENT_ID'], self.installed['client_id']) + + def test_wrong_client_types_rejected(self): + for value in [{'web': self.installed}, {'type': 'service_account'}, + {'installed': self.installed, 'web': {}}]: + self.write_google(value) + with self.assertRaises(release.ReleaseError): self.config() + + def test_known_application_fixture_rejected(self): + self.installed['client_id'] = 'fixture.apps.googleusercontent.com'; self.write_google() + with self.assertRaises(release.ReleaseError): self.config() + + def test_missing_client_id_rejected(self): + del self.installed['client_id']; self.write_google() + with self.assertRaises(release.ReleaseError): self.config() + + def test_missing_project_rejected(self): + del self.installed['project_id']; self.write_google() + with self.assertRaises(release.ReleaseError): self.config() + + def test_malformed_json_is_safe(self): + self.credentials.write_text('{"secret":"must-not-print"') + with self.assertRaises(release.ReleaseError) as caught: self.config() + self.assertNotIn('must-not-print', str(caught.exception)) + + def test_directory_selection_requires_one_json(self): + selected = self.root / 'selection'; selected.mkdir() + shutil.copy2(self.credentials, selected / 'first.json') + self.assertEqual(release.google_configuration(selected, self.source), self.config()) + shutil.copy2(self.credentials, selected / 'second.json') + with self.assertRaises(release.ReleaseError): release.google_configuration(selected, self.source) + self.assertEqual(release.google_configuration(selected / 'first.json', self.source), self.config()) + + def test_secrets_redacted_in_build_output(self): + log = self.root / 'build.log' + with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, self.installed['client_secret'])): + with self.assertRaises(release.ReleaseError) as caught: + release.run(['flutter'], self.source, log=log, redactions=self.config().values()) + self.assertNotIn(self.installed['client_secret'], log.read_text() + str(caught.exception)) + + def test_download_must_stay_outside_git(self): + internal = self.source / 'desktop.json'; shutil.copy2(self.credentials, internal) + with self.assertRaises(release.ReleaseError): release.google_configuration(internal, self.source) + self.source_files() + def runner(args, cwd, **kwargs): + if args[1] == 'ls-files': return 'client_secret_download.json' + return self.source_runner(args, cwd, **kwargs) + with patch.object(release, 'run', side_effect=runner): + with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'a'*40, '0.3.1') + + def test_wrong_source_version_rejected(self): + self.source_files() + for old in ['0.2.4', '0.3.0']: + (self.source / 'pubspec.yaml').write_text(f'version: {old}\n') + with patch.object(release, 'run', side_effect=self.source_runner): + with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'a'*40, '0.3.1') + + def test_wrong_revision_rejected(self): + with patch.object(release, 'run', side_effect=self.source_runner): + with self.assertRaises(release.ReleaseError): release.source_gate(self.source, 'b'*40, '0.3.1') + + def test_existing_workspace_and_shared_repository_untouched(self): + workspace = self.root / 'existing'; workspace.mkdir() + shared = workspace / 'busystack_graph'; shared.mkdir() + sentinel = shared / 'local-work'; sentinel.write_text('keep') + with self.assertRaises(release.ReleaseError): release.workspace_gate(workspace, self.source) + self.assertEqual(sentinel.read_text(), 'keep') + + def test_wrong_snapcraft_version_rejected(self): + sdk = self.root / 'sdk/bin'; (sdk / 'cache/dart-sdk/bin').mkdir(parents=True) + flutter = sdk / 'flutter'; flutter.touch(); (sdk / 'cache/dart-sdk/bin/dart').touch() + with patch.object(release, 'run', side_effect=['{}', 'snapcraft 8.0.0']): + with self.assertRaises(release.ReleaseError): release.tool_gate(self.source, flutter) + + def test_missing_bundle_rejected(self): + with self.assertRaises(release.ReleaseError): release.bundle_gate(self.root / 'missing', self.config()) + + def test_old_flutter_version_is_rejected(self): + bundle = self.root / 'bundle'; self.bundle(bundle) + (bundle / 'data/flutter_assets/version.json').write_text('{"app_name":"busymax","version":"0.3.0"}') + with self.assertRaises(release.ReleaseError): release.bundle_gate(bundle, self.config(), '0.3.1') + + def test_unconfigured_compilation_rejected(self): + bundle = self.root / 'bundle'; self.bundle(bundle) + (bundle / 'lib/libapp.so').write_bytes(b'unconfigured') + with self.assertRaises(release.ReleaseError): release.bundle_gate(bundle, self.config()) + + def test_missing_final_snap_rejected(self): + with self.assertRaises(release.ReleaseError): + release.package_gate(self.root / 'missing.snap', self.root, '0.3.1', self.config(), self.source) + + def package_tree(self, tree): + self.bundle(tree) + for name in ['share/applications/io.busystack.busymax.desktop', + 'share/applications/io.busystack.busymax-autostart.desktop', + 'share/icons/hicolor/scalable/apps/io.busystack.busymax.svg']: + p = tree / name; p.parent.mkdir(parents=True, exist_ok=True); p.write_text('fixture') + p = tree / 'share/metainfo/io.busystack.busymax.metainfo.xml'; p.parent.mkdir(parents=True) + p.write_text('') + p = tree / 'meta/snap.yaml'; p.parent.mkdir() + (tree / 'meta/gui').mkdir(); (tree / 'meta/gui/busymax.desktop').write_text('fixture') + p.write_text('name: busymax\nversion: 0.3.1\nbase: core24\ngrade: devel\nconfinement: strict\napps:\n busymax:\n command: busymax\n autostart: io.busystack.busymax-autostart.desktop\n') + + def test_packaged_metadata_verified(self): + snap = self.root / 'busymax.snap'; snap.touch() + self.package_tree(self.root / 'inspection') + with patch.object(release, 'run'): + release.package_gate(snap, self.root, '0.3.1', self.config(), self.source) + with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.0', self.config(), self.source) + (self.root / 'inspection/meta/snap.yaml').write_text('confinement: classic') + with patch.object(release, 'run'): + with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.1', self.config(), self.source) + + def test_unexpected_output_artifacts_rejected(self): + snap = self.root / 'busymax.snap'; snap.touch(); (self.root / 'other.snap').touch() + with self.assertRaises(release.ReleaseError): release.package_gate(snap, self.root, '0.3.1', self.config(), self.source) + + def test_upload_and_destructive_modes_absent(self): + script = (ROOT / 'tool/release/build_snap_local.py').read_text() + for forbidden in ['snapcraft upload', 'snapcraft login', '--destructive-mode', 'reset --hard', 'clean -fd', 'worktree remove']: + self.assertNotIn(forbidden, script) + self.assertIn("'pack', '--use-lxd'", script) + + def mocked_build(self, failure=None): + self.source_files() + workspace = self.root / 'workspace' + options = argparse.Namespace(source=self.source, workspace=workspace, google_oauth=self.credentials, + local_config=None, revision='a'*40, version='0.3.1', flutter=self.root/'flutter') + seen = [] + def runner(args, cwd, **kwargs): + seen.append([str(a) for a in args]) + if args[0] == 'git' and args[1] == 'clone': shutil.copytree(self.source, args[-1]) + if args[0] == 'git' and args[1] == 'ls-files': return '' + if failure == 'dependency' and 'tool/checkout_shared_dependencies.py' in args: + raise release.ReleaseError('Missing pinned dependency.') + if failure == 'stale' and args[0] == 'git' and args[1] == 'checkout': + self.bundle(workspace/'busymax/build/linux/x64/release/bundle') + if len(args) > 2 and args[1:3] == ['build', 'linux']: + configuration = Path(str(args[-1]).split('=',1)[1]) + self.assertEqual(configuration.stat().st_mode & 0o777, 0o600) + self.assertFalse(configuration.is_relative_to(workspace / 'busymax')) + self.bundle(workspace/'busymax/build/linux/x64/release/bundle') + if args[:2] == ['snapcraft', 'pack'] and failure != 'snap': + (workspace/'artifacts/busymax_0.3.1_amd64.snap').write_bytes(b'fixture-snap') + if args[0] == 'unsquashfs': self.package_tree(Path(args[3])) + if 'tool/release/verify_snap_runtime.sh' in args: + if failure == 'runtime': raise release.ReleaseError('Runtime failure.') + Path(args[-1]).write_text(json.dumps({'startup_x11':True,'elf_dependencies':True,'startup_wayland':True,'restart':True,'confined_network':True, + 'snap_sha256':release.hashlib.sha256(b'fixture-snap').hexdigest()})) + return '' + with patch.object(release, 'source_gate'), patch.object(release, 'tool_gate', return_value=self.root/'dart'), \ + patch.object(release, 'run', side_effect=runner), contextlib.redirect_stdout(io.StringIO()): + if failure: + with self.assertRaises(release.ReleaseError): release.build(options) + else: + artifact = release.build(options); self.assertTrue(artifact.exists()) + return workspace, seen + + def test_successful_pipeline_has_checksum_and_safe_manifest(self): + workspace, seen = self.mocked_build() + manifest = json.loads((workspace/'artifacts/manifest.json').read_text()) + self.assertFalse(manifest['release_ready']) + self.assertEqual(manifest['revision'], 'a'*40) + self.assertTrue(list((workspace/'artifacts').glob('*.sha256'))) + self.assertFalse(list(workspace.glob('.oauth-*'))) + self.assertNotIn(self.installed['client_secret'], json.dumps(manifest)) + self.assertTrue(any('tool/checkout_shared_dependencies.py' in cmd for cmd in seen)) + self.assertTrue(any(cmd[:2] == ['snapcraft','pack'] and '--use-lxd' in cmd for cmd in seen)) + + def test_failed_build_never_emits_success_manifest(self): + for failure in ['dependency','stale','snap','runtime']: + with self.subTest(failure=failure): + workspace, _ = self.mocked_build(failure) + self.assertFalse((workspace/'artifacts/manifest.json').exists()) + self.assertFalse(list(workspace.glob('.oauth-*'))) + shutil.rmtree(workspace) # Only this test's own temporary fixture. + + def test_optional_microsoft_and_invalid_public_settings(self): + self.assertEqual(release.public_configuration(None), {}) + p = self.root/'public.json' + for value in [{'MICROSOFT_OAUTH_CLIENT_ID':'bad'}, {'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID':''}, + {'GOOGLE_OAUTH_PROJECT_ID':'manual-project'}]: + p.write_text(json.dumps(value)) + with self.assertRaises(release.ReleaseError): release.public_configuration(p) + p.write_text('{"MICROSOFT_OAUTH_CLIENT_ID":"01234567-89ab-cdef-0123-456789abcdef"}') + self.assertIn('MICROSOFT_OAUTH_CLIENT_ID', release.public_configuration(p)) + + +if __name__ == '__main__': unittest.main() diff --git a/tool/README.md b/tool/README.md index 55a6e3d0..3a8a7405 100644 --- a/tool/README.md +++ b/tool/README.md @@ -17,8 +17,8 @@ in the linked development or release guides. against the reviewed source tree, including intentional uncommitted changes. Android and Windows local verification use this check; CI also requires checked-in generated sources. Windows `-Ci -Unconfigured -Stage WindowsCompile` validates -setup with the recommended method unavailable. Official packaging requires explicit -active production registrations and preserves protected originals separately. +setup with the recommended method unavailable. Official local Linux packaging requires the intended managed Google client from +an external Desktop JSON; original Google credentials and managed Microsoft are optional. See [desktop OAuth release prerequisites](../docs/desktop_oauth_release_checklist.md). Android's redirect/signing identity remains required even without the original Microsoft client. The pinned Gradle 9.4.1 supports JDK 17–26; use an installed @@ -26,6 +26,23 @@ supported runtime without downgrading it. ## Linux and Snap +- `release/build_snap_local.sh --version VERSION --revision SHA --google-oauth FILE_OR_DIRECTORY --local-config config/release.local.json --workspace NEW_DIRECTORY` + is the supported official local production command. It builds in new sibling + checkouts at the pinned revisions, validates exact SDK/source/version/configuration, + runs regression/native checks, builds a fresh bundle, packages with Snapcraft LXD, + checks a fresh strict installation and writes a candidate manifest/checksum. + It never installs on the owner's desktop or uploads. Credentials are temporary, + restricted and excluded from Git and package payloads. See [local release](../docs/beta_snap_release.md). +- `release/check_linux_native.sh` shares the existing Linux native checks between + CI and the local release command. +- `release/verify_snap_runtime.sh SNAP EVIDENCE_JSON` checks a new tagged Ubuntu + 24.04 LXD VM with X11 and Wayland. It retains no OAuth account data and never + reuses or deletes an unrelated instance. +- `check_desktop_oauth_config.dart [--config FILE]` validates production managed + Google and any supplied optional Microsoft/original settings without printing values. +- `package_source.py` remains available for explicit deterministic source archive + production. GitHub Actions no longer uploads source archives or Snap artifacts. + - `install_linux_dev_desktop.sh` installs or removes a BusyMax desktop entry and icon in the current user's XDG data directory, then refreshes the desktop database when available. The files can take precedence over an installed diff --git a/tool/check_desktop_oauth_config.dart b/tool/check_desktop_oauth_config.dart index db1d18d7..8061d6c3 100644 --- a/tool/check_desktop_oauth_config.dart +++ b/tool/check_desktop_oauth_config.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:busymax/src/config/desktop_oauth_configuration.dart'; -/// Official desktop builds must explicitly supply both recommended clients. +/// Official local Linux builds require the intended managed Google client. /// Never print configuration values: they may contain protected client secrets. void main(List args) { Map values; @@ -32,6 +32,17 @@ void main(List args) { values[key] is String ? values[key] as String : ''; var missing = false; const syntheticCiValues = { + 'managed.apps.googleusercontent.com', + 'later.apps.googleusercontent.com', + 'fixture.apps.googleusercontent.com', + 'original.apps.googleusercontent.com', + 'owned.apps.googleusercontent.com', + 'synthetic-public-secret', + 'synthetic-managed-project', + '11111111-1111-1111-1111-111111111111', + '33333333-3333-3333-3333-333333333333', + '55555555-5555-5555-5555-555555555555', + 'busymax-ci-managed.apps.googleusercontent.com', 'synthetic-ci-public-secret', 'busymax-ci-managed', @@ -45,6 +56,8 @@ void main(List args) { 'BUSYMAX_GOOGLE_OAUTH_PROJECT_ID', 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID', 'GOOGLE_OAUTH_CLIENT_ID', + 'GOOGLE_OAUTH_CLIENT_SECRET', + 'GOOGLE_OAUTH_PROJECT_ID', 'MICROSOFT_OAUTH_CLIENT_ID', ].any((key) => syntheticCiValues.contains(value(key).trim()))) { stderr.writeln( @@ -65,24 +78,66 @@ void main(List args) { ); missing = true; } - if (!validMicrosoftDesktopConfiguration( - clientId: value('BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID'), - )) { - stderr.writeln( - 'External prerequisite: Microsoft production public app ID ' - 'is missing or invalid. ' - 'The owner must support personal and organizational accounts and ' - 'configure the desktop redirect, delegated permissions ' - 'and applicable consent before enabling Connect with BusyMax.', - ); + for (final key in [ + 'MICROSOFT_OAUTH_CLIENT_ID', + 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID', + ]) { + if (values.containsKey(key) && + !validMicrosoftDesktopConfiguration(clientId: value(key))) { + stderr.writeln( + 'Supplied Microsoft public client configuration is invalid.', + ); + missing = true; + } + } + // Original Google values are optional, never a substitute for managed values. + if (values.containsKey('GOOGLE_OAUTH_CLIENT_ID') && + !RegExp(r'^[A-Za-z0-9_-]+\.apps\.googleusercontent\.com$') + .hasMatch(value('GOOGLE_OAUTH_CLIENT_ID').trim())) { + stderr.writeln('Supplied original Google client configuration is invalid.'); + missing = true; + } + for (final key in [ + 'GOOGLE_OAUTH_CLIENT_SECRET', + 'GOOGLE_OAUTH_PROJECT_ID', + 'MICROSOFT_OAUTH_AUTHORITY_TENANT', + ]) { + if (values.containsKey(key) && value(key).trim().isEmpty) { + stderr.writeln( + 'Supplied optional registration configuration is incomplete.', + ); + missing = true; + } + } + if (values.containsKey('MICROSOFT_OAUTH_AUTHORITY_TENANT') && + !{ + 'common', + 'consumers', + 'organizations', + }.contains(value('MICROSOFT_OAUTH_AUTHORITY_TENANT')) && + !validMicrosoftDesktopConfiguration( + clientId: value('MICROSOFT_OAUTH_AUTHORITY_TENANT'), + )) { + stderr.writeln('Supplied Microsoft authority configuration is invalid.'); + missing = true; + } + if (values.containsKey('GOOGLE_OAUTH_PROJECT_ID') && + !RegExp(r'^[a-z][a-z0-9-]{4,62}[a-z0-9]$') + .hasMatch(value('GOOGLE_OAUTH_PROJECT_ID'))) { + stderr.writeln('Supplied original Google project metadata is invalid.'); + missing = true; + } + if ((values.containsKey('GOOGLE_OAUTH_CLIENT_SECRET') || + values.containsKey('GOOGLE_OAUTH_PROJECT_ID')) && + !values.containsKey('GOOGLE_OAUTH_CLIENT_ID')) { + stderr.writeln('Supplied original Google configuration is incomplete.'); missing = true; } - if (value('GOOGLE_OAUTH_CLIENT_ID').trim().isEmpty || - value('MICROSOFT_OAUTH_CLIENT_ID').trim().isEmpty) { + if (value('GOOGLE_OAUTH_CLIENT_ID').isNotEmpty && + value('GOOGLE_OAUTH_CLIENT_ID').trim() == + value('BUSYMAX_GOOGLE_OAUTH_CLIENT_ID').trim()) { stderr.writeln( - 'Protected original registration IDs must be retained ' - 'separately for existing account bindings. Do not substitute the new ' - 'production registrations for these values.', + 'The active Google registration must not be substituted for the retiring registration.', ); missing = true; } diff --git a/tool/release/build_snap_local.py b/tool/release/build_snap_local.py new file mode 100644 index 00000000..9bc92067 --- /dev/null +++ b/tool/release/build_snap_local.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Build an exact committed release in new sibling checkouts; never publish.""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import importlib.util +import json +import os +import platform +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +PRIVACY = 'https://busystack.org/privacy-busymax' +SNAPCRAFT_VERSION = '9.1.4' +PUBLIC_KEYS = {'MICROSOFT_OAUTH_CLIENT_ID', 'MICROSOFT_OAUTH_AUTHORITY_TENANT', + 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID'} +SYNTHETIC = {'managed.apps.googleusercontent.com','later.apps.googleusercontent.com','fixture.apps.googleusercontent.com','original.apps.googleusercontent.com','owned.apps.googleusercontent.com','synthetic-public-secret','synthetic-managed-project','11111111-1111-1111-1111-111111111111','33333333-3333-3333-3333-333333333333','55555555-5555-5555-5555-555555555555', + 'busymax-ci-managed.apps.googleusercontent.com', + 'synthetic-ci-public-secret', 'busymax-ci-managed', + '44444444-4444-4444-4444-444444444444', + 'busymax-ci-original.apps.googleusercontent.com', + '22222222-2222-2222-2222-222222222222'} + + +class ReleaseError(Exception): + """Messages are fixed descriptions and never credential values.""" + + +def require(condition, message): + if not condition: + raise ReleaseError(message) + + +def read_object(path): + try: + require(path.is_file() and path.stat().st_size <= 65536, + 'Configuration must be a regular JSON file smaller than 64 KiB.') + value = json.loads(path.read_text(encoding='utf-8')) + require(isinstance(value, dict), 'Configuration must contain a JSON object.') + return value + except (OSError, ValueError): + raise ReleaseError('Cannot read a valid JSON configuration.') from None + + +def google_configuration(selection, source): + selection = selection.resolve() + if selection.is_dir(): + candidates = sorted(selection.glob('*.json')) + require(len(candidates) == 1, + 'Select one explicit Google Desktop JSON; directory selection is ambiguous or empty.') + selection = candidates[0].resolve() + require(not selection.is_relative_to(source.resolve()), + 'Keep the downloaded Google JSON outside the source repository.') + value = read_object(selection) + require(isinstance(value.get('installed'), dict) and + 'web' not in value and 'type' not in value, + 'Google configuration must be an installed Desktop OAuth client.') + installed = value['installed'] + fields = {key: installed.get(key) for key in ('client_id', 'client_secret', 'project_id')} + require(all(isinstance(v, str) and v.strip() for v in fields.values()), + 'Google Desktop JSON requires client ID, client secret and project metadata.') + require(re.fullmatch(r'[A-Za-z0-9_-]+\.apps\.googleusercontent\.com', fields['client_id']) and + re.fullmatch(r'[a-z][a-z0-9-]{4,62}[a-z0-9]', fields['project_id']) and + len(fields['client_secret']) <= 4096, + 'Google Desktop configuration is invalid.') + require(not any(v.strip() in SYNTHETIC for v in fields.values()), + 'Synthetic CI configuration cannot produce a production release.') + return {'BUSYMAX_GOOGLE_OAUTH_CLIENT_ID': fields['client_id'], + 'BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET': fields['client_secret'], + 'BUSYMAX_GOOGLE_OAUTH_PROJECT_ID': fields['project_id'], + 'BUSYMAX_PRIVACY_POLICY_URL': PRIVACY} + + +def public_configuration(path): + if path is None: + return {} + values = read_object(path) + require(set(values) <= PUBLIC_KEYS, + 'Local public configuration accepts only Microsoft public client/authority settings.') + require(all(isinstance(v, str) and v.strip() for v in values.values()), + 'Supplied local public configuration is incomplete.') + for key in ('MICROSOFT_OAUTH_CLIENT_ID', 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID'): + if key in values: + require(re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', values[key]), + 'Supplied Microsoft public client ID is invalid.') + if 'MICROSOFT_OAUTH_AUTHORITY_TENANT' in values: + tenant = values['MICROSOFT_OAUTH_AUTHORITY_TENANT'] + require(tenant in ('common', 'consumers', 'organizations') or + re.fullmatch(r'[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}', tenant), + 'Supplied Microsoft authority is invalid.') + require(not any(v.strip() in SYNTHETIC for v in values.values()), + 'Synthetic CI configuration cannot produce a production release.') + return values + + +def run(args, cwd, *, log=None, redactions=()): + # Capture output before printing. Build tools may repeat dart-defines on failure. + process = subprocess.run([str(a) for a in args], cwd=cwd, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = process.stdout + for value in sorted(redactions, key=len, reverse=True): + for encoded in (value, base64.b64encode(value.encode()).decode()): + output = output.replace(encoded, '[redacted]') + if log is not None: + with log.open('a', encoding='utf-8') as stream: + stream.write(output) + require(process.returncode == 0, f'Release step failed: {Path(str(args[0])).name}. Review the redacted build log.') + return output.strip() + + +def source_gate(source, revision, version): + require(re.fullmatch(r'[0-9a-f]{40}', revision), 'Supply an exact 40-character source revision.') + require(re.fullmatch(r'\d+\.\d+\.\d+', version), 'Supply an exact release version.') + require(run(['git', 'rev-parse', 'HEAD'], source) == revision, + 'Source HEAD does not match the requested revision.') + require(not run(['git', 'status', '--porcelain', '--untracked-files=all'], source), + 'Official releases require a clean, committed source checkout.') + branch = run(['git', 'branch', '--show-current'], source) + require(branch in ('main', f'release/v{version}'), 'Source is not the intended release branch or main.') + run(['git', 'fetch', 'origin', 'main'], source) + run(['git', 'merge-base', '--is-ancestor', 'origin/main', revision], source) + spec = importlib.util.spec_from_file_location('release_gate', source / 'tool/check_release_pull_request.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + try: + code = module.validate_versions(lambda p: (source / p).read_text(), version) + except (ValueError, ET.ParseError, OSError): + raise ReleaseError('Release metadata does not match the requested version.') from None + require(code >= 6 if version == '0.3.1' else code > 0, 'Android version code is too old.') + recipe = (source / 'snap/snapcraft.yaml').read_text() + for field, value in {'name': 'busymax', 'base': 'core24', 'grade': 'devel', 'confinement': 'strict'}.items(): + require(re.search(rf'^{field}: {value}$', recipe, re.M), 'Canonical Snap metadata changed unexpectedly.') + # No credential download or generated build configuration can enter Git archives. + for path in run(['git', 'ls-files'], source).splitlines(): + require(not re.search(r'(^|/)(client_secret.*\.json|release\.local\.json|busymax-dart-defines\.json)$', path), + 'A local credential/configuration file is tracked by Git.') + return code + + +def tool_gate(source, flutter): + flutter = flutter.resolve() + dart = flutter.parent / 'cache/dart-sdk/bin/dart' + require(flutter.is_file() and dart.is_file(), 'Flutter and its bundled Dart executable are required.') + run([dart, source / 'tool/verify_flutter_sdk.dart', '--flutter', flutter, '--dart', dart, + '--expected-flutter', '3.47.5', '--expected-dart', '3.13.4'], source) + require(run(['snapcraft', 'version'], source) == f'snapcraft {SNAPCRAFT_VERSION}', + 'Snapcraft version does not match the reviewed release toolchain.') + for tool in ('git', 'cmake', 'g++', 'pkg-config', 'xvfb-run', 'unsquashfs', 'lxc'): + require(shutil.which(tool) is not None, 'A required native/release tool is missing.') + return dart + + +def workspace_gate(workspace, source): + require(not workspace.exists() and not workspace.is_symlink(), + 'Release workspace already exists. Choose a new workspace; existing contents are never removed.') + require(not source.resolve().is_relative_to(workspace.resolve()), 'Workspace cannot contain the source checkout.') + workspace.mkdir(mode=0o700, parents=True, exist_ok=False) + (workspace / '.busymax-release-workspace').write_text('schema=1\n') + os.chmod(workspace / '.busymax-release-workspace', 0o600) + + +def bundle_gate(bundle, configuration, version=None): + required = ['busymax', 'lib/libapp.so', 'lib/libflutter_linux_gtk.so', + 'data/icudtl.dat', 'data/flutter_assets/docs/google_setup.md', + 'data/flutter_assets/docs/microsoft_setup.md', + 'data/flutter_assets/assets/branding/busymax-logo.svg'] + require(all((bundle / p).is_file() for p in required), 'Release bundle or a required asset is missing.') + require(os.access(bundle / 'busymax', os.X_OK), 'Release launcher is not executable.') + if version is not None: + metadata = read_object(bundle / 'data/flutter_assets/version.json') + require(metadata.get('version') == version and metadata.get('app_name') == 'busymax', + 'Compiled Flutter bundle has a stale or unintended application version.') + binary = (bundle / 'lib/libapp.so').read_bytes() + for key, value in configuration.items(): + require(value.encode() in binary or value.encode('utf-16le') in binary, + 'The intended OAuth/application metadata is absent from the compiled application.') + require(not list(bundle.rglob('client_secret*.json')) and + not list(bundle.rglob('busymax-oauth*.json')), 'Downloaded/generated credential files reached the bundle.') + + +def package_gate(artifact, output, version, configuration, source): + require(artifact.is_file(), 'Snapcraft did not produce the final Snap.') + require(list(output.glob('*.snap')) == [artifact], 'Unexpected package artifacts were produced.') + unpacked = output / 'inspection' + run(['unsquashfs', '-no-progress', '-d', unpacked, artifact], source) + recipe = (unpacked / 'meta/snap.yaml').read_text() + for field, value in {'name': 'busymax', 'version': version, 'base': 'core24', + 'grade': 'devel', 'confinement': 'strict'}.items(): + require(re.search(rf'^{field}: [\"\']?{re.escape(value)}[\"\']?$', recipe, re.M), + 'Packaged Snap metadata is inconsistent.') + require(re.search(r'^ command: busymax$', recipe, re.M), 'Packaged launcher is inconsistent.') + for asset in ['share/applications/io.busystack.busymax.desktop', + 'share/metainfo/io.busystack.busymax.metainfo.xml', + 'share/icons/hicolor/scalable/apps/io.busystack.busymax.svg']: + require((unpacked / asset).is_file(), 'A required packaged launcher or asset is missing.') + latest = ET.parse(unpacked / 'share/metainfo/io.busystack.busymax.metainfo.xml').find('releases/release') + require(latest is not None and latest.get('version') == version, 'Packaged AppStream version is inconsistent.') + require('autostart: io.busystack.busymax-autostart.desktop' in recipe, 'Packaged autostart configuration is missing.') + launchers = list((unpacked / 'meta/gui').glob('*.desktop')) + require([p.name for p in launchers] == ['busymax.desktop'], 'Packaged desktop launchers are inconsistent.') + bundle_gate(unpacked, configuration, version) + require(not (unpacked / 'lib/libdartjni.so').exists(), 'Unused Linux JNI bridge was packaged.') + return unpacked + + +def build(options): + require(platform.system() == 'Linux' and platform.machine() in ('x86_64', 'amd64'), + 'Official local Snap builds require Linux amd64.') + source = options.source.resolve() + workspace = options.workspace.absolute() + configuration = google_configuration(options.google_oauth, source) + configuration.update(public_configuration(options.local_config)) + source_gate(source, options.revision, options.version) + dart = tool_gate(source, options.flutter) + # Scan tracked files for the actual downloaded secret, without printing it. + secret = configuration['BUSYMAX_GOOGLE_OAUTH_CLIENT_SECRET'].encode() + for name in run(['git', 'ls-files'], source).splitlines(): + require(secret not in (source / name).read_bytes(), 'Downloaded client secret appears in a tracked source file.') + workspace_gate(workspace, source) + root = workspace / 'busymax' + log = workspace / 'build.log' + redactions = tuple(configuration.values()) + tuple(f'{key}={value}' for key, value in configuration.items()) + def step(arguments, label): + print(label, flush=True) + return run(arguments, root, log=log, redactions=redactions) + run(['git', 'clone', '--no-hardlinks', '--no-checkout', source, root], workspace, log=log) + run(['git', 'checkout', '--detach', options.revision], root, log=log) + require(not (root / 'build').exists(), 'A stale Flutter bundle reached the isolated source checkout.') + step(['python3', 'tool/checkout_shared_dependencies.py'], 'Materializing exact pinned sibling dependencies') + step([options.flutter.resolve(), 'pub', 'get', '--enforce-lockfile'], 'Resolving locked Flutter dependencies') + step([options.flutter.resolve(), 'gen-l10n'], 'Generating localization') + step([dart, 'run', 'build_runner', 'build', '--delete-conflicting-outputs', '--force-jit'], 'Generating Drift sources') + step(['git', 'diff', '--exit-code'], 'Verifying generated-source and lockfile consistency') + step([dart, 'format', '--output=none', '--set-exit-if-changed', '.'], 'Checking source formatting') + step([options.flutter.resolve(), 'analyze', '--no-pub'], 'Analyzing source') + step([dart, 'run', 'tool/check_platform_boundaries.dart'], 'Checking platform boundaries') + step([options.flutter.resolve(), 'test', '--no-pub'], 'Running Flutter regression tests') + step(['python3', '-m', 'unittest', 'discover', '-s', 'test/tool', '-p', '*_test.py'], 'Running tool regression tests') + step(['bash', 'tool/release/check_linux_native.sh'], 'Running native Linux checks') + step(['env', f'XDG_CONFIG_HOME={root / "build/linux/native-tests/gtk-config"}', 'xvfb-run', '-a', + options.flutter.resolve(), 'test', '--no-pub', 'integration_test/gtk_header_icons_integration_test.dart', '-d', 'linux'], + 'Verifying Flutter native GTK header integration') + step(['cmake', '-S', 'native/registration_reader', '-B', 'build/linux/registration-reader-tests'], 'Configuring native registration checks') + step(['cmake', '--build', 'build/linux/registration-reader-tests', '--target', 'busymax_registration_reader_tests'], 'Building native registration checks') + step(['ctest', '--test-dir', 'build/linux/registration-reader-tests', '--output-on-failure'], 'Testing native registration handling') + # The temporary secret file is outside the Snapcraft source, always mode 0600. + with tempfile.TemporaryDirectory(prefix='.oauth-', dir=workspace) as temporary: + defines = Path(temporary) / 'busymax-oauth.json' + with defines.open('x', encoding='utf-8') as stream: + os.chmod(defines, 0o600) + json.dump(configuration, stream) + step([dart, 'run', 'tool/check_desktop_oauth_config.dart', '--config', defines], 'Validating production configuration') + require(not (root / 'build/linux/x64/release/bundle').exists(), 'Unexpected stale release bundle exists before compilation.') + step([options.flutter.resolve(), 'build', 'linux', '--no-pub', '--release', '-t', 'lib/main_linux.dart', + f'--dart-define-from-file={defines}'], 'Compiling configured Linux application') + bundle_gate(root / 'build/linux/x64/release/bundle', configuration, options.version) + step(['git', 'diff', '--exit-code'], 'Verifying source and dependency locks after compilation') + output = workspace / 'artifacts' + output.mkdir(mode=0o700) + artifact = output / f'busymax_{options.version}_amd64.snap' + step(['snapcraft', 'pack', '--use-lxd', '--output', output], 'Packaging the canonical Snap in isolated Ubuntu 24.04 LXD') + package_gate(artifact, output, options.version, configuration, root) + step(['bash', 'tool/release/verify_snap_runtime.sh', artifact, output / 'runtime.json'], 'Verifying a fresh isolated strict Snap installation') + runtime = read_object(output / 'runtime.json') + require(runtime.get('startup_x11') is True and runtime.get('startup_wayland') is True and + runtime.get('restart') is True and runtime.get('confined_network') is True and + runtime.get('snap_sha256') == hashlib.sha256(artifact.read_bytes()).hexdigest() and + runtime.get('elf_dependencies') is True, + 'Fresh installation runtime checks did not pass.') + digest = hashlib.sha256(artifact.read_bytes()).hexdigest() + (output / f'{artifact.name}.sha256').write_text(f'{digest} {artifact.name}\n') + manifest = {'schema': 1, 'status': 'candidate', 'release_ready': False, + 'version': options.version, 'revision': options.revision, + 'sha256': digest, 'artifact': str(artifact), 'flutter': '3.47.5', 'dart': '3.13.4', + 'snapcraft': SNAPCRAFT_VERSION, 'base': 'core24', 'grade': 'devel', 'confinement': 'strict', + 'dependencies': json.loads((root / 'tool/shared_dependencies.json').read_text()), + 'google_registration': 'busymaxManaged', + 'google_client_id_sha256': hashlib.sha256(configuration['BUSYMAX_GOOGLE_OAUTH_CLIENT_ID'].encode()).hexdigest(), + 'google_project_id': configuration['BUSYMAX_GOOGLE_OAUTH_PROJECT_ID'], 'managed_microsoft': 'BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID' in configuration, + 'runtime': runtime, 'remaining_acceptance': [ + 'Google Console APIs, audience, publishing, branding and verification review', + 'Real Google Calendar/Tasks/optional read-only contacts authorization, same-account recovery and restart', + 'Provider upgrade/session recovery and Microsoft/Nextcloud/iCloud round trips', + 'Desktop tray, file pickers, browser callback, notifications and secret-storage interaction review']} + # Never emit a success manifest before every automated step passes. + (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + print(f'Candidate built; remaining acceptance is recorded in {output / "manifest.json"}.', flush=True) + print(artifact) + return artifact + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument('--revision', required=True) + parser.add_argument('--version', required=True) + parser.add_argument('--workspace', type=Path, required=True) + parser.add_argument('--google-oauth', type=Path, required=True, help='External Desktop JSON file or a directory containing exactly one JSON') + parser.add_argument('--local-config', type=Path, help='Ignored local Microsoft public settings JSON') + parser.add_argument('--flutter', type=Path, default=Path(shutil.which('flutter') or '/missing/flutter')) + try: + build(parser.parse_args()) + except (ReleaseError, OSError) as error: + # OS failures can include filenames; no configuration contents are emitted. + print(str(error) if isinstance(error, ReleaseError) else 'A required release file/tool could not be accessed.', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tool/release/build_snap_local.sh b/tool/release/build_snap_local.sh new file mode 100755 index 00000000..81b1a258 --- /dev/null +++ b/tool/release/build_snap_local.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# The supported production entry point. Never installs or uploads a package. +set -euo pipefail +exec python3 "$(dirname "$(readlink -f "$0")")/build_snap_local.py" "$@" diff --git a/tool/release/check_linux_native.sh b/tool/release/check_linux_native.sh new file mode 100755 index 00000000..7ca6c9ab --- /dev/null +++ b/tool/release/check_linux_native.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail +native_gtk_config="${BUSYMAX_GTK_CONFIG:-build/linux/native-tests/gtk-config}" +mkdir -p "$native_gtk_config/gtk-3.0" +printf '[Settings]\ngtk-icon-theme-name=Yaru\n' > "$native_gtk_config/gtk-3.0/settings.ini" +mkdir -p build/linux/native-tests +g++ -std=c++17 -Wall -Wextra -Werror \ + windows/runner/tests/short_time_pattern_test.cpp \ + -o build/linux/native-tests/short_time_pattern_test +build/linux/native-tests/short_time_pattern_test +g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/first_weekday_test.cc \ + -o build/linux/native-tests/first_weekday_test +build/linux/native-tests/first_weekday_test +mkdir -p build/linux/native-tests/weekday-schemas +glib-compile-schemas --strict \ + --targetdir=build/linux/native-tests/weekday-schemas \ + linux/runner/tests/schemas +g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/first_weekday_preference_test.cc \ + linux/runner/first_weekday_preference.cc \ + -o build/linux/native-tests/first_weekday_preference_test \ + $(pkg-config --cflags --libs gio-2.0) +build/linux/native-tests/first_weekday_preference_test \ + build/linux/native-tests/weekday-schemas +LC_ALL=C.UTF-8 MALLOC_PERTURB_=165 \ + build/linux/native-tests/first_weekday_test +g++ -std=c++17 -Wall -Wextra -Werror \ + -fsanitize=address -fno-omit-frame-pointer \ + linux/runner/tests/first_weekday_test.cc \ + -o build/linux/native-tests/first_weekday_test_asan +ASAN_OPTIONS=detect_leaks=0 \ + build/linux/native-tests/first_weekday_test_asan +g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/time_picker_test.cc \ + -o build/linux/native-tests/time_picker_test \ + $(pkg-config --cflags --libs gtk+-3.0) +xvfb-run -a build/linux/native-tests/time_picker_test +g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/gtk_window_preferences_test.cc \ + linux/runner/gtk_window_preferences.cc \ + -o build/linux/native-tests/gtk_window_preferences_test \ + $(pkg-config --cflags --libs gtk+-3.0) +xvfb-run -a build/linux/native-tests/gtk_window_preferences_test +g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/gtk_header_icons_test.cc \ + linux/runner/gtk_header_icons.cc \ + -o build/linux/native-tests/gtk_header_icons_test \ + $(pkg-config --cflags --libs gtk+-3.0) +XDG_CONFIG_HOME="${BUSYMAX_GTK_CONFIG:-build/linux/native-tests/gtk-config}" \ + xvfb-run -a sh -c 'build/linux/native-tests/gtk_header_icons_test' +g++ -std=c++17 -Wall -Wextra -Werror \ + linux/runner/tests/gtk_search_entry_theme_test.cc \ + linux/runner/gtk_search_entry_theme.cc \ + -o build/linux/native-tests/gtk_search_entry_theme_test \ + $(pkg-config --cflags --libs gtk+-3.0) +xvfb-run -a env GDK_BACKEND=x11 \ + build/linux/native-tests/gtk_search_entry_theme_test diff --git a/tool/release/verify_snap_runtime.sh b/tool/release/verify_snap_runtime.sh new file mode 100755 index 00000000..181fc545 --- /dev/null +++ b/tool/release/verify_snap_runtime.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Fresh core24 runtime smoke checks. This never touches an existing installation. +set -euo pipefail +snap_path="$(readlink -f "${1:?Supply the exact Snap}")" +evidence_path="$(realpath -m "${2:?Supply the evidence output path}")" +test -f "$snap_path" +test ! -e "$evidence_path" +digest="$(sha256sum "$snap_path" | cut -d ' ' -f1)" +instance="busymax-check-${digest:0:12}" +if lxc info "$instance" >/dev/null 2>&1; then + echo 'Runtime verification instance already exists; retain it and select a fresh build.' >&2 + exit 1 +fi +owned=false +cleanup() { + if $owned; then + # Only the instance created and tagged by this invocation may be removed. + if [ "$(lxc config get "$instance" user.busymax-release-verifier)" = "$digest" ]; then + lxc stop "$instance" --timeout 30 + lxc delete "$instance" + fi + fi +} +trap cleanup EXIT +lxc launch ubuntu:24.04 "$instance" --vm \ + -c limits.cpu=2 -c limits.memory=4GiB \ + -c "user.busymax-release-verifier=$digest" +owned=true +for attempt in $(seq 1 90); do + if lxc exec "$instance" -- true 2>/dev/null; then break; fi + sleep 2 +done +lxc exec "$instance" -- cloud-init status --wait +lxc file push "$snap_path" "$instance/tmp/busymax.snap" +lxc exec "$instance" -- bash -s <<'CHECK' +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq snapd xvfb weston dbus-x11 gnome-keyring xdotool +snap wait system seed.loaded +snap install --dangerous /tmp/busymax.snap +snap connections busymax +snap run --shell busymax -c 'test -x "$SNAP/busymax"' +# Resolve ELF dependencies inside the extension's command chain and confinement. +snap run --shell busymax -c ' + set -e + find "$SNAP" -type f \( -name "*.so" -o -name "*.so.*" -o -name busymax \) -print | + while IFS= read -r file; do + report=$(ldd "$file" 2>&1) || { + case "$report" in *"not a dynamic executable"*|*"statically linked"*) continue;; esac + printf "%s\n" "$report"; exit 1 + } + printf "%s\n%s\n" "$file" "$report" + case "$report" in *"not found"*|*"version "*" not found"*) exit 1;; esac + done +' > /tmp/busymax-elf.txt +cat /tmp/busymax-elf.txt +useradd -m -s /bin/bash busymaxcheck +uid=$(id -u busymaxcheck) +loginctl enable-linger busymaxcheck +install -d -m700 -o busymaxcheck -g busymaxcheck "/run/user/$uid" +runuser -u busymaxcheck -- env XDG_RUNTIME_DIR="/run/user/$uid" bash -s <<'SESSION' +set -euo pipefail +run_x11() { + set +e + rm -f /tmp/busymax-x11-ready + timeout 30s dbus-run-session -- xvfb-run -a env GDK_BACKEND=x11 bash -c ' + snap run busymax > /tmp/busymax-x11.log 2>&1 & + app_pid=$! + for attempt in $(seq 1 25); do + if xdotool search --onlyvisible --pid "$app_pid" >/dev/null 2>&1; then + touch /tmp/busymax-x11-ready + break + fi + sleep 1 + done + wait "$app_pid" + ' + status=$? + set -e + test "$status" = 124 + test -f /tmp/busymax-x11-ready + ! grep -Ei 'error while loading shared libraries|symbol lookup error|cannot open display|GLIBC.*not found|Segmentation fault' /tmp/busymax-x11.log +} +run_x11 +# Launch twice against the same per-user state to check session reopening. +run_x11 +weston --backend=headless --renderer=pixman --socket=wayland-busymax --idle-time=0 > /tmp/busymax-weston.log 2>&1 & +weston_pid=$! +trap 'kill "$weston_pid" 2>/dev/null || true' EXIT +for attempt in $(seq 1 30); do + if test -S "$XDG_RUNTIME_DIR/wayland-busymax"; then break; fi + sleep 1 +done +test -S "$XDG_RUNTIME_DIR/wayland-busymax" +set +e +timeout 30s dbus-run-session -- env WAYLAND_DISPLAY=wayland-busymax GDK_BACKEND=wayland snap run busymax > /tmp/busymax-wayland.log 2>&1 +status=$? +set -e +test "$status" = 124 +! grep -Ei 'error while loading shared libraries|symbol lookup error|cannot open display|GLIBC.*not found|Segmentation fault' /tmp/busymax-wayland.log +SESSION +cat /tmp/busymax-x11.log /tmp/busymax-wayland.log +# Basic confined network reachability, without authorizing or modifying accounts. +snap run --shell busymax -c 'bash -c "exec 3<>/dev/tcp/oauth2.googleapis.com/443"' +CHECK +# Evidence is only written after every command above succeeds. +python3 - "$evidence_path" "$digest" <<'PY' +import json, sys +from pathlib import Path +with Path(sys.argv[1]).open('x') as stream: + json.dump({'schema':1, 'snap_sha256':sys.argv[2], 'environment':'fresh Ubuntu 24.04 LXD VM', + 'elf_dependencies':True, 'startup_x11':True, 'startup_wayland':True, + 'restart':True, 'confined_network':True, + 'interactive_provider_acceptance':'pending'}, stream, indent=2) +PY diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 628c8440..4ea90c36 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #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 0,3,0,0 +#define VERSION_AS_NUMBER 0,3,1,0 #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "0.3.0" +#define VERSION_AS_STRING "0.3.1" #endif VS_VERSION_INFO VERSIONINFO From c9fcac75ff9d19c78a050fa9fa333be34328d775 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 11 Oct 2026 18:31:05 -0700 Subject: [PATCH 2/7] Keep runtime verification script whitespace clean --- tool/release/verify_snap_runtime.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/release/verify_snap_runtime.sh b/tool/release/verify_snap_runtime.sh index 181fc545..c6bf8a63 100755 --- a/tool/release/verify_snap_runtime.sh +++ b/tool/release/verify_snap_runtime.sh @@ -75,7 +75,7 @@ run_x11() { sleep 1 done wait "$app_pid" - ' + ' status=$? set -e test "$status" = 124 From beee0019ab4a81768f4e59fbf8496ca635611d9a Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 11 Oct 2026 18:54:43 -0700 Subject: [PATCH 3/7] Pack LXD Snap output inside the isolated project before exporting it --- docs/beta_snap_release.md | 4 +++- test/tool/local_snap_release_test.py | 5 ++++- tool/release/build_snap_local.py | 11 ++++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 213a2e23..96ebcbaa 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -77,7 +77,9 @@ them. v0.3.1 uses Android code **6**, confirmed available by the owner on runs formatting, analysis, platform boundaries, full Flutter/tool/native tests, and compiles a fresh configured bundle. It verifies the actual managed Google configuration in `libapp.so`, runs canonical Snapcraft packaging with -`--use-lxd`, and checks the package's version, strict confinement, launchers and +`--use-lxd`. Its output stays inside the checkout during the LXD pack step, +then the exact package moves to `artifacts/`. The command checks version, +strict confinement, launchers and assets. An old bundle cannot enter the new checkout. The recipe remains `base: core24`, `grade: devel`, `confinement: strict`. diff --git a/test/tool/local_snap_release_test.py b/test/tool/local_snap_release_test.py index bd02b283..3f9b7610 100644 --- a/test/tool/local_snap_release_test.py +++ b/test/tool/local_snap_release_test.py @@ -208,7 +208,7 @@ def runner(args, cwd, **kwargs): self.assertFalse(configuration.is_relative_to(workspace / 'busymax')) self.bundle(workspace/'busymax/build/linux/x64/release/bundle') if args[:2] == ['snapcraft', 'pack'] and failure != 'snap': - (workspace/'artifacts/busymax_0.3.1_amd64.snap').write_bytes(b'fixture-snap') + (Path(cwd) / args[-1] / 'busymax_0.3.1_amd64.snap').write_bytes(b'fixture-snap') if args[0] == 'unsquashfs': self.package_tree(Path(args[3])) if 'tool/release/verify_snap_runtime.sh' in args: if failure == 'runtime': raise release.ReleaseError('Runtime failure.') @@ -233,6 +233,9 @@ def test_successful_pipeline_has_checksum_and_safe_manifest(self): self.assertNotIn(self.installed['client_secret'], json.dumps(manifest)) self.assertTrue(any('tool/checkout_shared_dependencies.py' in cmd for cmd in seen)) self.assertTrue(any(cmd[:2] == ['snapcraft','pack'] and '--use-lxd' in cmd for cmd in seen)) + packing = next(cmd for cmd in seen if cmd[:2] == ['snapcraft','pack']) + self.assertEqual(packing[-1], 'build/snap-output') + self.assertFalse(Path(packing[-1]).is_absolute()) def test_failed_build_never_emits_success_manifest(self): for failure in ['dependency','stale','snap','runtime']: diff --git a/tool/release/build_snap_local.py b/tool/release/build_snap_local.py index 9bc92067..125e5804 100644 --- a/tool/release/build_snap_local.py +++ b/tool/release/build_snap_local.py @@ -265,7 +265,16 @@ def step(arguments, label): output = workspace / 'artifacts' output.mkdir(mode=0o700) artifact = output / f'busymax_{options.version}_amd64.snap' - step(['snapcraft', 'pack', '--use-lxd', '--output', output], 'Packaging the canonical Snap in isolated Ubuntu 24.04 LXD') + # Snapcraft's LXD provider requires output paths inside the project tree. + # Move only the exact resulting package to the external artifact directory. + pack_output = root / 'build/snap-output' + pack_output.mkdir(mode=0o700) + step(['snapcraft', 'pack', '--use-lxd', '--output', 'build/snap-output'], + 'Packaging the canonical Snap in isolated Ubuntu 24.04 LXD') + packed = pack_output / artifact.name + require(packed.is_file(), 'Snapcraft did not produce the final Snap.') + require(list(pack_output.iterdir()) == [packed], 'Unexpected package artifacts were produced.') + shutil.move(packed, artifact) package_gate(artifact, output, options.version, configuration, root) step(['bash', 'tool/release/verify_snap_runtime.sh', artifact, output / 'runtime.json'], 'Verifying a fresh isolated strict Snap installation') runtime = read_object(output / 'runtime.json') From b8de194ebe29f45c56ac081756b069d8e9a8b63c Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 11 Oct 2026 19:29:46 -0700 Subject: [PATCH 4/7] Validate Snap runtime in an ordinary confined desktop session --- docs/snap_runtime_libraries.md | 13 ++++ test/tool/snap_runtime_verifier_test.py | 80 +++++++++++++++++++++++++ tool/release/verify_snap_runtime.sh | 66 ++++++++++++++------ 3 files changed, 140 insertions(+), 19 deletions(-) create mode 100644 test/tool/snap_runtime_verifier_test.py diff --git a/docs/snap_runtime_libraries.md b/docs/snap_runtime_libraries.md index 8c64a099..06c9b6c1 100644 --- a/docs/snap_runtime_libraries.md +++ b/docs/snap_runtime_libraries.md @@ -35,6 +35,19 @@ because an ELF linter labels them unused. The pre-existing exclusion of `libdartjni.so` remains: the Linux runner does not register/use Android JNI and there is no JVM in the package. +The other unused warnings were checked separately. The registration-reader library +is loaded through Dart FFI, and libgweather is explicitly opened at runtime. +No packaged ELF consumer was found for the staged libdconf or libcolordprivate +copies. The GNOME content snap also contains both libraries and its dynamically +loaded GIO dconf settings backend. Retain the package-provided copies in this +patch until interactive settings/color integration has been checked; an unused +warning alone is insufficient evidence to remove desktop runtime files. + +The verifier runs the desktop checks as an ordinary user with a systemd user +manager and session bus. Running from LXD's root agent service does not model a +desktop login and can prevent snapd from creating its application cgroup. See +the [snapd maintainer's explanation](https://forum.snapcraft.io/t/cannot-launch-snap-applications-with-cgroup-v2/27700). + [Canonical's library-linter guidance](https://ubuntu.com/docs/snapcraft/9/how-to/debugging/use-the-library-linter/) requires staging packages for actually missing dependencies and explains why dynamically loaded libraries may appear unused. No broad `lint.ignore` entries diff --git a/test/tool/snap_runtime_verifier_test.py b/test/tool/snap_runtime_verifier_test.py new file mode 100644 index 00000000..830c57cf --- /dev/null +++ b/test/tool/snap_runtime_verifier_test.py @@ -0,0 +1,80 @@ +"""Runtime verifier failure/ownership contracts without launching a VM.""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] + + +class RuntimeVerifierTest(unittest.TestCase): + def invoke(self, mode): + with tempfile.TemporaryDirectory(prefix='busymax-runtime-test-') as directory: + root = Path(directory) + snap = root / 'fixture.snap' + snap.write_bytes(b'synthetic-package') + evidence = root / 'evidence.json' + calls = root / 'calls.jsonl' + executable = root / 'lxc' + executable.write_text(r"""#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +arguments = sys.argv[1:] +with Path(os.environ['FIXTURE_CALLS']).open('a') as stream: + stream.write(json.dumps(arguments) + '\n') +mode = os.environ['FIXTURE_MODE'] +if arguments[0] == 'info': + sys.exit(0 if mode == 'existing' else 1) +if arguments[0] == 'config': + print('changed-owner' if mode == 'ownership' else os.environ['FIXTURE_DIGEST']) +if arguments[0] == 'exec' and 'bash' in arguments: + sys.stdin.read() + if mode in ('failure', 'ownership'): + print('synthetic runtime failure', file=sys.stderr) + sys.exit(1) +""") + executable.chmod(0o755) + environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], + FIXTURE_CALLS=str(calls), FIXTURE_MODE=mode, + FIXTURE_DIGEST=hashlib.sha256(snap.read_bytes()).hexdigest()) + result = subprocess.run(['bash', str(ROOT/'tool/release/verify_snap_runtime.sh'), + str(snap), str(evidence)], env=environment, + text=True, capture_output=True) + commands = [json.loads(line) for line in calls.read_text().splitlines()] + data = json.loads(evidence.read_text()) if evidence.exists() else None + return result, commands, data, environment['FIXTURE_DIGEST'] + + def test_success_records_exact_package_and_cleans_own_vm(self): + result, calls, evidence, digest = self.invoke('success') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(evidence['snap_sha256'], digest) + self.assertTrue(evidence['startup_x11']) + self.assertTrue(evidence['startup_wayland']) + self.assertEqual([call[0] for call in calls][-2:], ['stop', 'delete']) + + def test_failed_runtime_never_writes_acceptance_evidence(self): + result, calls, evidence, _ = self.invoke('failure') + self.assertNotEqual(result.returncode, 0) + self.assertIn('synthetic runtime failure', result.stderr) + self.assertIsNone(evidence) + self.assertEqual([call[0] for call in calls][-2:], ['stop', 'delete']) + + def test_existing_vm_is_preserved(self): + result, calls, evidence, _ = self.invoke('existing') + self.assertNotEqual(result.returncode, 0) + self.assertEqual([call[0] for call in calls], ['info']) + self.assertIsNone(evidence) + + def test_changed_ownership_prevents_cleanup(self): + result, calls, evidence, _ = self.invoke('ownership') + self.assertNotEqual(result.returncode, 0) + self.assertIsNone(evidence) + self.assertNotIn('delete', [call[0] for call in calls]) + self.assertNotIn('stop', [call[0] for call in calls]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tool/release/verify_snap_runtime.sh b/tool/release/verify_snap_runtime.sh index c6bf8a63..e933bee4 100755 --- a/tool/release/verify_snap_runtime.sh +++ b/tool/release/verify_snap_runtime.sh @@ -36,15 +36,37 @@ lxc exec "$instance" -- bash -s <<'CHECK' set -euo pipefail export DEBIAN_FRONTEND=noninteractive apt-get update -qq -apt-get install -y -qq snapd xvfb weston dbus-x11 gnome-keyring xdotool +apt-get install -y -qq --no-install-recommends snapd xvfb xauth weston dbus-x11 dbus-user-session gnome-keyring xdotool snap wait system seed.loaded snap install --dangerous /tmp/busymax.snap snap connections busymax +useradd -m -s /bin/bash busymaxcheck +uid=$(id -u busymaxcheck) +loginctl enable-linger busymaxcheck +install -d -m700 -o busymaxcheck -g busymaxcheck "/run/user/$uid" +systemctl start "user@$uid.service" +runuser -u busymaxcheck -- env XDG_RUNTIME_DIR="/run/user/$uid" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$uid/bus" \ + systemd-run --user --scope --quiet bash -s <<'SESSION' +set -euo pipefail +cd "$HOME" +weston_pid='' +session_cleanup() { + status=$? + if [ "$status" != 0 ]; then + for log in /tmp/busymax-elf.txt /tmp/busymax-x11.log /tmp/busymax-wayland.log /tmp/busymax-weston.log; do + if test -f "$log"; then cat "$log"; fi + done + fi + if [ -n "$weston_pid" ]; then kill "$weston_pid" 2>/dev/null || true; fi +} +trap session_cleanup EXIT snap run --shell busymax -c 'test -x "$SNAP/busymax"' -# Resolve ELF dependencies inside the extension's command chain and confinement. -snap run --shell busymax -c ' +# Resolve BusyMax's packaged ELF files inside the content-mounted runtime. +# Do not scan every unrelated library exported by the GNOME/GPU content snaps. +if ! snap run --shell busymax -c ' set -e - find "$SNAP" -type f \( -name "*.so" -o -name "*.so.*" -o -name busymax \) -print | + find "$SNAP/busymax" "$SNAP/lib" "$SNAP/usr" -type f \( -name "*.so" -o -name "*.so.*" -o -name busymax \) -print | while IFS= read -r file; do report=$(ldd "$file" 2>&1) || { case "$report" in *"not a dynamic executable"*|*"statically linked"*) continue;; esac @@ -53,18 +75,15 @@ snap run --shell busymax -c ' printf "%s\n%s\n" "$file" "$report" case "$report" in *"not found"*|*"version "*" not found"*) exit 1;; esac done -' > /tmp/busymax-elf.txt +' > /tmp/busymax-elf.txt 2>&1; then + cat /tmp/busymax-elf.txt + exit 1 +fi cat /tmp/busymax-elf.txt -useradd -m -s /bin/bash busymaxcheck -uid=$(id -u busymaxcheck) -loginctl enable-linger busymaxcheck -install -d -m700 -o busymaxcheck -g busymaxcheck "/run/user/$uid" -runuser -u busymaxcheck -- env XDG_RUNTIME_DIR="/run/user/$uid" bash -s <<'SESSION' -set -euo pipefail run_x11() { set +e rm -f /tmp/busymax-x11-ready - timeout 30s dbus-run-session -- xvfb-run -a env GDK_BACKEND=x11 bash -c ' + timeout --kill-after=2s 30s xvfb-run -a env GDK_BACKEND=x11 bash -c ' snap run busymax > /tmp/busymax-x11.log 2>&1 & app_pid=$! for attempt in $(seq 1 25); do @@ -85,24 +104,33 @@ run_x11() { run_x11 # Launch twice against the same per-user state to check session reopening. run_x11 -weston --backend=headless --renderer=pixman --socket=wayland-busymax --idle-time=0 > /tmp/busymax-weston.log 2>&1 & +weston --backend=headless --renderer=pixman --socket=wayland-0 --idle-time=0 > /tmp/busymax-weston.log 2>&1 & weston_pid=$! -trap 'kill "$weston_pid" 2>/dev/null || true' EXIT for attempt in $(seq 1 30); do - if test -S "$XDG_RUNTIME_DIR/wayland-busymax"; then break; fi + if test -S "$XDG_RUNTIME_DIR/wayland-0"; then break; fi sleep 1 done -test -S "$XDG_RUNTIME_DIR/wayland-busymax" +test -S "$XDG_RUNTIME_DIR/wayland-0" set +e -timeout 30s dbus-run-session -- env WAYLAND_DISPLAY=wayland-busymax GDK_BACKEND=wayland snap run busymax > /tmp/busymax-wayland.log 2>&1 +timeout --kill-after=2s 30s env WAYLAND_DISPLAY=wayland-0 WAYLAND_DEBUG=1 GDK_BACKEND=wayland snap run busymax > /tmp/busymax-wayland.log 2>&1 status=$? set -e test "$status" = 124 +# Confirm GTK created a native Wayland toplevel and submitted a surface. +grep -q "get_toplevel" /tmp/busymax-wayland.log +grep -q "wl_surface.*commit" /tmp/busymax-wayland.log ! grep -Ei 'error while loading shared libraries|symbol lookup error|cannot open display|GLIBC.*not found|Segmentation fault' /tmp/busymax-wayland.log +# Basic confined network reachability, without authorizing or modifying accounts. +network_reachable=false +for attempt in 1 2 3; do + if timeout --kill-after=2s 15s snap run --shell busymax -c 'bash -c "exec 3<>/dev/tcp/oauth2.googleapis.com/443"'; then + network_reachable=true + break + fi +done +$network_reachable SESSION cat /tmp/busymax-x11.log /tmp/busymax-wayland.log -# Basic confined network reachability, without authorizing or modifying accounts. -snap run --shell busymax -c 'bash -c "exec 3<>/dev/tcp/oauth2.googleapis.com/443"' CHECK # Evidence is only written after every command above succeeds. python3 - "$evidence_path" "$digest" <<'PY' From 77ab53794a1ed50489e972e788dcd21db18f18b6 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 11 Oct 2026 22:58:12 -0700 Subject: [PATCH 5/7] fix(auth): keep the established Microsoft desktop client available --- docs/desktop_oauth_release_checklist.md | 3 + lib/src/config/build_config.dart | 19 +++++- .../config/desktop_oauth_configuration.dart | 13 +++- lib/src/core/auth/registration_staging.dart | 38 ++++++----- .../registration_setup_dialog.dart | 2 +- .../windows_registration_setup_dialog.dart | 2 +- test/config/build_config_test.dart | 24 +++++++ .../auth/authorization_transition_test.dart | 47 +++++++++++++ .../registration_setup_controller_test.dart | 67 +++++++++++++++++-- .../registration_setup_dialog_test.dart | 26 +++++++ 10 files changed, 215 insertions(+), 26 deletions(-) diff --git a/docs/desktop_oauth_release_checklist.md b/docs/desktop_oauth_release_checklist.md index d739dc1d..9d4b27a5 100644 --- a/docs/desktop_oauth_release_checklist.md +++ b/docs/desktop_oauth_release_checklist.md @@ -47,6 +47,9 @@ no Microsoft secret or new app is needed. Preserve its existing public-client redirect and permissions. Managed `BUSYMAX_MICROSOFT_OAUTH_CLIENT_ID` remains optional until a real application is supplied. An explicitly managed Microsoft client uses `common`; custom clients retain their own audience/tenant choices. +When no managed replacement is supplied, **Connect with BusyMax** uses the +established `MICROSOFT_OAUTH_CLIENT_ID` and its configured authority. That client +is also accepted in custom setup; it is not retired merely because Google changed. Verify expired Microsoft grants receive a clear reconnect instruction. Optional contacts retain `Contacts.Read`. Nextcloud, iCloud and read-only contacts flows must pass their existing regressions; none use release OAuth secrets. diff --git a/lib/src/config/build_config.dart b/lib/src/config/build_config.dart index 8851b0d0..527b644b 100644 --- a/lib/src/config/build_config.dart +++ b/lib/src/config/build_config.dart @@ -169,11 +169,11 @@ class BuildConfig { final bool useFakeProviderData; final BusyMaxDemoTheme demoTheme; - /// Protected original registration values serve eligible existing accounts only. + /// Google setup uses the managed or user-provided Desktop registration. bool get googleSetupAvailable => true; bool get microsoftSetupAvailable => !useFakeProviderData; - /// Explicitly designated desktop registrations. Original values above remain + /// Explicitly designated desktop registrations. Original Google values remain /// reserved for establishing legacy account bindings, including on Android. bool get hasBusyMaxGoogleRegistration => !androidNativeAuthorization && @@ -188,6 +188,21 @@ class BuildConfig { clientId: busyMaxMicrosoftOAuthClientId, ); + /// The established Microsoft public client remains usable when no replacement + /// managed registration has been supplied. Keep its configured authority. + String get microsoftDesktopClientId => hasBusyMaxMicrosoftRegistration + ? busyMaxMicrosoftOAuthClientId + : microsoftOAuthClientId; + String get microsoftDesktopAuthorityTenant => hasBusyMaxMicrosoftRegistration + ? 'common' + : microsoftOAuthAuthorityTenant; + bool get hasMicrosoftDesktopConnection => + !androidNativeAuthorization && + validMicrosoftDesktopConfiguration( + clientId: microsoftDesktopClientId, + authorityTenant: microsoftDesktopAuthorityTenant, + ); + bool get hasGoogleOAuthClientId => useFakeProviderData || androidNativeAuthorization || diff --git a/lib/src/config/desktop_oauth_configuration.dart b/lib/src/config/desktop_oauth_configuration.dart index 5c3ba41e..e0d03a49 100644 --- a/lib/src/config/desktop_oauth_configuration.dart +++ b/lib/src/config/desktop_oauth_configuration.dart @@ -10,7 +10,16 @@ bool validGoogleDesktopConfiguration({ RegExp(r'^[a-z][a-z0-9-]{4,62}[a-z0-9]$').hasMatch(projectId.trim()) && clientSecret.trim().isNotEmpty; -bool validMicrosoftDesktopConfiguration({required String clientId}) => - _uuid.hasMatch(clientId.trim()); +bool validMicrosoftDesktopConfiguration({ + required String clientId, + String authorityTenant = 'common', +}) => + _uuid.hasMatch(clientId.trim()) && + (const { + 'common', + 'organizations', + 'consumers', + }.contains(authorityTenant.trim().toLowerCase()) || + _uuid.hasMatch(authorityTenant.trim())); final _uuid = RegExp(r'^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$'); diff --git a/lib/src/core/auth/registration_staging.dart b/lib/src/core/auth/registration_staging.dart index 208b4e8c..4b80a1c4 100644 --- a/lib/src/core/auth/registration_staging.dart +++ b/lib/src/core/auth/registration_staging.dart @@ -90,11 +90,21 @@ final class RegistrationStaging extends ChangeNotifier { ); } if (provider == BusyProvider.microsoft && - config.hasBusyMaxMicrosoftRegistration) { + config.hasMicrosoftDesktopConnection) { + final tenant = config.microsoftDesktopAuthorityTenant + .trim() + .toLowerCase(); + final audience = switch (tenant) { + 'common' => MicrosoftAudience.personalAndOrganizations, + 'organizations' => MicrosoftAudience.organizations, + 'consumers' => MicrosoftAudience.personal, + _ => MicrosoftAudience.tenant, + }; return stage( MicrosoftPublicRegistration( - clientId: config.busyMaxMicrosoftOAuthClientId, - audience: MicrosoftAudience.personalAndOrganizations, + clientId: config.microsoftDesktopClientId, + audience: audience, + tenantId: audience == MicrosoftAudience.tenant ? tenant : null, origin: RegistrationOrigin.busyMaxManaged, ), ); @@ -111,9 +121,9 @@ final class RegistrationStaging extends ChangeNotifier { value.clientId == config.busyMaxGoogleOAuthClientId.trim(), MicrosoftPublicRegistration() => value.platform == AuthenticationPlatform.desktop && - config.hasBusyMaxMicrosoftRegistration && + config.hasMicrosoftDesktopConnection && value.clientId == - config.busyMaxMicrosoftOAuthClientId.trim().toLowerCase(), + config.microsoftDesktopClientId.trim().toLowerCase(), }; Future importGoogle( @@ -175,21 +185,19 @@ final class RegistrationStaging extends ChangeNotifier { String? tenantId, AuthenticationPlatform platform = AuthenticationPlatform.desktop, }) { - final registration = MicrosoftPublicRegistration( + var registration = MicrosoftPublicRegistration( clientId: clientId, audience: audience, tenantId: tenantId, platform: platform, - origin: - clientId.trim().toLowerCase() == - config.microsoftOAuthClientId.trim().toLowerCase() && - !(platform == AuthenticationPlatform.desktop && - config.hasBusyMaxMicrosoftRegistration && - clientId.trim().toLowerCase() == - config.busyMaxMicrosoftOAuthClientId.trim().toLowerCase()) - ? RegistrationOrigin.retiringShared - : RegistrationOrigin.userProvided, ); + if (!_isConfiguredActive(registration) && + registration.clientId == + config.microsoftOAuthClientId.trim().toLowerCase()) { + registration = registration.withOrigin( + RegistrationOrigin.retiringShared, + ) as MicrosoftPublicRegistration; + } return stage(registration); } diff --git a/lib/src/features/auth/presentation/registration_setup_dialog.dart b/lib/src/features/auth/presentation/registration_setup_dialog.dart index d2727ebc..74390d9e 100644 --- a/lib/src/features/auth/presentation/registration_setup_dialog.dart +++ b/lib/src/features/auth/presentation/registration_setup_dialog.dart @@ -112,7 +112,7 @@ class _RegistrationDialogState extends State<_RegistrationDialog> { bool get sharedAvailable => widget.provider == BusyProvider.google ? setup.staging.config.hasBusyMaxGoogleRegistration - : setup.staging.config.hasBusyMaxMicrosoftRegistration; + : setup.staging.config.hasMicrosoftDesktopConnection; void connectWithBusyMax() { final selected = setup.connectWithBusyMax(); diff --git a/lib/src/ui/windows/windows_registration_setup_dialog.dart b/lib/src/ui/windows/windows_registration_setup_dialog.dart index 58b3c746..da3b3055 100644 --- a/lib/src/ui/windows/windows_registration_setup_dialog.dart +++ b/lib/src/ui/windows/windows_registration_setup_dialog.dart @@ -109,7 +109,7 @@ class _RegistrationDialogState extends State<_RegistrationDialog> { bool get sharedAvailable => widget.provider == BusyProvider.google ? setup.staging.config.hasBusyMaxGoogleRegistration - : setup.staging.config.hasBusyMaxMicrosoftRegistration; + : setup.staging.config.hasMicrosoftDesktopConnection; void connectWithBusyMax() { final selected = setup.connectWithBusyMax(); diff --git a/test/config/build_config_test.dart b/test/config/build_config_test.dart index 8fe03f68..21d9fcd7 100644 --- a/test/config/build_config_test.dart +++ b/test/config/build_config_test.dart @@ -9,6 +9,11 @@ void main() { final originalsOnly = syntheticDesktopConfig(managed: false); expect(originalsOnly.hasBusyMaxGoogleRegistration, isFalse); expect(originalsOnly.hasBusyMaxMicrosoftRegistration, isFalse); + expect(originalsOnly.hasMicrosoftDesktopConnection, isTrue); + expect( + originalsOnly.microsoftDesktopClientId, + originalsOnly.microsoftOAuthClientId, + ); final configured = syntheticDesktopConfig(); expect(configured.hasBusyMaxGoogleRegistration, isTrue); expect(configured.hasBusyMaxMicrosoftRegistration, isTrue); @@ -36,8 +41,27 @@ void main() { } expect(BuildConfig.forAndroid().hasBusyMaxGoogleRegistration, isFalse); expect(BuildConfig.forAndroid().hasBusyMaxMicrosoftRegistration, isFalse); + expect(BuildConfig.forAndroid().hasMicrosoftDesktopConnection, isFalse); }); + test( + 'Microsoft desktop connection rejects invalid established authority', + () { + final config = syntheticDesktopConfig( + managed: false, + originalMicrosoftAuthority: 'not-a-tenant', + ); + expect(config.hasMicrosoftDesktopConnection, isFalse); + expect( + validMicrosoftDesktopConfiguration( + clientId: config.microsoftOAuthClientId, + authorityTenant: '', + ), + isFalse, + ); + }, + ); + test('managed Microsoft needs only its own valid client ID', () { const config = BuildConfig( googleOAuthClientId: '', diff --git a/test/core/auth/authorization_transition_test.dart b/test/core/auth/authorization_transition_test.dart index 95a9f97d..e990dc6d 100644 --- a/test/core/auth/authorization_transition_test.dart +++ b/test/core/auth/authorization_transition_test.dart @@ -327,6 +327,53 @@ void main() { expect(h.requests, hasLength(1)); }); }); + test('established Microsoft public client authorizes and remains bound after restart', () async { + h.staging.dispose(); + await h.db.close(); + final established = syntheticDesktopConfig( + managed: false, + originalMicrosoftAuthority: 'organizations', + ); + h = Harness(buildConfig: established); + final selected = h.staging.stageBusyMax(BusyProvider.microsoft); + await h.repository.signInWithMicrosoft( + request: AuthorizationRequest.newConnection(selected), + ); + const id = 'microsoft:ms-user'; + final saved = + await h.secrets.readCredential(id) as MicrosoftDesktopCredential; + await h.secrets.saveCredential( + id, + SecretRecord.fromJson(jsonDecode(jsonEncode(saved.toJson()))), + ); + final restarted = MicrosoftOAuthService( + config: established, + httpClient: h.client, + tokenStore: h.secrets, + loopbackFlow: h.flow, + registrations: h.staging, + persistence: AuthorizationPersistence(database: h.db, secrets: h.secrets), + ); + await restarted.refreshTokenForAccount(id); + expect(h.flow.clients, [established.microsoftOAuthClientId]); + expect( + h.requests + .where((r) => r.method == 'POST') + .every( + (r) => + r.url.path.startsWith('/organizations/') && + Uri.splitQueryString(r.body)['client_id'] == + established.microsoftOAuthClientId, + ), + isTrue, + ); + final current = + await h.secrets.readCredential(id) as MicrosoftDesktopCredential; + expect(current.registration.clientId, established.microsoftOAuthClientId); + expect(current.registration.authorityTenant, 'organizations'); + expect(await h.db.select(h.db.accounts).get(), hasLength(1)); + }); + for (final origin in [ RegistrationOrigin.busyMaxManaged, RegistrationOrigin.userProvided, diff --git a/test/core/auth/registration_setup_controller_test.dart b/test/core/auth/registration_setup_controller_test.dart index d4b9f27f..10ab5aee 100644 --- a/test/core/auth/registration_setup_controller_test.dart +++ b/test/core/auth/registration_setup_controller_test.dart @@ -70,7 +70,7 @@ void main() { expect(() => staging.consume(selected), throwsA(isA())); }, ); - test('$provider originals do not make shared authorization available', () { + test('$provider setup respects the established registration policy', () { final staging = RegistrationStaging( syntheticDesktopConfig(managed: false), ); @@ -79,13 +79,70 @@ void main() { setup.dispose(); staging.dispose(); }); - expect(setup.connectWithBusyMax(), isNull); - expect(setup.errorCode, 'OAuthSharedUnavailable'); - expect(setup.handle, isNull); - expect(setup.canConnect, isFalse); + final selected = setup.connectWithBusyMax(); + if (provider == BusyProvider.google) { + expect(selected, isNull); + expect(setup.errorCode, 'OAuthSharedUnavailable'); + expect(setup.handle, isNull); + expect(setup.canConnect, isFalse); + } else { + expect(selected, isNotNull); + expect( + staging.consume(selected!).clientId, + staging.config.microsoftOAuthClientId, + ); + expect(setup.errorCode, isNull); + } }); } + for (final authority in ['common', 'organizations', 'consumers', tenantId]) { + test( + 'established Microsoft client retains $authority without a new registration', + () { + final config = syntheticDesktopConfig( + managed: false, + originalMicrosoftAuthority: authority, + ); + final staging = RegistrationStaging(config); + addTearDown(staging.dispose); + staging.rememberRetiringClient(config.microsoftOAuthClientId); + final registration = staging.consume( + staging.stageBusyMax(BusyProvider.microsoft), + ) as MicrosoftPublicRegistration; + expect(registration.clientId, config.microsoftOAuthClientId); + expect(registration.authorityTenant, authority); + expect(registration.origin, RegistrationOrigin.busyMaxManaged); + expect(registration.summary().showRetirementNotice, isFalse); + expect(config.busyMaxMicrosoftOAuthClientId, isEmpty); + }, + ); + } + + test( + 'custom setup accepts the configured established Microsoft public client', + () { + final config = syntheticDesktopConfig(managed: false); + final staging = RegistrationStaging(config); + final setup = RegistrationSetupController( + staging, + BusyProvider.microsoft, + ); + addTearDown(() { + setup.dispose(); + staging.dispose(); + }); + staging.rememberRetiringClient(config.microsoftOAuthClientId); + setup.updateClientId(config.microsoftOAuthClientId); + expect(setup.errorCode, isNull); + expect(setup.canConnect, isTrue); + final registration = + staging.consume(setup.accept()!) as MicrosoftPublicRegistration; + expect(registration.clientId, config.microsoftOAuthClientId); + expect(registration.origin, RegistrationOrigin.userProvided); + }, + ); + test('managed Microsoft always uses common independently of originals', () { final config = syntheticDesktopConfig( originalMicrosoftAuthority: 'organizations', diff --git a/test/features/auth/presentation/registration_setup_dialog_test.dart b/test/features/auth/presentation/registration_setup_dialog_test.dart index 62dbc0c9..f8e9a600 100644 --- a/test/features/auth/presentation/registration_setup_dialog_test.dart +++ b/test/features/auth/presentation/registration_setup_dialog_test.dart @@ -211,6 +211,32 @@ void main() { ); } + testWidgets( + 'established Microsoft client enables BusyMax connection without a new registration', + (tester) async { + staging.dispose(); + final config = syntheticDesktopConfig( + managed: false, + originalMicrosoftAuthority: 'organizations', + ); + staging = RegistrationStaging(config); + harness = _Harness(windows, staging); + await harness.open(tester, BusyProvider.microsoft, methods: true); + expect(_key('shared-unavailable'), findsNothing); + expect(_key('client-id'), findsNothing); + await tester.tap(_key('busymax')); + await tester.pumpAndSettle(); + expect(harness.results, hasLength(1)); + final registration = staging.consume( + harness.results.single!, + ) as MicrosoftPublicRegistration; + expect(registration.clientId, config.microsoftOAuthClientId); + expect(registration.authorityTenant, 'organizations'); + expect(registration.origin, RegistrationOrigin.busyMaxManaged); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets('native form hierarchy and inline validation', ( tester, ) async { From b7fa6c68961df0d859b16325e31f430c264d32c4 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 11 Oct 2026 23:10:53 -0700 Subject: [PATCH 6/7] fix(auth): distinguish offline transport from provider failures --- lib/src/core/auth/oauth_models.dart | 2 ++ lib/src/features/sync/sync_auth_error.dart | 4 +++ .../sync_failure_notification_policy.dart | 4 ++- lib/src/l10n/oauth_error_description.dart | 1 + .../auth/code_exchange_response_test.dart | 30 +++++++++++++++++ .../auth/presentation/auth_routing_test.dart | 10 +++++- ...sync_failure_notification_policy_test.dart | 32 +++++++++++++++++++ test/support/desktop_connection_fixture.dart | 8 ++++- 8 files changed, 88 insertions(+), 3 deletions(-) diff --git a/lib/src/core/auth/oauth_models.dart b/lib/src/core/auth/oauth_models.dart index b01e6203..fcadf7ef 100644 --- a/lib/src/core/auth/oauth_models.dart +++ b/lib/src/core/auth/oauth_models.dart @@ -135,6 +135,7 @@ enum OAuthFailureKind { cancelled, timeout, throttled, + network, temporary, stale, alreadyConnected, @@ -158,6 +159,7 @@ class OAuthException implements Exception { 'OAuthMissingRefreshToken' || 'MicrosoftOAuthMissingRefreshToken' => OAuthFailureKind.permission, 'OAuthCallbackTimeout' || 'OAuthRequestTimeout' => OAuthFailureKind.timeout, + 'OAuthTemporaryTransport' => OAuthFailureKind.network, 'OAuthSetupRequired' || 'OAuthConfigurationRejected' || 'OAuthConfigurationExpired' || diff --git a/lib/src/features/sync/sync_auth_error.dart b/lib/src/features/sync/sync_auth_error.dart index a520da5e..60e56c0b 100644 --- a/lib/src/features/sync/sync_auth_error.dart +++ b/lib/src/features/sync/sync_auth_error.dart @@ -76,6 +76,10 @@ String syncFailureMessage( } if (effectiveError is OAuthException || effectiveError is RequestNotDispatchedException) { + if (effectiveError is OAuthException && + effectiveError.classification == OAuthFailureKind.network) { + return networkUnavailableMessage; + } return effectiveError is OAuthException && effectiveError.classification == OAuthFailureKind.permission ? 'Account permissions need to be updated to resume synchronization.' diff --git a/lib/src/features/sync/sync_failure_notification_policy.dart b/lib/src/features/sync/sync_failure_notification_policy.dart index a54a757a..20221a28 100644 --- a/lib/src/features/sync/sync_failure_notification_policy.dart +++ b/lib/src/features/sync/sync_failure_notification_policy.dart @@ -54,7 +54,9 @@ SyncFailureNotificationDisposition syncFailureNotificationDisposition( if (effectiveError is NetworkUnavailableException || effectiveError is TimeoutException || effectiveError is SocketException || - effectiveError is http.ClientException) { + effectiveError is http.ClientException || + effectiveError is OAuthException && + effectiveError.classification == OAuthFailureKind.network) { return SyncFailureNotificationDisposition.suppressed; } if (effectiveError is TaskRemoteError) { diff --git a/lib/src/l10n/oauth_error_description.dart b/lib/src/l10n/oauth_error_description.dart index a915e675..e32d6d33 100644 --- a/lib/src/l10n/oauth_error_description.dart +++ b/lib/src/l10n/oauth_error_description.dart @@ -35,6 +35,7 @@ String localizedOAuthFailure( OAuthFailureKind.authorizationCode => l10n.oauthAuthorizationCodeUnusable, OAuthFailureKind.permission => l10n.oauthPermissionRefused, OAuthFailureKind.throttled => l10n.oauthProviderThrottled, + OAuthFailureKind.network => l10n.networkOfflineTryAgain, OAuthFailureKind.temporary => l10n.oauthProviderTemporaryFailure, OAuthFailureKind.timeout => l10n.oauthAuthorizationTimedOut, OAuthFailureKind.wrongAccount => l10n.oauthWrongAccount, diff --git a/test/core/auth/code_exchange_response_test.dart b/test/core/auth/code_exchange_response_test.dart index 5733bd42..870450c1 100644 --- a/test/core/auth/code_exchange_response_test.dart +++ b/test/core/auth/code_exchange_response_test.dart @@ -2,6 +2,7 @@ import 'package:fake_async/fake_async.dart'; import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/core/auth/oauth_models.dart'; @@ -70,6 +71,35 @@ void main() { ); } + for (final failure in [ + http.ClientException('sensitive transport details'), + const SocketException('sensitive transport details'), + ]) { + test( + 'code exchange distinguishes network failure without exposing transport details microsoft=$microsoft failure=${failure.runtimeType}', + () async { + final client = ControlledResponseClient(() async => throw failure); + await expectLater( + exchange(client), + throwsA( + isA() + .having( + (e) => e.classification, + 'network', + OAuthFailureKind.network, + ) + .having((e) => e.requiresReconnect, 'reconnect', isFalse) + .having( + (e) => e.toString(), + 'redacted', + isNot(contains('sensitive')), + ), + ), + ); + }, + ); + } + for (final (status, body) in [ (400, 'not-json-sensitive'), (200, 'not-json-sensitive'), diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index e8abb12b..abbfd43c 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -110,7 +110,7 @@ void main() { await database.close(); }); - for (final mode in ['wrong-account', 'cancel', 'unverified']) { + for (final mode in ['wrong-account', 'cancel', 'unverified', 'offline']) { testWidgets( 'targeted Google recovery $mode retains the old account and offers safe recovery', (tester) async { @@ -144,6 +144,7 @@ void main() { : 'fixture-user'; fixture.flow.cancelAttempt = mode == 'cancel'; fixture.legacyAccessUnavailable = mode == 'unverified'; + fixture.tokenExchangeOffline = mode == 'offline'; await _pumpApp( tester, database: database, @@ -229,6 +230,13 @@ void main() { ); await tester.tap(find.byKey(const ValueKey('registration-close'))); await tester.pumpAndSettle(); + } else if (mode == 'offline') { + expect(find.textContaining('You’re offline.'), findsOneWidget); + expect( + find.textContaining('temporary provider problem'), + findsNothing, + ); + expect(find.textContaining('OAuthTemporaryTransport'), findsNothing); } else if (mode == 'unverified') { expect(find.textContaining('could not be verified'), findsOneWidget); await tester.tap(find.text('Add a separate Google connection')); diff --git a/test/features/sync/sync_failure_notification_policy_test.dart b/test/features/sync/sync_failure_notification_policy_test.dart index a1aab1e6..81e6a350 100644 --- a/test/features/sync/sync_failure_notification_policy_test.dart +++ b/test/features/sync/sync_failure_notification_policy_test.dart @@ -2,6 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:busymax/src/core/auth/oauth_models.dart'; +import 'package:busymax/l10n/generated/app_localizations.dart'; +import 'package:busymax/src/l10n/oauth_error_description.dart'; +import 'package:flutter/widgets.dart'; import 'package:busymax/src/core/http/request_dispatch_exception.dart'; import 'package:busymax/src/dav/dav_errors.dart'; import 'package:busymax/src/dav/sync/dav_account_sync_engine.dart'; @@ -16,6 +19,35 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; void main() { + test('OAuth transport failures retain network diagnostics and localized offline recovery', () { + const failure = OAuthException( + 'OAuthTemporaryTransport', + 'provider request details', + ); + final l10n = lookupAppLocalizations(const Locale('en')); + expect(syncFailureDiagnostic(failure), 'OAuth.network'); + expect( + syncFailureNotificationDisposition(failure), + SyncFailureNotificationDisposition.suppressed, + ); + expect(isOAuthReconnectRequiredError(failure), isFalse); + expect( + localizedAuthorizationError(l10n, failure), + l10n.networkOfflineTryAgain, + ); + expect( + localizedSyncFailureMessage(l10n, failure), + l10n.networkOfflineTryAgain, + ); + expect( + syncFailureMessage( + failure, + networkUnavailableMessage: 'Offline; try again', + ), + 'Offline; try again', + ); + }); + test( 'calendar and task provider payloads stay out of ordinary sync errors', () { diff --git a/test/support/desktop_connection_fixture.dart b/test/support/desktop_connection_fixture.dart index 7b0a0aba..a2b19657 100644 --- a/test/support/desktop_connection_fixture.dart +++ b/test/support/desktop_connection_fixture.dart @@ -53,7 +53,12 @@ class DesktopConnectionFixture { 200, ); } - final client = Uri.splitQueryString(request.body)['client_id']; + final parameters = Uri.splitQueryString(request.body); + if (tokenExchangeOffline && + parameters['grant_type'] == 'authorization_code') { + throw const SocketException('synthetic network unavailable'); + } + final client = parameters['client_id']; return http.Response( jsonEncode({ 'access_token': 'synthetic-access', @@ -90,6 +95,7 @@ class DesktopConnectionFixture { final BuildConfig config; String googleSubject = 'fixture-user'; bool legacyAccessUnavailable = false; + bool tokenExchangeOffline = false; final secrets = InMemorySecretStore(); final flow = _FixtureBrowser(); late final AuthorizationPersistence persistence; From 506eed1321d8dcc6a5b350874245ca570121bd58 Mon Sep 17 00:00:00 2001 From: albert Date: Mon, 12 Oct 2026 01:18:03 -0700 Subject: [PATCH 7/7] Remove local verification debris from source --- .gitignore | 2 + docs/development.md | 21 +- docs/development/contacts-read-only.md | 187 ++++--- .../evidence/contacts-read-only/baseline.json | 52 -- .../contacts-read-only/verification.json | 505 ------------------ 5 files changed, 143 insertions(+), 624 deletions(-) delete mode 100644 docs/development/evidence/contacts-read-only/baseline.json delete mode 100644 docs/development/evidence/contacts-read-only/verification.json diff --git a/.gitignore b/.gitignore index 7f9f0044..175e35e8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ migrate_working_dir/ # Per-run evidence belongs in build/ or a temporary directory. /docs/validation/ +/docs/development/evidence/ # Transient Kotlin compiler session state. .kotlin/ @@ -68,3 +69,4 @@ config/windows_store.local.json # Local public release settings; downloaded Google JSON stays outside Git. config/release.local.json +client_secret*.json diff --git a/docs/development.md b/docs/development.md index b7fc0b3c..241c0d32 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ and Windows. Release packaging has separate procedures linked below. BusyMax development and CI use Flutter **3.47.5** and the Dart **3.13.4** SDK bundled with that Flutter installation. Use the bundled `dart` executable; do not mix Flutter with a different global Dart SDK. The `pubspec.yaml` constraint -`sdk: ^3.12.0` is the Dart language/package compatibility constraint. It is not +`sdk: ^3.13.4` is the Dart language/package compatibility constraint. It is not the repository's build-toolchain version. The workflows verify the selected SDK with `tool/verify_flutter_sdk.dart`. @@ -156,6 +156,25 @@ dispatch, OAuth credentials or artifact uploads. Windows and Android CI remain independent. The official Linux build is `tool/release/build_snap_local.sh`; `tool/build_install_snap_local.sh` only repacks a development scaffold. +## Repository contents + +Keep application and platform source, tests and synthetic fixtures, locked +dependencies, CI-verified generated sources, vendored code and licenses, build +recipes, shared launch configurations and maintained documentation in Git. +Reviewed product screenshots and test golden images are also source inputs. +The checked-in Google Tasks discovery document is a pinned API contract used by +tests, not a disposable build cache. + +Keep local profiles, credentials, signing material, per-run logs, captures, +command receipts, delivery manifests and release artifacts outside Git. Use +ignored `build/` locations or temporary directories for validation evidence; +summarize results in the relevant pull request. Downloaded Google Desktop JSON +and local release settings follow the [local Snap release procedure](beta_snap_release.md). + +The [read-only contacts integration](development/contacts-read-only.md) +documents the provider, authorization, storage and lifecycle boundaries and +their regression coverage. + ## Release branches Start `release/v0.3.1` from current `main`, commit reviewed changes, and open it diff --git a/docs/development/contacts-read-only.md b/docs/development/contacts-read-only.md index 6bf97cbe..856a47ac 100644 --- a/docs/development/contacts-read-only.md +++ b/docs/development/contacts-read-only.md @@ -1,66 +1,121 @@ -# BusyMax contacts are read-only attendee lookup - -Implemented the owner's approved product policy. BusyMax synchronizes and caches contacts for attendee suggestions and offline lookup. It does not create, update or delete remote contacts, photos, Google groups, Microsoft folders or DAV address books. Contact account enrollment, consent, refresh, local disconnection/removal, source preferences and cache/cursor writes remain supported. Calendar/task writes and invitations retain their existing authorization and providers. - -The live starting point was `fix/contacts-read-only` at `42dd355454cfc5e86bd10f0c58ea786267f20b5f`, with no staged, unstaged or untracked work. No owner instruction files were found in BusyMax or its ancestor directories. [Baseline evidence](evidence/contacts-read-only/baseline.json) records sibling heads and pre-existing BusyCards IDE/cache work. The supplied archive identifies `788e8a8e4be563eecddc45f8f783e02e315ae0e2`, SHA-256 `e4830832d85d210df33fa459a8ec9623fd9eed1cbbb1c5ab6d06fc7da9b4dd86`; its static report was not treated as an executed test run of this correction. - -## Integration boundary - -`ReadOnlyContactsProvider` implements the existing public `ContactsProvider` contract. It keeps its concrete delegate private, delegates discovery, paging, retrieval, photo reads, create reconciliation reads, action decoding and disposal, and rejects `create`, `update`, `delete`, `setPhoto` and every provider action before calling the delegate. It preserves native contact records, identities, revisions, cancellation and pagination. Effective source capabilities disable create/update/delete/photo-write, rename and remove; read/photo-read and all other metadata retain their original values, including server-unreadable sources. - -`BusyMaxContactsController._bindProvider` applies this boundary to every `_bind` route, including the injectable factory, startup, enrollment, reconnect and removal recovery. The factory is private after construction. `_davProvider` also constructs CardDAV with `readOnly: true`. Stored OAuth grants remain actual provider grants rather than fabricated read-only sets. Old DAV configuration/passwords and `carddav:write` markers remain readable; only new DAV enrollment records the constant read-only configuration. Existing mode selection arguments and the Google contacts client's writable field were removed. - -`BusyMaxContactsStore` uses the existing Drift transaction to normalize cached source capabilities at startup before directory restoration. Reads and future source writes apply the same effective policy, including before online discovery and for disabled sources. IDs, records, source selection, cursors, preferences, account generations, credentials and schema 20 remain intact. No Drift regeneration, schema bump or data reset is needed. - -## Requested and granted authorization - -Contacts remain optional. First calendar/task sign-in requests no contacts scope. Explicit Google contacts enrollment requests only `https://www.googleapis.com/auth/contacts.readonly`; desktop Microsoft requests Graph-qualified `Contacts.Read`, and native MSAL uses the short representation. Already granted full Google contacts or Microsoft `Contacts.ReadWrite` permission satisfies read access without write consent or a new browser. Recognition of historical write grants is separate from outgoing scope assembly. - -Desktop reconnect/replacement requests carry previous contact access forward as read-only and retain base Calendar/Tasks, identity/offline access and enabled Microsoft shared-calendar/category scopes. Google keeps the installed-app system-browser PKCE full-required-set flow; it does not use web incremental authorization. Google refresh remains scope-free. Microsoft refresh now omits its optional scope parameter, including claims recovery, while retaining the issuing registration, rotation, returned-grant parsing, cooldown and generation fencing. Omitted returned scopes retain the existing grant under the renewal contract; explicit partial/revoked grants are honored. No local policy change revokes an existing provider grant or clears a working refresh token. - -The real Android broker sends read-only contacts scopes for interactive and silent requests. Existing sufficient native authorization uses silent acquisition, checks the actual result and preserves subject/tenant/registration/generation fencing. First optional enrollment persists accepted actual native grants before validating the contacts link, inside the existing native credential commit. Persistence failure rolls the grant/link back together. Requested scopes are never concatenated into a fictitious token grant. Existing optional native calendar/category access is carried into explicit reconnect/replacement. The Kotlin bridge already accepts supplied scope lists and returns provider-reported scopes; no new allowlist or bridge change was necessary. - -The resolved native artifacts were inspected: Google `play-services-auth:21.5.0` exposes `getGrantedScopes(): List` and `setRequestedScopes(List)`; MSAL `8.4.1` exposes `getScope(): String[]`. Artifact hashes and `javap` output are retained with delivery evidence. No SDK, runtime, dependency or registration versions changed. - -## Journals and lifecycle - -BusyMax no longer calls `directory.resumeQueued` during synchronization. Queued, unknown and conflicting entries retain their IDs, payloads and uncertainty evidence. Existing executing-to-unknown crash/removal handling remains in the shared directory, with unresolved recovery still actionable through the existing reconnect/removal state. Startup, timer, manual sync, reconnect and removal rollback do not dispatch historic contact writes. Explicit contact mutation attempts fail at the integration boundary; the shared directory may record a newly attempted provider action as failed, while existing journals are preserved. - -AuthRepository, AuthorizationPersistence, C1 lock ordering and C2 removal receipts were preserved. The actual LifecycleFixture still includes its production persistence/lock chain. Tests retain original enabled/disabled/absent state, cancellation/drain ordering, conditional restoration, detached-binding recovery, newer-generation fencing, unknown writes and shutdown assertions. - -## Settings and localization - -Linux/Yaru, Windows/Fluent and Android Settings retain account/source controls and platform routes. Editing/upgrade actions, writable badges and DAV mode switches were removed. Every supported locale contains a contacts-specific read-only attendee description, and the three unused editing keys were removed through normal `flutter gen-l10n`. The generic Portuguese alias remains empty; the existing European Portuguese locale carries its translation. Shared calendar/task editing strings remain. Manual attendee input, source-qualified cached suggestions, exclusions, limits, draft selections and stale-result fencing use the existing implementation. The embedded provider setup guides label contacts as optional and make no claim that prior grants were revoked or verification approved. - -## Verification and delivery - -Use the unchanged Flutter 3.47.5 and bundled Dart 3.13.4 pins verified by `tool/verify_flutter_sdk.dart`; `tool/shared_dependencies.json` retains contacts `cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2`, DAV `18f636f3227e3df8c1406bafd85b45e7f12cf8bd` and Graph `6572ee14ae992d4cd4f8c2218babaff5051bc132`. Sibling repositories are read-only dependencies; their checkout helper was inspected and not run over owner working trees. - -Commands and full logs are recorded under `build/contacts-read-only` and included in the delivery evidence archive. Command receipts retain their actual exit status, duration, head/tree and source fingerprint. The delivery manifest records the final commit/tree, archive and per-file checksums. Source uses the existing `python3 tool/package_source.py` sibling layout and contains the unchanged pinned dependency inputs. Linux release packaging preserves its native helper/assets. Extraction receipts verify both manifests, the relative dependency layout and locked offline resolution. The pre-existing source archive was preserved under `build/contacts-read-only/preserved-source-input` before replacing the packaging output; its revision is unverified and it is not presented as this correction. - -Targeted regressions include the existing contacts/store/enrollment/removal, authorization/consent/credential persistence, actual platform Settings routes and attendee fencing tests; additions inspect actual desktop authorization URLs, real Android broker method-channel scopes/native commits, shared-provider transports under broad grants, cached schema-20 state and journal preservation across startup/timer/reconnect/removal rollback. The final evidence also records normal localization generation, touched-file formatting, `git diff --check`, `flutter analyze`, `dart run tool/check_platform_boundaries.dart`, the full BusyMax Flutter suite, Linux debug/release builds, available native workflow checks and an isolated native Linux Settings session with a synthetic cache and screenshot. - -The final application and test inputs are commit `4a4bd5528725fe1eca067099a9671ced9e4a745a`, tree `e0e1c52405d73f26a259ecd59cff8f6ee48863f2`, source fingerprint `8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6`. Subsequent changes contain development evidence only; the delivery manifest identifies that final documentation commit and proves identical tested inputs. [Verification record](evidence/contacts-read-only/verification.json) includes exact commands, exit statuses, durations, log hashes and skip names. - -| Gate | Measured result | -| --- | --- | -| Locked resolution, localization generation, touched-file formatting, diff check, analysis, platform boundaries | Passed | -| Complete BusyMax Flutter suite | 3,619 passed, 10 live-provider skips, 0 failures | -| Affected cases within that final complete run | 435 passed, 0 skips | -| Corrective locale and linked-DAV focused run | 42 passed | -| Native Linux workflow checks and debug/release builds | Passed on the recorded Linux host | -| Real isolated Linux Settings session | 1 passed; synthetic offline cache, source selection, dialog and screenshot | -| Android bridge native unit tests and app lint | 11 tests passed; lint passed; unchanged native inputs checked across the correction | -| Independent Android test-signed APK/AAB builds and inspection | Passed; signatures, notification resources, 16 KB ELF segments and APK zip alignment checked | - -The first complete run caught the generic Portuguese alias regression (3,617 passes, 10 skips, 1 failure). It was corrected without changing supported locales, and the complete suite was rerun. An initial Android release build in the shared build layout failed because the generated registrant referenced the dev-only integration plugin without its release dependency. The independent temporary source/sibling layout resolved that build failure without changing pins or generated source manually. Both diagnostic logs remain in the evidence archive. Newly built Android files are labeled test-signed and were never installed. - -Native Windows builds/device acceptance require a Windows host; Fluent route tests are not native acceptance. Android on-device acceptance and live-provider tests are unverified: no device installation, disposable accounts or registrations were authorized. AAB bundletool alignment metadata remains unverified because no local bundletool jar is available; the other recorded artifact checks passed. Historical Windows/Android/Snap/offline artifacts are not relabeled as builds of this correction. Unchanged Snap/engine/driver qualification was not restarted. - -## References checked 11 October 2026 - -- [Google People connections authorization](https://developers.google.com/people/api/rest/v1/people.connections/list): accepts readonly or full contacts authorization. -- [Microsoft Graph permission meanings](https://learn.microsoft.com/en-us/graph/permissions-reference): `Contacts.Read` reads; `Contacts.ReadWrite` also includes mutations. -- [Google installed-app authorization](https://developers.google.com/identity/protocols/oauth2/native-app): PKCE and full-set reauthorization; installed-app incremental authorization is unsupported. -- [Microsoft refresh parameters](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token) and [RFC 6749 section 6](https://www.rfc-editor.org/rfc/rfc6749.html#section-6): optional refresh scope omission renews existing authorization. -- [Google sensitive scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification): scope access must match actual user-facing functionality. +# Read-only contacts integration + +BusyMax synchronizes and caches contacts for attendee suggestions and offline +lookup. It does not create, update or delete remote contacts, photos, Google +groups, Microsoft folders or DAV address books. Account enrollment, consent, +refresh, local disconnection/removal, source preferences and cache/cursor writes +remain supported. Calendar/task writes and invitations use their existing +provider permissions. + +## Provider and storage boundaries + +[`ReadOnlyContactsProvider`](../../lib/src/contacts/read_only_contacts_provider.dart) +wraps the shared `ContactsProvider` contract. It delegates discovery, paging, +retrieval, photo reads, reconciliation reads, action decoding and disposal. +It rejects contact mutations and provider actions before invoking its private +delegate. Native records, identities, revisions, cancellation and pagination +are preserved. Effective source capabilities disable contact/photo writes, +rename and remove while retaining read access and other source metadata. + +[`BusyMaxContactsController`](../../lib/src/contacts/busymax_contacts_controller.dart) +applies the wrapper to provider bindings during startup, enrollment, reconnect +and removal recovery, including bindings from the injectable provider factory. +Its CardDAV provider also uses `readOnly: true`. Existing DAV configuration, +passwords and historical `carddav:write` markers remain readable; new enrollment +records the read-only configuration. + +[`BusyMaxContactsStore`](../../lib/src/contacts/busymax_contacts_store.dart) +normalizes cached source capabilities in a Drift transaction before startup +restores the directory. Reads and subsequent source writes apply the same +policy, including for disabled sources and before online discovery. This +preserves account and contact IDs, records, selected sources, cursors, +preferences, generations and credentials without a schema change or data reset. + +## Authorization + +Contacts are optional. Initial calendar/task sign-in requests no contacts +permission. Explicit Google contacts enrollment requests only +`https://www.googleapis.com/auth/contacts.readonly`; desktop Microsoft requests +Graph-qualified `Contacts.Read`, while native MSAL uses its short form. +Historical full Google contacts and Microsoft `Contacts.ReadWrite` grants can +satisfy read access without new consent. Stored grants reflect the provider's +actual authorization; the read-only policy does not revoke those grants or +clear working refresh tokens. + +Desktop reconnect/replacement carries previous contacts access forward as +read-only while retaining Calendar/Tasks, identity/offline access and enabled +Microsoft shared-calendar/category permissions. Google uses the installed-app +system-browser PKCE flow with the complete required scope set. Google refresh +remains scope-free. Microsoft refresh omits its optional scope parameter, +including during claims recovery, while preserving registration binding, +rotation, cooldown and generation fencing. Omitted returned scopes retain the +existing grant; explicit partial or revoked grants are honored. + +The Android broker requests read-only contacts scopes for interactive and silent +authorization. Sufficient existing grants use silent acquisition with +subject/tenant/registration/generation checks. First optional enrollment commits +the actual native grants and contacts link through the existing credential +transaction; persistence failure rolls both back. Requested scopes are never +treated as evidence of a provider grant. + +## Pending work and lifecycle + +Contacts synchronization does not call `directory.resumeQueued`. Historic +queued, unknown and conflicting contact writes retain their IDs, payloads and +uncertainty evidence. Existing executing-to-unknown crash/removal handling and +unresolved recovery remain available. Startup, timers, manual synchronization, +reconnect and removal rollback do not dispatch historical contact writes. +Explicit new mutation attempts fail at the provider boundary. + +Authorization persistence, account lock ordering and removal receipts retain +their existing roles. The lifecycle regressions exercise the production +persistence/lock chain, cancellation and drain ordering, conditional restoration, +detached bindings, generation fencing, unknown writes and shutdown. + +## Settings and attendee lookup + +Linux/Yaru, Windows/Fluent and Android Settings retain account/source controls +and platform routes. Contact editing actions, writable badges and DAV mode +switches are absent. Localized descriptions explain read-only attendee lookup. +Shared calendar/task editing strings remain available. + +Attendee suggestions use source-qualified cached contacts, including offline. +Manual input, exclusions, limits, draft selections and stale-result fencing use +the existing implementation. Provider setup guides describe contacts as optional +and make no claim that historical grants were revoked or verification approved. + +## Regression coverage + +Follow [Development](../development.md) for the pinned toolchain, locked +dependencies, generated sources and offline checks. Contacts regressions cover: + +- [Provider boundaries](../../test/contacts/read_only_contacts_provider_test.dart) + and [cached state and journals](../../test/contacts/contacts_read_only_state_test.dart). +- [Desktop scope assembly](../../test/core/auth/contacts_authorization_requests_test.dart) + and the existing Google/Microsoft OAuth transport tests. +- [Parent account lifecycle](../../test/contacts/parent_removal_lifecycle_test.dart) + and authorization persistence/transition tests. +- Android broker, contacts enrollment, registration transition and platform + channel tests under `test/platform/android/`. +- Platform Settings routes and attendee fencing under `test/platform/`. +- [Native Linux Settings](../../integration_test/contacts_read_only_linux_test.dart) + with an isolated synthetic cache and XDG profile. + +Automated regressions do not establish live provider authorization or installed +Windows/Android behavior. Record those checks separately. Keep per-run logs, +captures, command receipts and delivery manifests in ignored `build/` locations +or temporary directories, with a concise result in the relevant pull request. + +## Provider contracts + +- [Google People connections authorization](https://developers.google.com/people/api/rest/v1/people.connections/list) + accepts read-only or full contacts authorization. +- [Microsoft Graph permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) + distinguish `Contacts.Read` from `Contacts.ReadWrite`. +- [Google installed-app authorization](https://developers.google.com/identity/protocols/oauth2/native-app) + requires the installed-app flow rather than web incremental authorization. +- [Microsoft token refresh](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#refresh-the-access-token) + and [RFC 6749 section 6](https://www.rfc-editor.org/rfc/rfc6749.html#section-6) + describe renewal when the optional refresh scope is omitted. +- [Google sensitive scope verification](https://developers.google.com/identity/protocols/oauth2/production-readiness/sensitive-scope-verification) + applies to the requested scopes and actual user-facing functionality. diff --git a/docs/development/evidence/contacts-read-only/baseline.json b/docs/development/evidence/contacts-read-only/baseline.json deleted file mode 100644 index 54e20ff5..00000000 --- a/docs/development/evidence/contacts-read-only/baseline.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "busymax": { - "branch": "fix/contacts-read-only", - "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f", - "status": "", - "staged": "", - "unstaged": "", - "schema": 20 - }, - "busystack_contacts": { - "branch": "main", - "head": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2", - "status": "", - "staged": "", - "unstaged": "" - }, - "busystack_dav": { - "branch": "main", - "head": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd", - "status": "", - "staged": "", - "unstaged": "" - }, - "busystack_graph": { - "branch": "main", - "head": "6572ee14ae992d4cd4f8c2218babaff5051bc132", - "status": "", - "staged": "", - "unstaged": "" - }, - "busycards": { - "branch": "main", - "head": "8cfb67e50e2d058228edc16dfcb053baf4abef56", - "status": "M .idea/busycards.iml\n M .idea/libraries/Dart_Packages.xml\n?? .idea/libraries/Flutter_Plugins.xml\n?? native/microsoft_auth_helper/__pycache__/", - "staged": "", - "unstaged": ".idea/busycards.iml | 3574 +++++++++++++++++++++++++++++++++++++\n .idea/libraries/Dart_Packages.xml | 558 +++++-\n 2 files changed, 4130 insertions(+), 2 deletions(-)" - }, - "busybox": { - "branch": "Release/v0.0.1", - "head": "5aeba272bde4f6798fadaa394c037e8b7aaf74e6", - "status": "", - "staged": "", - "unstaged": "" - }, - "busyfiles": { - "branch": "Release/v0.0.1", - "head": "14c2853cc089345d2b1ed053fc0d34b1a7477110", - "status": "", - "staged": "", - "unstaged": "" - } -} diff --git a/docs/development/evidence/contacts-read-only/verification.json b/docs/development/evidence/contacts-read-only/verification.json deleted file mode 100644 index 5662281e..00000000 --- a/docs/development/evidence/contacts-read-only/verification.json +++ /dev/null @@ -1,505 +0,0 @@ -{ - "schema": 1, - "recordedUtc": "2026-10-11T13:30:21.822384+00:00", - "verifiedCommit": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "verifiedTree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "implementationCommits": [ - "1c7a433950ebcd6ce691b7c3ae4deddb572acf2e", - "4a4bd5528725fe1eca067099a9671ced9e4a745a" - ], - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "fingerprintContract": "SHA-256 of json.dumps(sorted path->SHA-256 map, sort_keys=True); application, test, platform, native, tool and embedded provider guides. Only subsequent development evidence is excluded. Full per-file map is in the delivery evidence archive.", - "toolchain": { - "host": "Linux-7.0.0-38-generic-x86_64-with-glibc2.43", - "flutter": { - "frameworkVersion": "3.47.5", - "channel": "stable", - "repositoryUrl": "https://github.com/flutter/flutter.git", - "frameworkRevision": "6a19cca56475dbfba1478ee68d7bd0c2ef891da1", - "frameworkCommitDate": "2026-09-17 14:13:22 -0400", - "engineRevision": "af7e796e161ae0bb1ff0758c71a7105418bd9ded", - "engineCommitDate": "2026-09-16 18:35:09.000Z", - "engineContentHash": "ab598368592da0064197e2bc15c7f5b0a2c6bb1f", - "engineBuildDate": "2026-09-16 12:35:21.428", - "dartSdkVersion": "3.13.4", - "devToolsVersion": "2.60.0", - "flutterVersion": "3.47.5" - }, - "pubspecLockSha256": "d7ad8317a97382b3aa52fa7434fd6d3a3ef2c8cb2a13d431581894f0d8bef849", - "dependencyPins": { - "schema": 1, - "layout": "sibling-working-trees", - "dependencies": [ - { - "name": "busystack_contacts", - "repository": "https://github.com/ChenZhenIO/busystack_contacts.git", - "revision": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2", - "path": "../busystack_contacts" - }, - { - "name": "busystack_dav", - "repository": "https://github.com/ChenZhenIO/busystack_dav.git", - "revision": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd", - "path": "../busystack_dav" - }, - { - "name": "busystack_graph", - "repository": "https://github.com/ChenZhenIO/busystack_graph.git", - "revision": "6572ee14ae992d4cd4f8c2218babaff5051bc132", - "path": "../busystack_graph" - } - ] - }, - "instructions": "No applicable AGENTS.md files found in repository or ancestor directories.", - "java": { - "exit": 0, - "output": "openjdk version \"25.0.1\" 2025-10-21 LTS\nOpenJDK Runtime Environment Temurin-25.0.1+8 (build 25.0.1+8-LTS)\nOpenJDK 64-Bit Server VM Temurin-25.0.1+8 (build 25.0.1+8-LTS, mixed mode, sharing)\n" - }, - "clang": { - "exit": 0, - "output": "Ubuntu clang version 21.1.8 (6ubuntu1)\nTarget: x86_64-pc-linux-gnu\nThread model: posix\nInstalledDir: /usr/lib/llvm-21/bin\n" - }, - "cmake": { - "exit": 0, - "output": "cmake version 4.2.3\n\nCMake suite maintained and supported by Kitware (kitware.com/cmake).\n" - }, - "g++": { - "exit": 0, - "output": "g++ (Ubuntu 15.2.0-16ubuntu1) 15.2.0\nCopyright (C) 2025 Free Software Foundation, Inc.\nThis is free software; see the source for copying conditions. There is NO\nwarranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\n\n" - } - }, - "fullFlutterSuite": { - "passed": 3619, - "skipped": 10, - "failed": 0 - }, - "affectedCasesInFinalFullSuite": { - "passed": 435, - "skipped": 0, - "failed": 0 - }, - "priorFocusedCommand": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "test", - "test/contacts", - "test/core/auth/contacts_authorization_requests_test.dart", - "test/google_tasks/oauth/auth_url_test.dart", - "test/google_tasks/oauth/token_exchange_test.dart", - "test/microsoft_todo/oauth/microsoft_oauth_service_test.dart", - "test/core/auth/authorization_transition_test.dart", - "test/features/auth/data/auth_repository_test.dart", - "test/features/settings/presentation/settings_screen_test.dart", - "test/platform/contacts_settings_routes_test.dart", - "test/platform/attendee_contacts_context_test.dart", - "test/platform/android/android_authorization_broker_test.dart", - "test/platform/android/android_contacts_enrollment_test.dart", - "test/platform/android/android_registration_transition_test.dart", - "test/platform/android/android_platform_channel_test.dart", - "--machine" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f", - "tree": "bba83dd5c534ab05ed1c28a24d85de22942821a2", - "sourceFingerprint": "50fdbb2ef28c2a32ac592ce18abf4ca5a755de013c45bb6020116ea797e3fd38", - "startedUtc": "2026-10-11T12:52:28.288945+00:00", - "exit": 0, - "elapsedSeconds": 153.117, - "log": "focused.log", - "logSha256": "6232d43a982166bad84df8b7febbf9b0e6d26e8961bf6291008a59c7a89f5170" - }, - "correctiveFocusedCases": 42, - "skips": [ - { - "name": "real browser Login Flow returns and revokes a canonical app password", - "path": "test/dav/nextcloud_login_flow_live_test.dart", - "reason": "Set BUSYMAX_NEXTCLOUD_LOGIN_LIVE=1 and its URL, username, temporary app password, and TLS certificate variables to run this test." - }, - { - "name": "real Login Flow start can be cancelled while browser authorization opens", - "path": "test/dav/nextcloud_login_flow_live_test.dart", - "reason": "Live Nextcloud Login Flow is not enabled." - }, - { - "name": "real pending polling remains 404 until the operation expires", - "path": "test/dav/nextcloud_login_flow_live_test.dart", - "reason": "Live Nextcloud Login Flow is not enabled." - }, - { - "name": "real Apple iCloud Calendar preserves discovery and event semantics", - "path": "test/dav/apple_icloud_live_integration_test.dart", - "reason": "Set BUSYMAX_ICLOUD_LIVE=1 and the BUSYMAX_ICLOUD_LIVE_* credential variables to run the Apple iCloud integration test." - }, - { - "name": "real iCloud onboarding, credential replacement, and local removal are atomic", - "path": "test/dav/apple_icloud_live_integration_test.dart", - "reason": "Set BUSYMAX_ICLOUD_LIVE=1 and the BUSYMAX_ICLOUD_LIVE_* credential variables to run the Apple iCloud integration test." - }, - { - "name": "exact Nextcloud server preserves DAV calendar and task semantics", - "path": "test/dav/nextcloud_live_integration_test.dart", - "reason": "Set BUSYMAX_NEXTCLOUD_LIVE=1 and the BUSYMAX_NEXTCLOUD_LIVE_* credential variables to run the isolated live-server tests." - }, - { - "name": "large Nextcloud collection synchronizes and deletes without truncation", - "path": "test/dav/nextcloud_live_integration_test.dart", - "reason": "Set BUSYMAX_NEXTCLOUD_LIVE_LARGE=1 with the live-server variables to run the 128-member collection test." - }, - { - "name": "prepare a durable Nextcloud object for server restart", - "path": "test/dav/nextcloud_live_integration_test.dart", - "reason": "Set the live restart stage to prepare to create the durable probe." - }, - { - "name": "rediscover and mutate the durable object after server restart", - "path": "test/dav/nextcloud_live_integration_test.dart", - "reason": "Set the live restart stage to verify after restarting the server." - }, - { - "name": "Nextcloud user and group shares enforce live ACL changes", - "path": "test/dav/nextcloud_sharing_live_test.dart", - "reason": "Set BUSYMAX_NEXTCLOUD_SHARING_LIVE=1 and disposable user/group environment variables to run the live Nextcloud sharing test." - } - ], - "androidNativeUnitTests": { - "tests": 11, - "skipped": 0, - "failures": 0, - "errors": 0 - }, - "androidNativeInputReuse": "Unit/lint receipt at 1c7a433; git diff --exit-code confirms android/, packages/busymax_android_platform/ and lib/src/android/ unchanged through verifiedCommit. The independent release build uses verifiedCommit.", - "commands": { - "locked-pub-final": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "pub", - "get", - "--enforce-lockfile" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:12:06.693602+00:00", - "exit": 0, - "elapsedSeconds": 5.444, - "log": "locked-pub-final.log", - "logSha256": "81571a671d2c0bb8c6f76bb7b2225fe07ad8bfd2abfa126840513cf856701ae6" - }, - "localization-verified": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "gen-l10n" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:17:38.991692+00:00", - "exit": 0, - "elapsedSeconds": 8.163, - "log": "localization-verified.log", - "logSha256": "97362974d3ec503ef18e205dbcc7d8e1ca57e5426c80a4966c6608571fa88609" - }, - "format-final": { - "command": [ - "python3", - "-c", - "import subprocess; p=subprocess.check_output([\"git\",\"diff\",\"--name-only\",\"42dd355454cfc5e86bd10f0c58ea786267f20b5f\",\"HEAD\",\"--\",\"*.dart\"],text=True).splitlines(); subprocess.run([\"/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart\",\"format\",\"--output=none\",\"--set-exit-if-changed\",*p],check=True)" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:12:47.625274+00:00", - "exit": 0, - "elapsedSeconds": 2.032, - "log": "format-final.log", - "logSha256": "d7b931aeddd13dbfaf708346f8224047501416a8e8e4592df36bd16897905d6c" - }, - "diff-check-final": { - "command": [ - "git", - "diff", - "--check", - "42dd355454cfc5e86bd10f0c58ea786267f20b5f", - "HEAD" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:17:36.740548+00:00", - "exit": 0, - "elapsedSeconds": 0.477, - "log": "diff-check-final.log", - "logSha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - }, - "analyze-final": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "analyze" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:12:12.372728+00:00", - "exit": 0, - "elapsedSeconds": 32.2, - "log": "analyze-final.log", - "logSha256": "7fd056ddcba04d39591b14b27d7cb3f3a91ba0f18d01c23a4748fdb519566169" - }, - "boundaries-final": { - "command": [ - "/home/albert/flutter-sdk/bin/cache/dart-sdk/bin/dart", - "run", - "tool/check_platform_boundaries.dart" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:12:45.429983+00:00", - "exit": 0, - "elapsedSeconds": 1.996, - "log": "boundaries-final.log", - "logSha256": "3f00b10245d2e2189cf2f1a007fb552555a01bc024a4a7618cce5a011ca7680e" - }, - "locale-and-linked-verified": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "test", - "test/app/localization_audit_test.dart", - "test/contacts/contacts_read_only_state_test.dart", - "--machine" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "1c7a433950ebcd6ce691b7c3ae4deddb572acf2e", - "tree": "7ac2af1e93321a972376ed23e67c5e6d697abf0d", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:11:09.973830+00:00", - "exit": 0, - "elapsedSeconds": 7.584, - "log": "locale-and-linked-verified.log", - "logSha256": "83ea98596ed15665b2efce7f585c26e73dd751165d972c90984aee38dfe722d6" - }, - "full-suite-final": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "test", - "--machine" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:11:48.529349+00:00", - "exit": 0, - "elapsedSeconds": 870.015, - "log": "full-suite-final.log", - "logSha256": "508eeff6de15de7cae41ec84281a52115088c9faef8640e4c89726f063821b00" - }, - "native-linux-final": { - "command": [ - "bash", - "/tmp/busymax-native-checks.sh" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:12:49.868408+00:00", - "exit": 0, - "elapsedSeconds": 20.436, - "log": "native-linux-final.log", - "logSha256": "fffdb4efc17e2e656a8ed419a250e486b9a87908c9c72729758b62df327b5795" - }, - "linux-session-final": { - "command": [ - "env", - "BUSYMAX_ACCEPTANCE_ROOT=/home/albert/busystack/busymax/build/contacts-read-only/native-session", - "XDG_DATA_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/data", - "XDG_CONFIG_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/config", - "XDG_CACHE_HOME=/home/albert/busystack/busymax/build/contacts-read-only/native-session/cache", - "dbus-run-session", - "--", - "xvfb-run", - "-a", - "/home/albert/flutter-sdk/bin/flutter", - "test", - "integration_test/contacts_read_only_linux_test.dart", - "-d", - "linux", - "--reporter", - "expanded" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:13:10.513412+00:00", - "exit": 0, - "elapsedSeconds": 158.794, - "log": "linux-session-final.log", - "logSha256": "b3b714acab8e4bde0f5fbc88c25835242f564988a097b6189717ce236ae8a264" - }, - "linux-debug-final": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "build", - "linux", - "--debug", - "-t", - "lib/main_linux.dart" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:15:51.053606+00:00", - "exit": 0, - "elapsedSeconds": 58.835, - "log": "linux-debug-final.log", - "logSha256": "f2bdcc31bd06c32b9e3216c398f0c7567578f0b3737e80421e13a34ebfef5798" - }, - "linux-release-final": { - "command": [ - "/home/albert/flutter-sdk/bin/flutter", - "build", - "linux", - "--release", - "-t", - "lib/main_linux.dart" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:16:51.521909+00:00", - "exit": 0, - "elapsedSeconds": 228.576, - "log": "linux-release-final.log", - "logSha256": "cba4d7abad7028d9f049a4469a4c08d8eeb7823d35107f8b4ff5bb18a270a5a9" - }, - "android-native": { - "command": [ - "timeout", - "600", - "android/gradlew", - "--project-dir", - "android", - ":busymax_android_platform:testDebugUnitTest", - ":app:lintDebug", - "--console=plain" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "42dd355454cfc5e86bd10f0c58ea786267f20b5f", - "tree": "bba83dd5c534ab05ed1c28a24d85de22942821a2", - "sourceFingerprint": "50fdbb2ef28c2a32ac592ce18abf4ca5a755de013c45bb6020116ea797e3fd38", - "startedUtc": "2026-10-11T12:51:09.823115+00:00", - "exit": 0, - "elapsedSeconds": 318.333, - "log": "android-native.log", - "logSha256": "cce1d7c344ddb8f37aa1c26ea3fe8946305cfb20d75eecb9d5751bcf3ba1ec9d" - }, - "android-release-isolated": { - "command": [ - "timeout", - "900", - "python3", - "/tmp/busymax-android-isolated.py" - ], - "cwd": "/home/albert/busystack/busymax", - "head": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "tree": "e0e1c52405d73f26a259ecd59cff8f6ee48863f2", - "sourceFingerprint": "8d968885e8e340d57aa5ee640f865ef3ed6c0aff6a2110f873b7bd4781c93dd6", - "startedUtc": "2026-10-11T13:11:48.024453+00:00", - "exit": 0, - "elapsedSeconds": 575.814, - "log": "android-release-isolated.log", - "logSha256": "c6ad717468194d88b1e8a5512021c7cf0f33085f873cf7901cc1a0d42efa7427" - } - }, - "androidArtifacts": { - "commit": "4a4bd5528725fe1eca067099a9671ced9e4a745a", - "label": "TEST-SIGNED; not production distribution; never installed", - "artifacts": [ - { - "file": "build/contacts-read-only/android-test-signed/busymax-contacts-read-only-test-signed.apk", - "sha256": "dbe4a866878b437a0e8e99c00c4289199fabb2644fa545de110403ade4a9d7f5", - "bytes": 93277935 - }, - { - "file": "build/contacts-read-only/android-test-signed/busymax-contacts-read-only-test-signed.aab", - "sha256": "58a52279a6bc60ac835bfe63c85a90d6265dcf25bde139b06c842a1528fd5025", - "bytes": 88155491 - } - ], - "remainingGate": "AAB bundletool alignment metadata unverified: local bundletool jar unavailable", - "sourceLayout": "/tmp/busymax-contacts-android-check-etq4atas" - }, - "remainingGates": [ - "Windows native build/device acceptance: no Windows host. Real Fluent route tests passed on Linux; they are not native Windows acceptance.", - "Android device acceptance: no emulator/device installation performed or authorized.", - "AAB bundletool alignment metadata: no local bundletool jar. APK/AAB builds, signature/resource checks and 16 KB ELF/zip alignment inspection passed.", - "Live-provider authorization/sync/mutations: no disposable accounts or registrations authorized; 10 live DAV cases skipped. No live-provider success claimed.", - "Snap/engine/driver qualification: unchanged inputs; not restarted or relabeled." - ], - "diagnosticRuns": { - "full-suite": "3617 passed, 10 skipped, 1 Portuguese alias audit failure at 1c7a433; corrected and complete suite rerun.", - "android-release": "Shared-layout release exit 1: GeneratedPluginRegistrant referenced integration_test while its release dependency was absent. Independent committed source/sibling layout build and inspection exit 0.", - "locale-and-linked-regression": "Initial new fixture compilation/authority setup failures are retained in logs; corrected before final verification.", - "sdk-missing-arguments": "Verifier first invoked without required flags, exit 1. Correct pinned-SDK command exit 0." - }, - "siblingFinalState": { - "busystack_contacts": { - "branch": "main", - "head": "cf399b9af0b7bf8a315e8c5d6123e601c90b7bd2", - "status": "", - "staged": "", - "unstaged": "" - }, - "busystack_dav": { - "branch": "main", - "head": "18f636f3227e3df8c1406bafd85b45e7f12cf8bd", - "status": "", - "staged": "", - "unstaged": "" - }, - "busystack_graph": { - "branch": "main", - "head": "6572ee14ae992d4cd4f8c2218babaff5051bc132", - "status": "", - "staged": "", - "unstaged": "" - }, - "busycards": { - "branch": "main", - "head": "8cfb67e50e2d058228edc16dfcb053baf4abef56", - "status": "M .idea/busycards.iml\n M .idea/libraries/Dart_Packages.xml\n?? .idea/libraries/Flutter_Plugins.xml\n?? native/microsoft_auth_helper/__pycache__/", - "staged": "", - "unstaged": ".idea/busycards.iml | 3574 +++++++++++++++++++++++++++++++++++++\n .idea/libraries/Dart_Packages.xml | 558 +++++-\n 2 files changed, 4130 insertions(+), 2 deletions(-)" - }, - "busybox": { - "branch": "Release/v0.0.1", - "head": "5aeba272bde4f6798fadaa394c037e8b7aaf74e6", - "status": "", - "staged": "", - "unstaged": "" - }, - "busyfiles": { - "branch": "Release/v0.0.1", - "head": "14c2853cc089345d2b1ed053fc0d34b1a7477110", - "status": "", - "staged": "", - "unstaged": "" - } - }, - "preservedSourceInput": { - "originalPath": "dist/source/busymax-0.2.4-source.tar.gz", - "preservedPath": "build/contacts-read-only/preserved-source-input/busymax-0.2.4-source.tar.gz", - "sha256": "c6431035581860ab05e38cc115d81fcea1d8737354ff3ad423ce4902b194f94a", - "revision": "unverified pre-existing artifact; not a build of this correction" - } -}