Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ vp run ios:dev
```

If your Xcode account only has a Personal Team, use a bundle identifier you control and opt into the
reduced-capability local build. Personal Team builds omit the widget extension, push entitlement, and
native Sign in with Apple entitlement; builds without this opt-in are unchanged.
reduced-capability local build. Personal Team builds omit the widget and share extensions, push
entitlement, and native Sign in with Apple entitlement; builds without this opt-in are unchanged.

```bash
T3CODE_IOS_PERSONAL_TEAM=1 \
Expand Down
36 changes: 33 additions & 3 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ function resolveAppVariant(value: string | undefined): AppVariant {
}

const variant = VARIANT_CONFIG[APP_VARIANT];
const iosBundleIdentifier = isIosPersonalTeamBuild
? personalTeamBundleIdentifier!
: variant.iosBundleIdentifier;

const dmSansFonts = {
regular: "@expo-google-fonts/dm-sans/400Regular/DMSans_400Regular.ttf",
Expand All @@ -94,8 +97,8 @@ const dmSansFonts = {
const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
"expo-widgets",
{
bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${variant.iosBundleIdentifier}`,
bundleIdentifier: `${iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${iosBundleIdentifier}`,
enablePushNotifications: true,
// Agent activity can update many times an hour; without the
// frequent-updates entitlement iOS throttles the update budget sooner.
Expand All @@ -111,6 +114,30 @@ const widgetsPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
},
];

const sharingPlugin: NonNullable<ExpoConfig["plugins"]>[number] = [
"expo-sharing",
{
ios: {
// Personal Teams cannot sign App Groups or extension targets. Keep the
// reduced-capability local build usable while release builds expose the
// real system share target.
enabled: !isIosPersonalTeamBuild,
extensionBundleIdentifier: `${iosBundleIdentifier}.sharing`,
appGroupId: `group.${iosBundleIdentifier}`,
activationRule: {
supportsText: true,
supportsWebUrlWithMaxCount: 1,
supportsImageWithMaxCount: 8,
},
},
android: {
enabled: true,
singleShareMimeTypes: ["text/plain", "image/*"],
multipleShareMimeTypes: ["image/*"],
},
},
];

// These aliases match the fonts' PostScript names on iOS. Register the same
// names on Android so React Native and the native composer use one set of
// family names without waiting for runtime font loading.
Expand Down Expand Up @@ -140,7 +167,7 @@ const config: ExpoConfig = {
ios: {
icon: variant.assets.iosIcon,
supportsTablet: true,
bundleIdentifier: variant.iosBundleIdentifier,
bundleIdentifier: iosBundleIdentifier,
// Pin code signing to the T3 Tools team so non-interactive `expo run:ios`
// does not fall back to a personal team (which cannot sign app groups,
// Sign in with Apple, or push notification entitlements).
Expand Down Expand Up @@ -202,6 +229,9 @@ const config: ExpoConfig = {
],
"expo-secure-store",
"expo-sqlite",
...(isIosPersonalTeamBuild
? [sharingPlugin]
: ["./plugins/withShareExtensionDisplayName.cjs", sharingPlugin]),
[
"expo-notifications",
{
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
"expo-paste-input": "^0.1.15",
"expo-quick-actions": "^6.0.2",
"expo-secure-store": "~56.0.4",
"expo-sharing": "~56.0.18",
"expo-splash-screen": "~56.0.10",
"expo-sqlite": "~56.0.5",
"expo-symbols": "~56.0.6",
Expand Down
93 changes: 93 additions & 0 deletions apps/mobile/plugins/withShareExtensionDisplayName.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use strict";

// expo-sharing intentionally uses a fixed target name and also uses that
// internal target name as CFBundleDisplayName. Brand the extension while
// leaving its initially-empty signing team intact: Expo uses that state to
// select ios.appleTeamId and enable first-time profile provisioning.
//
// ORDERING: list this plugin BEFORE expo-sharing. Expo runs same-type mods in
// reverse registration order, so this executes after the extension exists.

const fs = require("fs");
const path = require("path");
const { withDangerousMod, withXcodeProject } = require("expo/config-plugins");

const TARGET_NAME = "expo-sharing-extension";

function stripComments(section) {
return Object.fromEntries(
Object.entries(section ?? {}).filter(([key]) => !key.endsWith("_comment")),
);
}

function findByName(section, name) {
return Object.entries(stripComments(section)).find(([, value]) => value?.name === name) ?? null;
}

function escapeXml(value) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}

function withDisplayNamePlist(config, displayName) {
return withDangerousMod(config, [
"ios",
(cfg) => {
const plistPath = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, "Info.plist");
if (!fs.existsSync(plistPath)) {
throw new Error(
`${TARGET_NAME}/Info.plist was not generated. Register withShareExtensionDisplayName before expo-sharing.`,
);
}
const source = fs.readFileSync(plistPath, "utf8");
const displayNamePattern = /(<key>CFBundleDisplayName<\/key>\s*<string>)[^<]*(<\/string>)/;
if (!displayNamePattern.test(source)) {
throw new Error(`Could not update CFBundleDisplayName in ${plistPath}.`);
}
const next = source.replace(displayNamePattern, `$1${escapeXml(displayName)}$2`);
if (next !== source) {
fs.writeFileSync(plistPath, next);
}
return cfg;
},
]);
}

function withExtensionDisplayNameBuildSetting(config, displayName) {
return withXcodeProject(config, (cfg) => {
const objects = cfg.modResults.hash.project.objects;
const targetEntry = findByName(objects.PBXNativeTarget, TARGET_NAME);
if (!targetEntry) {
throw new Error(
`${TARGET_NAME} Xcode target was not generated. Register withShareExtensionDisplayName before expo-sharing.`,
);
}
const target = targetEntry[1];
const configurationList = stripComments(objects.XCConfigurationList)[
target.buildConfigurationList
];
if (!configurationList) {
throw new Error(`Could not find build configurations for ${TARGET_NAME}.`);
}
const buildConfigurations = stripComments(objects.XCBuildConfiguration);
for (const reference of configurationList.buildConfigurations ?? []) {
const buildConfiguration = buildConfigurations[reference.value];
if (buildConfiguration?.buildSettings) {
buildConfiguration.buildSettings.INFOPLIST_KEY_CFBundleDisplayName = `"${displayName}"`;
}
}
return cfg;
});
}

module.exports = function withShareExtensionDisplayName(config) {
const displayName = config.name;
return withExtensionDisplayNameBuildSetting(
withDisplayNamePlist(config, displayName),
displayName,
);
};
16 changes: 11 additions & 5 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati
import { RegistryContext } from "@effect/atom-react";
import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider";
import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider";
import { RootStack } from "./Stack";
import { appAtomRegistry } from "./state/atom-registry";
Expand All @@ -26,7 +27,10 @@ const appLinking = {
// <scheme>://expo-development-client/?url=<packager> — that URL addresses
// the launcher, not app navigation. Without this filter it falls through
// to the NotFound wildcard route on every dev launch.
filter: (url: string) => !url.includes("expo-development-client"),
// expo-sharing uses a private lifecycle URL only to wake the app. The
// persisted share inbox below owns navigation once the payload is durable.
filter: (url: string) =>
!url.includes("expo-development-client") && !url.includes("://expo-sharing"),
};

const Navigation = createStaticNavigation(RootStack);
Expand Down Expand Up @@ -58,10 +62,12 @@ export default function App() {
the system is in dark mode. */}
{/* Blur target for Android dropdown backdrops — see appBlurTarget.ts. */}
<BlurTargetView ref={appBlurTargetRef} style={{ flex: 1 }}>
<Navigation
linking={appLinking}
theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
/>
<IncomingShareProvider>
<Navigation
linking={appLinking}
theme={colorScheme === "dark" ? DarkTheme : DefaultTheme}
/>
</IncomingShareProvider>
<ConfirmDialogHost />
</BlurTargetView>
{/* Anchored-menu overlays render here — in-window, so the
Expand Down
37 changes: 37 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef } from "react";
import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native";
import { useResolveClassNames } from "uniwind";

Expand Down Expand Up @@ -52,6 +53,7 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand Down Expand Up @@ -275,12 +277,47 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const presentedShareIdRef = useRef<string | null>(null);
useAgentNotificationNavigation();
useThreadOutboxDrain();
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
useAppShortcuts(props.state);
useEffect(() => {
const topRouteName = props.state.routes[props.state.index]?.name;
if (topRouteName === "NewTaskSheet") {
return;
}

if (presentedShareIdRef.current !== null) {
const previouslyPresentedShareId = presentedShareIdRef.current;
presentedShareIdRef.current = null;
// A dismissal leaves the share queued, but must not instantly trap the
// user in a reopened sheet. A later navigation/lifecycle update can
// present it again. If the old share was consumed and another is queued,
// continue below and present that distinct share immediately.
if (pendingShare?.id === previouslyPresentedShareId) {
return;
}
}

Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
if (!pendingShare) {
// Allow a later, intentional share of identical text/URL content to
// present again after the previous inbox item was fully consumed.
return;
}
if (presentedShareIdRef.current === pendingShare.id) {
return;
}
presentedShareIdRef.current = pendingShare.id;
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: pendingShare.id },
});
}, [navigation, pendingShare, props.state]);
Comment thread
cursor[bot] marked this conversation as resolved.
// Full pathname (sheets included) for keyboard-command scoping; the
// workspace layout only reacts to the underlying non-overlay route.
const path = getPathFromState(props.state, navigationPathConfig);
Expand Down
Loading
Loading