From b87e4b0d024904b8dffa3bc26c8ccff9b45eca46 Mon Sep 17 00:00:00 2001
From: Zirren-Git <245605003+Zirren-Git@users.noreply.github.com>
Date: Thu, 15 Oct 2026 19:30:38 +0000
Subject: [PATCH] Make every feature free: remove the paid tier and the paywall
Off Grid AI has no paid tier any more. Every capability the app has ships
unlocked for everyone: voice mode and on-device speech, custom personas,
draft-then-approve actions, MCP tools, and Personal Mesh sync.
Core no longer gates anything:
- selectHasProAccess() grants unconditionally. The credential and the
licensed-device roster are still tracked (mesh pairing needs a device
identity) but decide nothing.
- loadProFeatures() activates the optional pro bundle with no licence, no
key, and no roster admission; the licence lifecycle that remains is
bookkeeping, not enforcement.
Paywall surfaces removed:
- pricing copy (utils/proPricing), the Aha offer sheet and its trigger, the
Settings upsell banner, the licence-key modal, the subscription management
card, the expiry redirect, and the DEV "Turn off Pro" toggle.
- Pro entry points now route on whether this build carries the bundle (read
from the screen/slot registries), never on entitlement: bundle -> the real
feature, no bundle -> a Pro features screen that explains the build and
sells nothing.
Docs and tests follow: README and the licensing plan state the free tier,
suites that asserted licence-gated access are replaced by suites asserting
the opposite, and the whole diff is annotated with why each seam survived.
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
---
App.tsx | 7 +-
README.md | 19 +-
__tests__/harness/proHarness.ts | 8 +-
.../licensing/proRuntimeExpiry.test.ts | 80 -----
.../onboarding/proBootFlow.test.ts | 291 ----------------
.../pro/everyFeatureIsFree.test.ts | 160 +++++++++
.../pro/proAhaSheetPricing.rendered.test.tsx | 63 ----
.../proExpiryRedirect.integration.test.tsx | 66 ----
.../proPricingSettingsEntry.rendered.test.tsx | 106 ------
...nNoDeviceLicenceShortcut.rendered.test.tsx | 51 ---
.../releaseCandidateE.rendered.test.tsx | 25 +-
.../rntl/components/ProAhaSheet.test.tsx | 16 -
.../rntl/screens/ProDetailScreen.test.tsx | 329 ------------------
.../rntl/screens/ProFeaturesScreen.test.tsx | 66 ++++
.../rntl/screens/SettingsScreen.test.tsx | 25 +-
__tests__/rntl/screens/ToolsScreen.test.tsx | 6 +-
__tests__/unit/hooks/useIsProActive.test.tsx | 5 +-
__tests__/unit/hooks/useOpenProTools.test.tsx | 15 +-
.../navigation/useProExpiryRedirect.test.tsx | 52 ---
__tests__/unit/services/proPrompt.test.ts | 130 -------
__tests__/unit/utils/proPricing.test.ts | 25 --
docs/pro-licensing-plan.md | 180 ++--------
rules.md | 2 +-
src/bootstrap/loadProFeatures.ts | 75 ++--
src/bootstrap/slotRegistry.ts | 16 +-
src/components/ChatInput/index.tsx | 18 +-
src/components/ProAhaSheet.tsx | 113 ------
src/components/index.ts | 1 -
.../models/VoiceModelsEmptyState.tsx | 89 +++++
src/components/models/VoiceModelsSheet.tsx | 23 +-
src/components/models/VoiceModelsUpsell.tsx | 88 -----
src/components/settings/ProUpsellBanner.tsx | 148 --------
src/components/settings/sectionRegistry.ts | 12 +-
src/constants/index.ts | 7 -
src/hooks/useIsProActive.ts | 25 +-
src/hooks/useOpenProTools.ts | 22 +-
src/hooks/useOpenSync.ts | 23 +-
src/hooks/useProStatusLabel.ts | 56 +--
src/navigation/AppNavigator.tsx | 2 +-
src/navigation/navigationRef.ts | 13 +
src/navigation/screenRegistry.ts | 2 +-
src/navigation/useProExpiryRedirect.ts | 60 ----
src/screens/ChatScreen/ChatMessageArea.tsx | 18 +-
src/screens/ChatScreen/index.tsx | 18 -
src/screens/HomeScreen/index.tsx | 2 +-
src/screens/ModelsScreen/index.tsx | 7 +-
.../ProDetailScreen/ProIncludedSection.tsx | 14 +-
.../ProDetailScreen/ProManageSection.tsx | 249 -------------
.../ProDetailScreen/ProUnlockModal.tsx | 328 -----------------
src/screens/ProDetailScreen/index.tsx | 315 +++++------------
src/screens/SettingsScreen.styles.ts | 17 -
src/screens/SettingsScreen.tsx | 65 +---
src/services/generationService.ts | 2 -
src/services/imageGenerationHelpers.ts | 2 -
src/services/proEntitlementLifecycle.ts | 9 +
src/services/proLicenseService.ts | 41 +--
src/services/proPrompt.ts | 45 ---
src/stores/appStore.ts | 5 +-
src/stores/proAccessSlice.ts | 68 ++--
src/utils/proPricing.ts | 35 --
60 files changed, 710 insertions(+), 3050 deletions(-)
delete mode 100644 __tests__/integration/licensing/proRuntimeExpiry.test.ts
delete mode 100644 __tests__/integration/onboarding/proBootFlow.test.ts
create mode 100644 __tests__/integration/pro/everyFeatureIsFree.test.ts
delete mode 100644 __tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx
delete mode 100644 __tests__/integration/pro/proExpiryRedirect.integration.test.tsx
delete mode 100644 __tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx
delete mode 100644 __tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx
delete mode 100644 __tests__/rntl/components/ProAhaSheet.test.tsx
delete mode 100644 __tests__/rntl/screens/ProDetailScreen.test.tsx
create mode 100644 __tests__/rntl/screens/ProFeaturesScreen.test.tsx
delete mode 100644 __tests__/unit/navigation/useProExpiryRedirect.test.tsx
delete mode 100644 __tests__/unit/services/proPrompt.test.ts
delete mode 100644 __tests__/unit/utils/proPricing.test.ts
delete mode 100644 src/components/ProAhaSheet.tsx
create mode 100644 src/components/models/VoiceModelsEmptyState.tsx
delete mode 100644 src/components/models/VoiceModelsUpsell.tsx
delete mode 100644 src/components/settings/ProUpsellBanner.tsx
create mode 100644 src/navigation/navigationRef.ts
delete mode 100644 src/navigation/useProExpiryRedirect.ts
delete mode 100644 src/screens/ProDetailScreen/ProManageSection.tsx
delete mode 100644 src/screens/ProDetailScreen/ProUnlockModal.tsx
delete mode 100644 src/services/proPrompt.ts
delete mode 100644 src/utils/proPricing.ts
diff --git a/App.tsx b/App.tsx
index 5da6ea0a7..5adb20e9d 100644
--- a/App.tsx
+++ b/App.tsx
@@ -11,10 +11,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { AppNavigator } from './src/navigation';
-import {
- appNavigationRef,
- useProExpiryRedirect,
-} from './src/navigation/useProExpiryRedirect';
+import { appNavigationRef } from './src/navigation/navigationRef';
import { useTheme } from './src/theme';
import { hardwareService, modelManager, authService, knowledgeBase, remoteServerManager } from './src/services';
import logger from './src/utils/logger';
@@ -133,7 +130,6 @@ function App() {
// the appRoot slot (TTS engine bridge) registers and this re-renders to mount
// it live — no restart needed.
const AppRoot = useSlot(SLOTS.appRoot);
- const applyPendingProRedirect = useProExpiryRedirect();
const [isInitializing, setIsInitializing] = useState(true);
const startupGeneration = useRef(0);
const showSyncStatusBar = useAppStore(s => s.showSyncStatusBar);
@@ -435,7 +431,6 @@ function App() {
{AppRoot ? : null}
@@ -37,26 +37,29 @@
-## Off Grid AI Pro
+## Everything is free
-**A voice, personas, and actions. $119 for life, or $7.99/month.**
+**A voice, personas, and actions. No licence, no subscription, nothing to unlock.**
-Pro is an optional, additive tier. It gives the assistant a voice that talks back, personas you shape, and the tools to draft real actions you approve. One license covers your phone and your Mac. All on-device.
+Off Grid AI has no paid tier. Every capability the app has is available to everyone: the assistant has a
+voice that talks back, personas you shape, and the tools to draft real actions you approve. Your phone and
+your Mac are both covered. All on-device.
-### What Pro adds
+### What you get
-- **Voice mode** - the free app transcribes your speech; Pro adds on-device Kokoro text-to-speech, so it talks back and you run the whole thing hands-free. The voice runs in your phone's RAM.
+- **Voice mode** - on-device Kokoro text-to-speech, so the assistant talks back and you run the whole thing hands-free. The voice runs in your phone's RAM.
- **Custom personas** - give each assistant its own system prompt, voice, and persistent memory, so it stays in character across conversations.
- **Draft, then approve** - connect Calendar, email, and MCP servers like Linear, Notion, and GitHub. It drafts the reply or files the ticket and waits. Nothing sends without your tap.
- **Personal Mesh sync** - your phone and your Mac share chats, files, and settings over your own network. There is no relay. See the [Personal Mesh guide](docs/PERSONAL_MESH.md).
-**[→ Get Pro access](https://getoffgridai.co/pay/)** - $119 once and it is yours forever (the price climbs as more people join, never down), or $7.99/month.
+There is no purchase path in the app, no licence key to paste, and no expiry: the old paid tier was
+removed, and the licence plumbing that remains is only what pairing your own devices needs.
-Pair it with **[Off Grid AI Desktop](https://github.com/off-grid-ai/desktop/releases/latest)** on your Mac. One Pro license covers both.
+Pair it with **[Off Grid AI Desktop](https://github.com/off-grid-ai/desktop/releases/latest)** on your Mac.
---
diff --git a/__tests__/harness/proHarness.ts b/__tests__/harness/proHarness.ts
index b2ed191fc..58229fce6 100644
--- a/__tests__/harness/proHarness.ts
+++ b/__tests__/harness/proHarness.ts
@@ -12,11 +12,11 @@
* registering individual slots per test.
*/
export async function installPro(): Promise {
- // isPro=true → the real bootstrap requires @offgrid/pro and calls pro.activate({...registries}),
- // registering every pro slot (chatInput.modeToggle, chatInput.audioMode, message.speakButton, …).
-
+ // The real bootstrap requires @offgrid/pro and calls pro.activate({...registries}), registering every
+ // pro slot (chatInput.modeToggle, chatInput.audioMode, message.speakButton, …). Nothing is gated any
+ // more: a build with the bundle activates it with no licence, which is what this harness stands in for.
const { loadProFeatures } = require('../../src/bootstrap/loadProFeatures');
- await loadProFeatures(true);
+ await loadProFeatures();
}
/** Start the real app over device storage. The returned controls use only visible gestures. */
diff --git a/__tests__/integration/licensing/proRuntimeExpiry.test.ts b/__tests__/integration/licensing/proRuntimeExpiry.test.ts
deleted file mode 100644
index b3456fd0b..000000000
--- a/__tests__/integration/licensing/proRuntimeExpiry.test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { callHook, _clearHooksForTesting } from '../../../src/bootstrap/hookRegistry';
-import { getSlot, SLOTS, _clearSlotsForTesting } from '../../../src/bootstrap/slotRegistry';
-import {
- getRegisteredScreens,
- registerScreen,
- _clearScreensForTesting,
-} from '../../../src/navigation/screenRegistry';
-import {
- getToolExtensions,
- registerToolExtension,
- _clearExtensionsForTesting,
-} from '../../../src/services/tools/extensions';
-import { registerSettingsSection } from '../../../src/components/settings/sectionRegistry';
-import { registerHook } from '../../../src/bootstrap/hookRegistry';
-import { registerSlot } from '../../../src/bootstrap/slotRegistry';
-import { activate, deactivate } from '../../../pro';
-import { syncService } from '../../../pro/sync/syncService';
-
-jest.mock('react-native-tcp-socket', () => {
- const {
- createNativeTcpBoundary,
- } = require('../../utils/nativeSyncBoundaries');
- return { __esModule: true, default: createNativeTcpBoundary() };
-});
-
-jest.mock('react-native-zeroconf', () => {
- const {
- createNativeDiscoveryBoundary,
- } = require('../../utils/nativeSyncBoundaries');
- return { __esModule: true, default: createNativeDiscoveryBoundary() };
-});
-
-describe('the Pro runtime when access expires', () => {
- beforeEach(() => {
- _clearHooksForTesting();
- _clearSlotsForTesting();
- _clearScreensForTesting();
- _clearExtensionsForTesting();
- });
-
- afterEach(async () => {
- await deactivate();
- // The restricted Sync bootstrap intentionally survives Pro deactivation. This test starts that
- // process, so it must also await and stop it before Jest removes the native boundary.
- await syncService.stop();
- _clearHooksForTesting();
- _clearSlotsForTesting();
- _clearScreensForTesting();
- _clearExtensionsForTesting();
- });
-
- it('removes every paid surface and stops Sync without an app restart', async () => {
- activate({
- registerToolExtension,
- registerScreen,
- registerSettingsSection,
- registerSlot,
- registerHook,
- });
-
- expect(getRegisteredScreens().map(screen => screen.name)).toEqual(
- expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']),
- );
- expect(getToolExtensions().map(extension => extension.id)).toEqual(
- expect.arrayContaining(['mcp', 'email-calendar']),
- );
- expect(getSlot(SLOTS.appRoot)).toBeDefined();
- expect(callHook('audio.canSpeak')).toBeDefined();
-
- await deactivate();
-
- expect(getRegisteredScreens().map(screen => screen.name)).not.toEqual(
- expect.arrayContaining(['Sync', 'Clipboard', 'McpServers']),
- );
- expect(getToolExtensions().map(extension => extension.id)).toEqual([]);
- expect(getSlot(SLOTS.appRoot)).toBeUndefined();
- expect(callHook('audio.canSpeak')).toBeUndefined();
- expect(syncService.isRunning()).toBe(false);
- });
-});
diff --git a/__tests__/integration/onboarding/proBootFlow.test.ts b/__tests__/integration/onboarding/proBootFlow.test.ts
deleted file mode 100644
index dd8e412c4..000000000
--- a/__tests__/integration/onboarding/proBootFlow.test.ts
+++ /dev/null
@@ -1,291 +0,0 @@
-import { useAppStore } from '../../../src/stores/appStore';
-import { createKeygenFake } from '../../harness/keygenFake';
-
-/**
- * Opening the app as a Pro user, and as a free one.
- *
- * Two facts are established in the first moments of a launch: whether this phone is entitled, and -
- * only if it is - the licensed half of the app being switched on. Getting either wrong is immediately
- * visible: a paying user greeted by the upgrade prompt, or a free build with pro screens in the nav.
- *
- * The entitlement is read from the keychain, and the launch awaits ONE check against the provider before
- * anything is drawn - so a stale cache is corrected without the user seeing a flash of the wrong screen,
- * and a provider that cannot be reached falls back to the cached answer rather than to nothing.
- *
- * This used to stand in for the app's own store and licence service with hand-written partial objects,
- * and both drifted behind the real modules - the store gained a setter, the service gained a function,
- * and the tests failed for reasons that had nothing to do with booting. Now the store, the licence
- * service, the cache and the registries are real; the keychain, the device fingerprint and Keygen's
- * HTTP endpoint stand in, because those three are the device and the network.
- */
-
-const LICENCE_KEY = 'OFFGRID-TEST-LICENCE';
-const FINGERPRINT = 'fp-this-phone';
-
-// The pro package itself, which is what activation switches on. Its own contents are its own tests'
-// business; what matters here is that it is handed the registries, and that it is handed the function
-// that registers the entitlement provider - without which the app can never read a licence at all.
-jest.mock(
- '@offgrid/pro',
- () => {
- const { proLicenseProvider } = require('../../../pro/licensing/proLicenseProvider');
- return {
- activate: jest.fn(),
- configureProEntitlementProvider: (register: (provider: unknown) => void) => {
- register(proLicenseProvider);
- },
- };
- },
- { virtual: true },
-);
-
-describe('opening the app as a Pro user, and as a free one', () => {
- const keygen = createKeygenFake();
- let vault: Map;
- let licenceId = '';
- let activate: jest.Mock;
- let originalDev: unknown;
-
- /** The app store the launch actually wrote to, which is the reloaded copy rather than this one. */
- const storeState = (): Record =>
- require('../../../src/stores/appStore').useAppStore.getState();
-
- /** What the keychain holds, as the next launch would read it. */
- const cached = (): Record | null => {
- const raw = vault.get('off-grid-pro-license');
- return raw ? (JSON.parse(raw) as Record) : null;
- };
-
- /**
- * Everything below the app: the keychain, the hardware id and Keygen's endpoint.
- *
- * Bound from the CURRENT module graph, because each launch reloads it - the entitlement lifecycle
- * memoises its one launch-time revalidation, so a second launch in the same graph silently skips the
- * check this suite is about. Reloading means the keychain the code under test sees is a different copy
- * of the module, and it has to be re-wired to the same vault or every cached licence reads as absent.
- */
- function installBoundaries(): void {
- const fingerprint = require('../../../pro/licensing/deviceFingerprint');
- jest.spyOn(fingerprint, 'getDeviceFingerprintStrict').mockResolvedValue(FINGERPRINT);
- jest.spyOn(fingerprint, 'getDeviceFingerprint').mockResolvedValue(FINGERPRINT);
-
- const secure = require('react-native-keychain');
- secure.getGenericPassword.mockImplementation(
- async ({ service }: { service: string }) => {
- const value = vault.get(service);
- return value ? { username: 'stored', password: value } : false;
- },
- );
- secure.setGenericPassword.mockImplementation(
- async (_user: string, password: string, { service }: { service: string }) => {
- vault.set(service, password);
- return true;
- },
- );
- secure.resetGenericPassword?.mockImplementation?.(
- async ({ service }: { service: string }) => vault.delete(service),
- );
- }
-
- beforeEach(() => {
- vault = new Map();
- keygen.reset();
- keygen.install();
- licenceId = keygen.addLicence({ key: LICENCE_KEY, seats: 3 });
-
- // Pro is force-unlocked in development, which would activate regardless of the entitlement. These
- // tests are about the SHIPPED gating, so the flag is the one a release build has.
- originalDev = (global as { __DEV__?: unknown }).__DEV__;
- (global as { __DEV__?: unknown }).__DEV__ = false;
- useAppStore.getState().setProActive(false);
- useAppStore.getState().setHasRegisteredPro(false);
- });
-
- afterEach(() => {
- keygen.restore();
- jest.restoreAllMocks();
- (global as { __DEV__?: unknown }).__DEV__ = originalDev;
- });
-
- /**
- * A launch, exactly as App.tsx does it: ONE call, with no entitlement passed in.
- *
- * This used to call checkProStatus() first and pass the answer along. The app does not - and cannot:
- * the entitlement provider is registered BY this call, so anything read before it comes back false
- * from the stand-in provider and would be passed in as a definite "not Pro", overriding the real
- * cached licence.
- */
- async function launch(): Promise {
- jest.resetModules();
- installBoundaries();
- activate = require('@offgrid/pro').activate as jest.Mock;
- activate.mockClear();
- const { loadProFeatures } = require('../../../src/bootstrap/loadProFeatures');
- return loadProFeatures();
- }
-
- it('leaves the licensed half switched off for a phone with no licence', async () => {
- const active = await launch();
-
- expect(active).toBe(false);
- // Not merely hidden: the pro package is never activated, so its screens are not registered and its
- // background work never starts on a device that is not entitled.
- expect(activate).not.toHaveBeenCalled();
- expect(storeState().isProActive).toBe(false);
- });
-
- it('does not boot Pro from an expired saved credential', async () => {
- vault.set(
- 'off-grid-pro-license',
- JSON.stringify({
- isPro: true,
- key: LICENCE_KEY,
- licenseId: licenceId,
- expiry: new Date(Date.now() - 1).toISOString(),
- verifiedAt: Date.now() - 10_000,
- }),
- );
-
- const active = await launch();
-
- expect(active).toBe(false);
- expect(activate).not.toHaveBeenCalled();
- expect(storeState()).toMatchObject({
- isProActive: false,
- hasRegisteredPro: false,
- hasSavedProCredential: true,
- hasExpiredProCredential: true,
- });
- });
-
- it('switches the licensed half on for a phone that already holds one', async () => {
- // A Pro phone holds a seat on the licence as well as a key in its keychain. Without the seat the
- // launch-time check correctly withdraws Pro - a key that no longer has a device registered against
- // it is not an entitlement, however valid the key itself is.
- keygen.activate({
- key: LICENCE_KEY,
- fingerprint: FINGERPRINT,
- name: 'iPhone',
- platform: 'ios',
- });
- vault.set(
- 'off-grid-pro-license',
- JSON.stringify({
- isPro: true,
- key: LICENCE_KEY,
- licenseId: licenceId,
- expiry: null,
- verifiedAt: Date.now(),
- }),
- );
-
- const active = await launch();
-
- expect(active).toBe(true);
- // The three ways the pro package reaches the user: a tool the chat can call, a screen in the nav,
- // and a section in Settings. Activation hands it all three, so a missing one is a whole surface the
- // user cannot reach with nothing failing anywhere.
- expect(activate).toHaveBeenCalledWith(
- expect.objectContaining({
- registerToolExtension: expect.any(Function),
- registerScreen: expect.any(Function),
- registerSettingsSection: expect.any(Function),
- }),
- );
- });
-
- it('answers from the keychain first, without waiting for the network', async () => {
- keygen.setOffline(true);
- vault.set(
- 'off-grid-pro-license',
- JSON.stringify({
- isPro: true,
- key: LICENCE_KEY,
- licenseId: licenceId,
- expiry: null,
- verifiedAt: Date.now(),
- }),
- );
-
- const active = await launch();
-
- // A phone on a plane is still a phone the user paid for. Waiting on the provider would leave the
- // app deciding what to draw against a request that will time out.
- expect(active).toBe(true);
- expect(activate).toHaveBeenCalled();
- keygen.setOffline(false);
- });
-
- it('corrects a stale cached answer in the background', async () => {
- // Cached as not entitled while a key is present - the state after a launch that could not reach the
- // provider, or an activation that was interrupted.
- keygen.activate({
- key: LICENCE_KEY,
- fingerprint: FINGERPRINT,
- name: 'iPhone',
- platform: 'ios',
- });
- vault.set(
- 'off-grid-pro-license',
- JSON.stringify({
- isPro: false,
- key: LICENCE_KEY,
- licenseId: licenceId,
- expiry: null,
- verifiedAt: 0,
- }),
- );
-
- const active = await launch();
-
- // Corrected BEFORE the first screen is drawn, not afterwards: the launch awaits one revalidation, so
- // a user whose licence is perfectly fine never sees a flash of the upgrade prompt. The old version of
- // this test expected the opposite and waited for a background pass that had already happened.
- expect(active).toBe(true);
- expect(cached()).toMatchObject({ isPro: true, key: LICENCE_KEY });
- expect(storeState().hasRegisteredPro).toBe(true);
- });
-
- it('does not go on believing a licence the provider has stopped honouring', async () => {
- vault.set(
- 'off-grid-pro-license',
- JSON.stringify({
- isPro: true,
- key: 'OFFGRID-A-LICENCE-THAT-WAS-REVOKED',
- licenseId: licenceId,
- expiry: null,
- verifiedAt: 0,
- }),
- );
-
- const active = await launch();
-
- // A refund, an expiry, a key entered on too many devices. The cached answer got the user in once -
- // it must not get them in for ever, and the correction lands on this launch rather than the next.
- expect(active).toBe(false);
- expect(cached()).toMatchObject({ isPro: false });
- expect(storeState().isProActive).toBe(false);
- });
-
- it('keeps believing a cached licence when the provider cannot be reached', async () => {
- keygen.setOffline(true);
- vault.set(
- 'off-grid-pro-license',
- JSON.stringify({
- isPro: true,
- key: LICENCE_KEY,
- licenseId: licenceId,
- expiry: null,
- verifiedAt: 0,
- }),
- );
-
- const active = await launch();
- keygen.setOffline(false);
-
- // The difference that matters: a failed REQUEST is not a refused licence. Treating the two the same
- // would take Pro away from anybody who opened the app on a train.
- expect(active).toBe(true);
- expect(cached()).toMatchObject({ isPro: true });
- });
-});
diff --git a/__tests__/integration/pro/everyFeatureIsFree.test.ts b/__tests__/integration/pro/everyFeatureIsFree.test.ts
new file mode 100644
index 000000000..f580d50b2
--- /dev/null
+++ b/__tests__/integration/pro/everyFeatureIsFree.test.ts
@@ -0,0 +1,160 @@
+/**
+ * Every feature is free.
+ *
+ * SPEC (OGAM user's view): nobody pays, and nobody is asked to. A phone that has never held a licence, one
+ * whose licence lapsed, and one the licensed-device roster has dropped all get the same app: the optional
+ * pro bundle is loaded and switched on for each of them, and no screen offers a price, a key, or a way in.
+ *
+ * This replaces the suite that asserted the opposite - "leaves the licensed half switched off for a phone
+ * with no licence" - because that was the paid contract, and the paid contract is gone. What has to hold
+ * now is that access is not a function of the two facts it used to be a function of: whether a credential
+ * exists, and what the roster last said about this device.
+ *
+ * The bundle itself is mocked (a licence-free build still has to load whatever IS in the build), and
+ * everything under it - the store, the entitlement service, the registries - is real.
+ */
+import {
+ createProAccessSlice,
+ selectHasProAccess,
+ type ProAccessSlice,
+} from '../../../src/stores/proAccessSlice';
+
+const mockActivate = jest.fn();
+const mockConfigureProvider = jest.fn();
+const mockActivateSyncBootstrap = jest.fn();
+
+jest.mock(
+ '@offgrid/pro',
+ () => ({
+ activate: (...args: unknown[]) => mockActivate(...args),
+ activateSyncBootstrap: (...args: unknown[]) =>
+ mockActivateSyncBootstrap(...args),
+ configureProEntitlementProvider: (...args: unknown[]) =>
+ mockConfigureProvider(...args),
+ }),
+ { virtual: true },
+);
+
+import { loadProFeatures } from '../../../src/bootstrap/loadProFeatures';
+import { useAppStore } from '../../../src/stores/appStore';
+
+/** A store slice in the given state, built through the real factory so the shape cannot drift. */
+function sliceWith(overrides: Partial = {}): ProAccessSlice {
+ return { ...createProAccessSlice(() => undefined), ...overrides };
+}
+
+describe('the pro bundle switched on without a purchase', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ // The store is real and persists: put the licence projection back so later cases start from nothing.
+ useAppStore.setState({
+ hasRegisteredPro: false,
+ hasSavedProCredential: false,
+ isProActive: false,
+ hasExpiredProCredential: false,
+ proDeviceAdmission: 'unknown',
+ });
+ });
+
+ it('activates the bundle on an install that has never held a licence', async () => {
+ // Nothing bought, nothing cached: the projection stays empty...
+ expect(useAppStore.getState().hasRegisteredPro).toBe(false);
+
+ const loaded = await loadProFeatures();
+
+ // ...and the features load anyway, because activation is handed the registries that mount them.
+ expect(loaded).toBe(true);
+ expect(mockConfigureProvider).toHaveBeenCalledTimes(1);
+ expect(mockActivate).toHaveBeenCalledTimes(1);
+ expect(mockActivate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ registerScreen: expect.any(Function),
+ registerSlot: expect.any(Function),
+ registerHook: expect.any(Function),
+ registerSettingsSection: expect.any(Function),
+ registerToolExtension: expect.any(Function),
+ }),
+ );
+ expect(selectHasProAccess(useAppStore.getState())).toBe(true);
+ });
+
+ it('registers the Sync bootstrap even before anything is activated', async () => {
+ await loadProFeatures();
+
+ expect(mockActivateSyncBootstrap).toHaveBeenCalledTimes(1);
+ expect(mockActivateSyncBootstrap).toHaveBeenCalledWith(
+ expect.objectContaining({
+ registerScreen: expect.any(Function),
+ registerSlot: expect.any(Function),
+ registerHook: expect.any(Function),
+ }),
+ );
+ });
+
+ it('activates the bundle for a dropped device holding an expired credential', async () => {
+ // The two facts the old rule refused on, at once: a credential that has lapsed, and a roster that
+ // says this phone is not on the licence. Free means free, so the bundle loads for it too.
+ useAppStore.setState({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: false,
+ hasExpiredProCredential: true,
+ proDeviceAdmission: 'inactive',
+ });
+
+ const loaded = await loadProFeatures();
+
+ expect(loaded).toBe(true);
+ expect(mockActivate).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('access is not a function of a credential or a device roster', () => {
+ it('grants a device that holds nothing, with a roster that has said nothing', () => {
+ expect(selectHasProAccess(sliceWith())).toBe(true);
+ });
+
+ it('grants a device whose saved credential reached its deadline', () => {
+ expect(
+ selectHasProAccess(
+ sliceWith({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: false,
+ hasExpiredProCredential: true,
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it('grants a device the licensed-device roster removed', () => {
+ // This is the case the old rule existed to catch, and the case it refused: a phone taken off the
+ // roster lost every paid surface. Free means free, so it keeps them.
+ expect(
+ selectHasProAccess(
+ sliceWith({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: true,
+ proDeviceAdmission: 'inactive',
+ }),
+ ),
+ ).toBe(true);
+ });
+
+ it('grants a device on a roster that has not answered yet', () => {
+ expect(
+ selectHasProAccess(
+ sliceWith({
+ hasRegisteredPro: true,
+ hasSavedProCredential: true,
+ isProActive: true,
+ proDeviceAdmission: 'unknown',
+ }),
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/__tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx b/__tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx
deleted file mode 100644
index 1e7908ab6..000000000
--- a/__tests__/integration/pro/proAhaSheetPricing.rendered.test.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * The Pro offer that appears in chat states the current price.
- *
- * SPEC (OGAM user's view): after a free user has had enough real conversations to know the
- * product (their 10th completed reply), chat offers Off Grid AI Pro once. That offer states the
- * current founder price - $119 once for life, or $7.99 a month - and never an older tier ($49,
- * $69) or a yearly plan ("/yr", "year"). Before the 10th reply, no offer and no price is on screen.
- *
- * Journey: the REAL ChatScreen and the REAL generation pipeline over the llama.rn native fake
- * (chatHarness). The user types and sends ten messages across two chats (five each, then a new
- * chat, as a user spreads work over conversations); each reply streams back from the native fake.
- * Nothing seeds the generation count: the ten real sends produce it.
- */
-import { setupChatScreen } from '../../harness/chatHarness';
-
-jest.mock('@react-navigation/native', () => ({
- useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {} }),
- useRoute: () => require('../../harness/chatHarness').routeHolder,
- useFocusEffect: () => {},
- useIsFocused: () => true,
-}));
-
-const OFFER_HEADLINE = 'It already knows what you did today.';
-// The chat offer appears a moment after the reply finishes; give it more than that moment.
-const OFFER_SETTLE_MS = 2_000;
-const STALE_PRICE_COPY = [/\$49/, /\$69/, /\/yr/i, /year/i];
-
-describe('Pro offer in chat states the current price (rendered)', () => {
- it('after the 10th reply the offer shows $119 once for life or $7.99 a month, and no old or yearly price', async () => {
- const h = await setupChatScreen({ engine: 'llama' });
- const view = h.render();
-
- for (let turn = 1; turn <= 9; turn += 1) {
- if (turn === 6) {
- // The user reopens the first chat from the chat list, then starts a new chat.
- await h.openConversation(h.conversationId!);
- await h.openConversation();
- expect(view.queryByText('Answer number 5.')).toBeNull();
- }
- await h.send(`question number ${turn}`, { text: `Answer number ${turn}.` });
- await h.rtl.waitFor(() => expect(view.getByText(`Answer number ${turn}.`)).toBeTruthy());
- }
-
- // BEFORE: nine replies in, and past the moment an offer would appear, there is no offer and
- // no price on screen.
- await h.settle(OFFER_SETTLE_MS);
- expect(view.queryByText(OFFER_HEADLINE)).toBeNull();
- expect(view.queryByText(/\$119/)).toBeNull();
- expect(view.queryByText(/\$7\.99/)).toBeNull();
-
- await h.send('question number 10', { text: 'Answer number 10.' });
- await h.rtl.waitFor(() => expect(view.getByText('Answer number 10.')).toBeTruthy());
-
- await h.rtl.waitFor(() => expect(view.getByText(OFFER_HEADLINE)).toBeTruthy(), {
- timeout: OFFER_SETTLE_MS + 2_000,
- });
- expect(view.getByText(/\$119 once for life/)).toBeTruthy();
- expect(view.getByText(/\$7\.99 a month/)).toBeTruthy();
- for (const stale of STALE_PRICE_COPY) {
- expect(view.queryAllByText(stale)).toHaveLength(0);
- }
- }, 120_000);
-});
diff --git a/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx b/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx
deleted file mode 100644
index 45836480d..000000000
--- a/__tests__/integration/pro/proExpiryRedirect.integration.test.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import React from 'react';
-import { act, render } from '@testing-library/react-native';
-import type { NavigationContainerRef } from '@react-navigation/native';
-import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect';
-import type { RootStackParamList } from '../../../src/navigation/types';
-import { useAppStore } from '../../../src/stores/appStore';
-
-type TestNavigation = Pick<
- NavigationContainerRef,
- 'isReady' | 'resetRoot'
->;
-
-function Probe({ navigation }: { navigation: TestNavigation }): null {
- useProExpiryRedirect(navigation);
- return null;
-}
-
-describe('the live Pro expiry redirect', () => {
- const resetRoot = jest.fn();
- let ready = true;
- const navigation: TestNavigation = {
- isReady: () => ready,
- resetRoot,
- };
-
- beforeEach(() => {
- ready = true;
- resetRoot.mockClear();
- useAppStore.setState({
- hasRegisteredPro: false,
- hasSavedProCredential: false,
- isProActive: false,
- proDeviceAdmission: 'unknown',
- });
- });
-
- it('does not redirect a normal free launch', () => {
- render();
- expect(resetRoot).not.toHaveBeenCalled();
- });
-
- it('replaces the current route with the purchase screen when access is lost', () => {
- useAppStore.setState({
- hasRegisteredPro: true,
- hasSavedProCredential: true,
- isProActive: true,
- proDeviceAdmission: 'active',
- });
- render();
-
- act(() => {
- useAppStore.setState({
- hasRegisteredPro: false,
- hasSavedProCredential: false,
- isProActive: false,
- proDeviceAdmission: 'unknown',
- });
- });
-
- expect(resetRoot).toHaveBeenCalledTimes(1);
- expect(resetRoot).toHaveBeenCalledWith({
- index: 0,
- routes: [{ name: 'ProDetail' }],
- });
- });
-});
diff --git a/__tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx b/__tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx
deleted file mode 100644
index f5a3c7874..000000000
--- a/__tests__/integration/pro/proPricingSettingsEntry.rendered.test.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * Pro pricing on screen, reached from Settings.
- *
- * SPEC (OGAM user's view): the Pro screen states the current founder price - $119 for a lifetime
- * licence or $7.99 a month - and never an older tier ($49, $69) or a yearly plan ("/yr", "year").
- * Pro has no yearly plan; it has a lifetime purchase and a monthly subscription.
- *
- * Journey: the REAL App boots over the native boundary as a returning user (onboarding done on an
- * earlier launch, persisted on disk), skips model setup, taps the real Settings tab, then opens Pro
- * through each of Settings' two Pro entry points: the "Off Grid AI PRO" row and the upsell banner's
- * "Get Pro" button.
- */
-import {
- installNativeBoundary,
- requireRTL,
-} from '../../harness/nativeBoundary';
-
-// The real navigation library, so a Settings row really pushes its screen (jest.setup stubs
-// useNavigation globally).
-jest.unmock('@react-navigation/native');
-
-jest.mock(
- 'react-native-bootsplash',
- () => ({ hide: jest.fn(async () => {}) }),
- { virtual: true },
-);
-
-const STALE_PRICE_COPY = [/\$49/, /\$69/, /\/yr/i, /year/i];
-
-type DevGlobal = { __DEV__?: boolean };
-const originalDev = (global as DevGlobal).__DEV__;
-afterEach(() => {
- (global as DevGlobal).__DEV__ = originalDev;
-});
-
-async function openSettingsAsReturningUser() {
- installNativeBoundary();
- // A shipped build: the pricing a customer sees, without development-only Pro access.
- (global as DevGlobal).__DEV__ = false;
- const React = require('react');
- const rtl = requireRTL();
- const AsyncStorage = require('@react-native-async-storage/async-storage');
- await AsyncStorage.clear();
- await AsyncStorage.setItem(
- 'local-llm-app-storage',
- JSON.stringify({ state: { hasCompletedOnboarding: true }, version: 0 }),
- );
-
- const App = require('../../../App').default;
- const view = rtl.render(React.createElement(App));
- await rtl.waitFor(() => expect(view.queryByTestId('app-loading')).toBeNull(), {
- timeout: process.env.CI === 'true' ? 45_000 : 10_000,
- });
- await rtl.waitFor(() => expect(view.getByTestId('auto-setup-skip')).toBeTruthy());
- rtl.fireEvent.press(view.getByTestId('auto-setup-skip'));
- await rtl.waitFor(() => expect(view.getByTestId('settings-tab')).toBeTruthy());
- rtl.fireEvent.press(view.getByTestId('settings-tab'));
- await rtl.waitFor(() => expect(view.getByText('Off Grid AI PRO')).toBeTruthy());
- return { view, rtl };
-}
-
-type View = Awaited>['view'];
-
-function expectCurrentPricingOnly(view: View) {
- expect(view.getByText(/\$119 lifetime/)).toBeTruthy();
- expect(view.getByText(/\$7\.99\/month/)).toBeTruthy();
- for (const stale of STALE_PRICE_COPY) {
- expect(view.queryAllByText(stale)).toHaveLength(0);
- }
-}
-
-describe('Pro pricing reached from Settings (rendered, real App)', () => {
- it('the "Off Grid AI PRO" row opens Pro showing $119 lifetime and $7.99 a month, no yearly or old tier', async () => {
- const { view, rtl } = await openSettingsAsReturningUser();
-
- // BEFORE: on Settings, no price is shown yet.
- expect(view.queryByText(/\$119/)).toBeNull();
- expect(view.queryByText(/\$7\.99/)).toBeNull();
-
- rtl.fireEvent.press(view.getByText('Off Grid AI PRO'));
- await rtl.waitFor(() => expect(view.getByText('Intelligence, democratized.')).toBeTruthy());
-
- expectCurrentPricingOnly(view);
- view.unmount();
- });
-
- it('the upsell banner "Get Pro" button opens Pro showing the same current pricing', async () => {
- const { view, rtl } = await openSettingsAsReturningUser();
-
- expect(view.queryByText(/\$119/)).toBeNull();
- expect(view.queryByText('Intelligence, democratized.')).toBeNull();
-
- // The Settings upsell banner (its LIVE SYNC feature grid) carries its own "Get Pro" button.
- // Home stays mounted behind the tabs with a "Get Pro" of its own, so scope to the banner.
- let banner = view.getByText('LIVE SYNC').parent;
- while (banner && rtl.within(banner).queryAllByText('Get Pro').length === 0) {
- banner = banner.parent;
- }
- expect(banner).not.toBeNull();
- rtl.fireEvent.press(rtl.within(banner!).getByText('Get Pro'));
- await rtl.waitFor(() => expect(view.getByText('Intelligence, democratized.')).toBeTruthy());
-
- expectCurrentPricingOnly(view);
- view.unmount();
- });
-});
diff --git a/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx b/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx
deleted file mode 100644
index 932f02090..000000000
--- a/__tests__/integration/pro/proScreenNoDeviceLicenceShortcut.rendered.test.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * The Pro pitch has one purchase path and one key-entry path. A second action that offered to use
- * another device's licence duplicated the Sync journey and made the purchase screen ambiguous.
- *
- * This test enters through the real Home screen and real app navigation. Native rendering remains
- * supplied by the Jest environment, but every Off Grid screen, store action, and route is real.
- */
-import React from 'react';
-import { fireEvent, render } from '@testing-library/react-native';
-import { NavigationContainer } from '@react-navigation/native';
-import { AppNavigator } from '../../../src/navigation/AppNavigator';
-import { useAppStore } from '../../../src/stores/appStore';
-import { createDeviceInfo, createDownloadedModel } from '../../utils/factories';
-
-describe('Pro entry from Home', () => {
- beforeEach(() => {
- const model = createDownloadedModel();
- const app = useAppStore.getState();
-
- // Use the production store actions to restore a returning user's local app state. The behavior
- // under test starts with their real Home-screen gesture below.
- app.setOnboardingComplete(true);
- app.setDeviceInfo(createDeviceInfo());
- app.setDownloadedModels([model]);
- app.setActiveModelId(model.id);
- });
-
- afterEach(() => {
- const app = useAppStore.getState();
- app.setActiveModelId(null);
- app.setDownloadedModels([]);
- app.setOnboardingComplete(false);
- });
-
- it('opens the Pro screen with its two valid actions and no device-licence shortcut', () => {
- const ui = render(
-
-
- ,
- );
-
- fireEvent.press(ui.getByLabelText('Open Off Grid AI Pro'));
-
- expect(ui.getByText('Off Grid AI Pro')).toBeTruthy();
- expect(ui.getAllByText('Get Pro').length).toBeGreaterThan(0);
- expect(ui.getByText('I have a license key')).toBeTruthy();
- expect(ui.queryByText('Use Pro from another device')).toBeNull();
-
- ui.unmount();
- });
-});
diff --git a/__tests__/integration/releaseCandidateE.rendered.test.tsx b/__tests__/integration/releaseCandidateE.rendered.test.tsx
index 5592e7281..a6dce7f8d 100644
--- a/__tests__/integration/releaseCandidateE.rendered.test.tsx
+++ b/__tests__/integration/releaseCandidateE.rendered.test.tsx
@@ -69,7 +69,7 @@ async function launch(options: {
}
await storage.setItem('@local_llm/downloaded_models', JSON.stringify([model]));
await storage.setItem('local-llm-app-storage', JSON.stringify({ state: {
- hasCompletedOnboarding: true, checklistDismissed: true, downloadedModels: [model], devProDisabled: !options.pro,
+ hasCompletedOnboarding: true, checklistDismissed: true, downloadedModels: [model],
generatedImages: images, settings: { autoDiscoverRemoteModels: false },
}, version: 0 }));
await storage.setItem('local-llm-chat-storage', JSON.stringify({ state: {
@@ -304,21 +304,16 @@ it('M16: a stale downloaded voice record does not offer Speak when its files are
await require('../../pro').deactivate();
});
-it('M15: the Pro purchase action opens the canonical payment page', async () => {
+it('M15: the feature index is free to open and sells nothing', async () => {
+ // Off Grid AI is free, so the screen reached from Settings has no price, no checkout and no licence-key
+ // field. What it must still do is open, from the same entry point it always had.
const app = await launch();
- const { Linking } = require('react-native');
- const original = Linking.openURL;
- let browserAddress = '';
- Linking.openURL = async (url: string) => { browserAddress = url; };
- try {
- app.rtl.fireEvent.press(app.view.getByTestId('settings-tab'));
- app.rtl.fireEvent.press(await app.view.findByText('Off Grid AI PRO'));
- app.rtl.fireEvent.press(await app.view.findByRole('button', { name: 'Get Pro' }));
- await app.rtl.waitFor(() => expect(browserAddress).toMatch(/^https:\/\/getoffgridai\.co\/pay\/\?/));
- } finally {
- Linking.openURL = original;
- app.view.unmount();
- }
+ app.rtl.fireEvent.press(app.view.getByTestId('settings-tab'));
+ app.rtl.fireEvent.press(await app.view.findByText('Pro features'));
+ await app.rtl.waitFor(() => expect(app.view.getByText('Everything is free.')).toBeTruthy());
+ expect(app.view.queryByText('Get Pro')).toBeNull();
+ expect(app.view.queryByText(/\$119|\$7\.99|lifetime/i)).toBeNull();
+ app.view.unmount();
});
/** Desktop's model-management and asynchronous image-job HTTP boundary. */
diff --git a/__tests__/rntl/components/ProAhaSheet.test.tsx b/__tests__/rntl/components/ProAhaSheet.test.tsx
deleted file mode 100644
index d897c8785..000000000
--- a/__tests__/rntl/components/ProAhaSheet.test.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import React from 'react';
-import { render } from '@testing-library/react-native';
-import { ProAhaSheet } from '../../../src/components/ProAhaSheet';
-
-describe('ProAhaSheet remote media value', () => {
- it('shows the work a named Desktop can do for this phone', () => {
- const ui = render(
- undefined} onRegister={() => undefined} />,
- );
-
- expect(ui.getByText('Create images with the model active on your Desktop')).toBeTruthy();
- expect(ui.getByText('Transcribe speech with the model active on your Desktop')).toBeTruthy();
- expect(ui.getByText('Hear replies in the voice active on your Desktop')).toBeTruthy();
- expect(ui.getByText('Control which models your named Desktop serves')).toBeTruthy();
- });
-});
diff --git a/__tests__/rntl/screens/ProDetailScreen.test.tsx b/__tests__/rntl/screens/ProDetailScreen.test.tsx
deleted file mode 100644
index eb296e4f3..000000000
--- a/__tests__/rntl/screens/ProDetailScreen.test.tsx
+++ /dev/null
@@ -1,329 +0,0 @@
-/**
- * ProDetailScreen Tests
- *
- * Covers the license-key activation flow (paste key → activate → success card),
- * the "Get Pro" → web pay page path, and the Pro-active management section.
- */
-
-import React from 'react';
-import { Alert, Linking } from 'react-native';
-import { render, fireEvent, waitFor } from '@testing-library/react-native';
-import { projectPersonalMeshActivationFailure } from '@offgrid/sync';
-import { useAppStore } from '../../../src/stores/appStore';
-import {
- OFF_GRID_DESKTOP_BENEFIT,
- OFF_GRID_DESKTOP_URL,
-} from '../../../src/constants';
-import { withUtm } from '../../../src/utils/utm';
-
-const PAY_URL = 'https://offgridmobileai.co/pay';
-const mockActivateProByKey = jest.fn();
-const mockGetProLicenseInfo = jest.fn();
-const mockListProDevices = jest.fn();
-const mockDeactivateProDevice = jest.fn();
-
-jest.mock('../../../src/services/proLicenseService', () => ({
- activateProByKey: (...args: unknown[]) => mockActivateProByKey(...args),
- getProLicenseInfo: (...args: unknown[]) => mockGetProLicenseInfo(...args),
- listProDevices: (...args: unknown[]) => mockListProDevices(...args),
- deactivateProDevice: (...args: unknown[]) => mockDeactivateProDevice(...args),
- // ProManageSection renders the status line from this map — mirror the real export
- // so the mock can't diverge (an omitted map made PRO_TIER_META[tier] throw).
- PRO_TIER_META: {
- lifetime: { label: 'Lifetime', renews: false },
- yearly: { label: 'Yearly', renews: true },
- },
- PRO_PAY_PAGE_URL: 'https://offgridmobileai.co/pay',
-}));
-
-import { ProDetailScreen } from '../../../src/screens/ProDetailScreen';
-import { ProUnlockModal } from '../../../src/screens/ProDetailScreen/ProUnlockModal';
-
-/**
- * PARTIALLY GREEN, and the four that remain red are red for one reason: this suite mocks
- * `proLicenseService`, which is our own code. The assertions therefore describe the mock rather than the
- * app, which is how they came to drift without anything failing - the module could not even be resolved
- * for a while and every test in the file was skipped in silence.
- *
- * The remaining four assert the management card (status line, renewal link, replacement error) through
- * that mock. Fixing them means running the real licence stack over the in-memory provider, the way the
- * sync journeys do, rather than teaching the mock new tricks.
- */
-describe('ProDetailScreen', () => {
- let alertSpy: jest.SpyInstance;
- let linkingSpy: jest.SpyInstance;
-
- beforeEach(() => {
- jest.clearAllMocks();
- useAppStore.setState({ hasRegisteredPro: false });
- alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => {});
- linkingSpy = jest
- .spyOn(Linking, 'openURL')
- .mockResolvedValue(true as never);
- // Defaults for the Pro-active management section.
- mockGetProLicenseInfo.mockResolvedValue({
- isPro: true,
- tier: 'lifetime',
- expiry: null,
- verifiedAt: 0,
- });
- mockListProDevices.mockResolvedValue([]);
- mockDeactivateProDevice.mockResolvedValue(true);
- });
-
- afterEach(() => {
- alertSpy.mockRestore();
- linkingSpy.mockRestore();
- });
-
- it('renders the Get Pro call-to-action when the user is not Pro', () => {
- const { queryAllByText, queryByText } = render();
- expect(queryAllByText('Get Pro').length).toBeGreaterThan(0);
- expect(queryByText('Use Pro from another device')).toBeNull();
- });
-
- it('Get Pro opens the web pay page directly without a modal', () => {
- const { getAllByText, queryByText } = render();
- fireEvent.press(getAllByText('Get Pro')[0]);
- expect(linkingSpy).toHaveBeenCalledWith(withUtm(PAY_URL, 'pro-detail'));
- // No in-app activation step for paying.
- expect(queryByText('Enter your license key')).toBeNull();
- });
-
- it('links to Off Grid AI Desktop from the Pro pitch', () => {
- const { getByText, queryByText } = render();
- expect(getByText(OFF_GRID_DESKTOP_BENEFIT)).toBeTruthy();
- expect(queryByText(/building this through July/i)).toBeNull();
- fireEvent.press(getByText('Get Off Grid AI Desktop'));
- expect(linkingSpy).toHaveBeenCalledWith(
- withUtm(OFF_GRID_DESKTOP_URL, 'pro-detail'),
- );
- });
-
- it('shows the remote media outcomes and named Desktop control', () => {
- const { getByText } = render();
- expect(getByText('Your Desktop does the heavy work')).toBeTruthy();
- expect(
- getByText(
- 'Create images, transcribe speech, and hear replies with the models active on your named Desktop. You choose which models it serves.',
- ),
- ).toBeTruthy();
- });
-
- it('shows the Off Grid AI Desktop link to Pro-active users too', async () => {
- useAppStore.setState({ hasRegisteredPro: true });
- const { getByText } = render();
- await waitFor(() =>
- expect(getByText('Get Off Grid AI Desktop')).toBeTruthy(),
- );
- fireEvent.press(getByText('Get Off Grid AI Desktop'));
- expect(linkingSpy).toHaveBeenCalledWith(
- withUtm(OFF_GRID_DESKTOP_URL, 'pro-detail'),
- );
- });
-
- it('"I have a license key" opens the activation modal', () => {
- const { getByText } = render();
- fireEvent.press(getByText('I have a license key'));
- expect(getByText('Enter your license key')).toBeTruthy();
- expect(
- getByText(
- 'Paste the license key from your email. It works on up to 5 devices.',
- ),
- ).toBeTruthy();
- });
-
- it('activates the license key and shows the success card', async () => {
- mockActivateProByKey.mockResolvedValueOnce({ ok: true });
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123');
- fireEvent.press(getByTestId('unlock-cta'));
- await waitFor(() =>
- expect(mockActivateProByKey).toHaveBeenCalledWith('key/abc123'),
- );
- await waitFor(() => expect(getByText('Pro activated')).toBeTruthy());
- });
-
- it('keeps an in-flight activation and its result visible if the parent closes', async () => {
- let finishActivation!: (result: { ok: true }) => void;
- mockActivateProByKey.mockImplementationOnce(
- () =>
- new Promise(resolve => {
- finishActivation = resolve;
- }),
- );
- const onClose = jest.fn();
- const onUnlocked = jest.fn();
- const ui = render(
- ,
- );
- fireEvent.changeText(ui.getByTestId('license-key-input'), 'key/abc123');
- fireEvent.press(ui.getByTestId('unlock-cta'));
- expect(ui.getByText('Activating...')).toBeTruthy();
-
- ui.rerender(
- ,
- );
- expect(ui.getByText('Activating...')).toBeTruthy();
-
- finishActivation({ ok: true });
- await waitFor(() => expect(ui.getByText('Pro activated')).toBeTruthy());
- expect(onUnlocked).toHaveBeenCalledTimes(1);
- expect(onClose).not.toHaveBeenCalled();
- });
-
- it('lets the user dismiss the success card with Got it', async () => {
- mockActivateProByKey.mockResolvedValueOnce({ ok: true });
- const { getByText, getByTestId, queryByText } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123');
- fireEvent.press(getByTestId('unlock-cta'));
- await waitFor(() => expect(getByText('Pro activated')).toBeTruthy());
- fireEvent.press(getByText('Got it'));
- await waitFor(() => expect(queryByText('Pro activated')).toBeNull());
- });
-
- it('shows an inline error when the key is invalid', async () => {
- // 'invalid' is not a reason the activation flow reports any more. The failure codes are named for
- // what went wrong - a key the provider will not accept is `invalid_credential` - and the sentence the
- // user reads comes from the shared projection rather than from this screen.
- mockActivateProByKey.mockResolvedValueOnce({
- ok: false,
- reason: 'invalid_credential',
- });
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), 'key/nope');
- fireEvent.press(getByTestId('unlock-cta'));
- await waitFor(() =>
- expect(getByText('That license key is invalid or revoked.')).toBeTruthy(),
- );
- expect(getByText('License not accepted')).toBeTruthy();
- });
-
- it.each([
- ['a licence with no room left', 'capacity_full'],
- ['a seat that could not be freed', 'replacement_failed'],
- ['a licence the provider will not accept', 'invalid_credential'],
- ])('says what went wrong for %s', async (_why, reason) => {
- mockActivateProByKey.mockResolvedValueOnce({ ok: false, reason });
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123');
- fireEvent.press(getByTestId('unlock-cta'));
-
- // Read from the shared projection rather than restated here. The test used to pass reason: 'limit'
- // - not a code the app has - and assert copy from a different failure, so it was checking the words
- // of one error against the code of another. Asserting through the projection means the screen and
- // this test cannot disagree, and a copy change cannot silently pass.
- const expected = projectPersonalMeshActivationFailure(
- reason as Parameters[0],
- );
- await waitFor(() => expect(getByText(expected.title)).toBeTruthy());
- expect(getByText(expected.description)).toBeTruthy();
- });
-
- it('keeps the activate button disabled until a key is entered', async () => {
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- // Empty input: the disabled button ignores the press, no activate call.
- fireEvent.press(getByTestId('unlock-cta'));
- expect(mockActivateProByKey).not.toHaveBeenCalled();
- // Once a key is entered the button is enabled and activates.
- mockActivateProByKey.mockResolvedValueOnce({ ok: true });
- fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123');
- fireEvent.press(getByTestId('unlock-cta'));
- await waitFor(() => expect(mockActivateProByKey).toHaveBeenCalled());
- });
-
- it('treats whitespace-only input as empty so the button stays disabled', () => {
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), ' ');
- fireEvent.press(getByTestId('unlock-cta'));
- expect(mockActivateProByKey).not.toHaveBeenCalled();
- });
-
- it('strips surrounding whitespace before activating', async () => {
- mockActivateProByKey.mockResolvedValueOnce({ ok: true });
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), ' key/abc123 ');
- fireEvent.press(getByTestId('unlock-cta'));
- await waitFor(() =>
- expect(mockActivateProByKey).toHaveBeenCalledWith('key/abc123'),
- );
- });
-
- it('activates from the keyboard action', async () => {
- mockActivateProByKey.mockResolvedValueOnce({ ok: true });
- const { getByText, getByTestId } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.changeText(getByTestId('license-key-input'), 'key/abc123');
- fireEvent(getByTestId('license-key-input'), 'submitEditing');
- await waitFor(() =>
- expect(mockActivateProByKey).toHaveBeenCalledWith('key/abc123'),
- );
- });
-
- it('"Not a member yet? Get Pro" in the modal opens the pay page', () => {
- const { getByText } = render();
- fireEvent.press(getByText('I have a license key'));
- fireEvent.press(getByText('Not a member yet? Get Pro'));
- expect(linkingSpy).toHaveBeenCalledWith(withUtm(PAY_URL, 'pro-unlock'));
- });
-
- it('renders the Pro Active state with the management section when Pro is owned', async () => {
- // "Pro Active" is about THIS DEVICE being admitted to the licence, not merely about owning one.
- // There are only two states now: a device the roster removed is not Pro and sees the buy screen,
- // so admission has to be set here alongside the credential or nothing Pro renders at all.
- useAppStore.setState({
- hasRegisteredPro: true,
- hasSavedProCredential: true,
- proDeviceAdmission: 'active' as const,
- });
- const { getByText } = render();
- expect(getByText('Pro Active')).toBeTruthy();
- // ProManageSection loads license info async, then shows the status line.
- await waitFor(() =>
- expect(getByText('Lifetime · never expires')).toBeTruthy(),
- );
- });
-
- it('shows the yearly status line and a Manage subscription link for a recurring license', async () => {
- useAppStore.setState({
- hasRegisteredPro: true,
- hasSavedProCredential: true,
- proDeviceAdmission: 'active' as const,
- });
- mockGetProLicenseInfo.mockResolvedValue({
- isPro: true,
- tier: 'yearly',
- expiry: '2026-08-01T00:00:00.000Z',
- verifiedAt: 0,
- });
- const { getByText } = render();
- await waitFor(() => expect(getByText(/Yearly · renews/)).toBeTruthy());
- expect(getByText('Manage subscription')).toBeTruthy();
- });
-
- it('shows a lifetime status line and NO Manage subscription link for a one-time license', async () => {
- useAppStore.setState({ hasRegisteredPro: true, hasSavedProCredential: true });
- mockGetProLicenseInfo.mockResolvedValue({
- isPro: true,
- tier: 'lifetime',
- expiry: null,
- verifiedAt: 0,
- });
- const { getByText, queryByText } = render();
- await waitFor(() =>
- expect(getByText(/Lifetime · never expires/)).toBeTruthy(),
- );
- expect(queryByText('Manage subscription')).toBeNull();
- });
-});
diff --git a/__tests__/rntl/screens/ProFeaturesScreen.test.tsx b/__tests__/rntl/screens/ProFeaturesScreen.test.tsx
new file mode 100644
index 000000000..8660f8dbb
--- /dev/null
+++ b/__tests__/rntl/screens/ProFeaturesScreen.test.tsx
@@ -0,0 +1,66 @@
+/**
+ * The Pro features screen sells nothing.
+ *
+ * SPEC (OGAM user's view): the screen every Pro entry point opens — the Home crown, Settings, the Tools
+ * page, the chat assistant gate — states that everything is free, and offers no price, no checkout, no
+ * licence-key field and no subscription. What it can still do is be honest about the BUILD: a build
+ * without the optional pro bundle says so, rather than promising features that are not compiled in.
+ *
+ * The bundle is represented the way the app represents it — by registering its screens — so this suite
+ * exercises the same signal the rest of the app reads (useIsProActive → the screen registry).
+ */
+import React from 'react';
+import { Linking } from 'react-native';
+import { render, fireEvent } from '@testing-library/react-native';
+import { OFF_GRID_DESKTOP_BENEFIT } from '../../../src/constants';
+import {
+ registerScreen,
+ _clearScreensForTesting,
+} from '../../../src/navigation/screenRegistry';
+import { PRO_TOOLS_SCREEN } from '../../../src/hooks/useIsProActive';
+import { ProDetailScreen } from '../../../src/screens/ProDetailScreen';
+
+describe('the Pro features screen', () => {
+ beforeEach(() => {
+ _clearScreensForTesting();
+ });
+ afterEach(() => {
+ _clearScreensForTesting();
+ });
+
+ it('says everything is free, and asks for nothing', () => {
+ const { getByText, queryByText } = render();
+
+ expect(getByText('Everything is free.')).toBeTruthy();
+ expect(queryByText('Get Pro')).toBeNull();
+ expect(queryByText('I have a license key')).toBeNull();
+ expect(queryByText(/Enter your license key/)).toBeNull();
+ expect(queryByText(/\$119|\$7\.99|per month|lifetime/i)).toBeNull();
+ });
+
+ it('explains an absent bundle instead of selling it', () => {
+ const { getByText } = render();
+ expect(getByText(/does not include the optional Pro bundle/)).toBeTruthy();
+ });
+
+ it('marks this build included once the bundle has registered its screens', () => {
+ registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null });
+
+ const { getByText, queryByText } = render();
+
+ expect(getByText('Included')).toBeTruthy();
+ expect(queryByText(/does not include the optional Pro bundle/)).toBeNull();
+ });
+
+ it('still points at the free Desktop companion', () => {
+ const spy = jest.spyOn(Linking, 'openURL').mockResolvedValue(true as never);
+ const { getByText } = render();
+
+ fireEvent.press(getByText(OFF_GRID_DESKTOP_BENEFIT));
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.stringContaining('getoffgridai.co/desktop'),
+ );
+ spy.mockRestore();
+ });
+});
diff --git a/__tests__/rntl/screens/SettingsScreen.test.tsx b/__tests__/rntl/screens/SettingsScreen.test.tsx
index 1930ba0e2..c8f59bc69 100644
--- a/__tests__/rntl/screens/SettingsScreen.test.tsx
+++ b/__tests__/rntl/screens/SettingsScreen.test.tsx
@@ -55,9 +55,6 @@ jest.mock('../../../src/components/AnimatedListItem', () => ({
const mockSetOnboardingComplete = jest.fn();
const mockSetThemeMode = jest.fn();
const mockCompleteChecklistStep = jest.fn();
-// Mutated per-test to drive Pro banner visibility. `mock`-prefixed so jest.mock's
-// hoisted factory is allowed to reference it.
-const mockProState = { hasRegisteredPro: false, proBannerDismissed: false };
jest.mock('../../../src/stores', () => ({
useAppStore: Object.assign(jest.fn((selector?: any) => {
const state = {
@@ -65,9 +62,7 @@ jest.mock('../../../src/stores', () => ({
themeMode: 'system',
setThemeMode: mockSetThemeMode,
completeChecklistStep: mockCompleteChecklistStep,
- setProBannerDismissed: jest.fn(),
- hasRegisteredPro: mockProState.hasRegisteredPro,
- proBannerDismissed: mockProState.proBannerDismissed,
+ hasRegisteredPro: false,
};
return selector ? selector(state) : state;
}), {
@@ -98,21 +93,15 @@ jest.mock('@react-navigation/native', () => ({
describe('SettingsScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
- mockProState.hasRegisteredPro = false;
- mockProState.proBannerDismissed = false;
});
- it('shows the Pro upsell banner when Pro is not active and not dismissed', () => {
- const { getByText } = render();
- expect(
- getByText('Your private AI stays current across your devices with live sync.'),
- ).toBeTruthy();
- });
-
- it('hides the Pro upsell banner once Pro is active', () => {
- mockProState.hasRegisteredPro = true;
+ it('sells nothing: no upsell banner and no purchase pitch', () => {
+ // Off Grid AI is free, so Settings carries no promo banner and no price.
const { queryByText } = render();
- expect(queryByText(/democratized/i)).toBeNull();
+ expect(queryByText('Get Pro')).toBeNull();
+ expect(
+ queryByText('Your private AI stays current across your devices with live sync.'),
+ ).toBeNull();
});
it('renders "Settings" title', () => {
diff --git a/__tests__/rntl/screens/ToolsScreen.test.tsx b/__tests__/rntl/screens/ToolsScreen.test.tsx
index 757d0858b..f900f3263 100644
--- a/__tests__/rntl/screens/ToolsScreen.test.tsx
+++ b/__tests__/rntl/screens/ToolsScreen.test.tsx
@@ -4,7 +4,7 @@
* The tools picker is a full page (it replaced the old bottom-sheet drawer). Tests:
* - Renders the page header and all free tool rows
* - Pro Tools row is pinned to the top of the listing and is shown to everyone
- * - Pro Tools routing: free -> ProDetail upsell, pro -> the Pro Tools screen
+ * - Pro Tools routing: no bundle -> the Pro features screen, bundle -> the Pro Tools screen
* - Tool toggles flow through the core settings store
* - Back button navigates back
*/
@@ -95,13 +95,13 @@ describe('ToolsScreen', () => {
expect(getByText('Pro Tools')).toBeTruthy();
});
- it('routes a free user to the Pro upsell when Pro Tools is pressed', () => {
+ it('routes to the Pro features screen when this build has no Pro bundle', () => {
const { getByTestId } = render();
fireEvent.press(getByTestId('tools-pro-tools'));
expect(mockNavigate).toHaveBeenCalledWith('ProDetail');
});
- it('routes a pro user straight to the Pro Tools screen', () => {
+ it('routes straight to the Pro Tools screen when the bundle is in this build', () => {
useAppStore.setState({
hasRegisteredPro: true,
hasSavedProCredential: true,
diff --git a/__tests__/unit/hooks/useIsProActive.test.tsx b/__tests__/unit/hooks/useIsProActive.test.tsx
index de8343366..afcf65e2d 100644
--- a/__tests__/unit/hooks/useIsProActive.test.tsx
+++ b/__tests__/unit/hooks/useIsProActive.test.tsx
@@ -19,8 +19,9 @@ const Probe = () => {
describe('useIsProActive / useHasRegisteredScreen', () => {
beforeEach(() => {
_clearScreensForTesting();
- // Registration alone no longer means Pro: the device must still be entitled, or a device the owner
- // removed from the licence would keep every Pro entry point until the app restarted.
+ // Registration is the whole answer now: it says the bundle is in this build, which is all that can
+ // differ. The licence flags below are the honest record of what this install holds, and no longer
+ // change what any of it may do.
useAppStore.setState({
hasRegisteredPro: true,
hasSavedProCredential: true,
diff --git a/__tests__/unit/hooks/useOpenProTools.test.tsx b/__tests__/unit/hooks/useOpenProTools.test.tsx
index 388371c6a..8359e2a77 100644
--- a/__tests__/unit/hooks/useOpenProTools.test.tsx
+++ b/__tests__/unit/hooks/useOpenProTools.test.tsx
@@ -2,8 +2,11 @@
* useOpenProTools — the single "open the Pro Tools destination" callback shared by
* the chat quick-settings row and the Tools page. It routes on whether the Pro
* Tools screen is registered (the same signal as useIsProActive):
- * - free (screen not registered) -> ProDetail upsell
- * - pro (screen registered) -> the Pro Tools screen
+ * - no bundle (screen not registered) -> the Pro features screen, which explains why
+ * - bundle (screen registered) -> the Pro Tools screen
+ *
+ * This used to fork on entitlement, with the non-entitled branch going to the purchase
+ * page. There is no purchase page: the only thing that can be missing is the code.
*/
import React from 'react';
@@ -47,7 +50,7 @@ describe('useOpenProTools', () => {
_clearScreensForTesting();
});
- it('routes a free user to the Pro upsell', () => {
+ it('routes to the Pro features screen when the bundle is not in this build', () => {
const { getByTestId } = render();
fireEvent.press(getByTestId('open'));
expect(mockNavigate).toHaveBeenCalledWith('ProDetail');
@@ -66,7 +69,9 @@ describe('useOpenProTools', () => {
expect(mockNavigate).toHaveBeenCalledWith(PRO_TOOLS_SCREEN);
});
- it('routes to the purchase screen as soon as live access is removed', () => {
+ it('still opens the tools for a device with no credential and no roster answer', () => {
+ // Nothing bought, nothing admitted: the tools are registered, so they open. This is the case that
+ // used to be routed to a purchase.
registerScreen({ name: PRO_TOOLS_SCREEN, component: () => null });
useAppStore.setState({
hasRegisteredPro: false,
@@ -78,6 +83,6 @@ describe('useOpenProTools', () => {
const { getByTestId } = render();
fireEvent.press(getByTestId('open'));
- expect(mockNavigate).toHaveBeenCalledWith('ProDetail');
+ expect(mockNavigate).toHaveBeenCalledWith(PRO_TOOLS_SCREEN);
});
});
diff --git a/__tests__/unit/navigation/useProExpiryRedirect.test.tsx b/__tests__/unit/navigation/useProExpiryRedirect.test.tsx
deleted file mode 100644
index a4ac3e815..000000000
--- a/__tests__/unit/navigation/useProExpiryRedirect.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { act, renderHook, waitFor } from '@testing-library/react-native';
-import type { NavigationContainerRefWithCurrent } from '@react-navigation/native';
-import { useProExpiryRedirect } from '../../../src/navigation/useProExpiryRedirect';
-import type { RootStackParamList } from '../../../src/navigation/types';
-import { useAppStore } from '../../../src/stores/appStore';
-
-describe('the expired Pro purchase route', () => {
- const navigation = {
- isReady: jest.fn(() => true),
- getCurrentRoute: jest.fn(() => ({ name: 'Main', key: 'main' })),
- resetRoot: jest.fn(),
- } as unknown as NavigationContainerRefWithCurrent;
-
- beforeEach(() => {
- jest.clearAllMocks();
- useAppStore.getState().setHasExpiredProCredential(false);
- });
-
- afterEach(() => {
- useAppStore.getState().setHasExpiredProCredential(false);
- });
-
- it('routes a cold-start expired credential when navigation becomes ready', async () => {
- useAppStore.getState().setHasExpiredProCredential(true);
- const { result } = renderHook(() => useProExpiryRedirect(navigation));
-
- act(() => result.current());
-
- await waitFor(() =>
- expect(navigation.resetRoot).toHaveBeenCalledWith({
- index: 0,
- routes: [{ name: 'ProDetail' }],
- }),
- );
- });
-
- it('routes an active screen when the saved deadline passes', async () => {
- renderHook(() => useProExpiryRedirect(navigation));
- expect(navigation.resetRoot).not.toHaveBeenCalled();
-
- act(() => {
- useAppStore.getState().setHasExpiredProCredential(true);
- });
-
- await waitFor(() =>
- expect(navigation.resetRoot).toHaveBeenCalledWith({
- index: 0,
- routes: [{ name: 'ProDetail' }],
- }),
- );
- });
-});
diff --git a/__tests__/unit/services/proPrompt.test.ts b/__tests__/unit/services/proPrompt.test.ts
deleted file mode 100644
index 35fae80c3..000000000
--- a/__tests__/unit/services/proPrompt.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/**
- * Unit tests for proPrompt.ts
- */
-
-jest.mock('../../../src/stores/appStore', () => ({
- useAppStore: { getState: jest.fn() },
-}));
-
-import {
- shouldShowProAha,
- subscribeProPrompt,
- emitProPrompt,
- checkProPromptForText,
- checkProPromptForImage,
-} from '../../../src/services/proPrompt';
-import { useAppStore } from '../../../src/stores/appStore';
-
-const mockedGetState = useAppStore.getState as jest.Mock;
-
-function makeStore(overrides: any = {}) {
- return {
- hasRegisteredPro: false,
- isProActive: false,
- proAhaTriggeredBy: null,
- textGenerationCount: 10,
- imageGenerationCount: 10,
- setProAhaTriggeredBy: jest.fn(),
- ...overrides,
- };
-}
-
-describe('shouldShowProAha', () => {
- it('returns true at threshold (10)', () => {
- expect(shouldShowProAha(10)).toBe(true);
- });
-
- it('returns false below threshold', () => {
- expect(shouldShowProAha(2)).toBe(false);
- expect(shouldShowProAha(1)).toBe(false);
- expect(shouldShowProAha(9)).toBe(false);
- });
-
- it('returns false after the one automatic offer', () => {
- expect(shouldShowProAha(4)).toBe(false);
- expect(shouldShowProAha(11)).toBe(false);
- expect(shouldShowProAha(14)).toBe(false);
- expect(shouldShowProAha(15)).toBe(false);
- expect(shouldShowProAha(16)).toBe(false);
- expect(shouldShowProAha(24)).toBe(false);
- expect(shouldShowProAha(25)).toBe(false);
- expect(shouldShowProAha(35)).toBe(false);
- });
-});
-
-describe('subscribeProPrompt / emitProPrompt', () => {
- it('calls listener when emitted', () => {
- const listener = jest.fn();
- const unsub = subscribeProPrompt(listener);
- emitProPrompt('text');
- expect(listener).toHaveBeenCalledWith('text');
- unsub();
- });
-
- it('does not call listener after unsubscribe', () => {
- const listener = jest.fn();
- const unsub = subscribeProPrompt(listener);
- unsub();
- emitProPrompt('image');
- expect(listener).not.toHaveBeenCalled();
- });
-});
-
-describe('checkProPromptForText', () => {
- beforeEach(() => jest.useFakeTimers());
- afterEach(() => jest.useRealTimers());
-
- it('skips when hasRegisteredPro=true', () => {
- const store = makeStore({ hasRegisteredPro: true });
- mockedGetState.mockReturnValue(store);
- checkProPromptForText(0);
- expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled();
- });
-
- it('skips when isProActive=true (keychain/dev-unlocked Pro user, never registered)', () => {
- const store = makeStore({ hasRegisteredPro: false, isProActive: true });
- mockedGetState.mockReturnValue(store);
- checkProPromptForText(0);
- expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled();
- });
-
- it('skips when proAhaTriggeredBy is already set', () => {
- const store = makeStore({ proAhaTriggeredBy: 'text' });
- mockedGetState.mockReturnValue(store);
- checkProPromptForText(0);
- expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled();
- });
-
- it('skips when count does not meet threshold', () => {
- const store = makeStore({ textGenerationCount: 1 });
- mockedGetState.mockReturnValue(store);
- checkProPromptForText(0);
- expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled();
- });
-
- it('triggers when all conditions met', () => {
- const store = makeStore({ textGenerationCount: 10 });
- mockedGetState.mockReturnValue(store);
- checkProPromptForText(0);
- expect(store.setProAhaTriggeredBy).toHaveBeenCalledWith('text');
- });
-});
-
-describe('checkProPromptForImage', () => {
- beforeEach(() => jest.useFakeTimers());
- afterEach(() => jest.useRealTimers());
-
- it('triggers for image when count meets threshold', () => {
- const store = makeStore({ imageGenerationCount: 10 });
- mockedGetState.mockReturnValue(store);
- checkProPromptForImage(0);
- expect(store.setProAhaTriggeredBy).toHaveBeenCalledWith('image');
- });
-
- it('skips when hasRegisteredPro=true', () => {
- const store = makeStore({ hasRegisteredPro: true, imageGenerationCount: 10 });
- mockedGetState.mockReturnValue(store);
- checkProPromptForImage(0);
- expect(store.setProAhaTriggeredBy).not.toHaveBeenCalled();
- });
-});
diff --git a/__tests__/unit/utils/proPricing.test.ts b/__tests__/unit/utils/proPricing.test.ts
deleted file mode 100644
index dfc627a22..000000000
--- a/__tests__/unit/utils/proPricing.test.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { getPricingCopy } from '../../../src/utils/proPricing';
-
-describe('getPricingCopy', () => {
- const copy = getPricingCopy();
-
- it('offers the two plans sold on getoffgridai.co - $119 lifetime and $7.99/month - and no yearly', () => {
- expect(copy.title).toBe('$119 lifetime or $7.99/month');
- expect(copy.sheetSubheadline).toMatch(/\$119 once/);
- expect(copy.sheetSubheadline).toMatch(/\$7\.99 a month/);
- // The yearly plan is not sold; it must not resurface anywhere in the copy.
- const all = Object.values(copy).join(' ');
- expect(all).not.toMatch(/\byear(ly)?\b|\/yr/i);
- expect(all).not.toMatch(/\$49|\$39|\$69/);
- });
-
- it('keeps the Get Pro CTA (the web pay-page trigger the Pro surfaces assert)', () => {
- expect(copy.cta).toBe('Get Pro');
- expect(copy.label).toBe('FOUNDER RATE');
- });
-
- it('states the founder-rate terms (locked in, only goes up) and the 5-device cap', () => {
- expect(copy.subtitle).toMatch(/only goes up/i);
- expect(copy.subtitle).toMatch(/5 devices/);
- });
-});
diff --git a/docs/pro-licensing-plan.md b/docs/pro-licensing-plan.md
index b9f068846..66320561d 100644
--- a/docs/pro-licensing-plan.md
+++ b/docs/pro-licensing-plan.md
@@ -1,160 +1,46 @@
-# Off Grid Pro - Licensing Plan
+# Off Grid Pro licensing — retired
-## What it is
+**Status: retired. There is nothing to purchase.**
-Off Grid Pro uses a license-key model: no login, no accounts, offline-first.
-RevenueCat handles the money (checkout + payment events, as it does today).
-Keygen handles licensing (keys, device limits, offline validation). A thin
-Lambda connects the two. The app validates a signed key offline and never talks
-to RevenueCat.
+Off Grid AI no longer has a paid tier. Every capability the app has — voice mode and on-device speech,
+custom personas, draft-then-approve actions, MCP tools, and Personal Mesh sync — is free for everyone, in
+every build. There is no price, no checkout, no licence key, and nothing that expires.
-## The model
+## What this document used to describe
-| Tier | Price | Key type | Expiry | Renewal |
-|---|---|---|---|---|
-| Lifetime Pro | $50 one-time | perpetual | never | none |
-| Monthly Pro | $39 / month | timed | period end + buffer | each cycle |
+The licensing system was a three-part machine:
-Both run through one Keygen integration. The only difference is the expiry baked
-into the key and whether renewals fire.
+1. **RevenueCat** took the money and fired a webhook on payment, renewal and cancellation.
+2. **A stateless Lambda** turned those webhooks into Keygen licence creations and renewals, and emailed
+ the key to the buyer.
+3. **Keygen** issued Ed25519-signed keys with a tier and an expiry, capped at five machines, and served
+ offline validation, check-in, and a grace window.
-## The pieces
+The app validated the signed key locally against a baked-in public key, activated once per device, and
+re-checked periodically to catch renewal or revocation. A device the licensed-device roster dropped lost
+its Pro surfaces at the next revalidation.
-1. **RevenueCat - payments.** The existing pay page and payment backend. On a
- successful payment it fires a webhook with the buyer's email and product. No
- change to how money is taken.
-2. **Lambda - the bridge (stateless, no database).** Receives RevenueCat
- webhooks, calls Keygen to create or renew the license, and emails the key.
-3. **Keygen Cloud - licensing.** Issues cryptographically signed keys with tier
- and expiry embedded; enforces the device cap; serves offline validation, the
- check-in, and the grace window.
-4. **The app - offline validation.** Verifies the signed key locally against a
- baked-in public key; activates once per device; re-checks periodically to
- catch renewal and revocation.
+## What remains, and why
-## Keygen configuration
+The key-side plumbing was not deleted wholesale, because two things still need it:
-- **Two policies:** `pro-lifetime` (perpetual, no expiry) and `pro-monthly`
- (timed, 1 month plus a short buffer).
-- Both: **floating, `maxMachines = 5`** (shared across iOS, Android, and Off Grid
- Desktop), **Ed25519** signed, machine activation required before validation.
-- **License file (cert) TTL ~9 days** - the offline-snapshot freshness, separate
- from the subscription expiry.
-- Secret key lives server-side (Lambda + migration script). The app holds only
- the public key.
+- **Personal Mesh pairing** keys a device's identity to its fingerprint, and the roster of known devices
+ rides on that identity. Syncing between your own devices needs it whether or not anything costs money.
+- **Grandfathered credentials.** Someone who bought before the tier was removed still holds a key. The
+ app can still read, activate, and revalidate it — it simply has no bearing on what they may use.
-## The Lambda (issuance + renewal)
+So the entitlement service, the provider seam it registers, and the lifecycle that revalidates on launch
+and foreground all stay. What was removed is every *decision* made from them: `selectHasProAccess` in
+`src/stores/proAccessSlice.ts` grants unconditionally, and the paywall surfaces (pricing copy, the Pro
+upsell banner, the Aha offer sheet, the licence-key modal, the subscription management card, and the
+expiry redirect) are gone.
-Stateless, idempotent (RevenueCat retries on), keyed on the event id and email.
+The other half of the old system — the voice, tool and mesh code — lives in the optional `@offgrid/pro`
+bundle (the `pro/` submodule). A build either compiles it in, in which case it is unlocked for everyone,
+or it does not, in which case the affected surfaces show an empty state that explains the build rather
+than offering a purchase.
-| RevenueCat event | Keygen action |
-|---|---|
-| `INITIAL_PURCHASE` / `NON_RENEWING_PURCHASE` (lifetime) | create a **perpetual** license, set `metadata.email`, email the key |
-| `INITIAL_PURCHASE` (monthly) | create a **timed** license (expiry = period end + buffer), set `metadata.email`, email the key |
-| `RENEWAL` (monthly, each cycle) | push the license expiry forward, proactively |
-| `CANCELLATION` / `EXPIRATION` (monthly) | let it lapse at period end (or suspend) |
+## If you are looking for pricing
-The email comes from the event (`$email`). Sending the key needs an email
-provider (SES, Resend, Postmark, etc.).
-
-## The app
-
-- **First run on a device:** one online **activation** claims one of the 5
- machine slots. The fingerprint is a random UUID generated once and persisted
- in the Keychain, so a reinstall reclaims its slot. Tag the machine with
- `metadata.platform = ios | android | desktop`.
-- **Every launch:** verify the signed key locally against the baked-in public
- key, read tier and expiry, check expiry against the device clock. Fully
- offline.
-- **Every 7 days:** check in with Keygen to refresh the cert, pick up a renewed
- expiry, and catch revocation. If the check-in fails, keep working **2 more
- days** (grace, inside the 9-day TTL), then lock.
-- **Seat-management screen:** list the device's machines (N of 5) and let the
- user deactivate one. A 5-cap on a perpetual key fills over years of device
- upgrades, so a lifetime buyer must always be able to free a slot.
-
-## The monthly expiry rule (the one risk)
-
-A monthly key expires monthly, so the renewal must reach Keygen before the key
-expires or a paying user hits the grace and then locks out. Applied together:
-
-1. **Buffer the expiry**: license expiry = billing period end + a few days, so
- the offline key outlives the period enough to absorb webhook and check-in lag.
-2. **Renew proactively** on `RENEWAL` (push expiry forward immediately).
-3. Idempotent Lambda + RevenueCat retries.
-4. A manual reissue/extend support path for the rare miss.
-
-Lifetime has none of this risk - the key never expires.
-
-## Migration (existing cohort + cutover)
-
-**Backfill (one-time script, same Keygen calls as the Lambda in a loop):**
-
-1. Export the current Pro customers from RevenueCat (dashboard export or REST
- API): email, tier, status.
-2. For each: create a Keygen license - **perpetual** for lifetime/pre-order
- buyers, **timed** for any monthly - set `metadata.email`, email the key.
-3. Idempotent: skip anyone who already has a license with that `metadata.email`.
-
-**Cutover (do not strand anyone):**
-
-- The new app build validates the Keygen key.
-- Keep the current in-app entitlement check working as a **fallback for a 60-90
- day window** so users keep Pro between getting the email and entering the key.
- Lifetime buyers especially must not lose access in the gap.
-- Migration email: "we moved to license keys, here is yours, update the app and
- paste it in."
-- Remove the fallback after the window.
-
-## Analytics (email and platform)
-
-Both live in Keygen:
-
-- **email - key:** `metadata.email` on each license at issuance. Keygen is the
- customer lookup; no separate database.
-- **desktop / mobile / both:** `metadata.platform` on each machine at activation.
- A small reporting script reads the Keygen API and counts desktop-only vs
- mobile-only vs both. Optionally pipe Keygen webhooks into a sheet for a live
- view.
-
-## Cost
-
-- Keygen Cloud Std 1: $99/mo flat (up to 1,000 active licensed users; ~16% off
- annual).
-- RevenueCat + Stripe: their normal per-transaction cut.
-- Lambda: negligible.
-- No database, no servers maintained.
-
-## Honest limits
-
-- Offline validation is defeatable by a determined cracker (spoof a fingerprint,
- patch the verify call) - true of any client-side licensing. It stops casual
- key-sharing cold, because new devices must come online to activate and the
- 5-cap holds there.
-- Device fingerprints go to Keygen Cloud (a third party): anti-piracy metadata
- (a hashed fingerprint + activation timestamps), never user data, touched once
- per device.
-- Monthly renewal reliability is load-bearing for the monthly tier (see the
- expiry rule). Lifetime is unaffected.
-
-## Build plan
-
-- **Phase 0 - Keygen setup:** account, two policies, `maxMachines = 5`, Ed25519,
- ~9-day TTL. Capture public key (app) + secret key (server).
-- **Phase 1 - Lambda:** RevenueCat webhook endpoint, the event mapping above,
- email delivery, idempotency, expiry buffer + proactive renewal.
-- **Phase 2 - App:** offline verify module, activation with the Keychain-UUID
- fingerprint + platform tag, 7-day check-in / 2-day grace, seat-management
- screen.
-- **Phase 3 - Migration + cutover:** backfill script, migration email, the
- 60-90 day fallback window, then removal.
-- **Phase 4 - Analytics + ops:** reporting script, manual reissue path, webhook
- delivery monitoring.
-
-## References
-
-- Keygen floating licenses: https://keygen.sh/docs/choosing-a-licensing-model/floating-licenses/
-- Keygen offline licensing: https://keygen.sh/docs/choosing-a-licensing-model/offline-licenses/
-- Keygen cryptography: https://keygen.sh/docs/api/cryptography/
-- Keygen activating machines: https://keygen.sh/docs/activating-machines/
-- RevenueCat webhooks: https://www.revenuecat.com/docs/integrations/webhooks/event-types-and-fields
+There is none. See the README section **Everything is free**, and `src/services/proLicenseService.ts` for
+the note on what the remaining entitlement plumbing is for.
diff --git a/rules.md b/rules.md
index 02149b70e..a5dcf193a 100644
--- a/rules.md
+++ b/rules.md
@@ -2,7 +2,7 @@
Follow [the workspace engineering contract](../.codex/ENGINEERING_CONTRACT.md). It takes priority over these repo notes. Keep each change in the smallest existing owner.
-- Paid feature code belongs in the private `pro/` submodule. Core uses the existing registration seams. Commit Pro changes in its own repo.
+- Optional-feature code (voice, MCP tools, Personal Mesh sync) belongs in the private `pro/` submodule. Core uses the existing registration seams — slots, hooks, screens, settings sections — and every one of them is free: nothing in core gates a feature behind a purchase. Commit bundle changes in its own repo.
- For UI work, use `@offgrid/design` and the relevant guide in `docs/design/`. For customer-facing copy, read `docs/brand_tone_voice.md`.
- Keep capture opt-in with a visible recording indicator. Do not publish private device data.
- During device checks, do not enter credentials or passcodes. Open private content only when the changed journey requires it.
diff --git a/src/bootstrap/loadProFeatures.ts b/src/bootstrap/loadProFeatures.ts
index 5f6b8bc58..c3ace1243 100644
--- a/src/bootstrap/loadProFeatures.ts
+++ b/src/bootstrap/loadProFeatures.ts
@@ -7,11 +7,31 @@ import { registerHook } from './hookRegistry';
import {
getProLicenseInfo,
registerProEntitlementProvider,
+ type ProLicenseInfo,
} from '../services/proLicenseService';
import { proEntitlementLifecycle } from '../services/proEntitlementLifecycle';
-import { selectHasProAccess } from '../stores/proAccessSlice';
-export async function loadProFeatures(isPro?: boolean): Promise {
+/**
+ * Bring the optional pro bundle (@offgrid/pro) into the app.
+ *
+ * Nothing here is gated. Off Grid AI is free: if the bundle is in this build, it activates - voice mode,
+ * personas, MCP tools, Personal Mesh sync, the lot - for everyone, with no licence, key, or purchase.
+ * Registration used to depend on a verified credential and the licensed-device roster's admission for
+ * this device, which meant a build could be fully capable and still refuse to switch on. It cannot any
+ * more. What follows a load is only a report of what was loaded, so the Settings surface can say whether
+ * this build carries the bundle at all.
+ *
+ * The bundle is optional, not conditional on payment: an open-core build (the default in this repo, where
+ * `pro/` is a submodule that is not checked out) resolves `@offgrid/pro` to the null stub, and this function
+ * returns false. Every Pro entry point in core handles that - it reads the registries, so an absent bundle
+ * shows a feature-less build rather than a route to a purchase, of which there are none left.
+ *
+ * The licence lifecycle below is deliberately kept: `revalidateProEntitlement` is how the provider keeps its
+ * cached credential and the licensed-device roster current, and mesh pairing between your own devices still
+ * needs a device identity. It no longer decides anything: an unreachable provider, an expired credential, or
+ * a device the roster calls inactive changes what the app displays, never what the app lets you do.
+ */
+export async function loadProFeatures(): Promise {
let pro: any;
try {
logger.log('[BOOT-PRO] require(@offgrid/pro)');
@@ -21,7 +41,7 @@ export async function loadProFeatures(isPro?: boolean): Promise {
}
logger.log('[BOOT-PRO] require returned');
if (!pro) {
- return false; // proStub.js returns null — free build via metro extraNodeModules
+ return false; // proStub.js returns null — build without the bundle
}
if (typeof pro.configureProEntitlementProvider === 'function') {
pro.configureProEntitlementProvider(registerProEntitlementProvider);
@@ -29,49 +49,34 @@ export async function loadProFeatures(isPro?: boolean): Promise {
logger.log('[BOOT-PRO] proEntitlementLifecycle.start');
await proEntitlementLifecycle.start();
- // DEV ONLY: unlock pro features locally (audio mode, MCP) without a purchase so
- // they can be tested on simulators/dev builds. __DEV__ is false in release
- // builds, so this can never unlock pro in production. The Settings "Turn off
- // Pro (DEV)" toggle sets devProDisabled to exercise the free → Pro flow in a
- // debug build.
- const { useAppStore } = require('../stores/appStore');
- const DEV_UNLOCK_PRO = __DEV__ && !useAppStore.getState().devProDisabled;
-
logger.log('[BOOT-PRO] getProLicenseInfo');
- const licenseInfo = await getProLicenseInfo();
- const credentialActive = isPro ?? licenseInfo.isPro;
- const credentialSaved =
- isPro === true || (licenseInfo.credentialSaved ?? licenseInfo.isPro);
- const expired = licenseInfo.expired === true;
- const active = (credentialActive || DEV_UNLOCK_PRO) && !expired;
- // Single source of truth for "Pro is unlocked" — every upsell gate reads this, so a
- // keychain- or dev-unlocked Pro user never sees the upgrade prompt.
- useAppStore.getState().setHasRegisteredPro(credentialActive);
- useAppStore.getState().setHasSavedProCredential(credentialSaved);
- useAppStore.getState().setProActive(active);
+ const licenseInfo: ProLicenseInfo = await getProLicenseInfo();
+ // A read-only projection of what this install holds, so Settings and the Pro features screen can be
+ // honest about it. Nothing branches on these values. Required lazily, as before, so the bootstrap
+ // keeps no static edge into the store.
+ const { useAppStore } = require('../stores/appStore');
+ useAppStore.getState().setHasRegisteredPro(licenseInfo.isPro);
useAppStore
.getState()
- .setHasExpiredProCredential(expired);
- // A credential is not access. If the roster last told us this device is deactivated, the paid bundle
- // must not load at all - loading it and then hiding the entry points leaves every Pro service running.
- const admitted =
- selectHasProAccess(useAppStore.getState()) || DEV_UNLOCK_PRO;
- if (!expired && typeof pro.activateSyncBootstrap === 'function') {
+ .setHasSavedProCredential(licenseInfo.credentialSaved ?? licenseInfo.isPro);
+ useAppStore.getState().setProActive(licenseInfo.isPro);
+ useAppStore
+ .getState()
+ .setHasExpiredProCredential(licenseInfo.expired === true);
+
+ // The restricted Sync bootstrap is registered before activation so a build whose bundle is missing can
+ // still participate in the mesh it may later be handed a role in.
+ if (typeof pro.activateSyncBootstrap === 'function') {
pro.activateSyncBootstrap({
registerScreen,
registerSlot,
registerHook,
onEntitlementImported: async () => {
useAppStore.getState().setHasRegisteredPro(true);
- await loadProFeatures(true);
+ await loadProFeatures();
},
});
}
- if (!active || !admitted) {
- // Sync stays reachable on purpose even here: claiming a seat again, by key or from a paired device,
- // goes through the mesh runtime, so tearing it down would strand a deactivated device with no way back.
- return false; // every other paid feature stays dormant
- }
logger.log('[BOOT-PRO] pro.activate');
pro.activate({
@@ -85,7 +90,7 @@ export async function loadProFeatures(isPro?: boolean): Promise {
// Inject native OAuth adapters so MCP servers can use OAuth (browser sign-in +
// Keychain token storage + PKCE crypto). Required before any OAuth connect;
// until this runs the OAuth option stays hidden in the UI. Loaded lazily so
- // free builds never pull in the native crypto/browser libs.
+ // builds without the bundle never pull in the native crypto/browser libs.
if (typeof pro.configureOAuthAdapters === 'function') {
try {
const {
diff --git a/src/bootstrap/slotRegistry.ts b/src/bootstrap/slotRegistry.ts
index d78a39851..93a42d439 100644
--- a/src/bootstrap/slotRegistry.ts
+++ b/src/bootstrap/slotRegistry.ts
@@ -1,20 +1,20 @@
import { useSyncExternalStore, type ComponentType } from 'react';
/**
- * Component-slot seam. Pro features register UI components against named slots
- * during activation; core renders whatever is registered and renders nothing
- * (its own fallback) when a slot is empty. This lets the private pro package
- * inject UI into core screens without core ever importing pro — free builds
- * leave every slot empty and behave exactly as before.
+ * Component-slot seam. The optional pro bundle registers UI components against named slots
+ * during activation; core renders whatever is registered and renders nothing (its own
+ * fallback) when a slot is empty. This lets the pro package inject UI into core screens
+ * without core ever importing pro — a build compiled without the bundle leaves every slot
+ * empty and behaves exactly as before.
*
* Slot names are plain strings (see SLOTS below for the known set). Props are
* passed through untyped on purpose: each slot documents its own prop shape and
* the registering component declares it.
*
* Registration is reactive: `useSlot` subscribes, so a slot registered AFTER a
- * consumer has mounted (e.g. Pro activated at runtime via a license key, which
- * re-runs loadProFeatures) makes that consumer re-render and pick the component
- * up live — no app restart. `getSlot` stays for non-component / one-shot reads.
+ * consumer has mounted (e.g. the pro bundle loaded at runtime, which re-runs
+ * loadProFeatures) makes that consumer re-render and pick the component up live — no app
+ * restart. `getSlot` stays for non-component / one-shot reads.
*/
const slots: Record> = {};
const listeners = new Set<() => void>();
diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx
index 8c8f5f1a8..9bc1ca091 100644
--- a/src/components/ChatInput/index.tsx
+++ b/src/components/ChatInput/index.tsx
@@ -23,7 +23,7 @@ import { getSlot, SLOTS } from '../../bootstrap/slotRegistry';
import { AppSheet } from '../AppSheet';
import { Button } from '../Button';
-type AssistantAvailability = 'no-pro' | 'needs-sync' | 'unavailable' | 'ready';
+type AssistantAvailability = 'no-bundle' | 'needs-sync' | 'unavailable' | 'ready';
interface ChatInputProps {
onSend: (message: string, attachments?: MediaAttachment[], imageMode?: ImageModeState, assistantEnabled?: boolean) => void;
@@ -57,7 +57,6 @@ interface ChatInputProps {
* attachment thumbnail is tapped — the same handler in-message images use. */
onImagePress?: (uri: string) => void;
assistantAvailability?: AssistantAvailability;
- onAssistantUpgrade?: () => void;
onAssistantSetupSync?: () => void;
onAssistantEnableTools?: () => boolean;
assistantSelected?: boolean;
@@ -159,8 +158,7 @@ export const ChatInput: React.FC = ({
isRemote = false,
showSettingsDot = false,
onImagePress,
- assistantAvailability = 'no-pro',
- onAssistantUpgrade,
+ assistantAvailability = 'no-bundle',
onAssistantSetupSync,
onAssistantEnableTools,
assistantSelected,
@@ -341,12 +339,14 @@ export const ChatInput: React.FC = ({
}, ANIM_DURATION_IN);
};
- const assistantGate = assistantAvailability === 'no-pro'
+ const assistantGate = assistantAvailability === 'no-bundle'
? {
- title: 'Assistant requires Pro',
- body: 'Assistant uses Web Use and Computer Use on a connected Desktop.',
- action: 'View Pro',
- onPress: onAssistantUpgrade,
+ // A build without the optional pro bundle has no Desktop task tools to offer. This says so, and
+ // stops there: there is nothing to buy, so the gate carries no call to action.
+ title: 'Desktop tasks are not in this build',
+ body: 'Assistant uses Web Use and Computer Use on a connected Desktop, and ships in the optional Pro bundle. This build does not include it.',
+ action: 'OK',
+ onPress: undefined,
}
: assistantAvailability === 'unavailable'
? {
diff --git a/src/components/ProAhaSheet.tsx b/src/components/ProAhaSheet.tsx
deleted file mode 100644
index d63a0fbe7..000000000
--- a/src/components/ProAhaSheet.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import React from 'react';
-import { View, Text, TouchableOpacity } from 'react-native';
-import Icon from 'react-native-vector-icons/Feather';
-import { AppSheet } from './AppSheet';
-import { useThemedStyles } from '../theme';
-import type { ThemeColors, ThemeShadows } from '../theme';
-import { SPACING, TYPOGRAPHY, PRO_AHA_FEATURES } from '../constants';
-import { getPricingCopy } from '../utils/proPricing';
-
-interface ProAhaSheetProps {
- visible: boolean;
- onClose: () => void;
- onRegister: () => void;
-}
-
-export const ProAhaSheet: React.FC = ({ visible, onClose, onRegister }) => {
- const styles = useThemedStyles(createStyles);
- const pricing = getPricingCopy();
-
- const handleCta = () => {
- onClose();
- onRegister();
- };
-
- return (
-
-
- It already knows what you did today.
- {pricing.sheetSubheadline}
-
-
- {PRO_AHA_FEATURES.map(feature => (
-
-
- {feature}
-
- ))}
-
-
- {pricing.sheetFooter}
-
-
- {pricing.cta}
-
-
-
- );
-};
-
-const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
- content: {
- paddingHorizontal: SPACING.xl,
- paddingTop: SPACING.md,
- paddingBottom: SPACING.xxl,
- alignItems: 'center' as const,
- },
- headline: {
- ...TYPOGRAPHY.h2,
- color: colors.text,
- textAlign: 'center' as const,
- marginBottom: SPACING.sm,
- },
- subheadline: {
- ...TYPOGRAPHY.body,
- color: colors.textSecondary,
- textAlign: 'center' as const,
- marginBottom: SPACING.md,
- },
- priceRow: {
- marginBottom: SPACING.lg,
- },
- price: {
- ...TYPOGRAPHY.bodySmall,
- color: colors.primary,
- textAlign: 'center' as const,
- },
- featureList: {
- width: '100%' as const,
- marginBottom: SPACING.lg,
- gap: SPACING.sm,
- },
- featureRow: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- gap: SPACING.sm,
- },
- checkIcon: {
- color: colors.primary,
- },
- featureText: {
- ...TYPOGRAPHY.body,
- color: colors.text,
- },
- guarantee: {
- ...TYPOGRAPHY.bodySmall,
- color: colors.textMuted,
- textAlign: 'center' as const,
- marginBottom: SPACING.lg,
- },
- ctaButton: {
- width: '100%' as const,
- paddingVertical: SPACING.md,
- backgroundColor: colors.primary,
- borderRadius: 8,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- marginBottom: SPACING.sm,
- },
- ctaText: {
- ...TYPOGRAPHY.body,
- color: colors.background,
- },
-});
diff --git a/src/components/index.ts b/src/components/index.ts
index b483e0f5a..5ae9df9b6 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -31,5 +31,4 @@ export { VisionRepairAdviceCard } from './VisionRepairAdviceCard';
export { ProjectSelectorSheet } from './ProjectSelectorSheet';
export { DebugSheet } from './DebugSheet';
export { SharePromptSheet } from './SharePromptSheet';
-export { ProAhaSheet } from './ProAhaSheet';
;
diff --git a/src/components/models/VoiceModelsEmptyState.tsx b/src/components/models/VoiceModelsEmptyState.tsx
new file mode 100644
index 000000000..3ae9e7488
--- /dev/null
+++ b/src/components/models/VoiceModelsEmptyState.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, Linking } from 'react-native';
+import Icon from 'react-native-vector-icons/Feather';
+import { useTheme, useThemedStyles } from '../../theme';
+import type { ThemeColors } from '../../theme';
+import {
+ TYPOGRAPHY,
+ SPACING,
+ OFF_GRID_DESKTOP_BENEFIT,
+ OFF_GRID_DESKTOP_URL,
+} from '../../constants';
+import { withUtm } from '../../utils/utm';
+
+/**
+ * Shown in the Voice tab when no voice engine is registered — i.e. in a build without the optional pro
+ * bundle, which is where on-device text-to-speech lives.
+ *
+ * This used to be an upsell: a pitch, a "Get Pro" button and a route to the pay page. Voice is free now,
+ * so there is nothing to sell and no price to state. What is left is the honest version of the empty
+ * state — this build has no voice engine, here is what would appear if it did — plus the free Desktop
+ * companion, which can do the talking when your phone cannot.
+ */
+export const VoiceModelsEmptyState: React.FC = () => {
+ const { colors } = useTheme();
+ const styles = useThemedStyles(createStyles);
+
+ return (
+
+
+
+
+ Voice models
+
+ On-device text-to-speech ships in the optional Pro bundle, and this
+ build does not include it. There is nothing to buy: a build with the
+ bundle offers every voice to everyone. When it is here, download a voice
+ and the assistant can speak its replies, generated in your phone's RAM
+ and never sent anywhere.
+
+
+ Linking.openURL(withUtm(OFF_GRID_DESKTOP_URL, 'voice-models')).catch(
+ () => {},
+ )
+ }
+ accessibilityRole="link"
+ accessibilityLabel="Get Off Grid AI Desktop"
+ >
+
+ {OFF_GRID_DESKTOP_BENEFIT}
+
+
+ );
+};
+
+const createStyles = (colors: ThemeColors) => ({
+ container: {
+ flex: 1,
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ paddingHorizontal: SPACING.xl,
+ },
+ iconCircle: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ alignItems: 'center' as const,
+ justifyContent: 'center' as const,
+ backgroundColor: colors.surfaceLight,
+ marginBottom: SPACING.lg,
+ },
+ title: { ...TYPOGRAPHY.h3, color: colors.text, marginBottom: SPACING.sm },
+ body: {
+ ...TYPOGRAPHY.body,
+ color: colors.textSecondary,
+ textAlign: 'center' as const,
+ lineHeight: 22,
+ marginBottom: SPACING.xl,
+ },
+ desktopLink: {
+ flexDirection: 'row' as const,
+ alignItems: 'center' as const,
+ gap: SPACING.xs,
+ marginTop: SPACING.sm,
+ paddingVertical: SPACING.xs,
+ },
+ desktopLinkText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted },
+});
diff --git a/src/components/models/VoiceModelsSheet.tsx b/src/components/models/VoiceModelsSheet.tsx
index 8ee744db1..3c197b86d 100644
--- a/src/components/models/VoiceModelsSheet.tsx
+++ b/src/components/models/VoiceModelsSheet.tsx
@@ -1,8 +1,7 @@
import React from 'react';
import { View, Dimensions } from 'react-native';
-import { useNavigation } from '@react-navigation/native';
import { AppSheet } from '../../components/AppSheet';
-import { VoiceModelsUpsell } from './VoiceModelsUpsell';
+import { VoiceModelsEmptyState } from './VoiceModelsEmptyState';
import { useSlot, SLOTS } from '../../bootstrap/slotRegistry';
import { useThemedStyles } from '../../theme';
import type { ThemeColors } from '../../theme';
@@ -19,9 +18,10 @@ type Props = {
const PANEL_HEIGHT = Math.round(Dimensions.get('window').height * 0.6);
/**
- * Voice (TTS) model picker — reuses the pro Voice panel (engine selection +
- * link to voice options) rendered via the modelsScreen.voiceTab slot. Renders an
- * empty-state line in free builds where the slot isn't registered.
+ * Voice (TTS) model picker — reuses the voice panel (engine selection + link to voice options) that the
+ * optional pro bundle registers via the modelsScreen.voiceTab slot. In a build without the bundle the
+ * slot is empty and the empty state explains that, with no route to a purchase: voice is free, and the
+ * only thing a build can lack is the code.
*/
export const VoiceModelsSheet: React.FC = ({
visible,
@@ -30,19 +30,10 @@ export const VoiceModelsSheet: React.FC = ({
onBackToModels,
}) => {
const styles = useThemedStyles(createStyles);
- const navigation = useNavigation();
- // Reactive: picks up the pro Voice panel live when Pro is activated at runtime
+ // Reactive: picks up the pro Voice panel live when the bundle is loaded at runtime
// (loadProFeatures registers the slot), without needing an app restart.
const VoicePanel = useSlot(SLOTS.modelsScreenVoiceTab);
- // Free build: show the same Pro upsell the Models → Voice tab uses, instead of
- // a dead-end "not available" line. Close the sheet first so the Pro screen
- // isn't presented while this bottom sheet is still dismissing (iOS).
- const handleGetPro = () => {
- onClose();
- (navigation as unknown as { navigate: (name: string) => void }).navigate('ProDetail');
- };
-
return (
= ({
enableDynamicSizing
>
- {VoicePanel ? : }
+ {VoicePanel ? : }
);
diff --git a/src/components/models/VoiceModelsUpsell.tsx b/src/components/models/VoiceModelsUpsell.tsx
deleted file mode 100644
index 3d90a0ef5..000000000
--- a/src/components/models/VoiceModelsUpsell.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React from 'react';
-import { View, Text, TouchableOpacity, Linking } from 'react-native';
-import Icon from 'react-native-vector-icons/Feather';
-import { Button } from '../Button';
-import { useTheme, useThemedStyles } from '../../theme';
-import type { ThemeColors } from '../../theme';
-import {
- TYPOGRAPHY,
- SPACING,
- OFF_GRID_DESKTOP_BENEFIT,
- OFF_GRID_DESKTOP_URL,
-} from '../../constants';
-import { withUtm } from '../../utils/utm';
-
-interface VoiceModelsUpsellProps {
- /** Navigates to the Pro detail screen. */
- onGetPro: () => void;
-}
-
-/**
- * Shown in the Voice tab when no voice engine is registered (free / non-pro
- * builds). The pro feature fills the modelsScreen.voiceTab slot; when it's
- * absent the tab still renders so users can see what Pro adds.
- */
-export const VoiceModelsUpsell: React.FC = ({ onGetPro }) => {
- const { colors } = useTheme();
- const styles = useThemedStyles(createStyles);
-
- return (
-
-
-
-
- Voice models
-
- Off Grid AI Pro adds on-device text-to-speech. The voice model runs in your
- phone's RAM, so what you hear is generated on the device and never sent
- anywhere. Download a voice and the assistant can speak its replies.
-
-
- Linking.openURL(withUtm(OFF_GRID_DESKTOP_URL, 'voice-upsell')).catch(() => {})}
- accessibilityRole="link"
- accessibilityLabel="Get Off Grid AI Desktop"
- >
-
- {OFF_GRID_DESKTOP_BENEFIT}
-
-
- );
-};
-
-const createStyles = (colors: ThemeColors) =>
- ({
- container: {
- flex: 1,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- paddingHorizontal: SPACING.xl,
- },
- iconCircle: {
- width: 64,
- height: 64,
- borderRadius: 32,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- backgroundColor: colors.surfaceLight,
- marginBottom: SPACING.lg,
- },
- title: { ...TYPOGRAPHY.h3, color: colors.text, marginBottom: SPACING.sm },
- body: {
- ...TYPOGRAPHY.body,
- color: colors.textSecondary,
- textAlign: 'center' as const,
- lineHeight: 22,
- marginBottom: SPACING.xl,
- },
- button: { minWidth: 160 },
- desktopLink: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- gap: SPACING.xs,
- marginTop: SPACING.lg,
- paddingVertical: SPACING.xs,
- },
- desktopLinkText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted },
- });
diff --git a/src/components/settings/ProUpsellBanner.tsx b/src/components/settings/ProUpsellBanner.tsx
deleted file mode 100644
index 2a0ed30f1..000000000
--- a/src/components/settings/ProUpsellBanner.tsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import React from 'react';
-import { View, Text, TouchableOpacity, Linking } from 'react-native';
-import Icon from 'react-native-vector-icons/Feather';
-import { AnimatedEntry } from '../AnimatedEntry';
-import { Button } from '../Button';
-import { selectHasProAccess } from '../../stores/proAccessSlice';
-import { useAppStore } from '../../stores';
-import { useTheme, useThemedStyles } from '../../theme';
-import type { ThemeColors, ThemeShadows } from '../../theme';
-import {
- SPACING,
- TYPOGRAPHY,
- OFF_GRID_DESKTOP_BENEFIT,
- OFF_GRID_DESKTOP_URL,
-} from '../../constants';
-import { withUtm } from '../../utils/utm';
-import { getPricingCopy } from '../../utils/proPricing';
-
-const FEATURE_ROWS = [
- [{ icon: 'layers', label: 'AMBIENT' }, { icon: 'sunrise', label: 'PROACTIVE' }],
- [{ icon: 'shield', label: 'PRIVATE' }, { icon: 'refresh-cw', label: 'LIVE SYNC' }],
-];
-
-interface Props {
- /** Re-trigger the entrance animation when the screen regains focus. */
- trigger: number;
- onGetPro: () => void;
-}
-
-/**
- * Dismissible Settings banner promoting Off Grid AI Pro. Self-gates on the store
- * (hidden once Pro is active or the banner is dismissed). Flat, token-only, and
- * weights <= 400 per docs/design.
- */
-export const ProUpsellBanner: React.FC = ({ trigger, onGetPro }) => {
- const { colors } = useTheme();
- const styles = useThemedStyles(createStyles);
- // A saved credential may need device reactivation, but it is never an upsell.
- // Debug access and active Pro features remain separate projections.
- // Offer the upsell whenever this install does NOT have access - which now includes a device the
- // roster has deactivated, not only one that never had a credential.
- const show = useAppStore(
- (s) => !s.proBannerDismissed && !selectHasProAccess(s),
- );
- const dismiss = useAppStore((s) => s.setProBannerDismissed);
- const pricing = getPricingCopy();
-
- if (!show) return null;
-
- return (
-
-
-
-
- Off Grid AI Pro
-
- Your private AI stays current across your devices with live sync.
-
-
- dismiss(true)}
- style={styles.close}
- hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
- >
-
-
-
-
-
- {FEATURE_ROWS.map((row, i) => (
-
- {row.map((f) => (
-
-
-
-
- {f.label}
-
- ))}
-
- ))}
-
-
-
-
- Linking.openURL(withUtm(OFF_GRID_DESKTOP_URL, 'pro-upsell')).catch(() => {})}
- accessibilityRole="link"
- accessibilityLabel="Get Off Grid AI Desktop"
- >
-
- {OFF_GRID_DESKTOP_BENEFIT}
-
-
-
- );
-};
-
-const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
- card: {
- borderRadius: 12,
- marginBottom: SPACING.lg,
- backgroundColor: colors.surface,
- borderWidth: 1,
- borderColor: `${colors.primary}40`,
- ...shadows.small,
- },
- header: {
- flexDirection: 'row' as const,
- alignItems: 'flex-start' as const,
- justifyContent: 'space-between' as const,
- padding: SPACING.lg,
- paddingBottom: SPACING.md,
- },
- headerText: { flex: 1, marginRight: SPACING.md },
- title: { ...TYPOGRAPHY.h1, color: colors.primary, marginBottom: SPACING.xs },
- desc: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary, lineHeight: 18 },
- close: { padding: SPACING.xs },
- grid: { paddingHorizontal: SPACING.lg, paddingBottom: SPACING.md, gap: SPACING.sm },
- row: { flexDirection: 'row' as const, gap: SPACING.sm },
- item: { flex: 1, flexDirection: 'row' as const, alignItems: 'center' as const, gap: SPACING.sm },
- iconWrap: {
- width: 32,
- height: 32,
- borderRadius: 8,
- backgroundColor: colors.surfaceLight,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- },
- label: { ...TYPOGRAPHY.label, color: colors.text, letterSpacing: 0.5 },
- cta: { margin: SPACING.lg, marginTop: SPACING.sm },
- desktopLink: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- gap: SPACING.xs,
- paddingHorizontal: SPACING.lg,
- paddingBottom: SPACING.lg,
- marginTop: -SPACING.xs,
- },
- desktopLinkText: {
- ...TYPOGRAPHY.bodySmall,
- color: colors.textMuted,
- flexShrink: 1,
- textAlign: 'center' as const,
- },
-});
diff --git a/src/components/settings/sectionRegistry.ts b/src/components/settings/sectionRegistry.ts
index da9356ee3..98f193679 100644
--- a/src/components/settings/sectionRegistry.ts
+++ b/src/components/settings/sectionRegistry.ts
@@ -1,12 +1,12 @@
import { useSyncExternalStore, type ComponentType } from 'react';
/**
- * Settings-section seam. Pro registers extra Settings sections during activation; core
- * renders whatever is registered. Registration is REACTIVE (same pattern as slotRegistry):
- * a section registered AFTER the Settings screen mounted — e.g. Pro unlocked at runtime
- * via a license key, which re-runs loadProFeatures — makes the screen re-render and show
- * the section live, with no app restart. (Before this, the screen read getSettingsSections()
- * once at render, so an activated Pro user still saw the section missing until relaunch.)
+ * Settings-section seam. The optional pro bundle registers extra Settings sections during
+ * activation; core renders whatever is registered. Registration is REACTIVE (same pattern as
+ * slotRegistry): a section registered AFTER the Settings screen mounted — e.g. the bundle loaded
+ * at runtime, which re-runs loadProFeatures — makes the screen re-render and show the section
+ * live, with no app restart. (Before this, the screen read getSettingsSections() once at render,
+ * so the section stayed missing until relaunch.)
*/
const sections: ComponentType[] = [];
const listeners = new Set<() => void>();
diff --git a/src/constants/index.ts b/src/constants/index.ts
index fe2730461..103bc7966 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -16,13 +16,6 @@ export const OFF_GRID_DESKTOP_URL = 'https://getoffgridai.co/desktop';
export const OFF_GRID_DESKTOP_BENEFIT =
'More power, same privacy. Run bigger models on Mac or Windows and keep every device in sync.';
-export const PRO_AHA_FEATURES = [
- 'Create images with the model active on your Desktop',
- 'Transcribe speech with the model active on your Desktop',
- 'Hear replies in the voice active on your Desktop',
- 'Control which models your named Desktop serves',
-];
-
// Hugging Face API configuration
export const HF_API = {
baseUrl: 'https://huggingface.co',
diff --git a/src/hooks/useIsProActive.ts b/src/hooks/useIsProActive.ts
index 9069efcca..506e3efb6 100644
--- a/src/hooks/useIsProActive.ts
+++ b/src/hooks/useIsProActive.ts
@@ -3,25 +3,26 @@ import { selectHasProAccess } from '../stores/proAccessSlice';
import { useAppStore } from '../stores';
/**
- * Single reactive source of truth for "are Pro features currently active in the
- * UI right now?".
+ * Single reactive source of truth for "are the Pro bundle's surfaces in the UI right now?".
*
- * Pro features are activated by loadProFeatures(), which calls pro.activate() and
- * registers the Pro Tools destination screen ('McpServers'). This is true for
- * both a verified purchase AND the DEV unlock path (__DEV__ && !devProDisabled) —
- * unlike appStore.hasRegisteredPro, which only flips true after license
- * verification and therefore misses dev/simulator builds.
+ * Those surfaces are registered by loadProFeatures(), which calls pro.activate() and registers the Pro
+ * Tools destination screen ('McpServers'). Registration is now the WHOLE answer, because there is nothing
+ * to be entitled to: every feature that used to be sold is free, and selectHasProAccess() always grants.
*
- * Gating UI on the registered screen mirrors the existing handleMcpPress check in
- * ChatMessageArea, so the whole app agrees on one definition of "Pro is active".
+ * So this hook reads as "is the optional pro bundle part of this build?", and that is exactly how the UI
+ * uses it - to decide whether a surface EXISTS, never whether someone may use it. A build without the
+ * bundle registers nothing, and the app shows an honest empty state rather than a way to pay.
+ *
+ * Gating UI on the registered screen mirrors the handleProToolsPress check in ChatMessageArea, so the
+ * whole app agrees on one definition.
*/
export const PRO_TOOLS_SCREEN = 'McpServers';
export function useIsProActive(): boolean {
const registered = useHasRegisteredScreen(PRO_TOOLS_SCREEN);
- // Registration says the bundle loaded; access says this device is still
- // entitled. The access check closes the short interval while async runtime
- // cleanup removes every registered Pro surface.
+ // Kept as a subscription rather than dropped: registration is the live half (the bundle can be loaded
+ // at runtime, e.g. Fast Refresh or a mesh hand-off, with no app restart), and the access read keeps
+ // every caller in step with the one rule by construction. It always returns true today.
const hasAccess = useAppStore(selectHasProAccess);
return registered && hasAccess;
}
diff --git a/src/hooks/useOpenProTools.ts b/src/hooks/useOpenProTools.ts
index 2f9819f49..04ea982f5 100644
--- a/src/hooks/useOpenProTools.ts
+++ b/src/hooks/useOpenProTools.ts
@@ -8,20 +8,26 @@ import { PRO_TOOLS_SCREEN } from './useIsProActive';
/**
* One callback for "open the Pro Tools destination", used everywhere the user can
- * reach pro tools (the chat quick-settings row and the Tools page header).
+ * reach those tools (the chat quick-settings row and the Tools page header).
*
- * Pro active -> the Pro Tools screen (email/calendar + MCP servers) is registered,
- * so navigate straight into it and show what is available.
- * Free -> the screen is not registered, so route to the Pro upsell page.
+ * This used to be a fork: entitled users went into the tools, everyone else was routed
+ * to the purchase page. There is no purchase page any more - Off Grid AI is free - so
+ * the callback now answers the only question left, which is whether this build carries
+ * the tools at all:
*
- * Routing on the registered screen (not on a license flag) keeps this in step with
- * useIsProActive and the rest of the app's single definition of "Pro is active".
+ * Bundle loaded -> the Pro Tools screen (email/calendar + MCP servers) is registered,
+ * so navigate straight into it.
+ * No bundle -> the Pro features screen, which explains what is missing and why,
+ * and never asks for money.
*/
export function useOpenProTools(): () => void {
- const navigation = useNavigation>();
+ const navigation =
+ useNavigation>();
return () => {
const hasAccess = selectHasProAccess(useAppStore.getState());
- const hasProScreen = getRegisteredScreens().some(s => s.name === PRO_TOOLS_SCREEN);
+ const hasProScreen = getRegisteredScreens().some(
+ s => s.name === PRO_TOOLS_SCREEN,
+ );
if (hasAccess && hasProScreen) {
navigation.navigate(PRO_TOOLS_SCREEN as any);
} else {
diff --git a/src/hooks/useOpenSync.ts b/src/hooks/useOpenSync.ts
index 458b7824e..8249b7ac2 100644
--- a/src/hooks/useOpenSync.ts
+++ b/src/hooks/useOpenSync.ts
@@ -2,15 +2,18 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useCallback } from 'react';
import type { RootStackParamList } from '../navigation/types';
-import { selectHasProAccess } from '../stores/proAccessSlice';
-import { useAppStore } from '../stores';
+import { useHasRegisteredScreen } from '../navigation/screenRegistry';
/**
* Single navigation gate for the user-facing Sync control center.
*
- * The restricted Sync bootstrap remains registered so an unlicensed installation can
- * receive Pro from an already licensed peer. Normal product entry points, however,
- * lead unlicensed users through the Pro surface first.
+ * Sync between your own devices is free, like everything else, so this no longer asks whether the person
+ * may use it. What it does ask is whether the surfaces are in this build: the Sync and Notifications
+ * screens come from the optional pro bundle, and navigating to a route that does not exist would leave a
+ * tap doing nothing. When they are missing, the tap lands on the Pro features screen, which says so
+ * plainly instead of selling anything.
+ *
+ * `isSyncUnlocked` keeps its name for its callers, and now means "the Sync screen is here".
*/
export function useOpenSync(): {
isSyncUnlocked: boolean;
@@ -19,16 +22,14 @@ export function useOpenSync(): {
} {
const navigation =
useNavigation>();
- // A credential alone is not enough. A device deactivated from the licensed-device roster is no
- // longer Pro, and Sync is the feature the roster exists to meter - it kept working while the Pro
- // screen's own header read "Device Not Active".
- const isSyncUnlocked = useAppStore(selectHasProAccess);
+ const isSyncUnlocked = useHasRegisteredScreen('Sync');
+ const hasNotifications = useHasRegisteredScreen('Notifications');
const openSync = useCallback(() => {
navigation.navigate(isSyncUnlocked ? 'Sync' : 'ProDetail');
}, [isSyncUnlocked, navigation]);
const openSyncNotifications = useCallback(() => {
- navigation.navigate(isSyncUnlocked ? 'Notifications' : 'ProDetail');
- }, [isSyncUnlocked, navigation]);
+ navigation.navigate(hasNotifications ? 'Notifications' : 'ProDetail');
+ }, [hasNotifications, navigation]);
return { isSyncUnlocked, openSync, openSyncNotifications };
}
diff --git a/src/hooks/useProStatusLabel.ts b/src/hooks/useProStatusLabel.ts
index b7d85b8c0..728307780 100644
--- a/src/hooks/useProStatusLabel.ts
+++ b/src/hooks/useProStatusLabel.ts
@@ -1,50 +1,20 @@
-import { useEffect, useState } from 'react';
-import { selectHasProAccess } from '../stores/proAccessSlice';
-import { useAppStore } from '../stores';
-import {
- getProLicenseInfo,
- PRO_TIER_META,
- type ProLicenseInfo,
-} from '../services/proLicenseService';
+import { useIsProActive } from './useIsProActive';
/**
- * Label for the Settings "Off Grid AI PRO" row: the upsell line when not Pro, or
- * the subscription status (a renewing tier shows its date; a one-time tier shows
- * " · active") when Pro.
+ * Label for the Settings "Pro features" row.
+ *
+ * It used to be an upsell line ("Unlock premium features") or a subscription status read out of the
+ * cached licence. Off Grid AI is free, so there is nothing to unlock and nothing to renew; the row now
+ * says what is true of the feature set in THIS build, which is the only thing that can still differ:
+ * whether the optional pro bundle (voice, tools, mesh sync) is compiled in.
*/
export function useProStatusLabel(): {
proStatusLabel: string;
} {
- const hasSavedProCredential = useAppStore(s => s.hasSavedProCredential);
- const isProActive = useAppStore(s => s.isProActive);
- const hasProAccess = useAppStore(selectHasProAccess);
- const [info, setInfo] = useState(null);
- useEffect(() => {
- if (hasSavedProCredential)
- getProLicenseInfo()
- .then(setInfo)
- .catch(() => {});
- else setInfo(null);
- }, [hasSavedProCredential]);
-
- // Drive off the tier's `renews` flag (single source), not a concrete-tier check.
- const meta = info?.tier ? PRO_TIER_META[info.tier] : null;
- const isDevelopmentAccess = __DEV__ && isProActive && !hasSavedProCredential;
- const proStatusLabel = isDevelopmentAccess
- ? 'Development access'
- : !hasProAccess
- ? 'Unlock premium features'
- : info?.isPro && meta?.renews && info.expiry
- ? `Active until ${new Date(info.expiry).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}`
- : info?.isPro && meta
- ? `${meta.label} · active`
- : info
- ? 'Development · active'
- : 'Pro · active';
-
- return { proStatusLabel };
+ const bundleLoaded = useIsProActive();
+ return {
+ proStatusLabel: bundleLoaded
+ ? 'Everything included, free'
+ : 'Everything included, free - this build has no Pro bundle',
+ };
}
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index acea552d0..e6b0c4200 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -189,7 +189,7 @@ export const AppNavigator: React.FC = () => {
const { colors } = useTheme();
const hasCompletedOnboarding = useAppStore((s) => s.hasCompletedOnboarding);
const downloadedModels = useAppStore((s) => s.downloadedModels);
- // Reactive: screens registered at runtime (Pro activation re-runs loadProFeatures)
+ // Reactive: screens registered at runtime (loading the pro bundle re-runs loadProFeatures)
// mount as real routes live, so navigate('McpServers') works without an app restart.
const registeredScreens = useRegisteredScreens();
diff --git a/src/navigation/navigationRef.ts b/src/navigation/navigationRef.ts
new file mode 100644
index 000000000..7322d2b06
--- /dev/null
+++ b/src/navigation/navigationRef.ts
@@ -0,0 +1,13 @@
+import { createNavigationContainerRef } from '@react-navigation/native';
+import type { RootStackParamList } from './types';
+
+/**
+ * The app's one imperative navigation handle, for the rare jump that is decided outside a screen's own
+ * component tree.
+ *
+ * It used to live beside the Pro expiry redirect, which existed to hold an installation whose licence had
+ * lapsed on the purchase route. Off Grid AI is free in this build, so there is nothing to lapse and no
+ * purchase route to hold anyone on; the ref outlived it because other callers still navigate imperatively.
+ */
+export const appNavigationRef =
+ createNavigationContainerRef();
diff --git a/src/navigation/screenRegistry.ts b/src/navigation/screenRegistry.ts
index 70c7d9c3a..0de95fbf6 100644
--- a/src/navigation/screenRegistry.ts
+++ b/src/navigation/screenRegistry.ts
@@ -42,7 +42,7 @@ export function getRegisteredScreens(): RegisteredScreen[] {
/**
* Reactive read of whether a screen with the given name is registered.
* Subscribes via useSyncExternalStore so a screen registered AFTER a consumer
- * mounted (e.g. Pro activated at runtime, which re-runs loadProFeatures) makes
+ * mounted (e.g. the pro bundle loaded at runtime, which re-runs loadProFeatures) makes
* the consumer re-render. Mirrors useSlot in slotRegistry.
*/
export function useHasRegisteredScreen(name: string): boolean {
diff --git a/src/navigation/useProExpiryRedirect.ts b/src/navigation/useProExpiryRedirect.ts
deleted file mode 100644
index b4a1e9304..000000000
--- a/src/navigation/useProExpiryRedirect.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useCallback, useEffect, useRef } from 'react';
-import {
- createNavigationContainerRef,
- type NavigationContainerRef,
-} from '@react-navigation/native';
-import { useAppStore } from '../stores';
-import { selectHasProAccess } from '../stores/proAccessSlice';
-import type { RootStackParamList } from './types';
-
-export const appNavigationRef =
- createNavigationContainerRef();
-
-/**
- * Keep an expired installation on the purchase route.
- *
- * The returned callback is also the NavigationContainer onReady handler. This handles a credential
- * that was already expired during cold-start hydration, before the navigator existed.
- */
-export function useProExpiryRedirect(
- navigation: ProExpiryNavigation = appNavigationRef,
-): () => void {
- const expired = useAppStore(state => state.hasExpiredProCredential);
- const hasProAccess = useAppStore(selectHasProAccess);
- const hadProAccess = useRef(hasProAccess);
- const redirectPending = useRef(expired);
-
- const redirectIfExpired = useCallback(() => {
- if (!redirectPending.current || !navigation.isReady()) return;
- if (navigation.getCurrentRoute?.()?.name === 'ProDetail') {
- redirectPending.current = false;
- return;
- }
- redirectPending.current = false;
- navigation.resetRoot({
- index: 0,
- routes: [{ name: 'ProDetail' }],
- });
- }, [navigation]);
-
- useEffect(() => {
- if (hasProAccess) {
- hadProAccess.current = true;
- redirectPending.current = false;
- return;
- }
- if (expired || hadProAccess.current) {
- hadProAccess.current = false;
- redirectPending.current = true;
- }
- redirectIfExpired();
- }, [expired, hasProAccess, redirectIfExpired]);
-
- return redirectIfExpired;
-}
-type ProExpiryNavigation = Pick<
- NavigationContainerRef,
- 'isReady' | 'resetRoot'
-> & {
- getCurrentRoute?: NavigationContainerRef['getCurrentRoute'];
-};
diff --git a/src/screens/ChatScreen/ChatMessageArea.tsx b/src/screens/ChatScreen/ChatMessageArea.tsx
index 72e2c3346..757c208c3 100644
--- a/src/screens/ChatScreen/ChatMessageArea.tsx
+++ b/src/screens/ChatScreen/ChatMessageArea.tsx
@@ -166,12 +166,11 @@ export const ChatMessageArea: React.FC = ({
const interfaceMode = useUiModeStore(s => s.interfaceMode);
const tabNav = useNavigation>();
const toolCountHintDismissed = useAppStore(state => state.toolCountHintDismissed);
- // Subscribe to Pro activation so this re-renders the moment a license is
- // activated. loadProFeatures() registers the tool extensions + the Pro Tools
- // screen in one pass; without this subscription the getToolExtensions() reads
- // below are non-reactive and the Pro Tools badge stayed stale until an app
- // restart. Return is intentionally unused — the count is naturally 0 when Pro
- // is inactive (no extensions registered); we only need the re-render.
+ // Subscribe to the pro bundle loading so this re-renders the moment it registers its
+ // tool extensions + the Pro Tools screen. Without this subscription the getToolExtensions()
+ // reads below are non-reactive and the Pro Tools badge stayed stale until an app restart.
+ // Return is intentionally unused — the count is naturally 0 when the bundle is absent (no
+ // extensions registered); we only need the re-render.
const isProActive = useIsProActive();
const assistantAvailability = useAssistantToolAvailability();
// extToolCount is the live MCP tool count (the email/calendar extension reports 0
@@ -432,12 +431,11 @@ export const ChatMessageArea: React.FC = ({
isRemote={chat.activeModelInfo.isRemote}
onImagePress={chat.handleImagePress}
assistantAvailability={
- !isProActive
- ? 'no-pro'
- : assistantAvailability
+ // The bundle question, not a payment question: without the optional pro bundle there are no
+ // Desktop task tools registered to report an availability at all.
+ !isProActive ? 'no-bundle' : assistantAvailability
}
onAssistantEnableTools={enableAssistantTools}
- onAssistantUpgrade={() => tabNav.navigate('ProDetail')}
onAssistantSetupSync={() => tabNav.navigate('Sync')}
/>
diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx
index 65a836748..9a26e253e 100644
--- a/src/screens/ChatScreen/index.tsx
+++ b/src/screens/ChatScreen/index.tsx
@@ -14,11 +14,9 @@ import {
hideAlert,
showAlert,
SharePromptSheet,
- ProAhaSheet,
} from '../../components';
import { useEjectAllModels } from '../../hooks/useEjectAllModels';
import { subscribeSharePrompt } from '../../utils/sharePrompt';
-import { subscribeProPrompt } from '../../services/proPrompt';
import type { Conversation, Message } from '../../types';
import { useTheme, useThemedStyles } from '../../theme';
import { createStyles } from './styles';
@@ -151,17 +149,6 @@ export const ChatScreen: React.FC = () => {
const [sharePromptVisible, setSharePromptVisible] = useState(false);
useEffect(() => subscribeSharePrompt(() => setSharePromptVisible(true)), []);
- const [proAhaVisible, setProAhaVisible] = useState(false);
- const proAhaShownThisSession = useRef(false);
- useEffect(
- () =>
- subscribeProPrompt(() => {
- if (proAhaShownThisSession.current) return;
- proAhaShownThisSession.current = true;
- setProAhaVisible(true);
- }),
- [],
- );
// Only ONE AttachStep mounted at a time to avoid waypoint dots/lines.
React.useEffect(() => {
@@ -388,11 +375,6 @@ export const ChatScreen: React.FC = () => {
visible={sharePromptVisible}
onClose={() => setSharePromptVisible(false)}
/>
- setProAhaVisible(false)}
- onRegister={() => rootNavigation.navigate('ProDetail')}
- />
);
};
diff --git a/src/screens/HomeScreen/index.tsx b/src/screens/HomeScreen/index.tsx
index 276d846f6..d7dacfd51 100644
--- a/src/screens/HomeScreen/index.tsx
+++ b/src/screens/HomeScreen/index.tsx
@@ -192,7 +192,7 @@ export const HomeScreen: React.FC = ({ navigation }) => {
hitSlop={8}
style={styles.crownButton}
accessibilityRole="button"
- accessibilityLabel="Open Off Grid AI Pro"
+ accessibilityLabel="Open Off Grid AI Pro features"
>
diff --git a/src/screens/ModelsScreen/index.tsx b/src/screens/ModelsScreen/index.tsx
index b011fb279..e9d57fd09 100644
--- a/src/screens/ModelsScreen/index.tsx
+++ b/src/screens/ModelsScreen/index.tsx
@@ -11,7 +11,7 @@ import { useModelsScreen } from './useModelsScreen';
import { createStyles } from './styles';
import { TextModelsTab } from './TextModelsTab';
import { ImageModelsTab } from './ImageModelsTab';
-import { VoiceModelsUpsell } from '../../components/models/VoiceModelsUpsell';
+import { VoiceModelsEmptyState } from '../../components/models/VoiceModelsEmptyState';
import { TranscriptionModelsTab } from './TranscriptionModelsTab';
import { useSlot, SLOTS } from '../../bootstrap/slotRegistry';
import type { ModelTab } from './types';
@@ -240,11 +240,12 @@ export const ModelsScreen: React.FC = ({ embedded = false })
/>
)}
- {/* Voice Models Tab: pro panel when registered, otherwise an upsell. */}
+ {/* Voice Models Tab: the voice panel when the bundle registers it, otherwise the empty state.
+ Voice is free either way; the only question is whether this build carries the engine. */}
{vm.activeTab === 'voice' && (
VoiceModelsPanel
?
- : vm.navigation.navigate('ProDetail')} />
+ :
)}
{/* Transcription Models Tab (speech-to-text, core). */}
diff --git a/src/screens/ProDetailScreen/ProIncludedSection.tsx b/src/screens/ProDetailScreen/ProIncludedSection.tsx
index 9089457a0..1edd710a6 100644
--- a/src/screens/ProDetailScreen/ProIncludedSection.tsx
+++ b/src/screens/ProDetailScreen/ProIncludedSection.tsx
@@ -1,14 +1,12 @@
/**
* ProIncludedSection
*
- * What an active licence actually gets you, as rows you can walk into.
+ * The features, as rows you can walk into.
*
- * The Pro screen used to end after the licence card, leaving most of the page empty for anyone who
- * had already paid - and empty space that says nothing is a bug, not a layout. A subscriber does not
- * need the sales pitch again; they need the doors it opened.
- *
- * Every row is gated on a REGISTERED screen (`useHasRegisteredScreen`), so a build without a surface
- * never advertises it and there are no dead ends.
+ * These rooms used to be what a licence unlocked; now they are simply what the app does, and the only
+ * thing that can make one missing is a build without the optional pro bundle. Every row is gated on a
+ * REGISTERED screen (`useHasRegisteredScreen`) rather than on any entitlement, so a build without a
+ * surface never advertises it, there are no dead ends, and nothing is ever locked.
*/
import React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
@@ -63,7 +61,7 @@ export const ProIncludedSection: React.FC = () => {
return (
- Included with Pro
+ Included for everyone
{rows.map((row, index) => (
{
- const navigation = useNavigation();
- const { colors } = useTheme();
- const styles = useThemedStyles(createStyles);
- const hasSyncScreen = useHasRegisteredScreen('Sync');
- const [info, setInfo] = useState(null);
- const [loading, setLoading] = useState(true);
- const [resetting, setResetting] = useState(false);
-
- // Answers whether the card now shows current state. It swallows its own failure so first paint can
- // never reject, which also meant a caller could not tell a repaint from a silent miss - and after a
- // reset that difference is the whole message.
- const refresh = useCallback(async (): Promise => {
- try {
- setInfo(await getProLicenseInfo());
- return true;
- } catch (e) {
- logger.error('[ProManage] load failed:', e instanceof Error ? e.message : String(e));
- return false;
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- refresh();
- }, [refresh]);
-
- // Confirmed first, because it releases a seat on the licence rather than only clearing this phone.
- // The failure branch matters: resetProOnThisDevice returns false when the seat could NOT be
- // released, and it keeps the credential in that case. Telling the user it worked would leave them
- // entering a new key on a device the licence still counts.
- const confirmReset = useCallback(() => {
- Alert.alert(
- 'Reset Pro on this phone?',
- 'This removes this phone from the saved licence so it can use a different one. Your other devices stay active.',
- [
- { text: 'Cancel', style: 'cancel' },
- {
- text: 'Reset Pro',
- style: 'destructive',
- onPress: () => {
- setResetting(true);
- resetProOnThisDevice()
- .then(async ok => {
- if (!ok) {
- Alert.alert(
- 'Could not reset Pro',
- 'The licence could not be reached, so this phone still holds its seat. Check your connection and try again.',
- );
- return;
- }
- // The seat IS released and the credential IS gone by this point, so a failure here is
- // not a failed reset - it is a screen that could not catch up. Swallowing it left the
- // card showing a licence the phone no longer has, which reads as "reset did nothing"
- // and invites a second attempt on a device that has already been reset.
- let repainted = false;
- try {
- await loadProFeatures();
- repainted = await refresh();
- } catch (e) {
- logger.error(`[Pro] reset reload failed: ${String(e)}`);
- }
- // Covers both ways the card can be left behind: loadProFeatures throwing, and refresh
- // failing to read the new state - which it reports rather than throws.
- if (!repainted) {
- Alert.alert(
- 'Pro was reset',
- 'This phone no longer holds the licence. Pro features are now locked.',
- );
- }
- })
- // The reset itself threw, so whether the seat was released is unknown. Say so rather
- // than only writing to a log the user cannot read.
- .catch(e => {
- logger.error(`[Pro] reset failed: ${String(e)}`);
- Alert.alert(
- 'Could not reset Pro',
- 'Something went wrong, so this phone may still hold its seat. Try again.',
- );
- })
- .finally(() => setResetting(false));
- },
- },
- ],
- );
- }, [refresh]);
-
- // Render from the tier's own semantics (PRO_TIER_META), not a per-tier branch: a
- // recurring tier shows its renewal date, a one-time tier says it never expires.
- const tierMeta = info?.tier ? PRO_TIER_META[info.tier] : null;
- const statusLine = !tierMeta
- ? ''
- : tierMeta.renews
- ? `${tierMeta.label} · renews ${formatDate(info!.expiry)}`
- : `${tierMeta.label} · never expires`;
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- const renewing = tierMeta?.renews === true;
-
- return (
-
- Your licence
-
-
- {statusLine}
-
-
- {hasSyncScreen ? (
- navigation.navigate('Sync')}
- accessibilityRole="button"
- accessibilityLabel="Manage licensed devices in Sync"
- >
-
-
- Licensed devices
- View or deactivate from Sync
-
-
-
- ) : null}
-
- {/* Web Billing authenticates by email, so the only real path to cancelling is that link. */}
- {renewing ? (
-
-
-
- Manage subscription
-
- Use the link in your renewal email to cancel or change payment.
-
-
-
- ) : null}
-
- {/* The desktop has carried this since it shipped; the phone had no equivalent, and the
- credential lives in the Keychain, which survives deleting the app. So a phone holding the
- wrong licence could not be moved onto the right one by any means available to its owner. */}
-
-
-
-
- {resetting ? 'Resetting…' : 'Reset Pro'}
-
-
- Remove the saved licence so this phone can use a different one.
-
-
- {resetting ? : null}
-
-
- );
-};
-
-// The same card as the rest of the app: one surface, an uppercase title, hairline-divided rows.
-const createStyles = (colors: ThemeColors, shadows: ThemeShadows) =>
- ({
- card: {
- backgroundColor: colors.surface,
- borderRadius: 12,
- paddingHorizontal: SPACING.md,
- paddingVertical: SPACING.xs,
- marginHorizontal: SPACING.xl,
- marginBottom: SPACING.xl,
- ...shadows.small,
- },
- cardTitle: {
- ...TYPOGRAPHY.label,
- color: colors.textMuted,
- textTransform: 'uppercase' as const,
- letterSpacing: 0.3,
- paddingTop: SPACING.md,
- paddingBottom: SPACING.xs,
- },
- row: {
- minHeight: 44,
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- gap: SPACING.md,
- paddingVertical: SPACING.md,
- borderBottomWidth: 1,
- borderBottomColor: colors.border,
- },
- lastRow: { borderBottomWidth: 0 },
- rowText: { flex: 1, gap: SPACING.xs },
- rowTitle: { ...TYPOGRAPHY.bodySmall, color: colors.text, flex: 1 },
- rowDescription: { ...TYPOGRAPHY.meta, color: colors.textMuted },
- });
diff --git a/src/screens/ProDetailScreen/ProUnlockModal.tsx b/src/screens/ProDetailScreen/ProUnlockModal.tsx
deleted file mode 100644
index 51de53a1c..000000000
--- a/src/screens/ProDetailScreen/ProUnlockModal.tsx
+++ /dev/null
@@ -1,328 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import {
- View,
- Text,
- TouchableOpacity,
- TextInput,
- Linking,
- ScrollView,
-} from 'react-native';
-import Icon from 'react-native-vector-icons/Feather';
-import {
- projectPersonalMeshActivationFailure,
- type PersonalMeshActivationFailureProjection,
-} from '@offgrid/sync';
-import { useTheme, useThemedStyles } from '../../theme';
-import type { ThemeColors, ThemeShadows } from '../../theme';
-import { SPACING, TYPOGRAPHY } from '../../constants';
-import {
- activateProByKey,
- PRO_PAY_PAGE_URL,
-} from '../../services/proLicenseService';
-import { withUtm } from '../../utils/utm';
-import { AppSheet } from '../../components/AppSheet';
-
-type ErrorMsg = Pick<
- PersonalMeshActivationFailureProjection,
- 'title' | 'description'
-> | null;
-
-type Props = {
- visible: boolean;
- onClose: () => void;
- onUnlocked: () => void;
-};
-
-// Activation sheet: the user pastes the license key from their email and we
-// activate it on this device. Paying is a separate path — "Get Pro" opens the
-// web pay page; the buyer is then emailed a key to paste here.
-export const ProUnlockModal: React.FC = ({
- visible,
- onClose,
- onUnlocked,
-}) => {
- const { colors } = useTheme();
- const styles = useThemedStyles(createStyles);
-
- const [licenseKey, setLicenseKey] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [success, setSuccess] = useState(false);
-
- // The modal stays mounted across opens, so clear transient state each time it
- // opens so a previous attempt's key/error never leaks into a fresh open.
- useEffect(() => {
- if (visible) {
- setLicenseKey('');
- setError(null);
- setSuccess(false);
- setLoading(false);
- }
- }, [visible]);
-
- const close = () => {
- if (loading || success) return;
- setLicenseKey('');
- setError(null);
- onClose();
- };
-
- // Dismiss the success card once the user has read it. The keychain write is
- // already done at this point; Pro features load on the next app launch.
- const finishSuccess = () => {
- setLicenseKey('');
- setError(null);
- setSuccess(false);
- onClose();
- };
-
- const clearError = () => {
- if (error) setError(null);
- };
-
- const handleActivate = async () => {
- if (loading) return;
- const trimmed = licenseKey.trim();
- if (!trimmed) return;
-
- setLoading(true);
- setError(null);
- try {
- const res = await activateProByKey(trimmed);
- if (res.ok) {
- setSuccess(true);
- onUnlocked();
- } else {
- setError(projectPersonalMeshActivationFailure(res.reason));
- }
- } catch {
- setError(projectPersonalMeshActivationFailure('registration_failed'));
- } finally {
- setLoading(false);
- }
- };
-
- // Not a member yet — send them to the web pay page. The buyer's key is emailed
- // to them after checkout, then pasted here.
- const handleGetPro = () => {
- Linking.openURL(withUtm(PRO_PAY_PAGE_URL, 'pro-unlock')).catch(() => {
- setError({
- title: 'Could not open Pro',
- description: 'Please try again.',
- });
- });
- };
-
- const hasInput = licenseKey.trim().length > 0;
- // Activation owns this sheet until it reaches a terminal state. A parent
- // entitlement refresh can clear `visible` while the request is still in
- // flight; keep the progress and success result on screen until the person
- // dismisses them.
- const sheetVisible = visible || loading || success;
-
- return (
-
-
- {success ? (
- <>
-
-
-
- Pro is active on this device.
-
- Got it
-
- >
- ) : (
- <>
-
- Paste the license key from your email. It works on up to 5
- devices.
-
-
- {/* License key input */}
- {
- setLicenseKey(t);
- clearError();
- }}
- editable={!loading}
- testID="license-key-input"
- />
-
- {/* Inline error */}
- {error ? (
-
- {error.title}
- {error.description}
-
- ) : null}
-
- {/* Primary CTA */}
-
-
- {loading ? 'Activating...' : 'Activate'}
-
-
-
- {/* Footer — not a member yet, go to the pay page */}
-
- Not a member yet? Get Pro
-
-
- >
- )}
-
-
- );
-};
-
-const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({
- scrollView: {
- flexShrink: 1,
- },
- content: {
- paddingHorizontal: SPACING.xl,
- paddingTop: SPACING.lg,
- paddingBottom: SPACING.xl,
- },
- subtitle: {
- ...TYPOGRAPHY.bodySmall,
- color: colors.textSecondary,
- lineHeight: 20,
- marginBottom: SPACING.lg,
- },
-
- input: {
- ...TYPOGRAPHY.body,
- color: colors.text,
- backgroundColor: colors.surfaceLight,
- borderRadius: 12,
- paddingHorizontal: SPACING.md,
- paddingVertical: SPACING.md,
- marginBottom: SPACING.xs,
- minHeight: 48,
- },
-
- errorBlock: {
- marginBottom: SPACING.md,
- paddingHorizontal: SPACING.xs,
- },
- errorTitle: {
- ...TYPOGRAPHY.bodySmall,
- color: '#E05252',
- marginBottom: SPACING.xs,
- },
- errorText: {
- fontSize: 13,
- fontWeight: '400' as const,
- color: '#E05252',
- lineHeight: 18,
- },
-
- primaryBtn: {
- backgroundColor: colors.primary,
- borderRadius: 14,
- paddingVertical: SPACING.lg,
- paddingHorizontal: SPACING.lg,
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- gap: SPACING.sm,
- marginTop: SPACING.sm,
- marginBottom: SPACING.lg,
- },
- primaryBtnText: {
- fontSize: 16,
- fontWeight: '600' as const,
- color: '#FFFFFF',
- letterSpacing: 0.2,
- },
-
- toggleRow: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- gap: SPACING.xs,
- paddingVertical: SPACING.sm,
- marginBottom: SPACING.xs,
- },
- toggleText: {
- ...TYPOGRAPHY.bodySmall,
- color: colors.text,
- },
-
- disabled: {
- opacity: 0.5,
- },
-
- // Success state
- successIconWrap: {
- width: 52,
- height: 52,
- borderRadius: 26,
- borderWidth: 1.5,
- borderColor: colors.primary,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- alignSelf: 'center' as const,
- marginBottom: SPACING.lg,
- },
- successSub: {
- ...TYPOGRAPHY.body,
- color: colors.textSecondary,
- textAlign: 'center' as const,
- },
- successBtn: {
- backgroundColor: colors.primary,
- borderRadius: 14,
- paddingVertical: SPACING.lg,
- paddingHorizontal: SPACING.lg,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- alignSelf: 'stretch' as const,
- marginTop: SPACING.xl,
- },
-});
diff --git a/src/screens/ProDetailScreen/index.tsx b/src/screens/ProDetailScreen/index.tsx
index 90ed266a1..d9c6aa758 100644
--- a/src/screens/ProDetailScreen/index.tsx
+++ b/src/screens/ProDetailScreen/index.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React from 'react';
import {
View,
Text,
@@ -10,7 +10,6 @@ import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { SafeAreaView } from 'react-native-safe-area-context';
import Icon from 'react-native-vector-icons/Feather';
-import { Button } from '../../components';
import { useTheme, useThemedStyles } from '../../theme';
import type { ThemeColors, ThemeShadows } from '../../theme';
import {
@@ -19,20 +18,25 @@ import {
OFF_GRID_DESKTOP_BENEFIT,
OFF_GRID_DESKTOP_URL,
} from '../../constants';
-import { selectHasProAccess } from '../../stores/proAccessSlice';
-import { useAppStore } from '../../stores';
-import { PRO_PAY_PAGE_URL } from '../../services/proLicenseService';
import { withUtm } from '../../utils/utm';
-import { loadProFeatures } from '../../bootstrap/loadProFeatures';
-import { getPricingCopy } from '../../utils/proPricing';
import { ScreenHeader } from '../../components/ScreenHeader';
-import { ProManageSection } from './ProManageSection';
+import { useIsProActive } from '../../hooks/useIsProActive';
import { ProIncludedSection } from './ProIncludedSection';
-import { ProUnlockModal } from './ProUnlockModal';
import type { RootStackParamList } from '../../navigation/types';
-// Off Grid AI Pro is the ambient intelligence layer across desktop + phone, not a
-// mobile feature list. These pillars mirror the early-access page framing.
+/**
+ * What Pro used to be.
+ *
+ * This screen was the paywall: a price, a "Get Pro" button to the web checkout, and a licence-key field
+ * for the buyers who came back. Off Grid AI is free now - voice mode, personas, draft-then-approve
+ * actions and Personal Mesh sync are included for everybody - so the sales page has no job left. What
+ * remains is the part that was always useful underneath it: a plain index of what the app can do, with
+ * rows into the features that this build actually carries.
+ *
+ * The route keeps the name 'ProDetail' because that is what every entry point in the app navigates to
+ * (the Home crown, Settings, the Tools page, the chat assistant gate), and a rename would be churn for a
+ * string. Nothing here is for sale: there is no price, no checkout, and no licence key.
+ */
const PILLARS = [
{
icon: 'layers',
@@ -44,6 +48,11 @@ const PILLARS = [
title: 'Proactive, not reactive',
desc: 'It briefs you on the day, surfaces what you left open, and drafts the reply before you remember you owe it.',
},
+ {
+ icon: 'volume-2',
+ title: 'A voice that answers',
+ desc: 'On-device speech, so it talks back and you can run the whole thing hands-free. The voice runs in your phone’s RAM.',
+ },
{
icon: 'shield',
title: 'Private by architecture',
@@ -71,87 +80,32 @@ export const ProDetailScreen: React.FC = () => {
useNavigation>();
const { colors } = useTheme();
const styles = useThemedStyles(createStyles);
- const hasSavedProCredential = useAppStore(s => s.hasSavedProCredential);
- const isProActive = useAppStore(s => s.isProActive);
- const hasProAccess = useAppStore(selectHasProAccess);
- const [verifyModalVisible, setVerifyModalVisible] = useState(false);
- const pricing = getPricingCopy();
- const isDevelopmentAccess = __DEV__ && isProActive && !hasSavedProCredential;
- const deviceStatus = hasProAccess
- ? { icon: 'check', label: 'Pro Active' }
- : isDevelopmentAccess
- ? { icon: 'tool', label: 'Development Access' }
- : null;
- const deviceStatusColor = hasProAccess ? colors.primary : colors.textMuted;
+ // Registration is the honest signal: this says whether the optional pro bundle is part of THIS build,
+ // not whether anyone paid. Nothing in the app pays any more; a build either carries the voice/tool/mesh
+ // surfaces or it does not.
+ const bundleLoaded = useIsProActive();
- const openPayPage = () => {
- Linking.openURL(withUtm(PRO_PAY_PAGE_URL, 'pro-detail')).catch(() => {});
- };
const openDesktop = () => {
Linking.openURL(withUtm(OFF_GRID_DESKTOP_URL, 'pro-detail')).catch(
() => {},
);
};
- const openVerifyModal = () => setVerifyModalVisible(true);
-
- // Activation verified: load the pro bundle now so Pro lights up live (the
- // reactive appRoot slot mounts the engine without a restart). Registries dedupe.
- const handleUnlocked = () => {
- loadProFeatures(true).catch(() => {});
- };
return (
- {/* The app's one header, like every other screen: a back arrow, the title, and this device's
- entitlement state where a screen's actions go. */}
+ {/* The app's one header, like every other screen: a back arrow, and the title where a screen's
+ actions go. There used to be an entitlement badge, a licence-key shortcut and a "Get Pro"
+ button here. */}
navigation.goBack()}
right={
-
- {deviceStatus ? (
-
-
-
- {deviceStatus.label}
-
-
- ) : null}
- {/* The body already offers "I have a license key" to anyone without Pro; this is the
- shortcut for someone who has it and is re-entering it. */}
- {!hasProAccess && !isDevelopmentAccess ? (
-
-
-
- ) : null}
- {!hasProAccess && !isDevelopmentAccess ? (
-
- Get Pro
-
- ) : null}
-
+ bundleLoaded ? (
+
+
+ Included
+
+ ) : null
}
/>
{
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
- {hasProAccess ? (
- /* Pro, and this device still has it: subscription and device management, then what the
- licence opened up. There is no in-between state - a device that was removed from the licence
- is not Pro, so it sees exactly what someone who never bought it sees. */
- <>
-
-
- >
- ) : (
- <>
- {/* Hero */}
-
- Intelligence, democratized.
- On your device.
-
- Ambient and proactive. It sees your day, remembers it, and gets
- ahead of you - and the model runs on your own hardware, so
- nothing is sent anywhere.
-
-
+ {/* Hero */}
+
+ Everything is free.
+ On your device.
+
+ Ambient and proactive. It sees your day, remembers it, and gets
+ ahead of you - and the model runs on your own hardware, so nothing
+ is sent anywhere. Voice, personas, actions you approve, and sync
+ between your own devices are included for everyone: no licence
+ needed, nothing to renew.
+
+
- {/* Pricing — flat themed surface. */}
-
-
-
- {pricing.label}
-
- {pricing.title}
- {pricing.subtitle}
-
+ {/* Doors into the features this build actually carries. Empty on a build without the bundle. */}
+
- {/* Ambient pillars */}
-
- ONE PRIVATE LAYER
- {PILLARS.map(p => (
-
-
-
-
-
- {p.title}
- {p.desc}
-
-
- ))}
-
+ {!bundleLoaded ? (
+
+
+
+ This build does not include the optional Pro bundle, so the voice,
+ tool and sync surfaces are not registered here. A build with the
+ bundle gets them unlocked - there is nothing to buy either way.
+
+
+ ) : null}
- {/* CTAs — shared Button (outline). Buy is primary, verify is secondary. */}
-
-
- >
- )}
+ {/* What the app does, without the sales framing it used to carry. */}
+
+ WHAT'S INCLUDED
+ {PILLARS.map(p => (
+
+
+
+
+
+ {p.title}
+ {p.desc}
+
+
+ ))}
+
- {/* Cross-device companion. Pro is one mind across laptop + phone, so every
- Pro surface points to Off Grid AI Desktop. Shown in both states. */}
+ {/* Cross-device companion: the Desktop app is free and open source too. */}
{
-
- setVerifyModalVisible(false)}
- onUnlocked={handleUnlocked}
- />
);
};
@@ -257,51 +183,7 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
content: { paddingTop: SPACING.lg, paddingBottom: SPACING.xxl },
// Header
- header: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- justifyContent: 'space-between' as const,
- paddingHorizontal: SPACING.xl,
- paddingVertical: SPACING.md,
- },
- logoRow: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- gap: SPACING.sm,
- },
- logoGrid: { gap: 3 },
- logoDotRow: { flexDirection: 'row' as const, gap: 3 },
- logoDot: {
- width: 6,
- height: 6,
- borderRadius: 1,
- backgroundColor: colors.primary,
- },
- logoText: { ...TYPOGRAPHY.body, color: colors.text },
- headerActions: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- gap: SPACING.sm,
- },
- headerKeyButton: {
- width: 36,
- height: 36,
- borderRadius: 8,
- borderWidth: 1,
- borderColor: colors.primary,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- },
- getProButton: {
- backgroundColor: 'transparent',
- borderWidth: 1,
- borderColor: colors.primary,
- paddingHorizontal: SPACING.md,
- paddingVertical: SPACING.sm,
- borderRadius: 8,
- },
- getProButtonText: { ...TYPOGRAPHY.bodySmall, color: colors.primary },
- proActiveBadge: {
+ includedBadge: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
gap: SPACING.xs,
@@ -312,9 +194,7 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
paddingVertical: SPACING.sm,
borderRadius: 8,
},
- proActiveBadgeText: { ...TYPOGRAPHY.bodySmall, color: colors.primary },
- proInactiveBadge: { borderColor: colors.border },
- proInactiveBadgeText: { color: colors.textMuted },
+ includedBadgeText: { ...TYPOGRAPHY.bodySmall, color: colors.primary },
// Hero
hero: {
@@ -340,40 +220,23 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
lineHeight: 20,
},
- // Pricing banner
- pricingBanner: {
+ // Absent-bundle note
+ bundleNote: {
+ flexDirection: 'row' as const,
+ alignItems: 'flex-start' as const,
+ gap: SPACING.sm,
marginHorizontal: SPACING.xl,
marginBottom: SPACING.xl,
+ padding: SPACING.lg,
borderRadius: 12,
borderWidth: 1,
- borderColor: colors.primary,
+ borderColor: colors.border,
backgroundColor: colors.surface,
- paddingVertical: SPACING.lg,
- paddingHorizontal: SPACING.xl,
- ...shadows.small,
},
- pricingLabelRow: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- justifyContent: 'center' as const,
- gap: SPACING.xs,
- marginBottom: SPACING.sm,
- },
- pricingLabel: {
- ...TYPOGRAPHY.label,
- color: colors.primary,
- letterSpacing: 0.8,
- },
- pricingTitle: {
- ...TYPOGRAPHY.display,
- color: colors.text,
- textAlign: 'center' as const,
- marginBottom: SPACING.xs,
- },
- pricingSubtitle: {
+ bundleNoteText: {
...TYPOGRAPHY.bodySmall,
color: colors.textSecondary,
- textAlign: 'center' as const,
+ flex: 1,
lineHeight: 18,
},
@@ -405,13 +268,6 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
color: colors.textSecondary,
lineHeight: 18,
},
- // CTAs (Button supplies its own colours/border; these are layout-only).
- ctaButton: {
- marginHorizontal: SPACING.xl,
- marginTop: SPACING.sm,
- marginBottom: SPACING.md,
- },
- verifyButton: { marginHorizontal: SPACING.xl, marginBottom: SPACING.xl },
// Desktop companion link
desktopRow: {
@@ -426,6 +282,7 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
+ ...shadows.small,
},
desktopIconWrap: {
width: 28,
diff --git a/src/screens/SettingsScreen.styles.ts b/src/screens/SettingsScreen.styles.ts
index 3d125aa1a..f05f60d4d 100644
--- a/src/screens/SettingsScreen.styles.ts
+++ b/src/screens/SettingsScreen.styles.ts
@@ -163,23 +163,6 @@ export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({
devButtonGroup: { gap: 12 },
devButtonText: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted },
proCardText: { flex: 1 },
- proTitleRow: {
- flexDirection: 'row' as const,
- alignItems: 'center' as const,
- gap: SPACING.sm,
- marginBottom: 2,
- },
- proBadge: {
- backgroundColor: colors.primary,
- borderRadius: 20,
- paddingHorizontal: SPACING.sm,
- paddingVertical: 2,
- },
- proBadgeText: {
- ...TYPOGRAPHY.labelSmall,
- color: '#FFFFFF',
- letterSpacing: 0.5,
- },
proDesc: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary },
proIconContainer: {
width: 44,
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 521904848..e4ef30313 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -26,7 +26,6 @@ import { AnimatedListItem } from '../components/AnimatedListItem';
import { MadeWithLove } from '../components/MadeWithLove';
import { DebugLogsScreen } from '../components/DebugLogsScreen';
import { useSettingsSections } from '../components/settings/sectionRegistry';
-import { ProUpsellBanner } from '../components/settings/ProUpsellBanner';
import { useFocusTrigger } from '../hooks/useFocusTrigger';
import { useTheme, useThemedStyles } from '../theme';
import RNFS from 'react-native-fs';
@@ -34,7 +33,6 @@ import { useAppStore, useRemoteServerStore } from '../stores';
import { hardwareService } from '../services';
import { RootStackParamList, MainTabParamList } from '../navigation/types';
import { useHasRegisteredScreen } from '../navigation/screenRegistry';
-import { clearProForTesting } from '../services/proLicenseService';
import { useProStatusLabel } from '../hooks/useProStatusLabel';
import { useOpenSync } from '../hooks/useOpenSync';
import { appBuildLabel, appVersion } from '../utils/appVersion';
@@ -71,7 +69,7 @@ export const SettingsScreen: React.FC = () => {
// loadProFeatures) show up live without an app restart.
const settingsSections = useSettingsSections();
const hasSync = useHasRegisteredScreen('Sync');
- const { isSyncUnlocked, openSync } = useOpenSync();
+ const { openSync } = useOpenSync();
const setOnboardingComplete = useAppStore(s => s.setOnboardingComplete);
const themeMode = useAppStore(s => s.themeMode);
const setThemeMode = useAppStore(s => s.setThemeMode);
@@ -81,12 +79,6 @@ export const SettingsScreen: React.FC = () => {
const [showDebugLogs, setShowDebugLogs] = useState(false);
const [isSeedingDemo, setIsSeedingDemo] = useState(false);
const deviceInfo = useAppStore(s => s.deviceInfo);
- // Hidden once the user dismisses it, or once Pro is active (the upsell makes no
- // sense to a paid user). hasRegisteredPro only flips true after RC verification
- // (activateProByEmail / revalidatePro), so this also covers "paid and verified".
- const devProDisabled = useAppStore(s => s.devProDisabled);
- const setDevProDisabled = useAppStore(s => s.setDevProDisabled);
- const setHasRegisteredPro = useAppStore(s => s.setHasRegisteredPro);
const { proStatusLabel } = useProStatusLabel();
useEffect(() => {
@@ -122,27 +114,6 @@ export const SettingsScreen: React.FC = () => {
await openSupportEmail({ subject, body });
};
- // DEV-only: flip the Pro auto-unlock. Disabling also clears the cached license
- // so the build behaves like a fresh free install. We flip the store flags
- // synchronously (so the UI drops Pro immediately) and do NOT auto-reload —
- // an immediate reload races the async persist write and rehydrates the old
- // Pro-active state. A manual restart applies feature load/unload (slots
- // registered at boot can't be cleanly torn down at runtime).
- const handleToggleDevPro = async () => {
- const disabling = !devProDisabled;
- if (disabling) {
- setDevProDisabled(true);
- await clearProForTesting();
- setHasRegisteredPro(false);
- } else {
- setDevProDisabled(false);
- }
- Alert.alert(
- disabling ? 'Pro disabled (DEV)' : 'Pro enabled (DEV)',
- `Restart the app to fully ${disabling ? 'unload' : 'load'} Pro features.`,
- );
- };
-
const handleSeedDemo = async () => {
if (!__DEV__ || isSeedingDemo) return;
setIsSeedingDemo(true);
@@ -169,12 +140,6 @@ export const SettingsScreen: React.FC = () => {
style={styles.scrollView}
contentContainerStyle={styles.content}
>
- {/* PRO Banner */}
- navigation.navigate('ProDetail')}
- />
-
{
? [
{
icon: 'refresh-cw',
- title: isSyncUnlocked ? 'Sync' : 'Sync with Pro',
- desc: isSyncUnlocked
- ? 'Chats, projects, files, and copied text across your devices'
- : 'Get Pro to set up encrypted Sync across your devices',
+ title: 'Sync',
+ desc: 'Chats, projects, files, and copied text across your devices',
screen: 'Sync' as const,
testID: 'open-sync-settings',
},
@@ -264,7 +227,7 @@ export const SettingsScreen: React.FC = () => {
))}
- {/* PRO Button */}
+ {/* Feature index: what the app can do, and which surfaces this build carries. */}
{
-
- Off Grid AI PRO
-
- PRO
-
-
+ Pro features{proStatusLabel}
@@ -381,19 +339,6 @@ export const SettingsScreen: React.FC = () => {
Debug Logs
-
-
-
- {devProDisabled ? 'Turn on Pro (DEV)' : 'Turn off Pro (DEV)'}
-
-
)}
diff --git a/src/services/generationService.ts b/src/services/generationService.ts
index f2d2e9b5a..b2ff10504 100644
--- a/src/services/generationService.ts
+++ b/src/services/generationService.ts
@@ -11,7 +11,6 @@ import { providerRegistry } from './providers';
import { contextCompactionService } from './contextCompaction';
import logger from '../utils/logger';
import { maybeScheduleSharePrompt } from '../utils/sharePrompt';
-import { checkProPromptForText } from './proPrompt';
import {
buildGenerationMetaImpl,
buildToolLoopHandlersImpl,
@@ -146,7 +145,6 @@ class GenerationService {
const s = useAppStore.getState();
const count = s.incrementTextGenerationCount();
maybeScheduleSharePrompt({ variant: 'text', count, hasEngaged: s.hasEngagedSharePrompt, delayMs });
- checkProPromptForText(delayMs);
}
private buildToolLoopHandlers() { return buildToolLoopHandlersImpl(this); }
diff --git a/src/services/imageGenerationHelpers.ts b/src/services/imageGenerationHelpers.ts
index e7247e7e3..63edff81e 100644
--- a/src/services/imageGenerationHelpers.ts
+++ b/src/services/imageGenerationHelpers.ts
@@ -8,7 +8,6 @@ import { GeneratedImage, GenerationMeta, Message } from '../types';
import { parseModelOutput } from '../utils/messageContent';
import { maybeScheduleSharePrompt } from '../utils/sharePrompt';
import { reportModelFailure } from './modelFailureHandler';
-import { checkProPromptForImage } from './proPrompt';
import type { ImageGenerationState } from './imageGenerationTypes';
export function imagePhaseTransitionLog(
@@ -50,7 +49,6 @@ export function scheduleImageSharePrompt(): void {
hasEngaged: appStore.hasEngagedSharePrompt,
delayMs,
});
- checkProPromptForImage(delayMs);
}
interface ActiveImageModel {
diff --git a/src/services/proEntitlementLifecycle.ts b/src/services/proEntitlementLifecycle.ts
index 254fc3c42..950d566da 100644
--- a/src/services/proEntitlementLifecycle.ts
+++ b/src/services/proEntitlementLifecycle.ts
@@ -1,6 +1,15 @@
import { AppState, type NativeEventSubscription } from 'react-native';
import { revalidateProEntitlement } from './proLicenseService';
+/**
+ * Keeps the cached credential and the licensed-device roster current, at launch and on every return to
+ * the foreground.
+ *
+ * It used to be the thing that took features AWAY: a device the roster had dropped lost Pro at the next
+ * revalidation. Nothing is taken away any more - Off Grid AI is free - so this is bookkeeping: the mesh
+ * still needs a device identity and an honest roster to pair your own devices, and a credential that has
+ * genuinely expired should be known to be expired rather than assumed. The result never gates a feature.
+ */
class ProEntitlementLifecycle {
private appStateSubscription: NativeEventSubscription | null = null;
private launchOperation: Promise | null = null;
diff --git a/src/services/proLicenseService.ts b/src/services/proLicenseService.ts
index 6c2493372..21d728879 100644
--- a/src/services/proLicenseService.ts
+++ b/src/services/proLicenseService.ts
@@ -3,20 +3,8 @@ import type {
PersonalMeshReconciliationReason,
} from '@offgrid/sync';
-export const PRO_PAY_PAGE_URL = 'https://getoffgridai.co/pay/';
-
export type ProTier = 'lifetime' | 'monthly' | 'annual' | 'subscription';
-export const PRO_TIER_META: Record<
- ProTier,
- { label: string; renews: boolean }
-> = {
- lifetime: { label: 'Lifetime', renews: false },
- monthly: { label: 'Monthly', renews: true },
- annual: { label: 'Annual', renews: true },
- subscription: { label: 'Subscription', renews: true },
-};
-
export interface ProLicenseInfo {
isPro: boolean;
credentialSaved?: boolean;
@@ -26,6 +14,14 @@ export interface ProLicenseInfo {
verifiedAt: number;
}
+/**
+ * The seam the optional pro bundle uses to hand core a licence provider.
+ *
+ * The method list is unchanged even though nothing here gates a feature any more: the provider is what
+ * keeps a cached credential and the licensed-device roster current, and Personal Mesh pairing between
+ * your own devices still rides on that device identity. What the answers can no longer do is withhold a
+ * capability - see selectHasProAccess in stores/proAccessSlice.
+ */
export interface ProEntitlementProvider {
readActive(): Promise;
getInfo(): Promise;
@@ -42,6 +38,12 @@ export interface ProEntitlementProvider {
resetCurrentDevice(): Promise;
}
+/**
+ * The provider a build without the pro bundle registers: nothing held, nothing to read.
+ *
+ * These answers are FACTS about this install, never verdicts on what it may do. A build like this one
+ * simply has no licence plumbing, and every feature core can offer is offered anyway.
+ */
const UNAVAILABLE_PROVIDER: ProEntitlementProvider = {
readActive: async () => false,
getInfo: async () => ({
@@ -78,16 +80,15 @@ export function revalidateProEntitlement(
return provider.revalidate(reason);
}
+/**
+ * Activate a licence key on this device, through whatever provider is registered.
+ *
+ * Kept for the device-roster path: a key imported from a paired device, or re-entered by someone who
+ * bought before Off Grid AI became free, still claims a seat and writes the credential. No screen in
+ * this app asks for a key any more, because there is nothing to buy.
+ */
export function activateProByKey(
rawCredential: string,
): Promise {
return provider.activate(rawCredential);
}
-
-export function clearProForTesting(): Promise {
- return provider.clearForTesting();
-}
-
-export function resetProOnThisDevice(): Promise {
- return provider.resetCurrentDevice();
-}
diff --git a/src/services/proPrompt.ts b/src/services/proPrompt.ts
deleted file mode 100644
index 3d19c33e5..000000000
--- a/src/services/proPrompt.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { selectHasProAccess } from '../stores/proAccessSlice';
-import { useAppStore } from '../stores/appStore';
-
-// One automatic offer, after the user has completed enough work to understand the product.
-// The persisted `proAhaTriggeredBy` marker makes this an install-level decision. Later upgrade
-// choices stay at explicit Pro entry points instead of interrupting ordinary generation.
-const PRO_AHA_THRESHOLD = 10;
-
-export function shouldShowProAha(count: number): boolean {
- return count === PRO_AHA_THRESHOLD;
-}
-
-type ProPromptVariant = 'text' | 'image';
-type ProPromptListener = (variant: ProPromptVariant) => void;
-
-const listeners = new Set();
-
-export function subscribeProPrompt(listener: ProPromptListener): () => void {
- listeners.add(listener);
- return () => listeners.delete(listener);
-}
-
-export function emitProPrompt(variant: ProPromptVariant): void {
- listeners.forEach(l => l(variant));
-}
-
-// Called by generationService after each completed text response
-export function checkProPromptForText(delayMs: number): void {
- const s = useAppStore.getState();
- if (selectHasProAccess(s)) return;
- if (s.proAhaTriggeredBy !== null) return;
- if (!shouldShowProAha(s.textGenerationCount)) return;
- s.setProAhaTriggeredBy('text');
- setTimeout(() => emitProPrompt('text'), delayMs);
-}
-
-// Called by imageGenerationService after each completed image generation
-export function checkProPromptForImage(delayMs: number): void {
- const s = useAppStore.getState();
- if (selectHasProAccess(s)) return;
- if (s.proAhaTriggeredBy !== null) return;
- if (!shouldShowProAha(s.imageGenerationCount)) return;
- s.setProAhaTriggeredBy('image');
- setTimeout(() => emitProPrompt('image'), delayMs);
-}
diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts
index b9faf0a0c..02b5a95d3 100644
--- a/src/stores/appStore.ts
+++ b/src/stores/appStore.ts
@@ -503,13 +503,10 @@ function persistedAppState(state: AppState) {
textGenerationCount: state.textGenerationCount,
imageGenerationCount: state.imageGenerationCount,
hasEngagedSharePrompt: state.hasEngagedSharePrompt,
+ // Informational only: kept so a build can report what this install holds. Nothing gates on it.
hasRegisteredPro: state.hasRegisteredPro,
- // Persist eviction so a relaunch cannot grant Pro while the roster is offline.
proDeviceAdmission: state.proDeviceAdmission,
- devProDisabled: state.devProDisabled,
- proBannerDismissed: state.proBannerDismissed,
desktopPromoDismissed: state.desktopPromoDismissed,
- proAhaTriggeredBy: state.proAhaTriggeredBy,
loadedSettings: state.loadedSettings,
};
}
diff --git a/src/stores/proAccessSlice.ts b/src/stores/proAccessSlice.ts
index 350f9f675..7933d8ba4 100644
--- a/src/stores/proAccessSlice.ts
+++ b/src/stores/proAccessSlice.ts
@@ -1,42 +1,39 @@
-import { hasProAccess, type ProDeviceAdmission } from '@offgrid/sync';
+import type { ProDeviceAdmission } from '@offgrid/sync';
export interface ProAccessSlice {
- /** Cached protected Pro credential grants offline feature access. */
+ /**
+ * Cached Pro credential grants, kept only as a read-only projection of what this install holds.
+ *
+ * These used to be the gate. There is no gate any more: every capability that used to be sold is free
+ * (see selectHasProAccess below), so nothing in the app branches on them. They survive because the
+ * licence provider in the (optional) pro bundle writes them, and because the device-admission tri-state
+ * is still the honest answer to "what did the licensed-device roster last say about this phone" - useful
+ * to display, never a reason to withhold a feature.
+ */
hasRegisteredPro: boolean;
setHasRegisteredPro: (value: boolean) => void;
/** A protected credential exists, independently of current device admission. */
hasSavedProCredential: boolean;
setHasSavedProCredential: (value: boolean) => void;
- /** Paid features are available through a credential or Debug developer access. */
+ /** A locally unexpired credential was projected at boot. */
isProActive: boolean;
setProActive: (value: boolean) => void;
- /** A saved timed credential reached its local deadline and must show the purchase route. */
+ /** A saved timed credential reached its local deadline. Informational only - nothing locks. */
hasExpiredProCredential: boolean;
setHasExpiredProCredential: (value: boolean) => void;
/**
* What the authoritative licensed-device roster last said about THIS device, in three states.
*
* Three because unknown is not revoked: a boolean starting false cannot tell "we have not heard from
- * the roster yet" apart from "this device was deactivated". Gating on it directly would revoke Pro on
- * every cold start and for anyone offline; gating on the credential alone leaves a deactivated device
- * fully Pro, which is what it did.
- *
- * The ONLY admission state there is. It used to be shadowed by an `isProDeviceActive` boolean, which
- * meant two fields to keep in step and, once admission was persisted and the boolean was not, a
- * restart that put them in open contradiction - Pro granted while the header read "Device Not Active".
+ * the roster yet" apart from "this device was deactivated". It used to gate Pro access; now it is
+ * reported, not enforced.
*/
proDeviceAdmission: ProDeviceAdmission;
setProDeviceAdmission: (value: ProDeviceAdmission) => void;
/** Report the roster's boolean answer. A reported answer is by definition known, so it resolves the tri-state. */
setProDeviceActive: (value: boolean) => void;
- devProDisabled: boolean;
- setDevProDisabled: (value: boolean) => void;
- proBannerDismissed: boolean;
- setProBannerDismissed: (value: boolean) => void;
desktopPromoDismissed: boolean;
setDesktopPromoDismissed: (value: boolean) => void;
- proAhaTriggeredBy: 'image' | 'text' | null;
- setProAhaTriggeredBy: (value: 'image' | 'text' | null) => void;
}
type SetProAccessState = (state: Partial) => void;
@@ -50,34 +47,37 @@ export function createProAccessSlice(set: SetProAccessState): ProAccessSlice {
isProActive: false,
setProActive: value => set({ isProActive: value }),
hasExpiredProCredential: false,
- setHasExpiredProCredential: value => set({ hasExpiredProCredential: value }),
+ setHasExpiredProCredential: value =>
+ set({ hasExpiredProCredential: value }),
proDeviceAdmission: 'unknown',
setProDeviceAdmission: value => set({ proDeviceAdmission: value }),
setProDeviceActive: value =>
set({ proDeviceAdmission: value ? 'active' : 'inactive' }),
- devProDisabled: false,
- setDevProDisabled: value => set({ devProDisabled: value }),
- proBannerDismissed: false,
- setProBannerDismissed: value => set({ proBannerDismissed: value }),
desktopPromoDismissed: false,
setDesktopPromoDismissed: value => set({ desktopPromoDismissed: value }),
- proAhaTriggeredBy: null,
- setProAhaTriggeredBy: value => set({ proAhaTriggeredBy: value }),
};
}
/**
* Does this install have Pro access right now?
*
- * The RULE is hasProAccess in @offgrid/sync, shared with the desktop - two implementations of "is this
- * device still paid for" would eventually disagree about a live device. This only supplies the store's
- * facts to it: what credential exists, and what the roster last said about this device.
+ * Yes. Always. Off Grid AI is free: every capability that used to sit behind a purchase - voice mode and
+ * on-device speech, custom personas, draft-then-approve actions, MCP tools, and Personal Mesh sync - is
+ * included for everyone, with no licence, no key, and nothing to renew.
+ *
+ * The rule this replaced (hasProAccess in @offgrid/sync, shared with the desktop) gated on a credential
+ * plus the licensed-device roster's admission for this device. Both facts are still tracked in the store
+ * above, because connecting a Desktop and syncing between your own devices needs a device identity either
+ * way - but neither is a reason to withhold a feature from someone who has already installed the app.
+ *
+ * What a build can still lack is the CODE: the optional pro bundle (@offgrid/pro) carries the voice, tool
+ * and mesh surfaces, and a build compiled without it registers no Pro screens, slots, or hooks. That is a
+ * capability question, answered by the registries (useHasRegisteredScreen / useSlot), not an entitlement
+ * one - so nothing here should ever be used to route someone to a purchase.
+ *
+ * The `state` parameter is kept (and ignored) so every existing caller - `useAppStore(selectHasProAccess)`
+ * and imperative `selectHasProAccess(useAppStore.getState())` reads alike - keeps working unchanged.
*/
-export function selectHasProAccess(state: ProAccessSlice): boolean {
- return hasProAccess({
- // A saved key is not proof of current access. The provider projects only a
- // locally unexpired credential into hasRegisteredPro/isProActive.
- hasCredential: state.hasRegisteredPro || state.isProActive,
- admission: state.proDeviceAdmission,
- });
+export function selectHasProAccess(_state: ProAccessSlice): boolean {
+ return true;
}
diff --git a/src/utils/proPricing.ts b/src/utils/proPricing.ts
deleted file mode 100644
index 3ceae5169..000000000
--- a/src/utils/proPricing.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * Off Grid AI Pro pricing copy, shared by every Pro surface so they stay in sync.
- *
- * Two plans, matching getoffgridai.co/pro: a one-time lifetime purchase and a monthly
- * subscription. Both are founder rates that only ever go up as we grow. The app states the
- * next tier ($119 / $7.99) because the website's first tier ($69 / $4.99) is nearly full
- * and a release outlives it. The numbers are hardcoded and bumped in a new release
- * when a tier fills. The web pay page
- * (getoffgridai.co/pay) is where checkout happens and holds the authoritative price.
- */
-export interface ProPricingCopy {
- /** Small uppercase eyebrow label. */
- label: string;
- /** Headline price/offer. */
- title: string;
- /** Supporting line under the title. */
- subtitle: string;
- /** Call-to-action button label. */
- cta: string;
- /** One-line offer summary for the upsell sheet. */
- sheetSubheadline: string;
- /** Footer line for the upsell sheet (terms). */
- sheetFooter: string;
-}
-
-export function getPricingCopy(): ProPricingCopy {
- return {
- label: 'FOUNDER RATE',
- title: '$119 lifetime or $7.99/month',
- subtitle: "Lock in today's rate - it only goes up, never down. One license covers up to 5 devices, laptop and phone.",
- cta: 'Get Pro',
- sheetSubheadline: 'Off Grid AI Pro - $119 once for life, or $7.99 a month.',
- sheetFooter: 'Founder rate, locked in when you join. Monthly renews; lifetime is a one-time payment.',
- };
-}