From 5220684b7e8cec49a2b8c4756c2134a18cf9746a Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 11 Jul 2026 13:02:19 +0200 Subject: [PATCH] fix(app): hide donation page on macOS (#8915) * fix(app): hide donation page on macOS Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds. * fix(app): harden donation platform gating Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation. --- docs/wiki/3.05-Web-App-vs-Desktop.md | 6 ++ src/app/app.constants.spec.ts | 41 ++++++++++++++ src/app/app.constants.ts | 36 +++++++++--- src/app/app.guard.spec.ts | 56 ++++++++++++++++++- src/app/app.guard.ts | 11 ++++ src/app/app.routes.ts | 3 +- .../magic-nav-config.service.ts | 14 ++--- .../form-cfgs/app-features-form.const.ts | 8 +-- .../dialog-please-rate.component.html | 2 +- .../dialog-please-rate.component.ts | 11 ++-- .../donate-page/donate-page.component.html | 2 +- .../donate-page/donate-page.component.ts | 8 +-- 12 files changed, 164 insertions(+), 34 deletions(-) create mode 100644 src/app/app.constants.spec.ts diff --git a/docs/wiki/3.05-Web-App-vs-Desktop.md b/docs/wiki/3.05-Web-App-vs-Desktop.md index 972a3a7a6d..15eccb61b8 100644 --- a/docs/wiki/3.05-Web-App-vs-Desktop.md +++ b/docs/wiki/3.05-Web-App-vs-Desktop.md @@ -122,6 +122,12 @@ The web version does not have: Certain UI elements tied to these features are hidden in the web build via platform-specific CSS or logic. +## Platform-Specific Availability + +### Donation and Contribution Links + +The Support Us page and links that can lead to GitHub Sponsors are unavailable in native iOS and macOS desktop builds. They remain available in the web app, native Android, Windows desktop, and Linux desktop builds. + ## Documented Error Conditions (Web) - **CORS errors:** WebDAV (and similar) sync may report CORS failures with localized messages suggesting server configuration checks. diff --git a/src/app/app.constants.spec.ts b/src/app/app.constants.spec.ts new file mode 100644 index 0000000000..a3dac02cf4 --- /dev/null +++ b/src/app/app.constants.spec.ts @@ -0,0 +1,41 @@ +import { isDonationUiRestricted } from './app.constants'; + +describe('isDonationUiRestricted', () => { + const cases: Array< + [ + label: string, + context: Parameters[0], + expected: boolean, + ] + > = [ + ['native iOS', { isIosNative: true, isElectron: false, isMacOS: false }, true], + ['macOS Electron DMG', { isIosNative: false, isElectron: true, isMacOS: true }, true], + [ + 'macOS Electron App Store', + { isIosNative: false, isElectron: true, isMacOS: true }, + true, + ], + [ + 'macOS Electron development build', + { isIosNative: false, isElectron: true, isMacOS: true }, + true, + ], + [ + 'Windows or Linux Electron', + { isIosNative: false, isElectron: true, isMacOS: false }, + false, + ], + [ + 'macOS web browser', + { isIosNative: false, isElectron: false, isMacOS: true }, + false, + ], + ['native Android', { isIosNative: false, isElectron: false, isMacOS: false }, false], + ]; + + cases.forEach(([label, context, expected]) => { + it(`${expected ? 'restricts' : 'allows'} donation UI on ${label}`, () => { + expect(isDonationUiRestricted(context)).toBe(expected); + }); + }); +}); diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index 8f9d7260b6..d1d3dc0b1b 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -23,15 +23,35 @@ export const IS_GNOME_WAYLAND = IS_ELECTRON && window.ea.isGnomeWayland(); // True only inside the Electron build — preload exposes process.arch. // Web builds can't reliably distinguish Apple Silicon from Intel and stay false. export const IS_APPLE_SILICON = IS_ELECTRON && window.ea.isAppleSilicon(); -// True only in a Mac App Store (sandboxed) Electron build. Reuses the existing -// dist-channel bridge (driven by Electron's process.mas); direct-download macOS -// (mac-dmg), other channels and web builds stay false. -export const IS_MAC_APP_STORE = IS_ELECTRON && window.ea.getDistChannel() === 'mac-store'; +interface DonationUiPlatformContext { + isIosNative: boolean; + isElectron: boolean; + isMacOS: boolean; +} + +export const isDonationUiRestricted = ({ + isIosNative, + isElectron, + isMacOS, +}: DonationUiPlatformContext): boolean => isIosNative || (isElectron && isMacOS); + // Apple's App Store guidelines forbid donation/contribution links that route -// around In-App Purchase (Guideline 3.1.1). True for both Apple App Store -// distribution channels (iOS App Store and Mac App Store); direct-download and -// web builds stay false, so they keep the donation UI. -export const IS_APPLE_APP_STORE = IS_IOS_NATIVE || IS_MAC_APP_STORE; +// around In-App Purchase (Guideline 3.1.1). Apply the restriction to every +// macOS Electron build so App Store, direct-download and local review behavior +// cannot diverge based on the unreliable process.mas signal. +export const IS_DONATION_UI_RESTRICTED = isDonationUiRestricted({ + isIosNative: IS_IOS_NATIVE, + isElectron: IS_ELECTRON, + isMacOS: IS_ELECTRON && window.ea.isMacOS(), +}); + +export const IS_DONATION_UI_RESTRICTED_TOKEN = new InjectionToken( + 'IS_DONATION_UI_RESTRICTED', + { + providedIn: 'root', + factory: () => IS_DONATION_UI_RESTRICTED, + }, +); export const TRACKING_INTERVAL = 1000; diff --git a/src/app/app.guard.spec.ts b/src/app/app.guard.spec.ts index 74c77b80ed..6b290e9d55 100644 --- a/src/app/app.guard.spec.ts +++ b/src/app/app.guard.spec.ts @@ -1,13 +1,23 @@ +import { Component } from '@angular/core'; +import { provideLocationMocks } from '@angular/common/testing'; import { TestBed } from '@angular/core/testing'; -import { Router, UrlTree } from '@angular/router'; +import { provideRouter, Router, UrlTree } from '@angular/router'; import { BehaviorSubject, of, throwError } from 'rxjs'; -import { DefaultStartPageGuard } from './app.guard'; +import { DefaultStartPageGuard, DonatePageGuard } from './app.guard'; +import { IS_DONATION_UI_RESTRICTED_TOKEN } from './app.constants'; import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; import { ProjectService } from './features/project/project.service'; import { TODAY_TAG } from './features/tag/tag.const'; import { INBOX_PROJECT } from './features/project/project.const'; import { Project } from './features/project/project.model'; +import { APP_ROUTES } from './app.routes'; + +@Component({ standalone: true, template: '' }) +class DonateRouteTestComponent {} + +@Component({ standalone: true, template: '' }) +class HomeRouteTestComponent {} describe('DefaultStartPageGuard', () => { let guard: DefaultStartPageGuard; @@ -164,3 +174,45 @@ describe('DefaultStartPageGuard', () => { expectUrl(await runGuard(), TODAY_URL); }); }); + +describe('DonatePageGuard', () => { + const setup = (isRestricted: boolean): Router => { + TestBed.configureTestingModule({ + providers: [ + provideLocationMocks(), + provideRouter([ + { + path: 'donate', + component: DonateRouteTestComponent, + canActivate: [DonatePageGuard], + }, + { path: '', component: HomeRouteTestComponent }, + ]), + { provide: IS_DONATION_UI_RESTRICTED_TOKEN, useValue: isRestricted }, + ], + }); + return TestBed.inject(Router); + }; + + it('navigates to the donate page on unrestricted platforms', async () => { + const router = setup(false); + + await router.navigateByUrl('/donate'); + + expect(router.url).toBe('/donate'); + }); + + it('redirects donate navigation on restricted platforms', async () => { + const router = setup(true); + + await router.navigateByUrl('/donate'); + + expect(router.url).toBe('/'); + }); + + it('protects the donate route', () => { + const donateRoute = APP_ROUTES.find((route) => route.path === 'donate'); + + expect(donateRoute?.canActivate).toContain(DonatePageGuard); + }); +}); diff --git a/src/app/app.guard.ts b/src/app/app.guard.ts index b9f918db8f..74825679fe 100644 --- a/src/app/app.guard.ts +++ b/src/app/app.guard.ts @@ -17,6 +17,17 @@ import { selectIsOverlayShown } from './features/focus-mode/store/focus-mode.sel import { DataInitStateService } from './core/data-init/data-init-state.service'; import { GlobalConfigService } from './features/config/global-config.service'; import { getStartPageUrlPath } from './features/config/default-start-page.util'; +import { IS_DONATION_UI_RESTRICTED_TOKEN } from './app.constants'; + +@Injectable({ providedIn: 'root' }) +export class DonatePageGuard { + private _isDonationUiRestricted = inject(IS_DONATION_UI_RESTRICTED_TOKEN); + private _router = inject(Router); + + canActivate(): true | UrlTree { + return this._isDonationUiRestricted ? this._router.parseUrl('/') : true; + } +} @Injectable({ providedIn: 'root' }) export class ActiveWorkContextGuard { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index aabaa27d29..26e6a2c709 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -3,6 +3,7 @@ import { Routes } from '@angular/router'; import { ActiveWorkContextGuard, DefaultStartPageGuard, + DonatePageGuard, FocusOverlayOpenGuard, ValidProjectIdGuard, ValidTagIdGuard, @@ -94,7 +95,7 @@ export const APP_ROUTES: Routes = [ loadComponent: () => import('./routes/pages.routes').then((m) => m.DonatePageComponent), data: { page: 'donate' }, - canActivate: [FocusOverlayOpenGuard], + canActivate: [DonatePageGuard, FocusOverlayOpenGuard], }, { path: 'contrast-test', diff --git a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts index 8d24b098b7..27793fb155 100644 --- a/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts +++ b/src/app/core-ui/magic-side-nav/magic-nav-config.service.ts @@ -36,7 +36,7 @@ import { import { GlobalConfigService } from '../../features/config/global-config.service'; import { AppFeaturesConfig } from '../../features/config/global-config.model'; import { SnackService } from '../../core/snack/snack.service'; -import { IS_APPLE_APP_STORE } from '../../app.constants'; +import { IS_DONATION_UI_RESTRICTED } from '../../app.constants'; @Injectable({ providedIn: 'root', @@ -252,9 +252,9 @@ export class MagicNavConfigService { }, // Help Menu (rendered as mat-menu) - // Not allowed to display donation stuff on the iOS or Mac App Store per - // App Store guidelines (Guideline 3.1.1) - ...(this.isDonatePageEnabled() && !IS_APPLE_APP_STORE + // Donation links are disabled on native iOS and every macOS desktop build + // to keep App Store review behavior deterministic (Guideline 3.1.1). + ...(this.isDonatePageEnabled() && !IS_DONATION_UI_RESTRICTED ? [ { type: 'route', @@ -293,9 +293,9 @@ export class MagicNavConfigService { icon: 'feedback', href: 'https://github.com/super-productivity/super-productivity/discussions', }, - // Not allowed to display donation stuff on the iOS or Mac App Store - // per App Store guidelines (Guideline 3.1.1) - ...(!IS_APPLE_APP_STORE + // Donation links are disabled on native iOS and every macOS desktop + // build to keep App Store review behavior deterministic. + ...(!IS_DONATION_UI_RESTRICTED ? [ { type: 'href' as const, diff --git a/src/app/features/config/form-cfgs/app-features-form.const.ts b/src/app/features/config/form-cfgs/app-features-form.const.ts index 774eef6c17..e1d1431564 100644 --- a/src/app/features/config/form-cfgs/app-features-form.const.ts +++ b/src/app/features/config/form-cfgs/app-features-form.const.ts @@ -1,6 +1,6 @@ import { ConfigFormSection, AppFeaturesConfig } from '../global-config.model'; import { T } from '../../../t.const'; -import { IS_APPLE_APP_STORE } from '../../../app.constants'; +import { IS_DONATION_UI_RESTRICTED } from '../../../app.constants'; export const EXPERIMENTAL_APP_FEATURE_KEYS: ReadonlyArray = [ 'isEnableUserProfiles', @@ -94,9 +94,9 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection = { { key: 'isDonatePageEnabled', type: 'slide-toggle', - // Donations are fully hidden on Apple App Store builds (Guideline 3.1.1), - // so this toggle would be inert there — hide it to avoid a dead control. - hideExpression: () => IS_APPLE_APP_STORE, + // Donations are fully hidden on native iOS and macOS desktop builds, so + // this toggle would be inert there — hide it to avoid a dead control. + hideExpression: () => IS_DONATION_UI_RESTRICTED, templateOptions: { label: T.GCF.APP_FEATURES.DONATE_PAGE, icon: 'favorite', diff --git a/src/app/features/dialog-please-rate/dialog-please-rate.component.html b/src/app/features/dialog-please-rate/dialog-please-rate.component.html index 40dba3a88b..b25d910f43 100644 --- a/src/app/features/dialog-please-rate/dialog-please-rate.component.html +++ b/src/app/features/dialog-please-rate/dialog-please-rate.component.html @@ -79,7 +79,7 @@ - @if (!IS_APPLE_APP_STORE) { + @if (!IS_DONATION_UI_RESTRICTED) {