Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ xcode/build/
xcode/fastlane/screenshots/
xcode/fastlane/test_output/
xcode/fastlane/report.xml
xcode/fastlane/previews/
xcode/Gemfile.lock
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default tseslint.config([
"*.config.js",
"*.config.ts",
"container/**",
"containers/**",
"public/**",
"scripts/**",
"worker/scripts/**",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"hono": "^4.10.6",
"jose": "^6.1.3",
"lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

178 changes: 178 additions & 0 deletions worker/apns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { SignJWT, importPKCS8 } from "jose";

export interface ApnsConfig {
authKey: string;
keyId: string;
teamId: string;
bundleId: string;
}

export interface ApnsAlert {
title: string;
body: string;
}

export interface ApnsPayload {
aps: {
alert: ApnsAlert;
sound?: string;
badge?: number;
"mutable-content"?: number;
"thread-id"?: string;
};
workspaceId?: string;
workspaceName?: string;
action?: string;
}

// Live Activity content state - matches iOS CodespaceActivityAttributes.ContentState
export interface LiveActivityContentState {
status: string;
progress: number;
elapsedSeconds: number;
}

export interface LiveActivityUpdateOptions {
staleDate?: number; // Unix timestamp when the data becomes stale
dismissalDate?: number; // Unix timestamp when to dismiss the activity
event?: "update" | "end"; // 'end' to dismiss the activity
}

export async function sendPushNotification(
deviceToken: string,
payload: ApnsPayload,
config: ApnsConfig,
): Promise<{ success: boolean; error?: string }> {
try {
// Generate JWT for APNs authentication
const privateKey = await importPKCS8(config.authKey, "ES256");

const jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: config.keyId })
.setIssuer(config.teamId)
.setIssuedAt()
.setExpirationTime("1h")
.sign(privateKey);

// APNs production endpoint (HTTP/2)
const url = `https://api.push.apple.com/3/device/${deviceToken}`;

const response = await fetch(url, {
method: "POST",
headers: {
authorization: `bearer ${jwt}`,
"apns-topic": config.bundleId,
"apns-push-type": "alert",
"apns-priority": "10",
},
body: JSON.stringify(payload),
});

if (!response.ok) {
const error = (await response.json()) as { reason?: string };
console.error("APNs error:", response.status, error);
return {
success: false,
error: error.reason || `HTTP ${response.status}`,
};
}

console.log(
`✅ Push notification sent to device ${deviceToken.slice(0, 8)}...`,
);
return { success: true };
} catch (error) {
console.error("APNs send error:", error);
return { success: false, error: String(error) };
}
}

/**
* Send a Live Activity push notification to update the codespace creation progress widget.
* Uses a different APNs topic and payload format than regular push notifications.
*/
export async function sendLiveActivityUpdate(
pushToken: string,
contentState: LiveActivityContentState,
config: ApnsConfig,
options?: LiveActivityUpdateOptions,
): Promise<{ success: boolean; error?: string }> {
try {
// Generate JWT for APNs authentication
const privateKey = await importPKCS8(config.authKey, "ES256");

const jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: config.keyId })
.setIssuer(config.teamId)
.setIssuedAt()
.setExpirationTime("1h")
.sign(privateKey);

// APNs production endpoint (HTTP/2)
const url = `https://api.push.apple.com/3/device/${pushToken}`;

// Build Live Activity payload
const payload: {
aps: {
timestamp: number;
event: "update" | "end";
"content-state": LiveActivityContentState;
"stale-date"?: number;
"dismissal-date"?: number;
};
} = {
aps: {
timestamp: Math.floor(Date.now() / 1000),
event: options?.event || "update",
"content-state": contentState,
},
};

// Add optional stale-date (defaults to 60 seconds from now for regular updates)
if (options?.staleDate) {
payload.aps["stale-date"] = options.staleDate;
} else if (options?.event !== "end") {
// Default stale date: 60 seconds from now
payload.aps["stale-date"] = Math.floor(Date.now() / 1000) + 60;
}

// Add dismissal-date for end events
if (options?.dismissalDate) {
payload.aps["dismissal-date"] = options.dismissalDate;
} else if (options?.event === "end") {
// Default dismissal: 3 seconds from now (brief final display)
payload.aps["dismissal-date"] = Math.floor(Date.now() / 1000) + 3;
}

// Live Activity uses different topic format
const liveActivityTopic = `${config.bundleId}.push-type.liveactivity`;

const response = await fetch(url, {
method: "POST",
headers: {
authorization: `bearer ${jwt}`,
"apns-topic": liveActivityTopic,
"apns-push-type": "liveactivity",
"apns-priority": "10",
},
body: JSON.stringify(payload),
});

if (!response.ok) {
const error = (await response.json()) as { reason?: string };
console.error("APNs Live Activity error:", response.status, error);
return {
success: false,
error: error.reason || `HTTP ${response.status}`,
};
}

console.log(
`✅ Live Activity update sent: ${contentState.status} (${Math.round(contentState.progress * 100)}%)`,
);
return { success: true };
} catch (error) {
console.error("APNs Live Activity send error:", error);
return { success: false, error: String(error) };
}
}
Loading