Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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
24 changes: 24 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line numberDiff line numberDiff 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 DownExpand Up@@ -52,6 +53,11 @@ import {
SettingsLegalDocumentExternalHeaderButton,
} from "./features/settings/components/SettingsLegalDocumentRouteScreen";
import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts";
import { useIncomingShare } from "./features/sharing/IncomingShareProvider";
import {
EMPTY_INCOMING_SHARE_PRESENTATION_STATE,
transitionIncomingSharePresentation,
} from "./features/sharing/incoming-share-presentation";
import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader";
import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain";

Expand DownExpand Up@@ -275,12 +281,30 @@ function RootStackLayout(props: {
readonly children: React.ReactNode;
readonly state: NavigationState;
}) {
const navigation = useNavigation();
const { pendingShare } = useIncomingShare();
const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE);
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;
const transition = transitionIncomingSharePresentation(sharePresentationRef.current, {
isShareSheetPresented: topRouteName === "NewTaskSheet",
pendingShareId: pendingShare?.id ?? null,
});
sharePresentationRef.current = transition.state;
if (!transition.shareIdToPresent) {
return;
}
navigation.navigate("NewTaskSheet", {
screen: "NewTask",
params: { incomingShareId: transition.shareIdToPresent },
});
}, [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