diff --git a/app/core/Engine/Engine.ts b/app/core/Engine/Engine.ts index 249fec4f1b2..f5cfa8d5f8d 100644 --- a/app/core/Engine/Engine.ts +++ b/app/core/Engine/Engine.ts @@ -63,6 +63,7 @@ import { } from './controllers/core-backend'; import { assetsControllerInit } from './controllers/assets-controller/assets-controller-init'; import { AppStateWebSocketManager } from '../AppStateWebSocketManager'; +import { WidgetSyncManager } from '../WidgetData/WidgetSyncManager'; import { backupVault } from '../BackupVault'; import { CaipAssetType, @@ -249,6 +250,12 @@ export class Engine { */ appStateWebSocketManager: AppStateWebSocketManager; + /** + * Syncs the held-token list into the iOS home-screen widget. No-ops on + * Android and on builds without the native widget bridge. + */ + widgetSyncManager: WidgetSyncManager; + accountsController: AccountsController; gasFeeController: GasFeeController; gatorPermissionsController: GatorPermissionsController; @@ -519,6 +526,7 @@ export class Engine { this.appStateWebSocketManager = new AppStateWebSocketManager( backendWebSocketService, ); + this.widgetSyncManager = new WidgetSyncManager(); const accountActivityService = messengerClientsByName.AccountActivityService; const ohlcvService = messengerClientsByName.OHLCVService; @@ -1240,6 +1248,9 @@ export class Engine { // Cleanup AppStateWebSocketManager this.appStateWebSocketManager.cleanup(); + + // Cleanup WidgetSyncManager + this.widgetSyncManager.cleanup(); } async destroyEngineInstance() { diff --git a/app/core/NativeModules.ts b/app/core/NativeModules.ts index 6798edd989b..a839ff9367f 100644 --- a/app/core/NativeModules.ts +++ b/app/core/NativeModules.ts @@ -3,6 +3,14 @@ import { NativeModules } from 'react-native'; // Minimizer module allows the app to be pushed to the background const { Minimizer } = NativeModules; +/** + * iOS-only bridge that writes token data into the home-screen widget's App Group + * container and reloads the widget timelines. Undefined on Android and on builds + * (e.g. Flask) that do not embed the widget extension — callers must no-op when + * it is missing. + */ +const { RCTWidgetBridge } = NativeModules; + // TODO: add native modules named exports here /* eslint-disable import-x/prefer-default-export */ -export { Minimizer }; +export { Minimizer, RCTWidgetBridge as WidgetBridge }; diff --git a/app/core/WidgetData/WidgetSyncManager.ts b/app/core/WidgetData/WidgetSyncManager.ts new file mode 100644 index 00000000000..91bc2a95681 --- /dev/null +++ b/app/core/WidgetData/WidgetSyncManager.ts @@ -0,0 +1,129 @@ +import { + AppState, + AppStateStatus, + NativeEventSubscription, + Platform, +} from 'react-native'; +import Logger from '../../util/Logger'; +import ReduxService from '../redux'; +import { WidgetBridge } from '../NativeModules'; +import { buildWidgetPayload, WidgetTokenPayload } from './buildWidgetPayload'; +import { syncWidgetLogos } from './syncWidgetLogos'; + +/** + * Debounce window for store-driven syncs. Balances are recomputed on many Redux + * ticks; we coalesce them so the native bridge writes/downloads at most once per + * window. + */ +const SYNC_DEBOUNCE_MS = 5_000; + +/** + * Manages the iOS home-screen token widget: pushes the current token list into + * the widget's App Group container on app-state transitions and (debounced) on + * Redux store changes. + * + * No-ops on Android and on builds where the native {@link WidgetBridge} module + * is absent (e.g. Flask), so it is safe to construct unconditionally. + */ +export class WidgetSyncManager { + private appStateSubscription: NativeEventSubscription | null = null; + private storeUnsubscribe: (() => void) | null = null; + private debounceTimer: ReturnType | null = null; + private lastSerialized: string | null = null; + + constructor() { + if (!this.isSupported()) { + return; + } + this.appStateSubscription = AppState.addEventListener( + 'change', + this.handleAppStateChange, + ); + // Defensive: the Redux store may not be registered yet at Engine-init time. + // A failure here must never break Engine construction — the AppState + // listener will still drive syncs once the app is interactive. + try { + this.storeUnsubscribe = ReduxService.store.subscribe( + this.scheduleDebouncedSync, + ); + // Initial push so the widget reflects state from app start. + this.sync(); + } catch (error) { + Logger.error( + error as Error, + 'WidgetSyncManager: store not ready at init', + ); + } + } + + private isSupported(): boolean { + return Platform.OS === 'ios' && Boolean(WidgetBridge?.setTokens); + } + + private handleAppStateChange = (nextAppState: AppStateStatus): void => { + // Refresh on foreground (fresh prices) and before backgrounding (so the + // last seen state is captured for the home screen). + if (nextAppState === 'active' || nextAppState === 'background') { + this.sync(); + } + }; + + private scheduleDebouncedSync = (): void => { + if (this.debounceTimer) { + return; + } + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + this.sync(); + }, SYNC_DEBOUNCE_MS); + }; + + /** + * Build the payload from current state and push it to the widget. Skips the + * native call when the payload is unchanged since the last push. Dedup runs on + * the pre-logo payload (which is deterministic for a given state), so cached + * logos aren't re-downloaded on every Redux tick. + */ + sync(): void { + if (!this.isSupported()) { + return; + } + try { + const payload = buildWidgetPayload(ReduxService.store.getState()); + const serialized = JSON.stringify(payload); + if (serialized === this.lastSerialized) { + return; + } + this.lastSerialized = serialized; + this.pushToWidget(payload).catch((error: unknown) => { + Logger.error(error as Error, 'WidgetSyncManager: push failed'); + }); + } catch (error) { + Logger.error(error as Error, 'WidgetSyncManager: sync failed'); + } + } + + /** + * Downloads the token logos into the widget's App Group container (in JS) and + * writes the final payload through the native bridge. + */ + private async pushToWidget(payload: WidgetTokenPayload): Promise { + const output = await syncWidgetLogos(payload); + await WidgetBridge.setTokens(JSON.stringify(output)); + } + + cleanup(): void { + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; + } + if (this.storeUnsubscribe) { + this.storeUnsubscribe(); + this.storeUnsubscribe = null; + } + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + } +} diff --git a/app/core/WidgetData/buildWidgetPayload.test.ts b/app/core/WidgetData/buildWidgetPayload.test.ts new file mode 100644 index 00000000000..698f061c860 --- /dev/null +++ b/app/core/WidgetData/buildWidgetPayload.test.ts @@ -0,0 +1,219 @@ +import { RootState } from '../../reducers'; +import { selectAssetsBySelectedAccountGroup } from '../../selectors/assets/assets-list'; +import { selectTokenMarketData } from '../../selectors/tokenRatesController'; +import { selectMultichainAssetsRates } from '../../selectors/multichain/multichain'; +import { buildWidgetPayload, WIDGET_MAX_TOKENS } from './buildWidgetPayload'; + +jest.mock('../../selectors/assets/assets-list', () => ({ + selectAssetsBySelectedAccountGroup: jest.fn(), +})); + +jest.mock('../../selectors/tokenRatesController', () => ({ + selectTokenMarketData: jest.fn(), +})); + +jest.mock('../../selectors/multichain/multichain', () => ({ + selectMultichainAssetsRates: jest.fn(), +})); + +const mockSelector = selectAssetsBySelectedAccountGroup as jest.MockedFunction< + typeof selectAssetsBySelectedAccountGroup +>; +const mockMarketData = selectTokenMarketData as jest.MockedFunction< + typeof selectTokenMarketData +>; +const mockMultichainRates = selectMultichainAssetsRates as jest.MockedFunction< + typeof selectMultichainAssetsRates +>; + +// Minimal asset shape used by the builder. +const makeAsset = ( + symbol: string, + balance: string, + fiatBalance: number, + image = `https://logos/${symbol}.png`, + assetId = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId = '0x1', + extra: Record = {}, +) => + ({ + symbol, + balance, + image, + assetId, + chainId, + fiat: { balance: fiatBalance, currency: 'usd', conversionRate: 1 }, + ...extra, + }) as unknown as ReturnType< + typeof selectAssetsBySelectedAccountGroup + >[string][number]; + +const state = {} as RootState; + +describe('buildWidgetPayload', () => { + beforeEach(() => { + // Default: no market data available. Individual tests override as needed. + mockMarketData.mockReturnValue({}); + mockMultichainRates.mockReturnValue({}); + }); + + afterEach(() => jest.clearAllMocks()); + + it('includes only tokens whose holding value is over $1', () => { + mockSelector.mockReturnValue({ + '0x1': [ + makeAsset('USDC', '100', 100), // > $1 included + makeAsset('DUST', '0.5', 0.5), // < $1 ✗ excluded + ], + }); + + const { tokens } = buildWidgetPayload(state); + + expect(tokens.map((t) => t.symbol)).toEqual(['USDC']); + }); + + it('computes unit price as holding value / token balance', () => { + mockSelector.mockReturnValue({ + '0x1': [makeAsset('ETH', '2', 6000)], // unit price $3,000 + }); + + const { tokens } = buildWidgetPayload(state); + + expect(tokens[0].priceFormatted.replace(/,/g, '')).toContain('3000'); + expect(tokens[0].priceFormatted).toContain('$'); + expect(tokens[0].logoUrl).toBe('https://logos/ETH.png'); + }); + + it('sorts by holding value descending', () => { + mockSelector.mockReturnValue({ + '0x1': [makeAsset('A', '10', 10)], + '0x89': [makeAsset('B', '10', 500), makeAsset('C', '10', 50)], + }); + + const { tokens } = buildWidgetPayload(state); + + expect(tokens.map((t) => t.symbol)).toEqual(['B', 'C', 'A']); + }); + + it(`caps the list at ${WIDGET_MAX_TOKENS} tokens`, () => { + const many = Array.from({ length: WIDGET_MAX_TOKENS + 5 }, (_, i) => + makeAsset(`T${i}`, '1', 100 + i), + ); + mockSelector.mockReturnValue({ '0x1': many }); + + const { tokens } = buildWidgetPayload(state); + + expect(tokens).toHaveLength(WIDGET_MAX_TOKENS); + }); + + it('excludes tokens with zero balance even if fiat is set', () => { + mockSelector.mockReturnValue({ + '0x1': [makeAsset('ZERO', '0', 100)], + }); + + expect(buildWidgetPayload(state).tokens).toHaveLength(0); + }); + + it('returns an empty payload when no tokens qualify', () => { + mockSelector.mockReturnValue({}); + + expect(buildWidgetPayload(state)).toEqual({ tokens: [] }); + }); + + it('builds a swap deep link with the CAIP-19 source token', () => { + mockSelector.mockReturnValue({ + '0x1': [ + makeAsset( + 'USDC', + '100', + 100, + 'https://logos/USDC.png', + '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + '0x1', + ), + ], + }); + + const { tokens } = buildWidgetPayload(state); + + expect(tokens[0].deeplink).toBe( + `metamask://swap?from=${encodeURIComponent( + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + )}`, + ); + }); + + describe('24h price change', () => { + it('reads an EVM token change from market data keyed by [chainId][address]', () => { + const address = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; + mockSelector.mockReturnValue({ + '0x1': [ + makeAsset('USDC', '100', 100, undefined, address, '0x1', { address }), + ], + }); + mockMarketData.mockReturnValue({ + '0x1': { [address]: { pricePercentChange1d: 2.31 } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBe(2.31); + }); + + it('reads an EVM native token change keyed by the native token address', () => { + // getNativeTokenAddress(chainId) → the zero address. + const nativeAddress = '0x0000000000000000000000000000000000000000'; + mockSelector.mockReturnValue({ + '0x1': [ + makeAsset('ETH', '2', 6000, undefined, '0x0', '0x1', { + isNative: true, + }), + ], + }); + mockMarketData.mockReturnValue({ + '0x1': { [nativeAddress]: { pricePercentChange1d: -1.5 } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBe(-1.5); + }); + + it('reads a non-EVM change from the multichain rates controller', () => { + const caipAssetId = 'solana:101/token:abc'; + mockSelector.mockReturnValue({ + 'solana:101': [ + makeAsset('SOL', '5', 500, undefined, caipAssetId, 'solana:101'), + ], + }); + mockMultichainRates.mockReturnValue({ + [caipAssetId]: { marketData: { pricePercentChange: { P1D: 4.2 } } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBe(4.2); + }); + + it('leaves priceChange1d undefined when no rate is available', () => { + mockSelector.mockReturnValue({ '0x1': [makeAsset('USDC', '100', 100)] }); + + expect(buildWidgetPayload(state).tokens[0].priceChange1d).toBeUndefined(); + }); + }); + + describe('sparkline', () => { + it('attaches a sparkline series to each token', () => { + mockSelector.mockReturnValue({ '0x1': [makeAsset('USDC', '100', 100)] }); + + const sparkline = buildWidgetPayload(state).tokens[0].sparkline; + expect(Array.isArray(sparkline)).toBe(true); + expect((sparkline as number[]).length).toBeGreaterThan(1); + }); + + it('is deterministic for a given symbol across calls (keeps payload dedup stable)', () => { + mockSelector.mockReturnValue({ '0x1': [makeAsset('USDC', '100', 100)] }); + + const first = buildWidgetPayload(state).tokens[0].sparkline; + const second = buildWidgetPayload(state).tokens[0].sparkline; + expect(first).toEqual(second); + }); + }); +}); diff --git a/app/core/WidgetData/buildWidgetPayload.ts b/app/core/WidgetData/buildWidgetPayload.ts new file mode 100644 index 00000000000..40bf9ff18c6 --- /dev/null +++ b/app/core/WidgetData/buildWidgetPayload.ts @@ -0,0 +1,209 @@ +import { formatAddressToAssetId } from '@metamask/bridge-controller'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; +import { CaipAssetType, Hex } from '@metamask/utils'; +import { RootState } from '../../reducers'; +import { getLocaleLanguageCode } from '../../components/hooks/useFormatters'; +import { selectAssetsBySelectedAccountGroup } from '../../selectors/assets/assets-list'; +import { selectTokenMarketData } from '../../selectors/tokenRatesController'; +import { selectMultichainAssetsRates } from '../../selectors/multichain/multichain'; +import { formatWithThreshold } from '../../util/assets'; + +/** + * Minimum holding value (in the user's fiat currency) for a token to appear in + * the widget. Filters out dust / zero positions. + */ +export const WIDGET_MIN_HOLDING_VALUE = 1; + +/** + * Maximum number of tokens written to the widget. The Large family shows ~6-8 + * rows; we cap a little higher so reordering on the home screen stays stable. + */ +export const WIDGET_MAX_TOKENS = 10; + +/** + * A single row in the widget: token logo (resolved natively from `logoUrl`), + * symbol on the left, unit market price on the right. + */ +export interface WidgetTokenEntry { + symbol: string; + /** Pre-formatted unit market price in the user's currency, e.g. "$1.00". */ + priceFormatted: string; + /** + * Remote logo URL. Transient: {@link syncWidgetLogos} downloads this into the + * App Group container and replaces it with a local `logoFile` before the + * payload is written to the widget. + */ + logoUrl: string; + /** + * Deep link that opens the Swap screen with this token preselected as the + * source. Undefined when a CAIP-19 asset id can't be derived. Consumed + * directly by the widget (Swift no longer remaps field names). + */ + deeplink?: string; + /** + * 24h price change as a percentage (e.g. `2.31`, `-0.04`). Real market data; + * undefined when no rate is available. The widget colors it green/red and + * prefixes a ▲/▼ arrow. + */ + priceChange1d?: number; + /** + * Price points used to draw the row's mini sparkline (visualization only). + * The widget renders whatever series it receives; see {@link buildSparkline}. + */ + sparkline?: number[]; +} + +/** Number of points in a row's sparkline. */ +const SPARKLINE_POINTS = 16; + +/** + * Returns the real 24h price-change percentage for an asset, mirroring + * {@link useTokenPricePercentageChange}: EVM tokens are keyed by + * `[chainId][address]` in the TokenRatesController market data (native tokens + * use the chain's native-token address), and non-EVM assets come from the + * multichain rates controller keyed by their CAIP-19 asset id. + */ +function getPricePercentChange1d( + asset: { + address?: Hex; + assetId: string; + chainId: string; + isNative: boolean; + }, + marketData: ReturnType, + multichainRates: ReturnType, +): number | undefined { + const nonEvmChange = + multichainRates?.[asset.assetId as CaipAssetType]?.marketData + ?.pricePercentChange?.P1D; + if (nonEvmChange !== undefined) { + return nonEvmChange; + } + + const chainId = asset.chainId as Hex; + const lookupAddress = asset.isNative + ? (getNativeTokenAddress(chainId) as Hex) + : asset.address; + if (!lookupAddress) { + return undefined; + } + return marketData?.[chainId]?.[lookupAddress]?.pricePercentChange1d; +} + +/** + * Builds the price series for a token's sparkline. + * + * MOCK DATA: this currently synthesizes a deterministic series seeded by the + * token symbol (so a given token renders the same shape every sync — keeping + * {@link WidgetSyncManager}'s payload dedup intact) and biased to trend in the + * direction of `priceChange1d` so the line visually agrees with the ▲/▼ arrow. + * To wire real data later, replace this body with a historical-price fetch + * (e.g. price.api.cx.metamask.io/v3/historical-prices) — the rest of the + * pipeline already passes a `number[]` straight through to the widget. + */ +function buildSparkline(symbol: string, priceChange1d?: number): number[] { + // Small string hash → seed, for a cheap deterministic PRNG (mulberry32). + let seed = 0; + for (let i = 0; i < symbol.length; i++) { + seed = (seed * 31 + symbol.charCodeAt(i)) >>> 0; + } + const rand = () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + // Overall drift matches the 24h change sign; small per-point jitter on top. + const drift = (priceChange1d ?? 0) >= 0 ? 1 : -1; + const points: number[] = []; + let value = 100; + for (let i = 0; i < SPARKLINE_POINTS; i++) { + value += drift * rand() * 2 + (rand() - 0.5) * 3; + points.push(value); + } + return points; +} + +/** + * Builds a swap deep link that preselects `caipAssetId` as the source token. + * The app maps `metamask://` to the universal link and routes it to the Bridge + * (unified swap) view — see handleSwapUrl.ts. + */ +function buildSwapDeeplink(caipAssetId: string): string { + return `metamask://swap?from=${encodeURIComponent(caipAssetId)}`; +} + +export interface WidgetTokenPayload { + tokens: WidgetTokenEntry[]; +} + +const EMPTY_PAYLOAD: WidgetTokenPayload = { tokens: [] }; + +/** + * Builds the widget payload from Redux state: every token held by the selected + * account group, across all chains, whose holding value is over + * {@link WIDGET_MIN_HOLDING_VALUE}, sorted by holding value descending and + * capped at {@link WIDGET_MAX_TOKENS}. The right-hand figure is the token's + * **unit market price** (holding fiat value ÷ token balance), not the holding + * value. + * + * Pure function (no I/O) so it can be unit-tested without the native bridge. + */ +export function buildWidgetPayload(state: RootState): WidgetTokenPayload { + const assetsByChain = selectAssetsBySelectedAccountGroup(state); + const marketData = selectTokenMarketData(state); + const multichainRates = selectMultichainAssetsRates(state); + + const held = Object.values(assetsByChain) + .flat() + .map((asset) => { + const fiatBalance = asset.fiat?.balance ?? 0; + const tokenBalance = parseFloat(asset.balance ?? '0'); + return { asset, fiatBalance, tokenBalance }; + }) + .filter( + ({ fiatBalance, tokenBalance }) => + fiatBalance > WIDGET_MIN_HOLDING_VALUE && tokenBalance > 0, + ) + .sort((a, b) => b.fiatBalance - a.fiatBalance) + .slice(0, WIDGET_MAX_TOKENS); + + if (held.length === 0) { + return EMPTY_PAYLOAD; + } + + const tokens: WidgetTokenEntry[] = held.map( + ({ asset, fiatBalance, tokenBalance }) => { + const unitPrice = fiatBalance / tokenBalance; + const currency = asset.fiat?.currency ?? 'usd'; + // EVM assets carry a Hex assetId + Hex chainId; non-EVM assets already + // carry a CAIP-19 assetId (passed through). formatAddressToAssetId resolves + // native tokens to the correct per-chain CAIP id. + const caipAssetId = formatAddressToAssetId(asset.assetId, asset.chainId); + const priceChange1d = getPricePercentChange1d( + asset, + marketData, + multichainRates, + ); + return { + symbol: asset.symbol, + // Mirrors how the token list formats fiat (assets-list.ts): currency + // style with a sub-cent threshold so prices below $0.01 show "< $0.01". + priceFormatted: formatWithThreshold( + unitPrice, + 0.01, + getLocaleLanguageCode(), + { style: 'currency', currency }, + ), + logoUrl: asset.image ?? '', + deeplink: caipAssetId ? buildSwapDeeplink(caipAssetId) : undefined, + priceChange1d, + sparkline: buildSparkline(asset.symbol, priceChange1d), + }; + }, + ); + + return { tokens }; +} diff --git a/app/core/WidgetData/syncWidgetLogos.test.ts b/app/core/WidgetData/syncWidgetLogos.test.ts new file mode 100644 index 00000000000..804003de4ae --- /dev/null +++ b/app/core/WidgetData/syncWidgetLogos.test.ts @@ -0,0 +1,140 @@ +import type RNFSType from 'react-native-fs'; +import type { WidgetTokenPayload } from './buildWidgetPayload'; +import type { syncWidgetLogos as SyncWidgetLogos } from './syncWidgetLogos'; + +jest.mock('react-native-fs', () => ({ + exists: jest.fn(), + downloadFile: jest.fn(), + unlink: jest.fn(), +})); + +jest.mock('../NativeModules', () => ({ + WidgetBridge: { getLogosDirectoryPath: jest.fn() }, +})); + +jest.mock('../../util/Logger', () => ({ error: jest.fn() })); + +const LOGOS_DIR = '/group/TokenLogos'; + +const download = (statusCode: number) => + ({ promise: Promise.resolve({ statusCode }) }) as ReturnType< + typeof RNFSType.downloadFile + >; + +const makePayload = ( + tokens: Partial[], +): WidgetTokenPayload => ({ + tokens: tokens.map((t) => ({ + symbol: t.symbol ?? 'TKN', + priceFormatted: t.priceFormatted ?? '$1.00', + logoUrl: t.logoUrl ?? 'https://logos/TKN.png', + ...t, + })), +}); + +// The SUT memoizes the logos-dir path at module scope, so re-require it fresh +// per test to keep cases independent. +let syncWidgetLogos: typeof SyncWidgetLogos; +let mockExists: jest.MockedFunction; +let mockDownload: jest.MockedFunction; +let mockUnlink: jest.MockedFunction; +let mockGetDir: jest.MockedFunction<() => Promise>; + +describe('syncWidgetLogos', () => { + beforeEach(() => { + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const RNFS = require('react-native-fs'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { WidgetBridge } = require('../NativeModules'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + ({ syncWidgetLogos } = require('./syncWidgetLogos')); + + mockExists = RNFS.exists; + mockDownload = RNFS.downloadFile; + mockUnlink = RNFS.unlink; + mockGetDir = WidgetBridge.getLogosDirectoryPath; + + mockGetDir.mockResolvedValue(LOGOS_DIR); + mockExists.mockResolvedValue(false); + mockDownload.mockReturnValue(download(200)); + mockUnlink.mockResolvedValue(undefined); + }); + + it('downloads a logo and rewrites logoUrl to logoFile', async () => { + const { tokens } = await syncWidgetLogos( + makePayload([{ symbol: 'ETH', logoUrl: 'https://logos/eth.png' }]), + ); + + expect(mockDownload).toHaveBeenCalledWith({ + fromUrl: 'https://logos/eth.png', + toFile: `${LOGOS_DIR}/ETH.png`, + }); + expect(tokens[0]).toMatchObject({ symbol: 'ETH', logoFile: 'ETH.png' }); + expect(tokens[0]).not.toHaveProperty('logoUrl'); + }); + + it('skips the download when the file already exists', async () => { + mockExists.mockResolvedValue(true); + + const { tokens } = await syncWidgetLogos(makePayload([{ symbol: 'ETH' }])); + + expect(mockDownload).not.toHaveBeenCalled(); + expect(tokens[0].logoFile).toBe('ETH.png'); + }); + + it.each([ + ['empty', ''], + ['non-http', 'data:image/png;base64,abc'], + ['svg', 'https://logos/eth.svg'], + ['svg with query', 'https://logos/eth.svg?v=2'], + ])('does not download a %s logo url', async (_label, logoUrl) => { + const { tokens } = await syncWidgetLogos( + makePayload([{ symbol: 'ETH', logoUrl }]), + ); + + expect(mockDownload).not.toHaveBeenCalled(); + expect(tokens[0]).not.toHaveProperty('logoFile'); + expect(tokens[0]).not.toHaveProperty('logoUrl'); + }); + + it('drops logoFile and cleans up on a non-2xx response', async () => { + mockDownload.mockReturnValue(download(404)); + + const { tokens } = await syncWidgetLogos(makePayload([{ symbol: 'ETH' }])); + + expect(tokens[0]).not.toHaveProperty('logoFile'); + expect(mockUnlink).toHaveBeenCalledWith(`${LOGOS_DIR}/ETH.png`); + }); + + it('treats a download error as a missing logo', async () => { + mockDownload.mockReturnValue({ + promise: Promise.reject(new Error('network')), + } as ReturnType); + + const { tokens } = await syncWidgetLogos(makePayload([{ symbol: 'ETH' }])); + + expect(tokens[0]).not.toHaveProperty('logoFile'); + }); + + it('sanitizes the symbol into a safe filename', async () => { + await syncWidgetLogos(makePayload([{ symbol: 'a/b c' }])); + + expect(mockDownload).toHaveBeenCalledWith( + expect.objectContaining({ toFile: `${LOGOS_DIR}/a_b_c.png` }), + ); + }); + + it('returns the payload without logos when the container is unavailable', async () => { + mockGetDir.mockRejectedValue(new Error('app_group_unavailable')); + + const { tokens } = await syncWidgetLogos( + makePayload([{ symbol: 'ETH' }, { symbol: 'USDC' }]), + ); + + expect(mockDownload).not.toHaveBeenCalled(); + expect(tokens.map((t) => t.symbol)).toEqual(['ETH', 'USDC']); + expect(tokens[0]).not.toHaveProperty('logoUrl'); + expect(tokens[0]).not.toHaveProperty('logoFile'); + }); +}); diff --git a/app/core/WidgetData/syncWidgetLogos.ts b/app/core/WidgetData/syncWidgetLogos.ts new file mode 100644 index 00000000000..71c39b7c38e --- /dev/null +++ b/app/core/WidgetData/syncWidgetLogos.ts @@ -0,0 +1,110 @@ +import RNFS from 'react-native-fs'; +import Logger from '../../util/Logger'; +import { WidgetBridge } from '../NativeModules'; +import { WidgetTokenEntry, WidgetTokenPayload } from './buildWidgetPayload'; + +/** + * The final per-token shape written to the widget: like {@link WidgetTokenEntry} + * but with the transient remote `logoUrl` replaced by a locally-cached + * `logoFile` (resolved by the widget against the App Group logos directory). + */ +export type WidgetTokenOutput = Omit & { + /** Filename (relative to the App Group logos dir) of the cached PNG logo. */ + logoFile?: string; +}; + +export interface WidgetSyncPayload { + tokens: WidgetTokenOutput[]; +} + +// The logos dir path never changes for an install; resolve it once and cache it. +let logosDirPromise: Promise | null = null; + +function getLogosDir(): Promise { + if (!logosDirPromise) { + logosDirPromise = WidgetBridge.getLogosDirectoryPath(); + } + return logosDirPromise; +} + +/** Strip non-alphanumerics so the symbol is a safe PNG filename. */ +function logoFileName(symbol: string): string { + const cleaned = symbol.replace(/[^a-zA-Z0-9]/g, '_'); + return `${cleaned || 'token'}.png`; +} + +function stripLogoUrl(token: WidgetTokenEntry): WidgetTokenOutput { + const { logoUrl: _logoUrl, ...rest } = token; + return rest; +} + +/** + * Downloads a token logo into the App Group container, skipping URLs that are + * empty, non-http(s), SVG (SwiftUI can't render SVG), or already cached on disk. + * Returns the relative filename on success, or `undefined` on any failure — the + * widget renders a monogram fallback when a logo is absent. + */ +async function cacheLogo( + logoUrl: string, + symbol: string, + logosDir: string, +): Promise { + if ( + !logoUrl || + !/^https?:\/\//i.test(logoUrl) || + /\.svg(\?|$)/i.test(logoUrl) + ) { + return undefined; + } + + const fileName = logoFileName(symbol); + const toFile = `${logosDir}/${fileName}`; + + try { + // Logos rarely change; skip the download when the file already exists. + if (await RNFS.exists(toFile)) { + return fileName; + } + const { statusCode } = await RNFS.downloadFile({ fromUrl: logoUrl, toFile }) + .promise; + if (statusCode >= 200 && statusCode < 300) { + return fileName; + } + // Partial/failed downloads can leave a stub file behind; remove it so the + // next sync retries cleanly. + await RNFS.unlink(toFile).catch(() => undefined); + return undefined; + } catch { + // Network blips are expected and non-fatal; fall back to the monogram. + return undefined; + } +} + +/** + * Replaces each entry's remote `logoUrl` with a locally-cached `logoFile`, + * downloading the logos into the App Group container that the widget reads from. + * If the shared container is unavailable, returns the payload without logos + * rather than failing the whole sync. + */ +export async function syncWidgetLogos( + payload: WidgetTokenPayload, +): Promise { + let logosDir: string; + try { + logosDir = await getLogosDir(); + } catch (error) { + logosDirPromise = null; // allow a later retry + Logger.error(error as Error, 'syncWidgetLogos: logos dir unavailable'); + return { tokens: payload.tokens.map(stripLogoUrl) }; + } + + const tokens = await Promise.all( + payload.tokens.map(async (token) => { + const logoFile = await cacheLogo(token.logoUrl, token.symbol, logosDir); + const output = stripLogoUrl(token); + return logoFile ? { ...output, logoFile } : output; + }), + ); + + return { tokens }; +} diff --git a/ios/MetaMask.xcodeproj/project.pbxproj b/ios/MetaMask.xcodeproj/project.pbxproj index b9bbcb5f304..cc54d516eec 100644 --- a/ios/MetaMask.xcodeproj/project.pbxproj +++ b/ios/MetaMask.xcodeproj/project.pbxproj @@ -7,8 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 04FDA40C4DC41D727F40EA2E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0CC9CAF63E101FCCCFB3A2DA /* Foundation.framework */; }; 09D9851F119C43FBB54ED59C /* Geist-MediumItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 6E7939D848B5467AA6602966 /* Geist-MediumItalic.otf */; }; 124C1456DB6348928E0536A8 /* Geist-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = 4BFDB3B860044F1A9CF3CFEB /* Geist-Medium.otf */; }; + 13582479C566BD285F51BD3B /* RCTWidgetBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = F0A97EC7DFBB2AC04CFDD9D4 /* RCTWidgetBridge.m */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 153C1ABB2217BCDC0088EFE0 /* JavaScriptCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 153C1A742217BCDC0088EFE0 /* JavaScriptCore.framework */; }; 153F84CA2319B8FD00C19B63 /* Branch.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 153F84C92319B8DB00C19B63 /* Branch.framework */; }; @@ -17,6 +19,8 @@ 15AD28A921B7CFD9005DEB23 /* release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 15FDD86021B76461006B7C35 /* release.xcconfig */; }; 15AD28AA21B7CFDC005DEB23 /* debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 15FDD82721B7642B006B7C35 /* debug.xcconfig */; }; 15D158ED210BD912006982B5 /* Metamask.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 15D158EC210BD8C8006982B5 /* Metamask.ttf */; }; + 1BEF6468EE08554329953AA4 /* WidgetEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C91176041256B164F80C3 /* WidgetEntryView.swift */; }; + 1C737B03D72FE37A3F25BE28 /* RCTWidgetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB051F5251AFCDD864322822 /* RCTWidgetBridge.swift */; }; 2EF2825B2B0FF86900D7B4B1 /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654378AF243E2ADC00571B9C /* File.swift */; }; 2EF2825C2B0FF86900D7B4B1 /* RCTScreenshotDetect.m in Sources */ = {isa = PBXBuildFile; fileRef = CF98DA9B28D9FEB700096782 /* RCTScreenshotDetect.m */; }; 2EF2825E2B0FF86900D7B4B1 /* RCTMinimizer.m in Sources */ = {isa = PBXBuildFile; fileRef = CF9895762A3B49BE00B4C9B5 /* RCTMinimizer.m */; }; @@ -41,8 +45,10 @@ 3466654F43654D36B5D478CA /* config.json in Resources */ = {isa = PBXBuildFile; fileRef = 2679C48F8CD642C68116DD24 /* config.json */; }; 3F123FD0EA9146FEBC864879 /* MMSans-Medium.otf in Resources */ = {isa = PBXBuildFile; fileRef = 4A64F1985EEA45C0B027E517 /* MMSans-Medium.otf */; }; 49D8E62C506F4A63889EEC7F /* branch.json in Resources */ = {isa = PBXBuildFile; fileRef = FE3C9A2458A1416290DEDAD4 /* branch.json */; }; + 5D4059070420398B2A53C03C /* Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D6B5AE7B9C6EE92E0B8E12 /* Provider.swift */; }; 650F2B9D24DC5FF200C3B9C4 /* libRCTAesForked.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 650F2B9C24DC5FEC00C3B9C4 /* libRCTAesForked.a */; }; 654378B0243E2ADC00571B9C /* File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654378AF243E2ADC00571B9C /* File.swift */; }; + 7D9276EE8440FE594FBBBCAF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA7141BDE59B992AD7479C6A /* Assets.xcassets */; }; 8C3986ED969040AEBC7A3856 /* MMPoly-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = F10E7EBF946A4F6D8E229143 /* MMPoly-Regular.otf */; }; 8DE564ACA9934796B5E7B1EB /* MMSans-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = F3C919D8F42C47389FF643E7 /* MMSans-Regular.otf */; }; 9D9E53F67A884FDEBE9A4D3C /* Geist-RegularItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 978781C44CFB4434873EDB69 /* Geist-RegularItalic.otf */; }; @@ -50,15 +56,18 @@ A1F5C74197AA4BB4B2F9C001 /* Geist-SemiBold.otf in Resources */ = {isa = PBXBuildFile; fileRef = A1F5C74197AA4BB4B2F9C011 /* Geist-SemiBold.otf */; }; A1F5C74197AA4BB4B2F9C002 /* Geist-SemiBoldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = A1F5C74197AA4BB4B2F9C012 /* Geist-SemiBoldItalic.otf */; }; A9AB7F6A09E06325C0A71FA4 /* libPods-MetaMask-Flask.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F02EB68A6ACEF113F4693A8 /* libPods-MetaMask-Flask.a */; }; + A9E5867B741FE780D723508B /* MetaMaskWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 488DC986F6886363CE76C752 /* MetaMaskWidget.swift */; }; AA11BB22CC33DD44EE55FF66 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* SplashScreen.storyboard */; }; AA11BB22CC33DD44EE55FF68 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF67 /* SplashScreen.storyboard */; }; B0EF7FA927BD16EA00D48B4E /* ThemeColors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B0EF7FA827BD16EA00D48B4E /* ThemeColors.xcassets */; }; BAB8A7C7328F48B6AC38DCE9 /* Geist-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = B848D40B87744D32949BDC25 /* Geist-Regular.otf */; }; + C439C4934954AF37BA47E40F /* MetaMaskWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 1686FEEC950C1F6CB44D831F /* MetaMaskWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; C7B6D2EC4EBB469F9E0658BE /* MMSans-Bold.otf in Resources */ = {isa = PBXBuildFile; fileRef = D3350113F0764105B1E60002 /* MMSans-Bold.otf */; }; C8424AE52CCC01F900F0BEB7 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8424AE32CCC01F900F0BEB7 /* GoogleService-Info.plist */; }; C8424AE62CCC01F900F0BEB7 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C8424AE32CCC01F900F0BEB7 /* GoogleService-Info.plist */; }; CF9895772A3B49BE00B4C9B5 /* RCTMinimizer.m in Sources */ = {isa = PBXBuildFile; fileRef = CF9895762A3B49BE00B4C9B5 /* RCTMinimizer.m */; }; CF98DA9C28D9FEB700096782 /* RCTScreenshotDetect.m in Sources */ = {isa = PBXBuildFile; fileRef = CF98DA9B28D9FEB700096782 /* RCTScreenshotDetect.m */; }; + E17443F7C22491B3E0D20AA3 /* WidgetToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B91BF58276E83B9268B7B7F /* WidgetToken.swift */; }; E4B580722E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; }; E4B580742E32F462008165E1 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4B580712E32F462008165E1 /* Expo.plist */; }; E4B580762E33A001008165E1 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4B580752E33A001008165E1 /* AppDelegate.swift */; }; @@ -66,6 +75,7 @@ E83DB5522BBDF2AA00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; }; E83DB5542BBDF2AF00536063 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */; }; ED2E8FE6D71BE9319F3B27D3 /* libPods-MetaMask.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D2632307C64595BE1B8ABEAF /* libPods-MetaMask.a */; }; + F042E7F5D2CF32E2BE06805B /* TokenRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F94DDFA1E4E55884FD5B1112 /* TokenRowView.swift */; }; F0B2A3E101000001000000A1 /* BrazeHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = F0B2A3E101000001000000A0 /* BrazeHelper.mm */; }; F0B2A3E101000001000000A2 /* BrazeHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = F0B2A3E101000001000000A0 /* BrazeHelper.mm */; }; F23972D16903249A8EC120BD /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EB256CB3A1A7A1D942A95F6 /* ExpoModulesProvider.swift */; }; @@ -94,6 +104,13 @@ remoteGlobalIDString = E298D0511C73D1B800589D22; remoteInfo = Branch; }; + 64F487485007307041D0962A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F6C7731AF804057659B0BFE8; + remoteInfo = MetaMaskWidget; + }; 650F2B9B24DC5FEC00C3B9C4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 650F2B9724DC5FEB00C3B9C4 /* RCTAesForked.xcodeproj */; @@ -126,12 +143,24 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + AF44A4481D793DD3C9181FD5 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + C439C4934954AF37BA47E40F /* MetaMaskWidget.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 00E356F21AD99517003FC87E /* MetaMaskTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MetaMaskTests.m; sourceTree = ""; }; + 0CC9CAF63E101FCCCFB3A2DA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 13B07F961A680F5B00A75B9A /* MetaMask.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MetaMask.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = MetaMask/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = MetaMask/Info.plist; sourceTree = ""; }; @@ -143,6 +172,7 @@ 15D158EC210BD8C8006982B5 /* Metamask.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Metamask.ttf; path = ../app/fonts/Metamask.ttf; sourceTree = ""; }; 15FDD82721B7642B006B7C35 /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = debug.xcconfig; sourceTree = ""; }; 15FDD86021B76461006B7C35 /* release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = release.xcconfig; sourceTree = ""; }; + 1686FEEC950C1F6CB44D831F /* MetaMaskWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MetaMaskWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 178440FE3F1C4F4180D14622 /* libTcpSockets.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libTcpSockets.a; sourceTree = ""; }; 1C516951C09F43CB97129B66 /* Octicons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Octicons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Octicons.ttf"; sourceTree = ""; }; 2679C48F8CD642C68116DD24 /* config.json */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = config.json; path = ../app/fonts/config.json; sourceTree = ""; }; @@ -155,8 +185,10 @@ 42C239E9FAA64BD9A34B8D8A /* MaterialCommunityIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = MaterialCommunityIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf"; sourceTree = ""; }; 42C6DDE3B80F47AFA9C9D4F5 /* Foundation.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Foundation.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Foundation.ttf"; sourceTree = ""; }; 4444176409EB42CB93AB03C5 /* SimpleLineIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = SimpleLineIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf"; sourceTree = ""; }; + 488DC986F6886363CE76C752 /* MetaMaskWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetaMaskWidget.swift; sourceTree = ""; }; 4A2D27104599412CA00C35EF /* Ionicons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Ionicons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf"; sourceTree = ""; }; 4A64F1985EEA45C0B027E517 /* MMSans-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "MMSans-Medium.otf"; path = "../app/fonts/MMSans-Medium.otf"; sourceTree = ""; }; + 4B91BF58276E83B9268B7B7F /* WidgetToken.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetToken.swift; sourceTree = ""; }; 4BFDB3B860044F1A9CF3CFEB /* Geist-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist-Medium.otf"; path = "../app/fonts/Geist-Medium.otf"; sourceTree = ""; }; 4C81CC9BCD86AC7F96BA8CAD /* Pods-MetaMask.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MetaMask.debug.xcconfig"; path = "Target Support Files/Pods-MetaMask/Pods-MetaMask.debug.xcconfig"; sourceTree = ""; }; 57C103F40F394637B5A886FC /* FontAwesome5_Brands.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = FontAwesome5_Brands.ttf; path = "../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf"; sourceTree = ""; }; @@ -173,6 +205,7 @@ 91B348F39D8AD3220320E89D /* Pods-MetaMask-Flask.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MetaMask-Flask.debug.xcconfig"; path = "Target Support Files/Pods-MetaMask-Flask/Pods-MetaMask-Flask.debug.xcconfig"; sourceTree = ""; }; 978781C44CFB4434873EDB69 /* Geist-RegularItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist-RegularItalic.otf"; path = "../app/fonts/Geist-RegularItalic.otf"; sourceTree = ""; }; 9F02EB68A6ACEF113F4693A8 /* libPods-MetaMask-Flask.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MetaMask-Flask.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + A0D6B5AE7B9C6EE92E0B8E12 /* Provider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Provider.swift; sourceTree = ""; }; A1F5C74197AA4BB4B2F9C011 /* Geist-SemiBold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist-SemiBold.otf"; path = "../app/fonts/Geist-SemiBold.otf"; sourceTree = ""; }; A1F5C74197AA4BB4B2F9C012 /* Geist-SemiBoldItalic.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist-SemiBoldItalic.otf"; path = "../app/fonts/Geist-SemiBoldItalic.otf"; sourceTree = ""; }; A498EA4CD2F8488DB666B94C /* Entypo.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Entypo.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Entypo.ttf"; sourceTree = ""; }; @@ -180,8 +213,11 @@ AA11BB22CC33DD44EE55FF67 /* SplashScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = MetaMask/SplashScreen.storyboard; sourceTree = ""; }; AA9EDF17249955C7005D89EE /* MetaMaskDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = MetaMaskDebug.entitlements; path = MetaMask/MetaMaskDebug.entitlements; sourceTree = ""; }; B0EF7FA827BD16EA00D48B4E /* ThemeColors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = ThemeColors.xcassets; sourceTree = ""; }; + B83E00FC381E86EF587D1B3C /* MetaMaskWidget.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = MetaMaskWidget.entitlements; sourceTree = ""; }; B848D40B87744D32949BDC25 /* Geist-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Geist-Regular.otf"; path = "../app/fonts/Geist-Regular.otf"; sourceTree = ""; }; + BB051F5251AFCDD864322822 /* RCTWidgetBridge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RCTWidgetBridge.swift; sourceTree = ""; }; BF485CDA047B4D52852B87F5 /* EvilIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = EvilIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf"; sourceTree = ""; }; + BF6C91176041256B164F80C3 /* WidgetEntryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WidgetEntryView.swift; sourceTree = ""; }; C8424AE32CCC01F900F0BEB7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; CF9895752A3B48F700B4C9B5 /* RCTMinimizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = RCTMinimizer.h; path = MetaMask/NativeModules/RCTMinimizer/RCTMinimizer.h; sourceTree = ""; }; CF9895762A3B49BE00B4C9B5 /* RCTMinimizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = RCTMinimizer.m; path = MetaMask/NativeModules/RCTMinimizer/RCTMinimizer.m; sourceTree = ""; }; @@ -190,16 +226,20 @@ D0CBAE789660472DB719C765 /* libLottie.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libLottie.a; sourceTree = ""; }; D2632307C64595BE1B8ABEAF /* libPods-MetaMask.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MetaMask.a"; sourceTree = BUILT_PRODUCTS_DIR; }; D3350113F0764105B1E60002 /* MMSans-Bold.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "MMSans-Bold.otf"; path = "../app/fonts/MMSans-Bold.otf"; sourceTree = ""; }; + D34F59A693B1BA8545701DFA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; E4B580712E32F462008165E1 /* Expo.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; E4B580752E33A001008165E1 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetaMask/AppDelegate.swift; sourceTree = ""; }; E83DB5392BBDB14700536063 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = MetaMask/PrivacyInfo.xcprivacy; sourceTree = SOURCE_ROOT; }; E9629905BA1940ADA4189921 /* Feather.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Feather.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Feather.ttf"; sourceTree = ""; }; + EA7141BDE59B992AD7479C6A /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; EBC2B6371CD846D28B9FAADF /* FontAwesome5_Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = FontAwesome5_Regular.ttf; path = "../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf"; sourceTree = ""; }; + F0A97EC7DFBB2AC04CFDD9D4 /* RCTWidgetBridge.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = RCTWidgetBridge.m; sourceTree = ""; }; F0B2A3E101000001000000A0 /* BrazeHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = MetaMask/BrazeHelper.mm; sourceTree = ""; }; F10E7EBF946A4F6D8E229143 /* MMPoly-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "MMPoly-Regular.otf"; path = "../app/fonts/MMPoly-Regular.otf"; sourceTree = ""; }; F1CCBB0591B4D16C1710A05D /* Pods-MetaMask-Flask.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MetaMask-Flask.release.xcconfig"; path = "Target Support Files/Pods-MetaMask-Flask/Pods-MetaMask-Flask.release.xcconfig"; sourceTree = ""; }; F3C919D8F42C47389FF643E7 /* MMSans-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "MMSans-Regular.otf"; path = "../app/fonts/MMSans-Regular.otf"; sourceTree = ""; }; F562CA6B28AA4A67AA29B61C /* MaterialIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = MaterialIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf"; sourceTree = ""; }; + F94DDFA1E4E55884FD5B1112 /* TokenRowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TokenRowView.swift; sourceTree = ""; }; F961A36A28105CF9007442B5 /* LinkPresentation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LinkPresentation.framework; path = System/Library/Frameworks/LinkPresentation.framework; sourceTree = SDKROOT; }; F9DFF7AC557B46B6BEFAA1C1 /* libRNShakeEvent.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNShakeEvent.a; sourceTree = ""; }; FE3C9A2458A1416290DEDAD4 /* branch.json */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = branch.json; path = ../branch.json; sourceTree = ""; }; @@ -218,6 +258,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2C1937D1EE640D2CD9641A4D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 04FDA40C4DC41D727F40EA2E /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2EF282602B0FF86900D7B4B1 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -258,6 +306,14 @@ name = "MetaMask-Flask"; sourceTree = ""; }; + 125DD66BB0A3FD368D83FCEF /* iOS */ = { + isa = PBXGroup; + children = ( + 0CC9CAF63E101FCCCFB3A2DA /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; 13B07FAE1A68108700A75B9A /* MetaMask */ = { isa = PBXGroup; children = ( @@ -303,6 +359,7 @@ children = ( CF9895742A3B48DC00B4C9B5 /* RCTMinimizer */, CF98DA9228D9FE5000096782 /* RCTScreenshotDetect */, + BE9E2AE6E4A6ED49F1AA31A3 /* RCTWidgetBridge */, ); name = NativeModules; sourceTree = ""; @@ -323,6 +380,7 @@ 2D16E6891FA4F8E400B85C8A /* libReact.a */, D2632307C64595BE1B8ABEAF /* libPods-MetaMask.a */, 9F02EB68A6ACEF113F4693A8 /* libPods-MetaMask-Flask.a */, + 125DD66BB0A3FD368D83FCEF /* iOS */, ); name = Frameworks; sourceTree = ""; @@ -335,6 +393,22 @@ path = "Light-Swift-Untar-V2"; sourceTree = ""; }; + 32E91E13723DA8FC8963C831 /* MetaMaskWidget */ = { + isa = PBXGroup; + children = ( + 488DC986F6886363CE76C752 /* MetaMaskWidget.swift */, + A0D6B5AE7B9C6EE92E0B8E12 /* Provider.swift */, + 4B91BF58276E83B9268B7B7F /* WidgetToken.swift */, + F94DDFA1E4E55884FD5B1112 /* TokenRowView.swift */, + BF6C91176041256B164F80C3 /* WidgetEntryView.swift */, + EA7141BDE59B992AD7479C6A /* Assets.xcassets */, + D34F59A693B1BA8545701DFA /* Info.plist */, + B83E00FC381E86EF587D1B3C /* MetaMaskWidget.entitlements */, + ); + name = MetaMaskWidget; + path = MetaMaskWidget; + sourceTree = ""; + }; 4A27949D046C4516B9653BBB /* Resources */ = { isa = PBXGroup; children = ( @@ -406,6 +480,7 @@ AA342D524556DBBE26F5997C /* Pods */, 654378AE243E2ADB00571B9C /* MetaMask-Bridging-Header.h */, B1017F312FF6E8B14E7F30EB /* ExpoModulesProviders */, + 32E91E13723DA8FC8963C831 /* MetaMaskWidget */, ); indentWidth = 2; sourceTree = ""; @@ -417,6 +492,7 @@ children = ( 13B07F961A680F5B00A75B9A /* MetaMask.app */, 2EF282922B0FF86900D7B4B1 /* MetaMask-Flask.app */, + 1686FEEC950C1F6CB44D831F /* MetaMaskWidget.appex */, ); name = Products; sourceTree = ""; @@ -441,6 +517,16 @@ name = ExpoModulesProviders; sourceTree = ""; }; + BE9E2AE6E4A6ED49F1AA31A3 /* RCTWidgetBridge */ = { + isa = PBXGroup; + children = ( + BB051F5251AFCDD864322822 /* RCTWidgetBridge.swift */, + F0A97EC7DFBB2AC04CFDD9D4 /* RCTWidgetBridge.m */, + ); + name = RCTWidgetBridge; + path = MetaMask/NativeModules/RCTWidgetBridge; + sourceTree = SOURCE_ROOT; + }; CF9895742A3B48DC00B4C9B5 /* RCTMinimizer */ = { isa = PBXGroup; children = ( @@ -473,6 +559,7 @@ 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 15ACCA0022655C3A0063978B /* Embed Frameworks */, + AF44A4481D793DD3C9181FD5 /* Embed App Extensions */, 00DD1BFF1BD5951E006B06BC /* Bundle JS Code & Upload Sentry Files */, 1315792FDF9ED5C1277541D0 /* [CP] Embed Pods Frameworks */, FFED9AB1AACD0DA25EAA971D /* [CP] Copy Pods Resources */, @@ -483,6 +570,7 @@ ); dependencies = ( 153F84CD2319B8FD00C19B63 /* PBXTargetDependency */, + C6B5EFED7BB0833AA2F59652 /* PBXTargetDependency */, ); name = MetaMask; productName = "Hello World"; @@ -516,6 +604,23 @@ productReference = 2EF282922B0FF86900D7B4B1 /* MetaMask-Flask.app */; productType = "com.apple.product-type.application"; }; + F6C7731AF804057659B0BFE8 /* MetaMaskWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = 153E903DA34B268D6D86A589 /* Build configuration list for PBXNativeTarget "MetaMaskWidget" */; + buildPhases = ( + 9E86661E6BAFC830494C9912 /* Sources */, + 2C1937D1EE640D2CD9641A4D /* Frameworks */, + 572563AF6D4EEB449D6190BD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MetaMaskWidget; + productName = MetaMaskWidget; + productReference = 1686FEEC950C1F6CB44D831F /* MetaMaskWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -564,6 +669,7 @@ targets = ( 13B07F861A680F5B00A75B9A /* MetaMask */, 2EF282522B0FF86900D7B4B1 /* MetaMask-Flask */, + F6C7731AF804057659B0BFE8 /* MetaMaskWidget */, ); }; /* End PBXProject section */ @@ -633,6 +739,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 572563AF6D4EEB449D6190BD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7D9276EE8440FE594FBBBCAF /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -944,6 +1058,8 @@ CF98DA9C28D9FEB700096782 /* RCTScreenshotDetect.m in Sources */, CF9895772A3B49BE00B4C9B5 /* RCTMinimizer.m in Sources */, A1987088D4835E5FCCABC418 /* ExpoModulesProvider.swift in Sources */, + 1C737B03D72FE37A3F25BE28 /* RCTWidgetBridge.swift in Sources */, + 13582479C566BD285F51BD3B /* RCTWidgetBridge.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -963,6 +1079,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9E86661E6BAFC830494C9912 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A9E5867B741FE780D723508B /* MetaMaskWidget.swift in Sources */, + 5D4059070420398B2A53C03C /* Provider.swift in Sources */, + E17443F7C22491B3E0D20AA3 /* WidgetToken.swift in Sources */, + F042E7F5D2CF32E2BE06805B /* TokenRowView.swift in Sources */, + 1BEF6468EE08554329953AA4 /* WidgetEntryView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -976,6 +1104,11 @@ name = Branch; targetProxy = 2EF282562B0FF86900D7B4B1 /* PBXContainerItemProxy */; }; + C6B5EFED7BB0833AA2F59652 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F6C7731AF804057659B0BFE8 /* MetaMaskWidget */; + targetProxy = 64F487485007307041D0962A /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1246,6 +1379,37 @@ }; name = Release; }; + 466F3554B8DA4B4A2020CCFD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = MetaMaskWidget/MetaMaskWidget.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 4823; + DEVELOPMENT_TEAM = 48XVW22RCG; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MetaMaskWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 8.1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = io.metamask.MetaMask.MetaMaskWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = NO; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 15FDD82721B7642B006B7C35 /* debug.xcconfig */; @@ -1344,6 +1508,37 @@ }; name = Release; }; + FD2A9819E53BEC866B83C12B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = NO; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = MetaMaskWidget/MetaMaskWidget.entitlements; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 4823; + DEVELOPMENT_TEAM = 48XVW22RCG; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MetaMaskWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 8.1.0; + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = io.metamask.MetaMask.MetaMaskWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1356,6 +1551,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 153E903DA34B268D6D86A589 /* Build configuration list for PBXNativeTarget "MetaMaskWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 466F3554B8DA4B4A2020CCFD /* Release */, + FD2A9819E53BEC866B83C12B /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 2EF2828F2B0FF86900D7B4B1 /* Build configuration list for PBXNativeTarget "MetaMask-Flask" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/MetaMask/MetaMask.entitlements b/ios/MetaMask/MetaMask.entitlements index 24bf653d1a3..b8f87c3b17a 100644 --- a/ios/MetaMask/MetaMask.entitlements +++ b/ios/MetaMask/MetaMask.entitlements @@ -2,6 +2,10 @@ + com.apple.security.application-groups + + group.io.metamask.MetaMask + aps-environment development com.apple.developer.applesignin diff --git a/ios/MetaMask/MetaMaskDebug.entitlements b/ios/MetaMask/MetaMaskDebug.entitlements index 946fd9f2ad7..84639518c4a 100644 --- a/ios/MetaMask/MetaMaskDebug.entitlements +++ b/ios/MetaMask/MetaMaskDebug.entitlements @@ -2,6 +2,10 @@ + com.apple.security.application-groups + + group.io.metamask.MetaMask + aps-environment development com.apple.developer.applesignin diff --git a/ios/MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.m b/ios/MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.m new file mode 100644 index 00000000000..9469f872c9c --- /dev/null +++ b/ios/MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.m @@ -0,0 +1,17 @@ +// +// RCTWidgetBridge.m +// MetaMask +// +// Registers the Swift RCTWidgetBridge module with React Native. Mirrors RNTar.m. +// + +#import +#import "React/RCTBridgeModule.h" + +@interface RCT_EXTERN_MODULE(RCTWidgetBridge, NSObject) +RCT_EXTERN_METHOD(getLogosDirectoryPath:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setTokens:(nonnull NSString *)json + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +@end diff --git a/ios/MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.swift b/ios/MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.swift new file mode 100644 index 00000000000..c5608e7225d --- /dev/null +++ b/ios/MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.swift @@ -0,0 +1,91 @@ +// +// RCTWidgetBridge.swift +// MetaMask +// +// Thin bridge between React Native and the home-screen widget. The JS side owns +// all the logic: it builds the final widget payload (symbols, prices, deeplinks, +// 24h change, sparkline) AND downloads the token logos directly into the App +// Group container (via react-native-fs). This native module only exposes the +// two things JS can't do itself: +// 1. resolve the absolute App Group logos directory path, and +// 2. persist the JSON string and reload the widget timelines. +// +// Mirrors the Swift + RCT_EXTERN_MODULE pattern used by RnTar.swift / RNTar.m. +// + +import Foundation +import WidgetKit + +/// Shared between the app and the widget extension. Keep in sync with +/// `WidgetSharedStore` in the MetaMaskWidget target. +enum WidgetSharedStore { + static let appGroupId = "group.io.metamask.MetaMask" + static let tokensKey = "widgetTokens" + static let logosDirectory = "TokenLogos" +} + +@objc(RCTWidgetBridge) +class RCTWidgetBridge: NSObject { + + @objc + static func requiresMainQueueSetup() -> Bool { + return false + } + + /// Resolves (creating if needed) the App Group directory where JS writes token + /// logo files, and returns its absolute path. The widget reads logos from the + /// same directory by filename. Rejects if the App Group is unavailable. + @objc(getLogosDirectoryPath:rejecter:) + func getLogosDirectoryPath( + _ resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: WidgetSharedStore.appGroupId) + else { + rejecter( + "app_group_unavailable", + "App Group \(WidgetSharedStore.appGroupId) is not available. Check entitlements and provisioning profile.", + nil + ) + return + } + + let logosDir = container.appendingPathComponent( + WidgetSharedStore.logosDirectory, isDirectory: true) + do { + try FileManager.default.createDirectory( + at: logosDir, withIntermediateDirectories: true) + resolver(logosDir.path) + } catch { + rejecter("logos_dir_unavailable", "Could not create logos directory", error) + } + } + + /// Persists the final widget payload (built entirely in JS) verbatim to the + /// shared UserDefaults and reloads the widget timelines. + @objc(setTokens:resolver:rejecter:) + func setTokens( + _ json: String, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + guard let defaults = UserDefaults(suiteName: WidgetSharedStore.appGroupId) else { + rejecter( + "app_group_unavailable", + "App Group \(WidgetSharedStore.appGroupId) is not available. Check entitlements and provisioning profile.", + nil + ) + return + } + + defaults.set(json, forKey: WidgetSharedStore.tokensKey) + + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + + resolver(nil) + } +} diff --git a/ios/MetaMaskWidget/Assets.xcassets/Contents.json b/ios/MetaMaskWidget/Assets.xcassets/Contents.json new file mode 100644 index 00000000000..73c00596a7f --- /dev/null +++ b/ios/MetaMaskWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/Contents.json b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/Contents.json new file mode 100644 index 00000000000..821f5f18add --- /dev/null +++ b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "fox@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "fox@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "fox@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@1x.png b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@1x.png new file mode 100644 index 00000000000..97f613e5aed Binary files /dev/null and b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@1x.png differ diff --git a/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@2x.png b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@2x.png new file mode 100644 index 00000000000..c1011b03d03 Binary files /dev/null and b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@2x.png differ diff --git a/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@3x.png b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@3x.png new file mode 100644 index 00000000000..64ea262104a Binary files /dev/null and b/ios/MetaMaskWidget/Assets.xcassets/fox.imageset/fox@3x.png differ diff --git a/ios/MetaMaskWidget/Info.plist b/ios/MetaMaskWidget/Info.plist new file mode 100644 index 00000000000..951b242a5e2 --- /dev/null +++ b/ios/MetaMaskWidget/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + MetaMask + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + RCTNewArchEnabled + + + diff --git a/ios/MetaMaskWidget/MetaMaskWidget.entitlements b/ios/MetaMaskWidget/MetaMaskWidget.entitlements new file mode 100644 index 00000000000..0f18b5cbba3 --- /dev/null +++ b/ios/MetaMaskWidget/MetaMaskWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.io.metamask.MetaMask + + + diff --git a/ios/MetaMaskWidget/MetaMaskWidget.swift b/ios/MetaMaskWidget/MetaMaskWidget.swift new file mode 100644 index 00000000000..78355bc3d89 --- /dev/null +++ b/ios/MetaMaskWidget/MetaMaskWidget.swift @@ -0,0 +1,41 @@ +// +// MetaMaskWidget.swift +// MetaMaskWidget +// +// Widget entry point. Static configuration, Medium + Large families. +// + +import WidgetKit +import SwiftUI + +struct MetaMaskWidget: Widget { + let kind: String = "MetaMaskWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + WidgetEntryView(entry: entry) + } + .configurationDisplayName("Tokens") + .description("Your tokens across all chains, by market price.") + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +@main +struct MetaMaskWidgetBundle: WidgetBundle { + var body: some Widget { + MetaMaskWidget() + } +} + +struct MetaMaskWidget_Previews: PreviewProvider { + static var previews: some View { + let entry = TokenEntry(date: Date(), tokens: WidgetTokenLoader.sample) + Group { + WidgetEntryView(entry: entry) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + WidgetEntryView(entry: entry) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + } + } +} diff --git a/ios/MetaMaskWidget/Provider.swift b/ios/MetaMaskWidget/Provider.swift new file mode 100644 index 00000000000..c820273b312 --- /dev/null +++ b/ios/MetaMaskWidget/Provider.swift @@ -0,0 +1,34 @@ +// +// Provider.swift +// MetaMaskWidget +// +// Timeline provider. Data is push-driven: the app calls +// WidgetCenter.reloadAllTimelines() whenever it writes new token data, so a +// single entry is sufficient. A long refresh interval is set as a safety net. +// + +import WidgetKit +import SwiftUI + +struct TokenEntry: TimelineEntry { + let date: Date + let tokens: [WidgetToken] +} + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> TokenEntry { + TokenEntry(date: Date(), tokens: WidgetTokenLoader.sample) + } + + func getSnapshot(in context: Context, completion: @escaping (TokenEntry) -> Void) { + let tokens = context.isPreview ? WidgetTokenLoader.sample : WidgetTokenLoader.load() + completion(TokenEntry(date: Date(), tokens: tokens)) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = TokenEntry(date: Date(), tokens: WidgetTokenLoader.load()) + // Safety-net refresh; real updates arrive via reloadAllTimelines(). + let next = Calendar.current.date(byAdding: .hour, value: 1, to: Date()) ?? Date() + completion(Timeline(entries: [entry], policy: .after(next))) + } +} diff --git a/ios/MetaMaskWidget/README.md b/ios/MetaMaskWidget/README.md new file mode 100644 index 00000000000..3c3324eea43 --- /dev/null +++ b/ios/MetaMaskWidget/README.md @@ -0,0 +1,82 @@ +# MetaMask iOS Token Widget + +Home-screen WidgetKit extension that lists the tokens held by the selected +account group across all chains (holdings worth **over $1**), showing a circular +token logo + symbol on the left and the token's **unit market price** on the +right, with the MetaMask fox as a header. Families: **Medium** and **Large**. +Production **MetaMask** app only (not Flask). + +## How it works + +``` +Redux state ──► WidgetSyncManager (JS) ──► RCTWidgetBridge (Swift) + │ writes JSON + cached PNG logos + ▼ + App Group container + (group.io.metamask.MetaMask) + ▲ + MetaMaskWidget (SwiftUI) ─────┘ reads + renders +``` + +- **JS — payload:** `app/core/WidgetData/buildWidgetPayload.ts` reads + `selectAssetsBySelectedAccountGroup`, keeps holdings > $1, computes unit price + (fiat value ÷ token balance), sorts by value, caps at 10. +- **JS — trigger:** `app/core/WidgetData/WidgetSyncManager.ts` (constructed in + `Engine.ts`) pushes the payload on app foreground/background and on a debounced + store change. No-ops on Android and when the native bridge is absent. +- **Native bridge:** `ios/MetaMask/NativeModules/RCTWidgetBridge/` writes the JSON + to the App Group `UserDefaults`, downloads + downscales each PNG logo into the + shared container, and calls `WidgetCenter.reloadAllTimelines()`. +- **Widget:** `ios/MetaMaskWidget/` (SwiftUI) reads the shared container and + renders. SVG-only logos fall back to a symbol monogram (v1 limitation). + +## Regenerating the Xcode target + +The widget target, the `RCTWidgetBridge` source files, the App Group, and the +embed/dependency wiring are scripted (idempotent): + +```bash +cd ios && ruby scripts/add_widget_target.rb +``` + +The script requires the `xcodeproj` Ruby gem (`gem install xcodeproj`). It is +only needed if `project.pbxproj` is regenerated; the committed project already +contains the target. + +## ⚠️ Manual steps required (Apple Developer portal — Team `48XVW22RCG`) + +These cannot be scripted from the repo and must be done by someone with portal +access **before device/TestFlight/App Store builds will sign**: + +1. **Register the App Group** `group.io.metamask.MetaMask`. +2. **Enable App Groups** on the existing App ID `io.metamask.MetaMask` and add the + group. **Then regenerate** the app's provisioning profiles + (`development-metamask`, `Bitrise AppStore io.metamask.MetaMask`) so they carry + the new entitlement — otherwise `UserDefaults(suiteName:)` is `nil` at runtime + and uploads are rejected. +3. **Create a new App ID** `io.metamask.MetaMask.MetaMaskWidget` with App Groups + enabled. +4. **Create provisioning profiles** for the widget App ID: + - Development (e.g. `development-metamask-widget`) + - Distribution (e.g. `Bitrise AppStore io.metamask.MetaMask.MetaMaskWidget`) + Set them as the widget target's `PROVISIONING_PROFILE_SPECIFIER` for + Debug/Release, and add the mapping `io.metamask.MetaMask.MetaMaskWidget → ` + to the CI export-options plist / Bitrise signing step. + +**Simulator builds need none of the above** (`CODE_SIGNING_ALLOWED=NO`). + +## Versioning + +The widget's `CFBundleShortVersionString` / `CFBundleVersion` are driven by +`MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` build settings on the +`MetaMaskWidget` target. CI must keep these in sync with the host app +(App Store rejects extension/host version mismatches). Widget code ships only in +full store builds — it is **not** updated via Expo OTA. + +## Verifying locally + +1. `yarn watch:clean` then `yarn start:ios` (a wallet holding tokens worth > $1). +2. Background/foreground the app to trigger a sync. +3. Long-press the home screen → add the **MetaMask** widget → pick Medium/Large. +4. SwiftUI previews in `MetaMaskWidget.swift` render both families with sample + data without running the app. diff --git a/ios/MetaMaskWidget/TokenRowView.swift b/ios/MetaMaskWidget/TokenRowView.swift new file mode 100644 index 00000000000..632c2049f8e --- /dev/null +++ b/ios/MetaMaskWidget/TokenRowView.swift @@ -0,0 +1,129 @@ +// +// TokenRowView.swift +// MetaMaskWidget +// +// One token row: logo + symbol (leading), mini price sparkline (center), +// unit price over the 24h change % (trailing). +// + +import SwiftUI + +struct TokenRowView: View { + let token: WidgetToken + + private let logoSize: CGFloat = 26 + + // MetaMask success/error palette, approximated for the dark widget surface. + private static let upColor = Color(red: 0.18, green: 0.78, blue: 0.45) + private static let downColor = Color(red: 0.96, green: 0.33, blue: 0.33) + private static let neutralColor = Color.white.opacity(0.45) + + /// Direction of the 24h move; drives both the arrow/% color and the line color. + private var changeColor: Color { + guard let change = token.priceChange1d, change != 0 else { + return Self.neutralColor + } + return change > 0 ? Self.upColor : Self.downColor + } + + var body: some View { + HStack(spacing: 8) { + logo + Text(token.symbol) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + Spacer(minLength: 6) + if let sparkline = token.sparkline, sparkline.count > 1 { + SparklineView(points: sparkline, color: changeColor) + .frame(width: 52, height: 22) + } + VStack(alignment: .trailing, spacing: 1) { + Text(token.priceFormatted) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.white.opacity(0.85)) + .lineLimit(1) + .minimumScaleFactor(0.8) + changeLabel + } + } + } + + /// "▲ 2.31%" / "▼ 0.04%", colored green/red. Hidden when no change is known. + @ViewBuilder + private var changeLabel: some View { + if let change = token.priceChange1d { + let arrow = change > 0 ? "▲" : (change < 0 ? "▼" : "▪") + Text("\(arrow) \(String(format: "%.2f%%", abs(change)))") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(changeColor) + .lineLimit(1) + } + } + + @ViewBuilder + private var logo: some View { + if let image = token.loadLogo() { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: logoSize, height: logoSize) + .clipShape(Circle()) + } else { + // Monogram fallback (first letter of the symbol), matching the app's + // AvatarToken behavior when a logo can't be rendered. + ZStack { + Circle().fill(Color.white.opacity(0.15)) + Text(String(token.symbol.prefix(1)).uppercased()) + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + } + .frame(width: logoSize, height: logoSize) + } + } +} + +/// Non-interactive mini line chart. Normalizes the series to the available rect +/// (min → bottom, max → top) and strokes a single line. Pure SwiftUI `Shape`, +/// so it renders on every iOS version the widget targets without Swift Charts. +struct SparklineView: View { + let points: [Double] + let color: Color + + var body: some View { + SparklineShape(points: points) + .stroke( + color, + style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round) + ) + } +} + +private struct SparklineShape: Shape { + let points: [Double] + + func path(in rect: CGRect) -> Path { + var path = Path() + guard points.count > 1 else { return path } + + let minValue = points.min() ?? 0 + let maxValue = points.max() ?? 0 + let range = maxValue - minValue + let stepX = rect.width / CGFloat(points.count - 1) + + func pointAt(_ index: Int) -> CGPoint { + let x = rect.minX + stepX * CGFloat(index) + // Flat series → draw a centered horizontal line. + let normalized = + range == 0 ? 0.5 : (points[index] - minValue) / range + let y = rect.maxY - CGFloat(normalized) * rect.height + return CGPoint(x: x, y: y) + } + + path.move(to: pointAt(0)) + for index in 1.. some View { + if #available(iOS 17.0, *) { + self.containerBackground(color, for: .widget) + } else { + self.background(color) + } + } +} diff --git a/ios/MetaMaskWidget/WidgetToken.swift b/ios/MetaMaskWidget/WidgetToken.swift new file mode 100644 index 00000000000..e82a096bd52 --- /dev/null +++ b/ios/MetaMaskWidget/WidgetToken.swift @@ -0,0 +1,91 @@ +// +// WidgetToken.swift +// MetaMaskWidget +// +// Shared model + loader for data the app writes into the App Group container. +// Keep `WidgetSharedStore` in sync with RCTWidgetBridge.swift in the app target. +// + +import Foundation +import UIKit + +enum WidgetSharedStore { + static let appGroupId = "group.io.metamask.MetaMask" + static let tokensKey = "widgetTokens" + static let logosDirectory = "TokenLogos" +} + +/// One row: token symbol (leading), unit market price (trailing), and an +/// optional locally-cached logo filename resolved against the App Group. +struct WidgetToken: Codable, Identifiable { + let symbol: String + let priceFormatted: String + let logoFile: String? + /// Deep link that opens the Swap screen with this token preselected as source. + let deeplink: String? + /// 24h price change as a percentage (e.g. 2.31, -0.04). Nil when unavailable. + let priceChange1d: Double? + /// Price points for the row's mini sparkline (visualization only). + let sparkline: [Double]? + + var id: String { symbol } + + /// Parsed deep-link URL, if present and valid. + var deeplinkURL: URL? { + guard let deeplink = deeplink, !deeplink.isEmpty else { return nil } + return URL(string: deeplink) + } + + /// Loads the cached logo from the shared container, if present. + func loadLogo() -> UIImage? { + guard let logoFile = logoFile, !logoFile.isEmpty else { return nil } + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: WidgetSharedStore.appGroupId) + else { + return nil + } + let url = container + .appendingPathComponent(WidgetSharedStore.logosDirectory, isDirectory: true) + .appendingPathComponent(logoFile) + return UIImage(contentsOfFile: url.path) + } +} + +private struct WidgetTokenStore: Codable { + let tokens: [WidgetToken] +} + +enum WidgetTokenLoader { + /// Reads and decodes the token list the app last wrote. Returns [] on any + /// failure (missing App Group, no data yet, decode error). + static func load() -> [WidgetToken] { + guard + let defaults = UserDefaults(suiteName: WidgetSharedStore.appGroupId), + let json = defaults.string(forKey: WidgetSharedStore.tokensKey), + let data = json.data(using: .utf8), + let store = try? JSONDecoder().decode(WidgetTokenStore.self, from: data) + else { + return [] + } + return store.tokens + } + + /// Sample data for previews and the placeholder snapshot. + static var sample: [WidgetToken] { + [ + WidgetToken( + symbol: "ETH", priceFormatted: "$3,000.00", logoFile: nil, deeplink: nil, + priceChange1d: 2.31, sparkline: [100, 101, 100.5, 102, 103, 102.5, 104, 105]), + WidgetToken( + symbol: "USDC", priceFormatted: "$1.00", logoFile: nil, deeplink: nil, + priceChange1d: -0.04, sparkline: [100, 99.9, 100.1, 99.8, 100, 99.95, 100.05, 99.9]), + WidgetToken( + symbol: "MATIC", priceFormatted: "$0.72", logoFile: nil, deeplink: nil, + priceChange1d: -3.12, sparkline: [105, 104, 103.5, 102, 101, 100.5, 99, 98]), + WidgetToken( + symbol: "WBTC", priceFormatted: "$62,500.00", logoFile: nil, deeplink: nil, + priceChange1d: 0.0, sparkline: [100, 100.2, 99.8, 100.1, 99.9, 100, 100.1, 99.95]), + ] + } +} diff --git a/ios/scripts/add_widget_target.rb b/ios/scripts/add_widget_target.rb new file mode 100644 index 00000000000..d4569909f4a --- /dev/null +++ b/ios/scripts/add_widget_target.rb @@ -0,0 +1,181 @@ +#!/usr/bin/env ruby +# Adds the MetaMaskWidget WidgetKit extension target and the RCTWidgetBridge +# native module to ios/MetaMask.xcodeproj. Idempotent: safe to re-run. +# +# Run from the ios/ directory: ruby scripts/add_widget_target.rb + +require 'xcodeproj' + +PROJECT_PATH = 'MetaMask.xcodeproj' +APP_TARGET_NAME = 'MetaMask' +WIDGET_TARGET_NAME = 'MetaMaskWidget' +TEAM = '48XVW22RCG' +DEPLOYMENT_TARGET = '15.1' +MARKETING_VERSION = '8.1.0' +CURRENT_PROJECT_VERSION = '4823' + +WIDGET_SOURCES = %w[ + MetaMaskWidget/MetaMaskWidget.swift + MetaMaskWidget/Provider.swift + MetaMaskWidget/WidgetToken.swift + MetaMaskWidget/TokenRowView.swift + MetaMaskWidget/WidgetEntryView.swift +].freeze +WIDGET_RESOURCES = %w[MetaMaskWidget/Assets.xcassets].freeze +WIDGET_SUPPORT_FILES = %w[ + MetaMaskWidget/Info.plist + MetaMaskWidget/MetaMaskWidget.entitlements +].freeze + +BRIDGE_FILES = %w[ + MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.swift + MetaMask/NativeModules/RCTWidgetBridge/RCTWidgetBridge.m +].freeze + +project = Xcodeproj::Project.open(PROJECT_PATH) +app_target = project.targets.find { |t| t.name == APP_TARGET_NAME } +raise "App target #{APP_TARGET_NAME} not found" unless app_target + +# Helper: find-or-create a nested group by path segments under main_group. +def group_at(project, *segments) + group = project.main_group + segments.each do |seg| + found = group.children.find { |c| c.is_a?(Xcodeproj::Project::Object::PBXGroup) && c.display_name == seg } + group = found || group.new_group(seg, seg) + end + group +end + +# --------------------------------------------------------------------------- +# 1. RCTWidgetBridge native module -> MetaMask app target only. +# --------------------------------------------------------------------------- +bridge_group = group_at(project, 'MetaMask', 'NativeModules', 'RCTWidgetBridge') +# The parent MetaMask/NativeModules groups are pathless logical groups, so the +# leaf group must carry the full path relative to SOURCE_ROOT for the files to +# resolve on disk. +bridge_group.source_tree = 'SOURCE_ROOT' +bridge_group.path = 'MetaMask/NativeModules/RCTWidgetBridge' +BRIDGE_FILES.each do |path| + next if project.files.any? { |f| f.path == File.basename(path) && (f.parent == bridge_group) } + ref = bridge_group.new_reference(File.basename(path)) + if path.end_with?('.swift', '.m') + app_target.source_build_phase.add_file_reference(ref, true) + end +end + +# --------------------------------------------------------------------------- +# 2. MetaMaskWidget app-extension target. +# --------------------------------------------------------------------------- +widget_target = project.targets.find { |t| t.name == WIDGET_TARGET_NAME } +unless widget_target + widget_target = project.new_target( + :app_extension, WIDGET_TARGET_NAME, :ios, DEPLOYMENT_TARGET, nil, :swift + ) +end + +widget_group = group_at(project, WIDGET_TARGET_NAME) + +# Source files. +WIDGET_SOURCES.each do |path| + base = File.basename(path) + ref = widget_group.children.find { |c| c.respond_to?(:path) && c.path == base } + ref ||= widget_group.new_reference(base) + unless widget_target.source_build_phase.files_references.include?(ref) + widget_target.source_build_phase.add_file_reference(ref, true) + end +end + +# Resources (asset catalog). +WIDGET_RESOURCES.each do |path| + base = File.basename(path) + ref = widget_group.children.find { |c| c.respond_to?(:path) && c.path == base } + ref ||= widget_group.new_reference(base) + unless widget_target.resources_build_phase.files_references.include?(ref) + widget_target.resources_build_phase.add_file_reference(ref) + end +end + +# Support files (referenced via build settings, not compiled). +WIDGET_SUPPORT_FILES.each do |path| + base = File.basename(path) + next if widget_group.children.any? { |c| c.respond_to?(:path) && c.path == base } + widget_group.new_reference(base) +end + +# Build settings. +widget_target.build_configurations.each do |config| + bs = config.build_settings + bs['PRODUCT_NAME'] = '$(TARGET_NAME)' + bs['PRODUCT_BUNDLE_IDENTIFIER'] = 'io.metamask.MetaMask.MetaMaskWidget' + bs['INFOPLIST_FILE'] = 'MetaMaskWidget/Info.plist' + bs['CODE_SIGN_ENTITLEMENTS'] = 'MetaMaskWidget/MetaMaskWidget.entitlements' + bs['GENERATE_INFOPLIST_FILE'] = 'NO' + bs['SWIFT_VERSION'] = '5.0' + bs['IPHONEOS_DEPLOYMENT_TARGET'] = DEPLOYMENT_TARGET + bs['TARGETED_DEVICE_FAMILY'] = '1,2' + bs['MARKETING_VERSION'] = MARKETING_VERSION + bs['CURRENT_PROJECT_VERSION'] = CURRENT_PROJECT_VERSION + bs['SKIP_INSTALL'] = 'NO' + bs['CODE_SIGN_STYLE'] = 'Manual' + bs['DEVELOPMENT_TEAM'] = TEAM + bs['LD_RUNPATH_SEARCH_PATHS'] = [ + '$(inherited)', '@executable_path/Frameworks', + '@executable_path/../../Frameworks' + ] + bs['ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS'] = 'NO' + # Local/simulator builds work with a development identity and no profile. + # Device + App Store signing uses the manual profiles created in the portal; + # set their names here once they exist (see the widget setup doc). + bs['CODE_SIGN_IDENTITY[sdk=iphoneos*]'] = 'Apple Development' + if config.name == 'Release' + bs['CODE_SIGN_IDENTITY[sdk=iphoneos*]'] = 'iPhone Distribution' + bs['SWIFT_OPTIMIZATION_LEVEL'] = '-O' + else + bs['SWIFT_OPTIMIZATION_LEVEL'] = '-Onone' + bs['SWIFT_ACTIVE_COMPILATION_CONDITIONS'] = 'DEBUG' + end +end + +# --------------------------------------------------------------------------- +# 3. Embed the .appex into the MetaMask app target + dependency. +# --------------------------------------------------------------------------- +# Build the target dependency manually. We avoid PBXNativeTarget#add_dependency +# because it scans every file reference's real_path, which trips over a +# pre-existing project inconsistency (SplashScreen.storyboard has duplicate +# build-file entries) unrelated to this change. +already_dep = app_target.dependencies.any? { |d| d.target&.uuid == widget_target.uuid } +unless already_dep + proxy = project.new(Xcodeproj::Project::Object::PBXContainerItemProxy) + proxy.container_portal = project.root_object.uuid + proxy.proxy_type = '1' + proxy.remote_global_id_string = widget_target.uuid + proxy.remote_info = widget_target.name + + dependency = project.new(Xcodeproj::Project::Object::PBXTargetDependency) + dependency.target = widget_target + dependency.target_proxy = proxy + app_target.dependencies << dependency +end + +embed_phase = app_target.copy_files_build_phases.find { |p| p.name == 'Embed App Extensions' } +unless embed_phase + embed_phase = app_target.new_copy_files_build_phase('Embed App Extensions') + embed_phase.symbol_dst_subfolder_spec = :plug_ins +end +appex_ref = widget_target.product_reference +unless embed_phase.files_references.include?(appex_ref) + bf = embed_phase.add_file_reference(appex_ref) + bf.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } +end + +# Position the embed phase right after "Embed Frameworks". Appending it after the +# JS-bundle / Sentry / Strip-Bitcode script phases (which declare no outputs) +# produces a "Cycle inside MetaMask" build error. +embed_fw = app_target.build_phases.find { |p| p.respond_to?(:name) && p.name == 'Embed Frameworks' } +if embed_fw + app_target.build_phases.delete(embed_phase) + app_target.build_phases.insert(app_target.build_phases.index(embed_fw) + 1, embed_phase) +end + +project.save +puts "Done. Targets now: #{project.targets.map(&:name).join(', ')}"