Skip to content

Commit 699073f

Browse files
authored
fix(portal): fix downloading device agent on safari (#1791)
1 parent 1bbff8b commit 699073f

File tree

7 files changed

+157
-87
lines changed

7 files changed

+157
-87
lines changed

apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
'use client';
22

3+
import {
4+
MAC_APPLE_SILICON_FILENAME,
5+
MAC_INTEL_FILENAME,
6+
WINDOWS_FILENAME,
7+
} from '@/app/api/download-agent/constants';
38
import { detectOSFromUserAgent, SupportedOS } from '@/utils/os';
49
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@comp/ui/accordion';
510
import { Button } from '@comp/ui/button';
@@ -51,6 +56,11 @@ export function DeviceAgentAccordionItem({
5156
const isCompleted = hasInstalledAgent && failedPoliciesCount === 0;
5257

5358
const handleDownload = async () => {
59+
if (!detectedOS) {
60+
toast.error('Could not detect your OS. Please refresh and try again.');
61+
return;
62+
}
63+
5464
setIsDownloading(true);
5565

5666
try {
@@ -61,6 +71,7 @@ export function DeviceAgentAccordionItem({
6171
body: JSON.stringify({
6272
orgId: member.organizationId,
6373
employeeId: member.id,
74+
os: detectedOS,
6475
}),
6576
});
6677

@@ -73,20 +84,17 @@ export function DeviceAgentAccordionItem({
7384

7485
// Now trigger the actual download using the browser's native download mechanism
7586
// This will show in the browser's download UI immediately
76-
const downloadUrl = `/api/download-agent?token=${encodeURIComponent(token)}&os=${detectedOS}`;
87+
const downloadUrl = `/api/download-agent?token=${encodeURIComponent(token)}`;
7788

7889
// Method 1: Using a temporary link (most reliable)
7990
const a = document.createElement('a');
8091
a.href = downloadUrl;
8192

8293
// Set filename based on OS and architecture
8394
if (isMacOS) {
84-
a.download =
85-
detectedOS === 'macos'
86-
? 'Comp AI Agent-1.0.0-arm64.dmg'
87-
: 'Comp AI Agent-1.0.0-intel.dmg';
95+
a.download = detectedOS === 'macos' ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME;
8896
} else {
89-
a.download = 'Comp AI Agent 1.0.0.exe';
97+
a.download = WINDOWS_FILENAME;
9098
}
9199

92100
document.body.appendChild(a);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const MAC_APPLE_SILICON_FILENAME = 'Comp AI Agent-1.0.0-arm64.dmg';
2+
export const MAC_INTEL_FILENAME = 'Comp AI Agent-1.0.0-intel.dmg';
3+
export const WINDOWS_FILENAME = 'Comp AI Agent 1.0.0.exe';
4+
Lines changed: 97 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,149 @@
11
import { logger } from '@/utils/logger';
22
import { s3Client } from '@/utils/s3';
3-
import { GetObjectCommand } from '@aws-sdk/client-s3';
3+
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
44
import { client as kv } from '@comp/kv';
55
import { type NextRequest, NextResponse } from 'next/server';
66
import { Readable } from 'stream';
77

8+
import { MAC_APPLE_SILICON_FILENAME, MAC_INTEL_FILENAME, WINDOWS_FILENAME } from './constants';
9+
import type { SupportedOS } from './types';
10+
811
export const runtime = 'nodejs';
912
export const dynamic = 'force-dynamic';
1013
export const maxDuration = 60;
1114

12-
// GET handler for direct browser downloads using token
13-
export async function GET(req: NextRequest) {
14-
const searchParams = req.nextUrl.searchParams;
15-
const token = searchParams.get('token');
16-
const os = searchParams.get('os');
15+
interface DownloadTokenInfo {
16+
orgId: string;
17+
employeeId: string;
18+
userId: string;
19+
os: SupportedOS;
20+
createdAt: number;
21+
}
22+
23+
interface DownloadTarget {
24+
key: string;
25+
filename: string;
26+
contentType: string;
27+
}
28+
29+
const getDownloadTarget = (os: SupportedOS): DownloadTarget => {
30+
if (os === 'windows') {
31+
return {
32+
key: `windows/${WINDOWS_FILENAME}`,
33+
filename: WINDOWS_FILENAME,
34+
contentType: 'application/octet-stream',
35+
};
36+
}
1737

18-
if (!os) {
19-
return new NextResponse('Missing OS', { status: 400 });
38+
const isAppleSilicon = os === 'macos';
39+
const filename = isAppleSilicon ? MAC_APPLE_SILICON_FILENAME : MAC_INTEL_FILENAME;
40+
41+
return {
42+
key: `macos/${filename}`,
43+
filename,
44+
contentType: 'application/x-apple-diskimage',
45+
};
46+
};
47+
48+
const buildResponseHeaders = (
49+
target: DownloadTarget,
50+
contentLength?: number | null,
51+
): Record<string, string> => {
52+
const headers: Record<string, string> = {
53+
'Content-Type': target.contentType,
54+
'Content-Disposition': `attachment; filename="${target.filename}"`,
55+
'Cache-Control': 'no-cache, no-store, must-revalidate',
56+
'X-Accel-Buffering': 'no',
57+
};
58+
59+
if (typeof contentLength === 'number' && Number.isFinite(contentLength)) {
60+
headers['Content-Length'] = contentLength.toString();
2061
}
2162

63+
return headers;
64+
};
65+
66+
const getDownloadToken = async (token: string): Promise<DownloadTokenInfo | null> => {
67+
const info = await kv.get<DownloadTokenInfo>(`download:${token}`);
68+
return info ?? null;
69+
};
70+
71+
const ensureBucket = (): string | null => {
72+
const bucket = process.env.FLEET_AGENT_BUCKET_NAME;
73+
return bucket ?? null;
74+
};
75+
76+
const handleDownload = async (req: NextRequest, isHead: boolean) => {
77+
const token = req.nextUrl.searchParams.get('token');
78+
2279
if (!token) {
2380
return new NextResponse('Missing download token', { status: 400 });
2481
}
2582

26-
// Retrieve download info from KV store
27-
const downloadInfo = await kv.get(`download:${token}`);
83+
const downloadInfo = await getDownloadToken(token);
2884

2985
if (!downloadInfo) {
3086
return new NextResponse('Invalid or expired download token', { status: 403 });
3187
}
3288

33-
// Delete token after retrieval (one-time use)
34-
await kv.del(`download:${token}`);
35-
36-
// Hardcoded device marker paths used by the setup scripts
37-
const fleetBucketName = process.env.FLEET_AGENT_BUCKET_NAME;
89+
const fleetBucketName = ensureBucket();
3890

3991
if (!fleetBucketName) {
92+
logger('Device agent download misconfigured: missing bucket');
4093
return new NextResponse('Server configuration error', { status: 500 });
4194
}
4295

43-
// For macOS, serve the DMG directly. For Windows, create a zip with script and installer.
44-
if (os === 'macos' || os === 'macos-intel') {
45-
try {
46-
// Direct DMG download for macOS
47-
const macosPackageFilename =
48-
os === 'macos' ? 'Comp AI Agent-1.0.0-arm64.dmg' : 'Comp AI Agent-1.0.0.dmg';
49-
const packageKey = `macos/${macosPackageFilename}`;
96+
const target = getDownloadTarget(downloadInfo.os);
5097

51-
const getObjectCommand = new GetObjectCommand({
98+
try {
99+
if (isHead) {
100+
const headCommand = new HeadObjectCommand({
52101
Bucket: fleetBucketName,
53-
Key: packageKey,
102+
Key: target.key,
54103
});
55104

56-
const s3Response = await s3Client.send(getObjectCommand);
105+
const headResult = await s3Client.send(headCommand);
57106

58-
if (!s3Response.Body) {
59-
return new NextResponse('DMG file not found', { status: 404 });
60-
}
61-
62-
// Convert S3 stream to Web Stream for NextResponse
63-
const s3Stream = s3Response.Body as Readable;
64-
const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream;
65-
66-
// Return streaming response with headers that trigger browser download
67-
return new NextResponse(webStream, {
68-
headers: {
69-
'Content-Type': 'application/x-apple-diskimage',
70-
'Content-Disposition': `attachment; filename="${macosPackageFilename}"`,
71-
'Cache-Control': 'no-cache, no-store, must-revalidate',
72-
'X-Accel-Buffering': 'no',
73-
},
107+
return new NextResponse(null, {
108+
headers: buildResponseHeaders(target, headResult.ContentLength ?? null),
74109
});
75-
} catch (error) {
76-
logger('Error downloading macOS DMG', { error });
77-
return new NextResponse('Failed to download macOS agent', { status: 500 });
78110
}
79-
}
80-
81-
// Windows flow: Generate script and create zip const fleetDevicePath = fleetDevicePathWindows;
82-
try {
83-
const windowsPackageFilename = 'Comp AI Agent 1.0.0.exe';
84-
const packageKey = `windows/${windowsPackageFilename}`;
85111

86112
const getObjectCommand = new GetObjectCommand({
87113
Bucket: fleetBucketName,
88-
Key: packageKey,
114+
Key: target.key,
89115
});
90116

91117
const s3Response = await s3Client.send(getObjectCommand);
92118

93119
if (!s3Response.Body) {
94-
return new NextResponse('Executable file not found', { status: 404 });
120+
return new NextResponse('Installer file not found', { status: 404 });
95121
}
96122

97-
// Convert S3 stream to Web Stream for NextResponse
123+
await kv.del(`download:${token}`);
124+
98125
const s3Stream = s3Response.Body as Readable;
99126
const webStream = Readable.toWeb(s3Stream) as unknown as ReadableStream;
100127

101-
// Return streaming response with headers that trigger browser download
102128
return new NextResponse(webStream, {
103-
headers: {
104-
'Content-Type': 'application/octet-stream',
105-
'Content-Disposition': `attachment; filename="${windowsPackageFilename}"`,
106-
'Cache-Control': 'no-cache, no-store, must-revalidate',
107-
'X-Accel-Buffering': 'no',
108-
},
129+
headers: buildResponseHeaders(target, s3Response.ContentLength ?? null),
109130
});
110131
} catch (error) {
111-
logger('Error creating agent download', { error });
112-
return new NextResponse('Failed to create download', { status: 500 });
132+
logger('Error serving device agent download', {
133+
error,
134+
token,
135+
os: downloadInfo.os,
136+
method: isHead ? 'HEAD' : 'GET',
137+
});
138+
139+
return new NextResponse('Failed to download agent', { status: 500 });
113140
}
141+
};
142+
143+
export async function GET(req: NextRequest) {
144+
return handleDownload(req, false);
145+
}
146+
147+
export async function HEAD(req: NextRequest) {
148+
return handleDownload(req, true);
114149
}

apps/portal/src/app/api/download-agent/token/route.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import { createFleetLabel } from '../fleet-label';
77
import type { DownloadAgentRequest, SupportedOS } from '../types';
88
import { detectOSFromUserAgent, validateMemberAndOrg } from '../utils';
99

10+
const SUPPORTED_OSES: SupportedOS[] = ['macos', 'macos-intel', 'windows'];
11+
12+
const isSupportedOS = (value: unknown): value is SupportedOS =>
13+
typeof value === 'string' && SUPPORTED_OSES.includes(value as SupportedOS);
14+
1015
export async function POST(req: NextRequest) {
1116
// Authentication
1217
const session = await auth.api.getSession({
@@ -18,7 +23,7 @@ export async function POST(req: NextRequest) {
1823
}
1924

2025
// Validate request body
21-
const { orgId, employeeId }: DownloadAgentRequest = await req.json();
26+
const { orgId, employeeId, os }: DownloadAgentRequest = await req.json();
2227

2328
if (!orgId || !employeeId) {
2429
return new NextResponse('Missing orgId or employeeId', { status: 400 });
@@ -30,13 +35,13 @@ export async function POST(req: NextRequest) {
3035
return new NextResponse('Member not found or organization invalid', { status: 404 });
3136
}
3237

33-
// Auto-detect OS from User-Agent
38+
// Auto-detect OS from User-Agent, but allow explicit overrides from the client
3439
const userAgent = req.headers.get('user-agent');
35-
const detectedOS = detectOSFromUserAgent(userAgent);
40+
const detectedOS = isSupportedOS(os) ? os : detectOSFromUserAgent(userAgent);
3641

3742
if (!detectedOS) {
3843
return new NextResponse(
39-
'Could not detect OS from User-Agent. Please use a standard browser on macOS or Windows.',
44+
'Could not determine operating system. Please select an OS and try again.',
4045
{ status: 400 },
4146
);
4247
}
@@ -58,7 +63,7 @@ export async function POST(req: NextRequest) {
5863
await createFleetLabel({
5964
employeeId,
6065
memberId: member.id,
61-
os: detectedOS as SupportedOS,
66+
os: detectedOS,
6267
fleetDevicePathMac,
6368
fleetDevicePathWindows,
6469
});

apps/portal/src/app/api/download-agent/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface CreateFleetLabelParams {
2323
export interface DownloadAgentRequest {
2424
orgId: string;
2525
employeeId: string;
26+
os?: SupportedOS;
2627
}
2728

2829
export interface FleetDevicePaths {

apps/portal/src/app/api/download-agent/utils.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,34 @@ import type { SupportedOS } from './types';
1515
* - macOS (Intel): "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
1616
* - macOS (Apple Silicon): "Mozilla/5.0 (Macintosh; ARM Mac OS X 11_2_3) AppleWebKit/537.36"
1717
*/
18+
const isSafariUA = (ua: string) =>
19+
ua.includes('safari') &&
20+
!ua.includes('chrome') &&
21+
!ua.includes('crios') &&
22+
!ua.includes('fxios') &&
23+
!ua.includes('edgios');
24+
25+
const hasArmIndicators = (ua: string) =>
26+
ua.includes('arm') || ua.includes('arm64') || ua.includes('aarch64') || ua.includes('apple');
27+
1828
export function detectOSFromUserAgent(userAgent: string | null): SupportedOS | null {
1929
if (!userAgent) return null;
2030

2131
const ua = userAgent.toLowerCase();
2232

23-
// Check for Windows (must check before Android since Android UA contains "linux")
2433
if (ua.includes('windows') || ua.includes('win32') || ua.includes('win64')) {
2534
return 'windows';
2635
}
2736

28-
// Check for macOS (and further distinguish Apple Silicon vs Intel)
2937
if (ua.includes('macintosh') || (ua.includes('mac os') && !ua.includes('like mac'))) {
30-
// User-Agent containing 'arm' or 'apple' usually means Apple Silicon
31-
if (ua.includes('arm') || ua.includes('apple')) {
38+
if (hasArmIndicators(ua)) {
3239
return 'macos';
3340
}
34-
// 'intel' in UA indicates Intel-based mac
35-
if (ua.includes('intel')) {
41+
42+
if (!isSafariUA(ua) && ua.includes('intel')) {
3643
return 'macos-intel';
3744
}
38-
// Fallback for when arch info is missing, treat as Apple Silicon (modern default)
45+
3946
return 'macos';
4047
}
4148

0 commit comments

Comments
 (0)