From 3044b5d262f3c6f2d27b67bf3a1188ae4d66e58d Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 21 Jul 2026 18:38:42 -0700 Subject: [PATCH 1/5] refactor: update desktop file handling in snap installation script --- test/tools/build_install_snap_local_test.dart | 63 +++++++++++++++++++ tools/build_install_snap_local.sh | 22 ++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/test/tools/build_install_snap_local_test.dart b/test/tools/build_install_snap_local_test.dart index fcafbc2..43e777c 100644 --- a/test/tools/build_install_snap_local_test.dart +++ b/test/tools/build_install_snap_local_test.dart @@ -25,6 +25,43 @@ void main() { ); }); + test('replaces duplicate launchers with one canonical launcher', () async { + final fixture = await _LocalSnapFixture.create(); + addTearDown(fixture.dispose); + + final result = await fixture.run( + arguments: ['--root', fixture.validRoot.path], + ); + + expect( + result.exitCode, + 0, + reason: _processFailure(result, fixture.commandLogContents), + ); + final launchers = Directory('${fixture.validRoot.path}/meta/gui') + .listSync() + .whereType() + .where((file) => file.path.endsWith('.desktop')) + .toList(); + expect(launchers, hasLength(1)); + expect(launchers.single.uri.pathSegments.last, 'busymax_test.desktop'); + expect( + launchers.single.readAsStringSync(), + contains('Exec=busymax_test'), + ); + expect( + launchers.single.readAsStringSync(), + contains(r'Icon=${SNAP}/meta/gui/icon.svg'), + ); + expect( + File( + '${fixture.validRoot.path}/share/applications/' + 'com.example.busymax_test.desktop', + ).existsSync(), + isTrue, + ); + }); + test('accepts and can reuse an owned temporary staging root', () async { final fixture = await _LocalSnapFixture.create(); addTearDown(fixture.dispose); @@ -306,10 +343,23 @@ final class _LocalSnapFixture { '${project.path}/snap/snapcraft.yaml', 'name: busymax_test\n' 'version: 1.2.3\n' + 'icon: assets/test-icon.svg\n' 'apps:\n' ' busymax_test:\n' ' command: busymax_test\n', ); + _writeFile( + '${project.path}/assets/test-icon.svg', + '\n', + ); + _writeFile( + '${project.path}/linux/com.example.busymax_test.desktop', + '[Desktop Entry]\n' + 'Name=BusyMax Test\n' + 'Exec=busymax_test\n' + 'Icon=com.example.busymax_test\n' + 'Type=Application\n', + ); _writeFile( '${project.path}/build/linux/x64/release/bundle/busymax_test', 'test binary\n', @@ -322,6 +372,18 @@ final class _LocalSnapFixture { ' busymax_test:\n' ' command: busymax_test\n', ); + _writeFile( + '${scaffold.path}/meta/gui/busymax_test.desktop', + '[Desktop Entry]\nName=BusyMax Test\nExec=busymax_test\n', + ); + _writeFile( + '${scaffold.path}/meta/gui/com.example.busymax_test.desktop', + '[Desktop Entry]\nName=BusyMax Test\nExec=busymax_test\n', + ); + _writeFile( + '${scaffold.path}/meta/gui/obsolete.desktop', + '[Desktop Entry]\nName=Obsolete BusyMax\nExec=busymax_test\n', + ); await _writeExecutable('${fakeBin.path}/flutter', r'''#!/usr/bin/env bash set -euo pipefail @@ -373,6 +435,7 @@ set -euo pipefail printf 'unsquashfs\t%s\n' "$*" >> "$COMMAND_LOG" if [[ " $* " == *' -ll '* ]]; then echo 'squashfs-root/busymax_test' + echo 'squashfs-root/meta/gui/busymax_test.desktop' else echo 'version: 1.2.3' fi diff --git a/tools/build_install_snap_local.sh b/tools/build_install_snap_local.sh index 2420600..9b42d6c 100755 --- a/tools/build_install_snap_local.sh +++ b/tools/build_install_snap_local.sh @@ -328,6 +328,9 @@ METAINFO_SOURCE="linux/${APP_ID}.metainfo.xml" if [[ -f "$DESKTOP_SOURCE" ]]; then install -Dm644 "$DESKTOP_SOURCE" \ "$SNAP_ROOT/share/applications/${APP_ID}.desktop" + mkdir -p "$SNAP_ROOT/meta/gui" + find "$SNAP_ROOT/meta/gui" -mindepth 1 -maxdepth 1 \ + \( -type f -o -type l \) -name '*.desktop' -exec rm -f -- {} + else echo "No desktop file found at $DESKTOP_SOURCE" fi @@ -340,9 +343,8 @@ if [[ -n "$ICON_SOURCE" && -f "$ICON_SOURCE" ]]; then "$SNAP_ROOT/share/icons/hicolor/scalable/apps/${APP_ID}.${ICON_EXT}" if [[ -f "$DESKTOP_SOURCE" ]]; then - mkdir -p "$SNAP_ROOT/meta/gui" - sed "s#^Icon=.*#Icon=\${SNAP}/meta/gui/${APP_ID}.${ICON_EXT}#" \ - "$DESKTOP_SOURCE" > "$SNAP_ROOT/meta/gui/${APP_ID}.desktop" + sed "s#^Icon=.*#Icon=\${SNAP}/meta/gui/icon.${ICON_EXT}#" \ + "$DESKTOP_SOURCE" > "$SNAP_ROOT/meta/gui/${SNAP_NAME}.desktop" fi else echo "No icon file found from snapcraft icon: ${ICON_SOURCE:-}" @@ -353,6 +355,13 @@ if [[ -f "$METAINFO_SOURCE" ]]; then "$SNAP_ROOT/share/metainfo/${APP_ID}.metainfo.xml" fi +STAGED_DESKTOP_MANIFEST="$( + find "$SNAP_ROOT/meta/gui" -mindepth 1 -maxdepth 1 \ + -type f -name '*.desktop' -printf '%f\n' | LC_ALL=C sort +)" +[[ "$STAGED_DESKTOP_MANIFEST" == "${SNAP_NAME}.desktop" ]] || + fail "expected exactly one staged launcher: ${SNAP_NAME}.desktop" + echo "== Patch staged snap metadata ==" python3 - "$SNAP_ROOT/meta/snap.yaml" "snap/snapcraft.yaml" "$VERSION" "$SNAP_NAME" <<'PY' from pathlib import Path @@ -490,6 +499,13 @@ snap pack "$SNAP_ROOT" --filename="$OUT" echo "== Verify packed snap ==" unsquashfs -cat "$OUT" meta/snap.yaml | grep '^version:' unsquashfs -ll "$OUT" | grep -F "$BINARY_NAME" +PACKED_DESKTOP_MANIFEST="$( + unsquashfs -ll "$OUT" | + sed -nE 's#^.*squashfs-root/meta/gui/([^/]+\.desktop)$#\1#p' | + LC_ALL=C sort +)" +[[ "$PACKED_DESKTOP_MANIFEST" == "${SNAP_NAME}.desktop" ]] || + fail "expected exactly one packed launcher: ${SNAP_NAME}.desktop" if [[ -d "$BUNDLE_DIR/lib" ]]; then while IFS= read -r plugin; do name="$(basename "$plugin")" From 1357c30b34ebe569f1ae5e611871c13eadcb378d Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 21 Jul 2026 18:54:26 -0700 Subject: [PATCH 2/5] Make the main window to return a freshly loaded Agenda snapshot instead of its cached copy --- .../platform/main_window_command_bridge.dart | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart index 6477940..1182d6f 100644 --- a/lib/src/platform/main_window_command_bridge.dart +++ b/lib/src/platform/main_window_command_bridge.dart @@ -12,6 +12,17 @@ import '../features/sync/sync_auth_error.dart'; import '../schedule/schedule_commands.dart'; import 'main_window_command_client.dart'; +Future> loadFreshCompactAgendaSnapshot( + WidgetRef ref, + Object? rawArgs, +) async { + final query = decodeCompactAgendaQuery(rawArgs); + final data = await ref.refresh( + compactAgendaDataForQueryProvider(query).future, + ); + return encodeCompactAgendaData(data); +} + class MainWindowCommandBridge extends ConsumerStatefulWidget { const MainWindowCommandBridge({super.key, required this.child}); @@ -117,11 +128,7 @@ class _MainWindowCommandBridgeState } Future> _compactAgendaSnapshot(Object? rawArgs) async { - final query = decodeCompactAgendaQuery(rawArgs); - final data = await ref.read( - compactAgendaDataForQueryProvider(query).future, - ); - return encodeCompactAgendaData(data); + return loadFreshCompactAgendaSnapshot(ref, rawArgs); } Future _requestTaskSync(Object? rawArgs) async { From 2a0b1b991690c39d5f68e563c2b82a5622cabb50 Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 21 Jul 2026 18:55:57 -0700 Subject: [PATCH 3/5] Make the main window to return a freshly loaded Agenda snapshot instead of its cached copy --- .../main_window_command_bridge_test.dart | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 test/platform/main_window_command_bridge_test.dart diff --git a/test/platform/main_window_command_bridge_test.dart b/test/platform/main_window_command_bridge_test.dart new file mode 100644 index 0000000..bc26ea7 --- /dev/null +++ b/test/platform/main_window_command_bridge_test.dart @@ -0,0 +1,94 @@ +import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; +import 'package:busymax/src/features/schedule/application/compact_agenda_snapshot.dart'; +import 'package:busymax/src/platform/main_window_command_bridge.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('compact agenda snapshot refreshes a cached query', ( + tester, + ) async { + const query = CompactAgendaQuery.initial; + var current = _agendaData(); + var loadCount = 0; + final container = ProviderContainer( + overrides: [ + compactAgendaDataLoaderProvider.overrideWithValue((ref, query) async { + loadCount += 1; + return current; + }), + ], + ); + final provider = compactAgendaDataForQueryProvider(query); + // Keep the initial value cached so a plain read would reproduce the bug. + final subscription = container.listen(provider, (_, _) {}); + addTearDown(() { + subscription.close(); + container.dispose(); + }); + + final initial = await container.read(provider.future); + expect(initial.items, isEmpty); + expect(loadCount, 1); + + late WidgetRef widgetRef; + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: Consumer( + builder: (context, ref, child) { + widgetRef = ref; + return const SizedBox.shrink(); + }, + ), + ), + ); + + current = _agendaData(items: [_googleTask('New Google task')]); + final encoded = await loadFreshCompactAgendaSnapshot( + widgetRef, + encodeCompactAgendaQuery(query), + ); + final refreshed = decodeCompactAgendaData(encoded); + + expect(refreshed.items, hasLength(1)); + expect(refreshed.items.single.title, 'New Google task'); + expect(loadCount, 2); + + await tester.pumpWidget(const SizedBox.shrink()); + }); +} + +CompactAgendaData _agendaData({List items = const []}) { + final today = DateTime(2026, 7, 21); + return CompactAgendaData( + today: today, + range: ScheduleRange( + start: today, + end: today.add(const Duration(days: 30)), + ), + items: items, + hasMoreOverdueTasks: false, + hasMoreNoDateTasks: false, + hasSignedInAccounts: true, + hasSources: true, + generatedAt: today.add(Duration(minutes: items.length)), + ); +} + +TaskScheduleItem _googleTask(String title) { + return TaskScheduleItem( + id: 'new-google-task', + accountId: 'google-account', + provider: TaskProvider.google, + sourceId: 'google-list', + title: title, + completed: false, + allDay: true, + start: DateTime(2026, 7, 21), + ); +} From a81cd869081ce64e94bb65f19e3e47f20225423a Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 21 Jul 2026 19:16:58 -0700 Subject: [PATCH 4/5] docs: update Snap build and beta release instructions in documentation --- .gitignore | 3 + README.md | 7 +- docs/beta_snap_release.md | 217 +++++++++++++++++++++++++++----------- tools/README.md | 4 +- 4 files changed, 168 insertions(+), 63 deletions(-) diff --git a/.gitignore b/.gitignore index ee425f6..eb0e8ec 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ app.*.map.json /android/app/release .snap-local/ + +# Local Snap packages contain compiled build-time configuration. +/*.snap diff --git a/README.md b/README.md index 003ab05..08cdca8 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,8 @@ The local Snap helper accepts the same value with `--dart-define BUSYSTACK_FEEDBACK_ENDPOINT=http://127.0.0.1:8090/api/feedback`. No API, CAPTCHA, or other private server credential is used by the desktop application. -## Beta snap +## Build and publish the Snap -See [Beta Snap Release](docs/beta_snap_release.md) for local beta build, -install, and validation notes. +See [Snap Build and Beta Release](docs/beta_snap_release.md) for OAuth build +configuration, canonical Snapcraft packaging, local installation, artifact +verification, Store review, and beta release instructions. diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 47c4077..94d2c4a 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -1,80 +1,179 @@ -# BusyMax Beta Snap Release +# BusyMax Snap Build and Beta Release -BusyMax `0.1.1` is a beta release target for public/listed visibility in -Ubuntu App Center and the Snap Store. +`snap/snapcraft.yaml` packages an existing Flutter Linux bundle. It does not +run `flutter build` or read `.snap-local/busymax-dart-defines.json`, so the +OAuth-enabled Flutter build must run first. -## Build +## Prepare + +Required: Linux amd64, the Flutter Linux toolchain, snapd, Snapcraft with LXD, +`unsquashfs` from `squashfs-tools`, and BusyMax Store access for publishing. + +Check that the package versions match: + +```bash +grep -nE '^version:|_amd64.snap`. Use the exact path it prints. +If the app says **This provider is not configured**, the bundle was built +without valid defines; reconnecting cannot fix it, so rebuild the package. + +For a local scaffold smoke build instead: + +```bash +./tools/build_install_snap_local.sh \ + --dart-define-from-file .snap-local/busymax-dart-defines.json +``` + +The helper requires `/snap/busymax/current` or `--scaffold DIR`, repacks and +installs the local payload, and is not the canonical Store build above. Leave +`--root` at its safe default. It does not remove or purge app data. Its +`Defines: 1` output only confirms that one file argument was passed, not that +the required values are present. Use `--skip-tests` only for a repeat build of +the same commit after its tests passed; `--no-run` still installs the package. -## Install The Beta +## Verify Locally -For local validation: +Set the exact artifact path: ```bash -sudo snap install --dangerous busymax_0.1.1_amd64.snap +SNAP_FILE=./busymax_RELEASE_VERSION_amd64.snap ``` -For store users after the revision is uploaded and released to beta: +Check its metadata and save its checksum: + +```bash +unsquashfs -cat "$SNAP_FILE" meta/snap.yaml | + sed -n '/^name:/p;/^version:/p;/^grade:/p;/^confinement:/p' +sha256sum "$SNAP_FILE" +``` + +Check the top-level launchers: + +```bash +unsquashfs -ll "$SNAP_FILE" | + sed -nE 's#^.*squashfs-root/meta/gui/([^/]+\.desktop)$#\1#p' +``` + +The output must contain exactly `busymax.desktop`. +`share/applications/io.busystack.busymax.desktop` is an expected internal file, +not a second top-level launcher. + +Close BusyMax and its tray process, then install and launch the local package: + +```bash +sudo snap install --dangerous "$SNAP_FILE" +snap connections busymax +snap run busymax +``` + +`--dangerous` bypasses Store signature checks, not strict confinement. + +Before upload, verify: + +- Desktop search shows one BusyMax launcher; both main and Agenda windows open. +- Google and Microsoft sign-in complete successfully. +- Tasks and events can be created, edited, completed, and deleted; a task + created in Agenda appears immediately without manual refresh. +- Accounts, settings, and data survive restart. +- Notifications and tray actions, including Agenda and Quit, work. + +## Upload To Beta + +Authenticate if needed, then upload once with the beta release target: + +```bash +snapcraft login +snapcraft whoami +snapcraft upload --release=beta "$SNAP_FILE" +snapcraft revisions busymax --arch amd64 +snapcraft status busymax --arch amd64 +``` + +Save the numeric Store revision printed for the verified checksum. It is an +immutable upload identifier, separate from the app version. Do not re-upload +the artifact because review or release is pending. + +The `busymax-dbus` session D-Bus slot may trigger manual review. If an older +revision blocks the new one, reject it only when it is obsolete; otherwise +wait or contact the +[Store reviewers](https://forum.snapcraft.io/c/store-requests/19). A +`resource-not-ready` or inconsistent-state error means nothing was released. +Check the [publisher dashboard](https://dashboard.snapcraft.io/) and retry only +after review clears. + +If manual review completes but the revision was not automatically released, +release the exact reviewed revision: + +```bash +snapcraft release busymax STORE_REVISION beta +snapcraft status busymax --arch amd64 +``` + +The recipe currently has `grade: devel`, so only `beta` and `edge` are allowed. +Candidate or stable requires `grade: stable`, a rebuild, a new upload, and the +same verification. + +## Verify The Store Revision + +Prefer a separate test machine. For a fresh install: ```bash sudo snap install busymax --beta +snap info busymax +snap run busymax +``` + +For an existing Store-tracking install: + +```bash +sudo snap refresh busymax --channel=beta +snap info busymax ``` -## Scope - -- Snap confinement is strict. -- The tray/status-notifier feature and background-on-close behavior are enabled - by default for the beta. Users can disable them in settings. -- Tray support currently uses a vendored patched `xdg_status_notifier_item` - StatusNotifierItem/DBusMenu dependency under `third_party`. -- The tray menu is intentionally simple: Open BusyMax, Agenda, and Quit. -- The tray Agenda action opens the compact Agenda utility window. On GNOME - Wayland this window is a normal top-level utility window and may be placed by - Mutter instead of under the tray icon. Under X11/XWayland, BusyMax requests a - top-right position when the backend supports absolute movement. -- Settings, task data, and token metadata are stored inside the snap user data - sandbox. Normal app restarts preserve data. Removing the snap removes user - data unless snapd creates and restores a snapshot. -- OAuth uses the system browser and a local loopback callback listener. -- OAuth tokens use the XDG Secret portal in the snap to retrieve a - per-application encryption secret, then store only AES-GCM ciphertext under - `XDG_DATA_HOME`. BusyMax must not require the `password-manager-service` - interface. - -## Validation Matrix - -Record the exact desktop environments tested before upload: - -- Ubuntu GNOME on Wayland: pending local installed-snap validation. -- Ubuntu GNOME on X11/XWayland: pending local installed-snap validation. - -Required smoke checks before upload: - -- Launch from terminal and desktop launcher. -- Google and Microsoft sign-in open in the browser and complete the loopback - callback. -- Secure token storage survives app restart. -- `grep -R` over snap user data does not show plaintext OAuth tokens. -- Settings and task data survive app restart. -- Notifications appear through the desktop notification service. -- Tray icon appears, the menu opens, Open BusyMax restores the existing main - window, Agenda opens the compact Agenda window, and Quit exits cleanly. -- Compact Agenda data loads through the main-window bridge without opening a - second migrating SQLite connection. - -## Reporting Bugs - -Report beta issues at https://github.com/busystack/busymax/issues. - -Source code is available at https://github.com/busystack/busymax. +Repeat the local smoke checks against the Store-delivered revision. + +Official references: [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/). diff --git a/tools/README.md b/tools/README.md index e925abc..4846128 100644 --- a/tools/README.md +++ b/tools/README.md @@ -2,5 +2,7 @@ Project maintenance scripts live here. -- `build_install_snap_local.sh` builds and installs a local Snap package. +- `build_install_snap_local.sh` builds and installs a local Snap package. See + the [Snap build and beta release guide](../docs/beta_snap_release.md) before + using its scaffold workflow or publishing an artifact. - `google_tasks_discovery/fetch_tasks_discovery.dart` refreshes the cached Google Tasks v1 discovery document and checks its locked revision. From 6b44ab9f519a29904899ae9e3678b8ec3d23947f Mon Sep 17 00:00:00 2001 From: albert Date: Tue, 21 Jul 2026 19:24:59 -0700 Subject: [PATCH 5/5] chore: bump version to 0.1.2 and update release notes --- docs/beta_snap_release.md | 3 ++- linux/io.busystack.busymax.metainfo.xml | 5 +++++ pubspec.yaml | 2 +- snap/snapcraft.yaml | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 94d2c4a..ef14f09 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -59,7 +59,8 @@ Snapcraft writes `busymax__amd64.snap`. Use the exact path it prints. If the app says **This provider is not configured**, the bundle was built without valid defines; reconnecting cannot fix it, so rebuild the package. -For a local scaffold smoke build instead: +For a local scaffold smoke build instead, first quit every running BusyMax +instance, including its tray process and any development build: ```bash ./tools/build_install_snap_local.sh \ diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index 9ee77a5..43dfa21 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -35,6 +35,11 @@ + + +

Beta maintenance release.

+
+

Beta maintenance release.

diff --git a/pubspec.yaml b/pubspec.yaml index d8934d4..bd2d7f2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: busymax description: BusyMax calendar and task manager. publish_to: 'none' -version: 0.1.1 +version: 0.1.2 environment: sdk: ^3.12.0 diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 64d185f..d3cdf54 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: busymax title: BusyMax -version: "0.1.1" +version: "0.1.2" summary: Calendar and task manager description: | BusyMax is a Linux desktop calendar and task manager.