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.
This commit is contained in:
Johannes Millan 2026-07-11 13:02:19 +02:00 committed by GitHub
parent 6f1cacc553
commit 5220684b7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 164 additions and 34 deletions

View file

@ -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.

View file

@ -0,0 +1,41 @@
import { isDonationUiRestricted } from './app.constants';
describe('isDonationUiRestricted', () => {
const cases: Array<
[
label: string,
context: Parameters<typeof isDonationUiRestricted>[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);
});
});
});

View file

@ -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<boolean>(
'IS_DONATION_UI_RESTRICTED',
{
providedIn: 'root',
factory: () => IS_DONATION_UI_RESTRICTED,
},
);
export const TRACKING_INTERVAL = 1000;

View file

@ -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);
});
});

View file

@ -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 {

View file

@ -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',

View file

@ -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,

View file

@ -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<keyof AppFeaturesConfig> = [
'isEnableUserProfiles',
@ -94,9 +94,9 @@ export const APP_FEATURES_FORM_CFG: ConfigFormSection<AppFeaturesConfig> = {
{
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',

View file

@ -79,7 +79,7 @@
<!-- Fallback: if no mail client is registered the mailto: link above does
nothing, so show the address as selectable text (never a dead end). -->
<small class="feedback-email">{{ maintainerEmail }}</small>
@if (!IS_APPLE_APP_STORE) {
@if (!IS_DONATION_UI_RESTRICTED) {
<a
class="action-item"
[href]="contributingUrl"

View file

@ -6,7 +6,7 @@ import {
MatDialogTitle,
} from '@angular/material/dialog';
import { T } from '../../t.const';
import { IS_APPLE_APP_STORE } from '../../app.constants';
import { IS_DONATION_UI_RESTRICTED } from '../../app.constants';
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
@ -47,11 +47,10 @@ export class DialogPleaseRateComponent {
protected readonly mailtoUrl = buildFeedbackMailto();
protected readonly discussionsUrl = DISCUSSIONS_URL;
protected readonly contributingUrl = CONTRIBUTING_URL;
// CONTRIBUTING.md links to GitHub Sponsors; Apple App Store builds must not
// surface donation paths outside In-App Purchase (Guideline 3.1.1). Mirrors
// the gate on the identical Help-menu link. This dialog isn't shown on iOS
// (native StoreReview card) but is shown on the Mac App Store (Electron).
protected readonly IS_APPLE_APP_STORE = IS_APPLE_APP_STORE;
// CONTRIBUTING.md links to GitHub Sponsors. This dialog isn't shown on iOS
// (native StoreReview card), but it is shown on macOS, where the link remains
// hidden alongside the identical Help-menu link.
protected readonly IS_DONATION_UI_RESTRICTED = IS_DONATION_UI_RESTRICTED;
// Shown as selectable text under the email option so the channel isn't a dead
// end when no mail client is registered (common on Linux/web) and the mailto:
// link silently does nothing.

View file

@ -1,6 +1,6 @@
<div class="page-wrapper">
<div class="content">
@if (!IS_APPLE_APP_STORE) {
@if (!IS_DONATION_UI_RESTRICTED) {
<p>
{{ T.DONATE_PAGE.INTRO_1 | translate }}
</p>

View file

@ -3,7 +3,7 @@ import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { T } from '../../t.const';
import { TranslatePipe } from '@ngx-translate/core';
import { IS_APPLE_APP_STORE } from '../../app.constants';
import { IS_DONATION_UI_RESTRICTED } from '../../app.constants';
@Component({
selector: 'donate-page',
@ -15,7 +15,7 @@ import { IS_APPLE_APP_STORE } from '../../app.constants';
})
export class DonatePageComponent {
readonly T = T;
// Hides the whole page body on Apple App Store builds — the route stays
// reachable by direct URL even though the nav entry is gone. See IS_APPLE_APP_STORE.
readonly IS_APPLE_APP_STORE = IS_APPLE_APP_STORE;
// Defense in depth: DonatePageGuard redirects restricted platforms before
// this component renders, and this gate prevents accidental direct rendering.
readonly IS_DONATION_UI_RESTRICTED = IS_DONATION_UI_RESTRICTED;
}