Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9c3eb7b
Improve live activity routing, widgets, and registration integrity
juliusmarminge Jul 4, 2026
73e2d1d
Redesign Live Activity UI, brand it with the T3 mark, and stop relay …
juliusmarminge Jul 6, 2026
5ad2eea
Fix review findings across publish linking and registration
juliusmarminge Jul 6, 2026
34338a8
Alert Live Activity updates when agents newly need attention or finish
juliusmarminge Jul 6, 2026
870cc6f
Fix registration lifecycle drift and publish-only CLI gaps from PR re…
juliusmarminge Jul 6, 2026
4ed4a4c
Make relay request hangs observable instead of vanishing
juliusmarminge Jul 6, 2026
2c68eb1
Fix device registration hang: never hand drizzle proxy chains to Effe…
juliusmarminge Jul 7, 2026
e694f66
Reuse the APNs provider JWT instead of re-signing per push
juliusmarminge Jul 7, 2026
990372a
Share the APNs provider JWT through the database, not just the isolate
juliusmarminge Jul 7, 2026
011a09b
Derive the APNs provider JWT deterministically instead of storing it
juliusmarminge Jul 7, 2026
95e4ac2
Keep finished agents visible in the Live Activity and buzz on completion
juliusmarminge Jul 7, 2026
bf5de3c
Re-validate queued Live Activity starts at delivery time
juliusmarminge Jul 7, 2026
0c5ffc5
Retire the stored activity token when a start or end delivery lands
juliusmarminge Jul 7, 2026
a2e0830
Arm Live Activities from the app instead of remote push-to-start
juliusmarminge Jul 7, 2026
2e02973
Arm the Live Activity only when there is something to show
juliusmarminge Jul 7, 2026
558a811
Never render an empty Live Activity: show Done, end when truly empty,…
juliusmarminge Jul 7, 2026
41296c5
Close the arm-on-create races: start before awaiting, never start twice
juliusmarminge Jul 7, 2026
617a88c
Confirm tombstones before publishing them; dismiss contentless ends fast
juliusmarminge Jul 7, 2026
fd82469
Give freshly armed Live Activities a grace window before end-on-empty
juliusmarminge Jul 7, 2026
ed4c4ec
Project completed-at-bearing turns as completed, not nothing
juliusmarminge Jul 7, 2026
42ad1f1
Log the shell behind every tombstone and dedupe pending confirmations
juliusmarminge Jul 7, 2026
4e2a475
Project ready/idle sessions as completed: the shell has no other signal
juliusmarminge Jul 7, 2026
387d068
Suppress spurious Done at thread birth and on replayed completions
juliusmarminge Jul 7, 2026
97ed442
Address review findings: worker-ordered confirms, unthrottled Done, L…
juliusmarminge Jul 7, 2026
be4e710
Clear stale confirm deadlines when the projection returns to publishe…
juliusmarminge Jul 7, 2026
6f0b32d
Use Layer.succeed for the pure ApnsProviderTokens construction
juliusmarminge Jul 7, 2026
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
6 changes: 6 additions & 0 deletions apps/mobile/app.config.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

infoPlist: {
NSAppTransportSecurity: {

The frequentUpdates: true option under the expo-widgets plugin does not add the required NSSupportsLiveActivitiesFrequentUpdates key to Info.plist, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set NSSupportsLiveActivitiesFrequentUpdates: true in the ios.infoPlist block to actually grant the entitlement.

    infoPlist: {
+     NSSupportsLiveActivitiesFrequentUpdates: true,
      NSAppTransportSecurity: {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/app.config.ts around lines 96-97:

The `frequentUpdates: true` option under the `expo-widgets` plugin does not add the required `NSSupportsLiveActivitiesFrequentUpdates` key to `Info.plist`, so iOS keeps the standard Live Activity update budget and frequent updates are never enabled. Set `NSSupportsLiveActivitiesFrequentUpdates: true` in the `ios.infoPlist` block to actually grant the entitlement.

Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ const config: ExpoConfig = {
bundleIdentifier: `${variant.iosBundleIdentifier}.widgets`,
groupIdentifier: `group.${variant.iosBundleIdentifier}`,
enablePushNotifications: true,
// Agent activity can update many times an hour; without the
// frequent-updates entitlement iOS throttles the update budget sooner.
frequentUpdates: true,
widgets: [
{
name: "AgentActivity",
Expand All @@ -171,6 +174,9 @@ const config: ExpoConfig = {
],
},
],
// Must run after expo-widgets so the widget target exists when it wires in
// the branded logo asset catalog.
"./plugins/withWidgetLogoAsset.cjs",
"./plugins/withIosSceneLifecycle.cjs",
"./plugins/withAndroidCleartextTraffic.cjs",
],
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/assets/widget/T3Mark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
73 changes: 73 additions & 0 deletions apps/mobile/plugins/lib/addWidgetAssetCatalog.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"use strict";

// Bundle the widget asset catalog into an app-extension (widget) target.
//
// expo-widgets generates the widget target without a Resources build phase, and
// hand-adding a PBXResourcesBuildPhase does NOT get picked up — xcodebuild's
// planner never schedules `actool` for it (verified: even a full re-plan skips
// it). So instead we add a shell-script phase that runs `actool` directly and
// drops the compiled Assets.car into the extension bundle. Marked
// alwaysOutOfDate so the build system always runs it.
//
// Idempotent across re-runs. Returns true when it added the phase, false when it
// was already present or the target was not found.

const PHASE_NAME = "Compile Widget Assets";

// Compiles ExpoWidgetsTarget/Assets.xcassets into the extension's resources dir.
// Uses only Xcode-provided build settings so it works for device + simulator.
const ACTOOL_SCRIPT = [
"set -e",
'CATALOG="${SRCROOT}/ExpoWidgetsTarget/Assets.xcassets"',
'if [ ! -d "$CATALOG" ]; then',
' echo "warning: widget asset catalog not found at $CATALOG"',
" exit 0",
"fi",
'DEST="${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"',
'mkdir -p "$DEST"',
'xcrun actool "$CATALOG" --compile "$DEST" --platform "${PLATFORM_NAME}" --minimum-deployment-target "${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" --output-format human-readable-text',
].join("\n");

function stripComments(map) {
const out = {};
for (const key of Object.keys(map || {})) {
if (key.endsWith("_comment")) continue;
out[key] = map[key];
}
return out;
}

function findByName(map, name) {
for (const [uuid, value] of Object.entries(stripComments(map))) {
if (value && value.name === name) return { uuid, value };
}
return null;
}

/**
* @param {import('xcode').XcodeProject} proj
* @param {{ targetName: string }} opts
*/
function addWidgetAssetCatalog(proj, opts) {
const objects = proj.hash.project.objects;
const target = findByName(objects.PBXNativeTarget, opts.targetName);
if (!target) return false;

const phases = target.value.buildPhases || [];
const existing = objects.PBXShellScriptBuildPhase || {};
const already = Object.entries(stripComments(existing)).some(
([uuid, value]) =>
value && value.name === `"${PHASE_NAME}"` && phases.some((p) => p.value === uuid),
);
if (already) return false;

const { uuid } = proj.addBuildPhase([], "PBXShellScriptBuildPhase", PHASE_NAME, target.uuid, {
shellPath: "/bin/sh",
shellScript: ACTOOL_SCRIPT,
});
// Always run: input-analysis is exactly what skipped the Resources phase.
objects.PBXShellScriptBuildPhase[uuid].alwaysOutOfDate = 1;
return true;
}

module.exports = { addWidgetAssetCatalog };
62 changes: 62 additions & 0 deletions apps/mobile/plugins/withWidgetLogoAsset.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use strict";

// Ships the branded T3 mark to the Live Activity / widget extension.
//
// expo-widgets generates ExpoWidgetsTarget without a Resources build phase and
// has no asset support, so this plugin (a) writes an SVG template image set into
// the generated widget asset catalog and (b) wires that catalog into the widget
// target with a dedicated Resources build phase. Both steps are idempotent and
// survive `expo prebuild --clean`. Must be listed AFTER "expo-widgets" in the
// plugins array so the widget target exists when this runs.

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

const TARGET_NAME = "ExpoWidgetsTarget";
const CATALOG_NAME = "Assets.xcassets";
const IMAGE_SET = "T3Mark.imageset";
const SVG_NAME = "T3Mark.svg";

const CATALOG_CONTENTS = JSON.stringify({ info: { author: "expo", version: 1 } }, null, 2) + "\n";
const IMAGE_SET_CONTENTS =
JSON.stringify(
{
images: [{ idiom: "universal", filename: SVG_NAME }],
info: { author: "expo", version: 1 },
properties: {
"preserves-vector-representation": true,
"template-rendering-intent": "template",
},
},
null,
2,
) + "\n";

function withAssetFiles(config) {
return withDangerousMod(config, [
"ios",
(cfg) => {
const source = path.join(cfg.modRequest.projectRoot, "assets", "widget", SVG_NAME);
const catalogDir = path.join(cfg.modRequest.platformProjectRoot, TARGET_NAME, CATALOG_NAME);
const imageSetDir = path.join(catalogDir, IMAGE_SET);
fs.mkdirSync(imageSetDir, { recursive: true });
fs.writeFileSync(path.join(catalogDir, "Contents.json"), CATALOG_CONTENTS);
fs.writeFileSync(path.join(imageSetDir, "Contents.json"), IMAGE_SET_CONTENTS);
fs.copyFileSync(source, path.join(imageSetDir, SVG_NAME));
return cfg;
},
]);
}

function withAssetWiring(config) {
return withXcodeProject(config, (cfg) => {
addWidgetAssetCatalog(cfg.modResults, { targetName: TARGET_NAME });
return cfg;
});
}

module.exports = function withWidgetLogoAsset(config) {
return withAssetWiring(withAssetFiles(config));
};
30 changes: 30 additions & 0 deletions apps/mobile/scripts/wire-widget-asset-catalog.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use strict";

// One-off: apply the widget asset-catalog wiring to the already-generated
// ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets
// without a full `expo prebuild`. The durable equivalent lives in
// plugins/withWidgetLogoAsset.cjs and runs on prebuild.

const path = require("path");
const fs = require("fs");

const xcodePath = require.resolve("xcode", {
paths: [
require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }),
],
});
const xcode = require(xcodePath);
const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs");

const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj");
const proj = xcode.project(pbxprojPath);
proj.parseSync();

const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" });

if (added) {
fs.writeFileSync(pbxprojPath, proj.writeSync());
console.log("Added widget asset-compile phase to ExpoWidgetsTarget.");
} else {
console.log("No change: phase already present or target not found.");
}
11 changes: 11 additions & 0 deletions apps/mobile/src/features/agent-awareness/registrationPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";

import type { Preferences } from "../../lib/storage";

// Development builds are Xcode-signed and receive sandbox APNs tokens;
// preview and production builds are distribution-signed and use production
// APNs. The relay routes each device's pushes accordingly.
export function resolveApsEnvironment(appVariant: unknown): "sandbox" | "production" {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium agent-awareness/registrationPayload.ts:8

resolveApsEnvironment returns "production" for every value except the literal string "development". Locally run ios:preview and ios:prod builds launched via expo run:ios are development-signed and receive sandbox APNs tokens, but this function classifies them as "production". The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or Configuration/ entitlements) rather than the variant name, or document why locally-run preview/prod builds are expected to use production APNs.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/agent-awareness/registrationPayload.ts around line 8:

`resolveApsEnvironment` returns `"production"` for every value except the literal string `"development"`. Locally run `ios:preview` and `ios:prod` builds launched via `expo run:ios` are development-signed and receive sandbox APNs tokens, but this function classifies them as `"production"`. The relay then routes pushes to the production APNs gateway for a sandbox token, so notifications and Live Activities fail to deliver for those builds. Consider keying off the signing/provisioning environment (e.g. EAS build profile or `Configuration`/ entitlements) rather than the variant name, or document why locally-run `preview`/`prod` builds are expected to use production APNs.

return appVariant === "development" ? "sandbox" : "production";
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function makeRelayDeviceRegistrationRequest(input: {
readonly deviceId: string;
readonly label: string;
readonly iosMajorVersion: number;
readonly appVersion?: string;
readonly bundleId?: string;
readonly apsEnvironment?: "sandbox" | "production";
readonly pushToken?: string;
readonly pushToStartToken?: string;
readonly notificationsEnabled: boolean;
Expand All @@ -19,6 +28,8 @@ export function makeRelayDeviceRegistrationRequest(input: {
platform: "ios",
iosMajorVersion: input.iosMajorVersion,
appVersion: input.appVersion,
...(input.bundleId ? { bundleId: input.bundleId } : {}),
...(input.apsEnvironment ? { apsEnvironment: input.apsEnvironment } : {}),
...(input.pushToken ? { pushToken: input.pushToken } : {}),
...(input.pushToStartToken ? { pushToStartToken: input.pushToStartToken } : {}),
preferences: {
Expand Down
Loading
Loading