diff --git a/.github/workflows/auto-publish-google-play-on-release.yml b/.github/workflows/auto-publish-google-play-on-release.yml index 3b3a8deb38..81ede3073c 100644 --- a/.github/workflows/auto-publish-google-play-on-release.yml +++ b/.github/workflows/auto-publish-google-play-on-release.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit allowed-endpoints: > diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 1c9c549bda..686d27b2fe 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -77,7 +77,7 @@ jobs: # APK is now signed automatically by Gradle using signingConfig - name: 'Upload APK files' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sup-android-release path: android/app/build/outputs/apk/**/*.apk diff --git a/.github/workflows/build-create-windows-store-on-release.yml b/.github/workflows/build-create-windows-store-on-release.yml index db7256726b..d458efeb60 100644 --- a/.github/workflows/build-create-windows-store-on-release.yml +++ b/.github/workflows/build-create-windows-store-on-release.yml @@ -77,7 +77,7 @@ jobs: github_token: ${{ secrets.github_token }} - name: 'Upload Artifact' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: WinStoreRelease path: .tmp/app-builds/*.appx diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 7cb617de24..83f36608a8 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -200,7 +200,7 @@ jobs: run: ls -la "$RUNNER_TEMP/ipa-output" - name: Upload IPA artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sup-ios-release path: ${{ runner.temp }}/ipa-output/*.ipa diff --git a/.github/workflows/build-publish-to-mac-store-on-release.yml b/.github/workflows/build-publish-to-mac-store-on-release.yml index e103e60518..d307f0a8ea 100644 --- a/.github/workflows/build-publish-to-mac-store-on-release.yml +++ b/.github/workflows/build-publish-to-mac-store-on-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit allowed-endpoints: > diff --git a/.github/workflows/build-update-web-app-on-release.yml b/.github/workflows/build-update-web-app-on-release.yml index 72378cace3..84aff205b1 100644 --- a/.github/workflows/build-update-web-app-on-release.yml +++ b/.github/workflows/build-update-web-app-on-release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit allowed-endpoints: > @@ -69,7 +69,7 @@ jobs: run: npm run buildFrontend:prodWeb - name: Deploy to Web Server - uses: easingthemes/ssh-deploy@a1aa0b6cf96ce2406eef90faa35007a4a7bf0ac0 # v5.1.1 + uses: easingthemes/ssh-deploy@2cc5b27bf3029d0455dd5e09fe02633904031447 # v6.0.3 env: SSH_PRIVATE_KEY: ${{ secrets.WEB_SERVER_SSH_KEY }} ARGS: '-rltgoDzvO --delete --exclude "news.json"' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c39cff29c..aacfec7f93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,7 +31,7 @@ jobs: fi } > /tmp/release-body.md - name: Create Draft Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: ${{ github.ref_name }} draft: true @@ -102,7 +102,7 @@ jobs: - name: 'Upload E2E results on failure' if: ${{ failure() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2eResults path: .tmp/e2e-test-results/**/*.* @@ -314,7 +314,7 @@ jobs: - name: Upload unsigned executables for SignPath id: upload-unsigned - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: unsigned-windows-executables path: .tmp/app-builds/*.exe @@ -322,7 +322,7 @@ jobs: retention-days: 1 - name: Sign Windows executables with SignPath - uses: signpath/github-action-submit-signing-request@3f9250c56651ff692d6729a2fbb0603a42d7d322 # v2 + uses: signpath/github-action-submit-signing-request@bc66d86b015a46e9c6d9700de73143a82f9570ff # v2 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} @@ -474,7 +474,7 @@ jobs: " - name: Publish signed Windows binaries to GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} files: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eee6282d46..9f74513bc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,7 +120,7 @@ jobs: run: npm run e2e:ci - name: 'Upload E2E results on failure' if: ${{ failure() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2eResults path: .tmp/e2e-test-results/**/*.* diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 63e4653092..b7c75fa373 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -32,7 +32,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@6e2bd52842c65e914eba5c8badd17560bd26b5de # v1 + uses: anthropics/claude-code-action@b47fd721da662d48c5680e154ad16a73ed74d2e0 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/e2e-scheduled.yml b/.github/workflows/e2e-scheduled.yml index 37dfe370a2..a879d99d77 100644 --- a/.github/workflows/e2e-scheduled.yml +++ b/.github/workflows/e2e-scheduled.yml @@ -50,7 +50,7 @@ jobs: test -f .tmp/angular-dist/browser/assets/bundled-plugins/api-test-plugin/manifest.json || (echo "Plugin assets missing from build" && exit 1) - name: Upload Build Artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-build-${{ github.run_id }} path: .tmp/angular-dist @@ -86,7 +86,7 @@ jobs: - name: Upload E2E Results on Failure if: ${{ failure() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-results-regular-${{ github.run_id }} path: .tmp/e2e-test-results/**/*.* @@ -143,7 +143,7 @@ jobs: - name: Upload E2E Results on Failure if: ${{ failure() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-results-webdav-${{ github.run_id }} path: .tmp/e2e-test-results/**/*.* @@ -205,7 +205,7 @@ jobs: - name: Upload E2E Results on Failure if: ${{ failure() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-results-supersync-${{ matrix.shard }}-${{ github.run_id }} path: .tmp/e2e-test-results/**/*.* diff --git a/.github/workflows/manual-build.yml b/.github/workflows/manual-build.yml index ce3353b999..77a20a86db 100644 --- a/.github/workflows/manual-build.yml +++ b/.github/workflows/manual-build.yml @@ -62,7 +62,7 @@ jobs: release: false - name: 'Upload Artifact' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: WinBuildStuff path: .tmp/app-builds/*.exe @@ -162,7 +162,7 @@ jobs: # if: always() # run: ls -la && cat notarization-error.log - name: 'Upload Artifact' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dmg path: .tmp/app-builds/*.dmg diff --git a/.github/workflows/pr-preview-build.yml b/.github/workflows/pr-preview-build.yml index 6b22e4736e..938ecc095d 100644 --- a/.github/workflows/pr-preview-build.yml +++ b/.github/workflows/pr-preview-build.yml @@ -41,7 +41,7 @@ jobs: run: npm run buildFrontend:prodWeb - name: Upload build artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr-preview-build path: dist/browser diff --git a/.github/workflows/publish-to-hub-docker.yml b/.github/workflows/publish-to-hub-docker.yml index 885086f451..fb73c5afb1 100644 --- a/.github/workflows/publish-to-hub-docker.yml +++ b/.github/workflows/publish-to-hub-docker.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit allowed-endpoints: > @@ -55,7 +55,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push Docker image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true diff --git a/.github/workflows/supersync-docker.yml b/.github/workflows/supersync-docker.yml index 8af1cde34b..b80ea5bdf4 100644 --- a/.github/workflows/supersync-docker.yml +++ b/.github/workflows/supersync-docker.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1 + uses: step-security/harden-runner@f808768d1510423e83855289c910610ca9b43176 # v2.17.0 with: egress-policy: audit allowed-endpoints: > @@ -59,7 +59,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build and push Docker image - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: ./packages/super-sync-server/Dockerfile diff --git a/.github/workflows/test-mac-dmg-build.yml b/.github/workflows/test-mac-dmg-build.yml index 7613ccd018..4e827c9da2 100644 --- a/.github/workflows/test-mac-dmg-build.yml +++ b/.github/workflows/test-mac-dmg-build.yml @@ -117,7 +117,7 @@ jobs: fi - name: Upload DMG artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: mac-dmg-build path: .tmp/app-builds/*.dmg diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/plugins/SafBridgePlugin.kt b/android/app/src/main/java/com/superproductivity/superproductivity/plugins/SafBridgePlugin.kt index 060f3743d8..48b72b8fd2 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/plugins/SafBridgePlugin.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/plugins/SafBridgePlugin.kt @@ -2,7 +2,9 @@ package com.superproductivity.superproductivity.plugins import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Process import android.provider.DocumentsContract import androidx.activity.result.ActivityResult import androidx.documentfile.provider.DocumentFile @@ -40,18 +42,26 @@ class SafBridgePlugin : Plugin() { @ActivityCallback private fun handleFolderSelectionResult(call: PluginCall, result: ActivityResult) { if (result.resultCode == Activity.RESULT_OK) { - val uri = result.data?.data + val resultData = result.data + val uri = resultData?.data if (uri != null) { - // Take persistable permission + // Use only the flags that were actually granted by the result intent. + // Applying flags not present in the result throws SecurityException on some devices. + // resultData is Intent? but uri != null proves resultData is non-null here; + // Kotlin cannot infer this transitively, so !! is required and safe. + val grantedFlags = resultData!!.flags and + (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) try { - val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION - context.contentResolver.takePersistableUriPermission(uri, takeFlags) + context.contentResolver.takePersistableUriPermission(uri, grantedFlags) } catch (e: SecurityException) { - // Some devices/Android versions fail to persist SAF permission - android.util.Log.w("SafBridgePlugin", "Failed to persist URI permission: ${e.message}") - call.reject("Failed to persist folder permission: ${e.message}") - return + // Some devices/OEM ROMs fail to persist SAF permission even when + // FLAG_GRANT_PERSISTABLE_URI_PERMISSION was requested. Fall through and + // still return the URI — it will work for this session even if it can't + // be persisted across reboots (user will need to re-select next launch). + android.util.Log.w( + "SafBridgePlugin", + "Could not persist URI permission (session-only access): ${e.message}", + ) } val ret = JSObject() @@ -208,8 +218,19 @@ class SafBridgePlugin : Plugin() { try { val uri = Uri.parse(uriString) - val persistedUris = context.contentResolver.persistedUriPermissions - val hasPermission = persistedUris.any { it.uri == uri && it.isReadPermission && it.isWritePermission } + // Check both persistent grants (survive reboots) and temporary session grants + // (active for current app session only). This is required to support devices/OEM + // ROMs where takePersistableUriPermission throws SecurityException — on those + // devices the session grant is still valid even though it could not be persisted. + val pid = Process.myPid() + val uid = Process.myUid() + val canRead = context.checkUriPermission( + uri, pid, uid, Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) == PackageManager.PERMISSION_GRANTED + val canWrite = context.checkUriPermission( + uri, pid, uid, Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) == PackageManager.PERMISSION_GRANTED + val hasPermission = canRead && canWrite val ret = JSObject() ret.put("hasPermission", hasPermission) diff --git a/docs/long-term-plans/location-based-reminders.md b/docs/long-term-plans/location-based-reminders.md new file mode 100644 index 0000000000..1c53422c43 --- /dev/null +++ b/docs/long-term-plans/location-based-reminders.md @@ -0,0 +1,513 @@ +# Location-Based Reminders — Design Document + +> **Status: Planned (Brainstorm)** + +## Overview + +Add location-based reminders to Super Productivity. Users can attach a saved location to a task and receive a notification when they arrive at that place. This is primarily a mobile feature (Android/iOS via Capacitor), with passive location display on desktop/web. + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Primary platform | Mobile (Android/iOS) | Geofencing requires GPS + background location. Desktop/web show location info but don't trigger. | +| Trigger type | Arrive only (v1) | Simplest. Leave triggers can be added later. | +| Location management | Saved locations entity | Users revisit the same places. Follows existing Tag entity pattern. | +| Location picker (v1) | "Use current location" + label | No map needed for v1. Map picker is a v2 enhancement. | +| Geofencing approach | Custom native Capacitor plugin | `@capacitor/geolocation` only does point-in-time reads, not geofencing. Need native `GeofencingClient` (Android) / `CLLocationManager` (iOS). | +| Sync | Sync by default | Location data treated like any other entity. Each device manages its own geofences locally after sync. | +| Feature toggle | `isLocationRemindersEnabled` in `AppFeaturesConfig` | Opt-in, default false. | + +--- + +## 1. Data Model + +### 1.1 SavedLocation Entity + +New file: `src/app/features/saved-location/saved-location.model.ts` + +```typescript +import { EntityState } from '@ngrx/entity'; + +export interface SavedLocationCopy { + id: string; + title: string; // "Office", "Grocery Store", "Gym" + lat: number; // latitude + lng: number; // longitude + radius: number; // geofence radius in meters (default 200) + icon?: string | null; // material icon name, e.g. 'home', 'work', 'shopping_cart' + created: number; // creation timestamp + modified?: number; // last update timestamp +} + +export type SavedLocation = Readonly; +export type SavedLocationState = EntityState; +``` + +### 1.2 Task Model Changes + +File: `src/app/features/tasks/task.model.ts` — add to `TaskCopy`: + +```typescript +/** ID of a SavedLocation. When set, a geofence reminder is active for this task. */ +locationReminderId?: string | null; +``` + +### 1.3 Config Changes + +File: `src/app/features/config/global-config.model.ts` — add to `AppFeaturesConfig`: + +```typescript +isLocationRemindersEnabled: boolean; // default false +``` + +File: `src/app/features/config/default-global-config.const.ts` — set default: + +```typescript +isLocationRemindersEnabled: false, +``` + +### 1.4 Platform Capabilities + +File: `src/app/core/platform/platform-capabilities.model.ts` — add: + +```typescript +/** Whether the platform supports native geofencing (background location monitoring). */ +readonly geofencing: boolean; +``` + +| Platform | `geofencing` | +|----------|-------------| +| Android (`ANDROID_CAPABILITIES`) | `true` | +| iOS (`IOS_CAPABILITIES`) | `true` | +| Electron (`ELECTRON_CAPABILITIES`) | `false` | +| Web (`WEB_CAPABILITIES`) | `false` | + +--- + +## 2. Sync & Persistence Registration + +All registration steps required for the new entity, following existing patterns: + +### 2.1 Entity Type + +File: `packages/shared-schema/src/entity-types.ts` + +Add `'SAVED_LOCATION'` to the `ENTITY_TYPES` array. + +### 2.2 Action Types Enum + +File: `src/app/op-log/core/action-types.enum.ts` + +```typescript +// SavedLocation actions +SAVED_LOCATION_ADD = '[SavedLocation] Add SavedLocation', +SAVED_LOCATION_UPDATE = '[SavedLocation] Update SavedLocation', +SAVED_LOCATION_DELETE = '[SavedLocation] Delete SavedLocation', +``` + +These string values are **immutable** once deployed — they are used for encoding/decoding operations in IndexedDB and sync between clients. + +### 2.3 Entity Registry + +File: `src/app/op-log/core/entity-registry.ts` + +Add to `ENTITY_CONFIGS`: + +```typescript +SAVED_LOCATION: { + storagePattern: 'adapter', + featureName: SAVED_LOCATION_FEATURE_NAME, + payloadKey: 'savedLocation', + adapter: savedLocationAdapter, + selectEntities: createSelector( + selectSavedLocationFeatureState, + selectSavedLocationEntitiesFromAdapter, + ), + selectById: selectSavedLocationById, +}, +``` + +### 2.4 Model Config + +File: `src/app/op-log/model/model-config.ts` + +Add to `AllModelConfig` type: + +```typescript +savedLocation: ModelCfg; +``` + +Add to `MODEL_CONFIGS`: + +```typescript +savedLocation: { + defaultData: initialSavedLocationState, + isMainFileModel: true, + repair: fixEntityStateConsistency, +}, +``` + +### 2.5 Root State + +File: `src/app/root-store/root-state.ts` + +```typescript +[SAVED_LOCATION_FEATURE_NAME]: SavedLocationState; +``` + +### 2.6 Feature Store Registration + +File: `src/app/root-store/feature-stores.module.ts` + +```typescript +StoreModule.forFeature(SAVED_LOCATION_FEATURE_NAME, savedLocationReducer), +``` + +### 2.7 Cascading Delete Meta-Reducer + +File: `src/app/root-store/meta/task-shared-meta-reducers/saved-location-shared.reducer.ts` + +When a SavedLocation is deleted, clear `locationReminderId` on all tasks that reference it. This must be a meta-reducer (not an effect) to ensure atomicity — one operation in the sync log. + +Register in `src/app/root-store/meta/meta-reducer-registry.ts` in **Phase 5** (Entity-Specific Cascades), alongside `tagSharedMetaReducer`, `projectSharedMetaReducer`, etc. + +--- + +## 3. NgRx Store + +### 3.1 Actions + +File: `src/app/features/saved-location/store/saved-location.actions.ts` + +Following the Tag action pattern with `PersistentActionMeta`: + +| Action | OpType | Payload | +|--------|--------|---------| +| `addSavedLocation` | `Create` | `{ savedLocation: SavedLocation }` | +| `updateSavedLocation` | `Update` | `{ savedLocation: Update }` | +| `deleteSavedLocation` | `Delete` | `{ id: string }` | + +All include `meta: { isPersistent: true, entityType: 'SAVED_LOCATION', entityId, opType } satisfies PersistentActionMeta`. + +### 3.2 Reducer + +File: `src/app/features/saved-location/store/saved-location.reducer.ts` + +Standard `@ngrx/entity` adapter: + +```typescript +export const SAVED_LOCATION_FEATURE_NAME = 'savedLocation'; +export const savedLocationAdapter = createEntityAdapter({ + sortComparer: (a, b) => a.title.localeCompare(b.title), +}); +export const initialSavedLocationState = savedLocationAdapter.getInitialState(); +``` + +### 3.3 Selectors + +File: `src/app/features/saved-location/store/saved-location.selectors.ts` + +| Selector | Returns | +|----------|---------| +| `selectSavedLocationFeatureState` | Feature state | +| `selectAllSavedLocations` | `SavedLocation[]` | +| `selectSavedLocationById` | `SavedLocation \| undefined` | +| `selectSavedLocationEntities` | `Dictionary` | + +Task-side selector (in task selectors or a cross-feature selector): + +| Selector | Returns | +|----------|---------| +| `selectTasksWithLocationReminder` | All undone tasks that have `locationReminderId` set | + +### 3.4 Service + +File: `src/app/features/saved-location/saved-location.service.ts` + +Thin wrapper dispatching actions to the store. Methods: `addSavedLocation()`, `updateSavedLocation()`, `deleteSavedLocation()`, `getById$()`. + +--- + +## 4. Geofencing Service + +### 4.1 Architecture + +New file: `src/app/features/saved-location/geofence.service.ts` + +``` +Store (undone tasks with locationReminderId) + → GeofenceService watches selector (distinctUntilChanged) + → Computes which locations need active geofences + → Registers/unregisters via custom Capacitor plugin + → Receives geofence enter events + → Emits to ReminderService for dialog/notification +``` + +### 4.2 Why a Custom Capacitor Plugin + +`@capacitor/geolocation` only provides one-time and continuous position reads — it does **not** support geofencing. Native geofencing requires: + +- **Android:** `com.google.android.gms.location.GeofencingClient` (Google Play Services). Supports up to 100 geofences. Fires `BroadcastReceiver` on enter/exit. +- **iOS:** `CLLocationManager.startMonitoring(for: CLCircularRegion)`. Supports up to 20 monitored regions. Fires delegate callbacks on enter/exit. + +Implementation: Create `GeofencePlugin.kt` (Android) and `GeofencePlugin.swift` (iOS) extending Capacitor's `Plugin` class. Register in `CapacitorMainActivity.onCreate()` following the pattern of `SafBridgePlugin`, `WebDavHttpPlugin`, etc. + +### 4.3 Lifecycle Rules + +**Register geofence when:** +- Feature is enabled + a task gets `locationReminderId` assigned +- App starts with existing location-reminded tasks + +**Unregister geofence when:** +- Task is completed, deleted, or `locationReminderId` cleared +- SavedLocation is deleted +- No more undone tasks reference that location +- Feature is disabled + +**iOS 20-region limit:** Only register geofences for the 20 locations with the most active tasks. Re-evaluate when tasks change. + +### 4.4 Effects + +Effects MUST use `inject(LOCAL_ACTIONS)` — geofence registration should never happen during remote sync replay. Each device manages its own geofences based on local state after sync. + +### 4.5 Background Behavior + +When geofence fires while app is in background: +- **Android:** `BroadcastReceiver` shows native notification directly (same pattern as `ReminderAlarmReceiver`). Tapping opens app + reminder dialog. +- **iOS:** `CLLocationManager` delegate fires local notification. Tapping opens app + reminder dialog. + +### 4.6 Permission Flow + +On first use: +1. Check `CapacitorPlatformService.hasCapability('geofencing')` +2. Request `ACCESS_FINE_LOCATION` + `ACCESS_BACKGROUND_LOCATION` (Android) or "Always" location access (iOS) +3. If denied: feature degrades to display-only, show explanation + +--- + +## 5. Reminder Integration + +### 5.1 Integration Point + +The existing `ReminderService.onRemindersActive$` is a derived observable — external code cannot emit to it directly. Two approaches: + +**Option A (recommended):** Add a new public subject on `ReminderService`: + +```typescript +// In ReminderService +private _onLocationReminders$ = new Subject(); +locationReminders$ = this._onLocationReminders$.asObservable(); +``` + +Then merge in `ReminderModule`: + +```typescript +merge( + this._reminderService.onRemindersActive$, + this._reminderService.locationReminders$, +).subscribe(reminders => /* existing dialog handling */); +``` + +**Option B:** Add a public method `emitLocationReminders()` that pushes to the private `_onRemindersActive$` subject. + +### 5.2 Data Shape + +Location-triggered reminders must satisfy `TaskWithReminderData`: + +```typescript +interface TaskWithReminderData extends Task { + readonly reminderData: { remindAt: number }; // use trigger timestamp + readonly parentData?: Task; + readonly isDeadlineReminder?: boolean; // false for location +} +``` + +Consider adding `isLocationReminder?: boolean` for UI differentiation. + +### 5.3 Dialog UX Changes + +The existing `DialogViewTaskRemindersComponent` works as-is with minor additions: +- Show location name/icon in header (e.g., "At: Grocery Store") +- **Snooze** = suppress this location reminder for 1 hour (temporary geofence pause) +- **Dismiss** = clear `locationReminderId` on the task +- **Done** = mark task complete (same as today) +- Hide "Edit Reminder" time picker for location-triggered reminders + +### 5.4 Native Notifications (Background) + +Reuse `CapacitorReminderService` patterns: +- Android: native notification via `ReminderNotificationHelper` triggered from geofence `BroadcastReceiver` +- iOS: `LocalNotifications.schedule()` triggered from `CLLocationManager` delegate +- Both support Done/Snooze action buttons + +--- + +## 6. UI Components + +### 6.1 New Components + +| Component | Path | Purpose | +|-----------|------|---------| +| `saved-location-settings` | `src/app/features/saved-location/saved-location-settings/` | CRUD list in settings page | +| `location-picker-dialog` | `src/app/features/saved-location/location-picker-dialog/` | Assign location to task (dropdown + "Use current location") | + +### 6.2 Modified Components + +| Component | Change | +|-----------|--------| +| Task detail panel | Add "Location" field showing assigned location | +| Task schedule dialog | Add location option alongside time-based reminder | +| Settings page | Add "Locations" section | +| Reminder dialog | Show location name when triggered by geofence | + +### 6.3 Location Picker UX + +``` +┌─────────────────────────────────┐ +│ Set Location Reminder │ +├─────────────────────────────────┤ +│ │ +│ 📍 Use Current Location │ ← gets GPS, prompts for label +│ │ +│ ─── Saved Locations ────────── │ +│ │ +│ 🏠 Home │ +│ 🏢 Office │ +│ 🛒 Grocery Store │ +│ │ +│ [ Remove Location ] [ Cancel ] │ +└─────────────────────────────────┘ +``` + +### 6.4 Settings Section + +``` +┌─────────────────────────────────┐ +│ Location Reminders │ +├─────────────────────────────────┤ +│ [Toggle] Enable location │ +│ reminders │ +│ │ +│ Saved Locations: │ +│ ┌─────────────────────────┐ │ +│ │ 🏠 Home 200m [✏️][🗑]│ │ +│ │ 🏢 Office 150m [✏️][🗑]│ │ +│ │ 🛒 Grocery 200m [✏️][🗑]│ │ +│ └─────────────────────────┘ │ +│ [ + Add Location ] │ +└─────────────────────────────────┘ +``` + +--- + +## 7. Cross-Platform Behavior + +| Platform | Geofencing | Location Display | Notifications | +|----------|-----------|-----------------|---------------| +| **Android** | Native `GeofencingClient`, up to 100 fences, background | Yes | Native via `BroadcastReceiver` | +| **iOS** | Native `CLLocationManager`, up to 20 regions, background | Yes | Native via `LocalNotifications` | +| **Electron** | None | Yes (label only) | None | +| **Web** | None | Yes (label only) | None | + +--- + +## 8. File Structure + +``` +src/app/features/saved-location/ +├── saved-location.model.ts +├── saved-location.const.ts # DEFAULT_RADIUS = 200 +├── saved-location.service.ts +├── geofence.service.ts # Capacitor geofencing bridge +├── store/ +│ ├── saved-location.actions.ts +│ ├── saved-location.reducer.ts +│ └── saved-location.selectors.ts +├── saved-location-settings/ +│ ├── saved-location-settings.component.ts +│ └── saved-location-settings.component.html +└── location-picker-dialog/ + ├── location-picker-dialog.component.ts + └── location-picker-dialog.component.html + +src/app/root-store/meta/task-shared-meta-reducers/ +└── saved-location-shared.reducer.ts # Cascading delete + +android/app/src/main/java/.../ +└── GeofencePlugin.kt # Native Android geofencing + GeofenceBroadcastReceiver.kt # Handles fence enter events + +ios/App/App/ +└── GeofencePlugin.swift # Native iOS geofencing +``` + +--- + +## 9. All Files to Touch + +### New Files + +| File | Purpose | +|------|---------| +| `src/app/features/saved-location/saved-location.model.ts` | Entity interface | +| `src/app/features/saved-location/saved-location.const.ts` | Defaults | +| `src/app/features/saved-location/saved-location.service.ts` | Service | +| `src/app/features/saved-location/geofence.service.ts` | Capacitor bridge | +| `src/app/features/saved-location/store/saved-location.actions.ts` | Actions | +| `src/app/features/saved-location/store/saved-location.reducer.ts` | Reducer + adapter | +| `src/app/features/saved-location/store/saved-location.selectors.ts` | Selectors | +| `src/app/features/saved-location/saved-location-settings/*` | Settings UI | +| `src/app/features/saved-location/location-picker-dialog/*` | Picker dialog | +| `src/app/root-store/meta/task-shared-meta-reducers/saved-location-shared.reducer.ts` | Cascading deletes | +| `android/.../GeofencePlugin.kt` | Android native geofencing | +| `android/.../GeofenceBroadcastReceiver.kt` | Android fence event handler | +| `ios/App/App/GeofencePlugin.swift` | iOS native geofencing | + +### Modified Files + +| File | Change | +|------|--------| +| `packages/shared-schema/src/entity-types.ts` | Add `'SAVED_LOCATION'` | +| `src/app/op-log/core/action-types.enum.ts` | Add `SAVED_LOCATION_ADD/UPDATE/DELETE` | +| `src/app/op-log/core/entity-registry.ts` | Add `SAVED_LOCATION` config | +| `src/app/op-log/model/model-config.ts` | Add to `AllModelConfig` + `MODEL_CONFIGS` | +| `src/app/root-store/root-state.ts` | Add to `RootState` | +| `src/app/root-store/feature-stores.module.ts` | Register feature store | +| `src/app/root-store/meta/meta-reducer-registry.ts` | Register cascading delete in Phase 5 | +| `src/app/features/tasks/task.model.ts` | Add `locationReminderId` field | +| `src/app/features/config/global-config.model.ts` | Add `isLocationRemindersEnabled` | +| `src/app/features/config/default-global-config.const.ts` | Set default `false` | +| `src/app/core/platform/platform-capabilities.model.ts` | Add `geofencing` capability | +| `src/app/features/reminder/reminder.service.ts` | Add `locationReminders$` subject | +| `src/app/features/reminder/reminder.module.ts` | Merge location reminders into dialog flow | +| `android/app/src/main/AndroidManifest.xml` | Add location permissions | +| `android/.../CapacitorMainActivity.kt` | Register `GeofencePlugin` | + +--- + +## 10. Future Enhancements (Out of Scope) + +- Map picker with OpenStreetMap tiles +- Address search / geocoding (Nominatim) +- Leave triggers ("remind when I leave the office") +- Time + location combos ("at the store, but only after 5 PM") +- Project default locations +- Location-based task views ("show tasks for where I am now") +- WiFi-based triggers (alternative to GPS, works indoors) +- Bluetooth beacon triggers + +--- + +## 11. Verification Plan + +1. **Unit tests:** SavedLocation reducer, selectors, service, cascading delete meta-reducer +2. **Manual mobile testing:** + - Create location from "Use current location" + - Assign to a task, verify geofence registration + - Move in/out of geofence, verify notification fires + - Complete task, verify geofence unregistered + - Delete location, verify `locationReminderId` cleared on tasks +3. **Desktop/web:** Verify location label displays on tasks, no geofencing attempted +4. **Sync:** Create location on device A, verify it appears on device B +5. **Lint/format:** `npm run lint`, `npm run prettier`, `npm test` diff --git a/e2e/fixtures/supersync.fixture.ts b/e2e/fixtures/supersync.fixture.ts index ad7013f1bc..1c91d4dbff 100644 --- a/e2e/fixtures/supersync.fixture.ts +++ b/e2e/fixtures/supersync.fixture.ts @@ -47,9 +47,18 @@ export const test = base.extend({ * Also checks server health and skips test if server unavailable. */ testRunId: async ({}, use, testInfo) => { - // Check server health once per worker + // Check server health once per worker. + // Prefer the result pre-computed in globalSetup (stored as an env var before workers + // were forked) to avoid a stampede of concurrent HTTP requests from all workers + // simultaneously — which can overload the server and produce false negatives. if (serverHealthyCache === null) { - serverHealthyCache = await isServerHealthy(); + if (process.env.SUPERSYNC_SERVER_HEALTHY !== undefined) { + serverHealthyCache = process.env.SUPERSYNC_SERVER_HEALTHY === 'true'; + } else { + // Fallback for when tests are run directly without going through globalSetup + // (e.g., `npx playwright test --config ... e2e/tests/sync/foo.spec.ts`) + serverHealthyCache = await isServerHealthy(); + } if (!serverHealthyCache) { console.warn( 'SuperSync server not healthy at http://localhost:1901 - skipping tests', @@ -69,9 +78,13 @@ export const test = base.extend({ * Tests are automatically skipped if the server is not healthy. */ serverHealthy: async ({}, use, testInfo) => { - // Only check once per worker + // Only check once per worker — use globalSetup env var if available (see testRunId above) if (serverHealthyCache === null) { - serverHealthyCache = await isServerHealthy(); + if (process.env.SUPERSYNC_SERVER_HEALTHY !== undefined) { + serverHealthyCache = process.env.SUPERSYNC_SERVER_HEALTHY === 'true'; + } else { + serverHealthyCache = await isServerHealthy(); + } if (!serverHealthyCache) { console.warn( 'SuperSync server not healthy at http://localhost:1901 - skipping tests', diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 9217ded795..b390d4cb6e 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -2,6 +2,7 @@ import { FullConfig } from '@playwright/test'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import { isServerHealthy } from './utils/supersync-helpers'; /** * Warm up the dev server by fetching the app once before any tests start. @@ -69,6 +70,20 @@ const globalSetup = async (config: FullConfig): Promise => { config.projects[0]?.use?.baseURL || 'http://localhost:4242'; await warmUpDevServer(baseURL); + + // Check SuperSync server health ONCE here, before workers start. + // Without this, each worker checks independently on startup — with many workers + // running simultaneously, the concurrent health-check requests can overload the + // supersync server and cause false negatives, making workers skip all their tests. + // By storing the result in an env var set before workers are forked, every worker + // reads the cached result instantly instead of making HTTP requests. + const healthy = await isServerHealthy().catch(() => false); + process.env.SUPERSYNC_SERVER_HEALTHY = healthy ? 'true' : 'false'; + if (healthy) { + console.log('SuperSync server healthy — supersync tests will run'); + } else { + console.log('SuperSync server not available — supersync tests will be skipped'); + } }; export default globalSetup; diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index 324bd3e1d3..52a60276d8 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -191,8 +191,14 @@ export class SuperSyncPage extends BasePage { // piggybacked ops in the response, causing data to sync between clients // outside of explicit syncAndWait(). Set here (not in createSimulatedClient) // so tests using enableWebSocket:true still get immediate uploads. + // 3. Block WsTriggeredDownloadService: Even when routeWebSocket() closes the + // connection, a WS notification can slip through the moment the connection + // opens (before it's closed). Setting __SP_E2E_BLOCK_WS_DOWNLOAD ensures + // WsTriggeredDownloadService ignores any such notifications, preventing + // uncontrolled background syncs that race with explicit syncAndWait() calls. await this.page.evaluate(() => { (globalThis as any).__SP_E2E_BLOCK_IMMEDIATE_UPLOAD = true; + (globalThis as any).__SP_E2E_BLOCK_WS_DOWNLOAD = true; }); } diff --git a/e2e/tests/sync/supersync.spec.ts b/e2e/tests/sync/supersync.spec.ts index b078240a3b..548a6470ea 100644 --- a/e2e/tests/sync/supersync.spec.ts +++ b/e2e/tests/sync/supersync.spec.ts @@ -672,8 +672,11 @@ test.describe('@supersync SuperSync E2E', () => { await saveAndGoHomeBtn.click(); console.log('[Archive Test] Client B clicked Save and go home (archiving)'); - // Wait for navigation back to work view - await clientB.page.waitForURL(/tag\/TODAY/, { timeout: 10000 }); + // Wait for navigation back to work view. + // Use negative lookahead to avoid matching /tag/TODAY/daily-summary prematurely. + await clientB.page.waitForURL(/(tag\/TODAY(?!\/daily-summary))/, { + timeout: 10000, + }); await clientB.page.waitForLoadState('networkidle'); console.log('[Archive Test] Client B back on work view after archiving'); diff --git a/e2e/utils/supersync-helpers.ts b/e2e/utils/supersync-helpers.ts index 8a4e2368d4..38205b44b3 100644 --- a/e2e/utils/supersync-helpers.ts +++ b/e2e/utils/supersync-helpers.ts @@ -239,8 +239,34 @@ export const createSimulatedClient = async ( } }); - // Navigate to app and wait for ready - await page.goto('/'); + // Navigate to app with retry for transient ERR_CONNECTION_REFUSED. + // Under parallel load (many workers × 2-3 browser contexts each), the Angular + // dev server can temporarily refuse connections. Retrying recovers from this + // without failing the test outright. + let lastGotoError: Error | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await page.goto('/'); + lastGotoError = null; + break; + } catch (e) { + lastGotoError = e as Error; + const isConnectionRefused = lastGotoError.message.includes( + 'ERR_CONNECTION_REFUSED', + ); + if (attempt < 2 && isConnectionRefused) { + const delay = 1000 * (attempt + 1); + console.log( + `[Client ${clientName}] page.goto('/') failed (attempt ${attempt + 1}/3): ERR_CONNECTION_REFUSED — retrying in ${delay}ms`, + ); + await new Promise((r) => setTimeout(r, delay)); + } else { + break; // Non-connection error or last attempt — let it throw below + } + } + } + if (lastGotoError) throw lastGotoError; + await waitForAppReady(page); const workView = new WorkViewPage(page, `${clientName}-${testPrefix}`); diff --git a/electron/clear-stale-idb-locks.ts b/electron/clear-stale-idb-locks.ts new file mode 100644 index 0000000000..47138c52fa --- /dev/null +++ b/electron/clear-stale-idb-locks.ts @@ -0,0 +1,57 @@ +import { join } from 'path'; +import { readdir, unlink } from 'fs/promises'; +import { log, warn } from 'electron-log/main'; + +/** + * Deletes stale LevelDB LOCK files left in the IndexedDB directory. + * + * When Electron (Chromium) exits uncleanly (e.g., session logout with autostart), + * the LevelDB LOCK files inside the IndexedDB backing stores are sometimes not + * cleaned up. On the next launch these orphaned files block IndexedDB from opening, + * producing the "Internal error opening backing store for indexedDB.open" error. + * + * This is safe to call because by the time it runs, `requestSingleInstanceLock()` + * has already ensured we are the only running instance, so no legitimate process + * can hold those locks. + * + * Only runs on Linux, where this startup race condition has been observed. + * + * @see https://github.com/electron/electron/issues/18263 + * @see https://github.com/super-productivity/super-productivity/issues/7191 + */ +export const clearStaleLevelDbLocks = async (userDataPath: string): Promise => { + if (process.platform !== 'linux') { + return; + } + + const idbDir = join(userDataPath, 'IndexedDB'); + + let entries: string[]; + try { + entries = await readdir(idbDir); + } catch (e: unknown) { + // Directory doesn't exist yet (fresh install) — nothing to clean up. + // Any other error (e.g., EACCES, EPERM) is unexpected and worth logging. + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + warn(`[clearStaleLevelDbLocks] Could not read IndexedDB directory:`, e); + } + return; + } + + const leveldbDirs = entries.filter((e) => e.endsWith('.leveldb')); + + await Promise.all( + leveldbDirs.map(async (dir) => { + const lockPath = join(idbDir, dir, 'LOCK'); + try { + await unlink(lockPath); + log(`[clearStaleLevelDbLocks] Removed stale lock: ${lockPath}`); + } catch (e: unknown) { + // LOCK file doesn't exist or is already released — this is the normal case + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + warn(`[clearStaleLevelDbLocks] Could not remove ${lockPath}:`, e); + } + } + }), + ); +}; diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index e0df585db0..c2f17fd858 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -48,7 +48,7 @@ export interface ElectronAPI { fileSyncRemove(args: { filePath: string }): Promise; - fileSyncListFiles(args: { dirPath: string }): Promise; // NEW + fileSyncListFiles(args: { dirPath: string }): Promise; checkDirExists(args: { dirPath: string }): Promise; diff --git a/electron/local-file-sync.ts b/electron/local-file-sync.ts index 100abfc7d5..63c69d0a03 100644 --- a/electron/local-file-sync.ts +++ b/electron/local-file-sync.ts @@ -112,6 +112,36 @@ export const initLocalFileSyncAdapter = (): void => { return true; } catch (e) { log('ERR: error while checking dir ' + dirPath); + if ((e as NodeJS.ErrnoException).code === 'EACCES') { + log( + 'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.', + ); + } + error(e); + return e instanceof Error ? e : new Error(String(e)); + } + }, + ); + + ipcMain.handle( + IPC.FILE_SYNC_LIST_FILES, + ( + ev, + { + dirPath, + }: { + dirPath: string; + }, + ): string[] | Error => { + try { + return readdirSync(dirPath); + } catch (e) { + log('ERR: Sync error while listing files in ' + dirPath); + if ((e as NodeJS.ErrnoException).code === 'EACCES') { + log( + 'ERR: Permission denied. If running as a snap, ensure the "home" or "removable-media" interface is connected.', + ); + } error(e); return e instanceof Error ? e : new Error(String(e)); } diff --git a/electron/main-window.ts b/electron/main-window.ts index ed36e48332..7323cba23d 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -459,7 +459,7 @@ const appCloseHandler = (app: App): void => { mainWin.on('close', (event) => { // NOTE: this might not work if we run a second instance of the app - log('close, isQuiting:', getIsQuiting()); + log('close event: isQuiting=', getIsQuiting(), 'pendingBeforeCloseIds=', ids); if (!getIsQuiting()) { if (getIsMinimizeToTray()) { const indicator = ensureIndicator(); diff --git a/electron/preload.ts b/electron/preload.ts index 2e3fcd96b2..db715e9520 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -68,8 +68,8 @@ const ea: ElectronAPI = { dataStr: string | undefined; }>, fileSyncRemove: (filePath) => _invoke('FILE_SYNC_REMOVE', filePath) as Promise, - fileSyncListFiles: ({ dirPath }) => - _invoke('FILE_SYNC_LIST_FILES', dirPath) as Promise, + fileSyncListFiles: (args) => + _invoke('FILE_SYNC_LIST_FILES', args) as Promise, checkDirExists: (dirPath) => _invoke('CHECK_DIR_EXISTS', dirPath) as Promise, diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index b0d5ac251d..85998c2cee 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -43,7 +43,7 @@ export enum IPC { FILE_SYNC_LOAD = 'FILE_SYNC_LOAD', FILE_SYNC_SAVE = 'FILE_SYNC_SAVE', FILE_SYNC_REMOVE = 'FILE_SYNC_REMOVE', - FILE_SYNC_LIST_FILES = 'FILE_SYNC_LIST_FILES', // NEW + FILE_SYNC_LIST_FILES = 'FILE_SYNC_LIST_FILES', FILE_SYNC_GET_REV_AND_CLIENT_UPDATE = 'FILE_SYNC_GET_REV_AND_CLIENT_UPDATE', CHECK_DIR_EXISTS = 'CHECK_DIR_EXISTS', diff --git a/electron/start-app.ts b/electron/start-app.ts index 08a55169d3..af936c8db0 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -13,14 +13,15 @@ import { CONFIG } from './CONFIG'; import { lazySetInterval } from './shared-with-frontend/lazy-set-interval'; import { initIndicator } from './indicator'; import { quitApp, showOrFocus } from './various-shared'; -import { createWindow } from './main-window'; +import { createWindow, getWin } from './main-window'; import { IdleTimeHandler } from './idle-time-handler'; import { destroyTaskWidget } from './task-widget/task-widget'; import { initializeProtocolHandling, processPendingProtocolUrls, } from './protocol-handler'; -import { getIsQuiting, setIsLocked } from './shared-state'; +import { getIsQuiting, setIsQuiting, setIsLocked } from './shared-state'; +import { clearStaleLevelDbLocks } from './clear-stale-idb-locks'; import * as fs from 'fs'; const ICONS_FOLDER = __dirname + '/assets/icons/'; @@ -356,18 +357,32 @@ export const startApp = (): void => { appIN.on('will-quit', () => { // un-register all shortcuts. globalShortcut.unregisterAll(); + // Safe to remove IPC listeners here: all windows are closed and before-close + // IPC flows (sync, finish-day) are guaranteed to have completed. + ipcMain.removeAllListeners(); }); - appIN.on('before-quit', () => { - log('App before-quit: cleaning up resources'); - - // Clean up task widget before quitting + appIN.on('before-quit', (event) => { + log('App before-quit: isQuiting=', getIsQuiting()); + if (!getIsQuiting()) { + // Native quit path (Cmd+Q, Dock > Quit on macOS): app.quit() was called + // by the OS without going through quitApp(), so isQuiting was never set. + // Prevent the immediate quit and delegate to the window close handler, + // which manages the before-close callback flow (sync, finish-day, etc.) + // and sets isQuiting=true before re-quitting. + event.preventDefault(); + const win = getWin(); + if (win && !win.isDestroyed()) { + win.close(); + } else { + // No window to close — set flag and re-trigger quit directly. + setIsQuiting(true); + app.quit(); + } + return; + } + // isQuiting=true: all before-close IPC work is complete — safe to clean up. destroyTaskWidget(); - - // Remove all IPC listeners to prevent memory leaks - ipcMain.removeAllListeners(); - - // Clear any pending timeouts/intervals if (global.gc) { global.gc(); } @@ -425,6 +440,10 @@ export const startApp = (): void => { // eslint-disable-next-line prefer-arrow/prefer-arrow-functions async function createMainWin(): Promise { + // Remove stale LevelDB LOCK files before the renderer opens IndexedDB. + // Orphaned locks from unclean session shutdowns block the backing store open. + await clearStaleLevelDbLocks(app.getPath('userData')); + mainWin = await createWindow({ app, IS_DEV, diff --git a/packages/plugin-dev/github-issue-provider/i18n/en.json b/packages/plugin-dev/github-issue-provider/i18n/en.json index 012babeba6..d97589e083 100644 --- a/packages/plugin-dev/github-issue-provider/i18n/en.json +++ b/packages/plugin-dev/github-issue-provider/i18n/en.json @@ -1,7 +1,7 @@ { "CFG": { "REPO": "Repository (owner/repo)", - "TOKEN": "Personal Access Token (optional, for private repos)", + "TOKEN": "Personal Access Token (required for private repos/optional for public repos)", "FILTER_USERNAME": "Filter username (ignore own comments for update detection)", "BACKLOG_QUERY": "Backlog query (default: \"sort:updated state:open assignee:@me\")", "HOW_TO_GET_TOKEN": "How to get a token" diff --git a/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html index e62a17af99..7d3542f9fe 100644 --- a/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html +++ b/src/app/core-ui/mobile-bottom-nav/mobile-bottom-nav.component.html @@ -1,6 +1,7 @@