From de1aa7a6ae9d4f91871bf6dabb19ed49941df781 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:59:16 -0700 Subject: [PATCH] Include hardware details in feedback submissions (#1726) Add chip (e.g. Apple M1 Pro), RAM, hardware model, architecture (arm64/x86_64), and display info to feedback metadata. All fields are non-sensitive system properties collected via sysctlbyname, ProcessInfo, and NSScreen. Server-side route accepts and renders the new fields in both plain text and HTML email bodies. Co-authored-by: Lawrence Chen --- Sources/ContentView.swift | 53 ++++++++++++++++++++++++++++++++++- web/app/api/feedback/route.ts | 46 ++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 181d1d0e..508b495f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8400,6 +8400,11 @@ private struct FeedbackComposerAppMetadata { let bundleIdentifier: String let osVersion: String let localeIdentifier: String + let hardwareModel: String + let chip: String + let memoryGB: String + let architecture: String + let displayInfo: String static var current: FeedbackComposerAppMetadata { let infoDictionary = Bundle.main.infoDictionary ?? [:] @@ -8414,9 +8419,50 @@ private struct FeedbackComposerAppMetadata { appCommit: commit ?? "", bundleIdentifier: Bundle.main.bundleIdentifier ?? "", osVersion: ProcessInfo.processInfo.operatingSystemVersionString, - localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier + localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier, + hardwareModel: sysctlString("hw.model") ?? "", + chip: sysctlString("machdep.cpu.brand_string") ?? "", + memoryGB: formatMemoryGB(), + architecture: currentArchitecture(), + displayInfo: currentDisplayInfo() ) } + + private static func sysctlString(_ name: String) -> String? { + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + return String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func formatMemoryGB() -> String { + let bytes = ProcessInfo.processInfo.physicalMemory + let gb = Double(bytes) / (1024 * 1024 * 1024) + return "\(Int(gb)) GB" + } + + private static func currentArchitecture() -> String { + #if arch(arm64) + return "arm64" + #elseif arch(x86_64) + return "x86_64" + #else + return "unknown" + #endif + } + + private static func currentDisplayInfo() -> String { + let screens = NSScreen.screens + let descriptions = screens.map { screen -> String in + let frame = screen.frame + let scale = screen.backingScaleFactor + return "\(Int(frame.width))x\(Int(frame.height)) @\(Int(scale))x" + } + let count = screens.count + let prefix = "\(count) display\(count == 1 ? "" : "s")" + return "\(prefix), \(descriptions.joined(separator: "; "))" + } } private enum FeedbackComposerSubmissionError: Error { @@ -8470,6 +8516,11 @@ private enum FeedbackComposerClient { appendField("bundleIdentifier", value: metadata.bundleIdentifier, to: &body, boundary: boundary) appendField("osVersion", value: metadata.osVersion, to: &body, boundary: boundary) appendField("locale", value: metadata.localeIdentifier, to: &body, boundary: boundary) + appendField("hardwareModel", value: metadata.hardwareModel, to: &body, boundary: boundary) + appendField("chip", value: metadata.chip, to: &body, boundary: boundary) + appendField("memoryGB", value: metadata.memoryGB, to: &body, boundary: boundary) + appendField("architecture", value: metadata.architecture, to: &body, boundary: boundary) + appendField("displayInfo", value: metadata.displayInfo, to: &body, boundary: boundary) for attachment in preparedAttachments { appendFile( diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts index 33256634..96560d1c 100644 --- a/web/app/api/feedback/route.ts +++ b/web/app/api/feedback/route.ts @@ -32,6 +32,11 @@ const feedbackSchema = z.object({ bundleIdentifier: z.string().trim().max(200).optional().default(""), osVersion: z.string().trim().max(200).optional().default(""), locale: z.string().trim().max(120).optional().default(""), + hardwareModel: z.string().trim().max(120).optional().default(""), + chip: z.string().trim().max(200).optional().default(""), + memoryGB: z.string().trim().max(20).optional().default(""), + architecture: z.string().trim().max(20).optional().default(""), + displayInfo: z.string().trim().max(200).optional().default(""), }); type PreparedAttachment = { @@ -83,6 +88,11 @@ export async function POST(request: Request) { bundleIdentifier: getString(formData, "bundleIdentifier"), osVersion: getString(formData, "osVersion"), locale: getString(formData, "locale"), + hardwareModel: getString(formData, "hardwareModel"), + chip: getString(formData, "chip"), + memoryGB: getString(formData, "memoryGB"), + architecture: getString(formData, "architecture"), + displayInfo: getString(formData, "displayInfo"), }); if (!parsed.success) { @@ -96,8 +106,10 @@ export async function POST(request: Request) { return attachmentsResult.errorResponse; } - const { appBuild, appCommit, appVersion, bundleIdentifier, email, locale, message, osVersion } = - parsed.data; + const { + appBuild, appCommit, appVersion, architecture, bundleIdentifier, chip, + displayInfo, email, hardwareModel, locale, memoryGB, message, osVersion, + } = parsed.data; const subject = buildSubject(email, message, appVersion); const attachments = attachmentsResult.attachments; const resend = new Resend(feedbackConfig.resendApiKey); @@ -116,6 +128,11 @@ export async function POST(request: Request) { bundleIdentifier, osVersion, locale, + hardwareModel, + chip, + memoryGB, + architecture, + displayInfo, attachments, }), html: buildHtmlBody({ @@ -127,6 +144,11 @@ export async function POST(request: Request) { bundleIdentifier, osVersion, locale, + hardwareModel, + chip, + memoryGB, + architecture, + displayInfo, attachments, }), attachments: attachments.map((attachment) => ({ @@ -241,6 +263,11 @@ function buildTextBody(input: { bundleIdentifier: string; osVersion: string; locale: string; + hardwareModel: string; + chip: string; + memoryGB: string; + architecture: string; + displayInfo: string; attachments: PreparedAttachment[]; }) { const attachmentLines = @@ -262,6 +289,11 @@ function buildTextBody(input: { `Bundle identifier: ${input.bundleIdentifier || "unknown"}`, `macOS: ${input.osVersion || "unknown"}`, `Locale: ${input.locale || "unknown"}`, + `Hardware model: ${input.hardwareModel || "unknown"}`, + `Chip: ${input.chip || "unknown"}`, + `Memory: ${input.memoryGB || "unknown"}`, + `Architecture: ${input.architecture || "unknown"}`, + `Displays: ${input.displayInfo || "unknown"}`, attachmentLines, "", "Message:", @@ -278,6 +310,11 @@ function buildHtmlBody(input: { bundleIdentifier: string; osVersion: string; locale: string; + hardwareModel: string; + chip: string; + memoryGB: string; + architecture: string; + displayInfo: string; attachments: PreparedAttachment[]; }) { const attachmentMarkup = @@ -304,6 +341,11 @@ function buildHtmlBody(input: { )}

macOS: ${escapeHtml(input.osVersion || "unknown")}

Locale: ${escapeHtml(input.locale || "unknown")}

+

Hardware model: ${escapeHtml(input.hardwareModel || "unknown")}

+

Chip: ${escapeHtml(input.chip || "unknown")}

+

Memory: ${escapeHtml(input.memoryGB || "unknown")}

+

Architecture: ${escapeHtml(input.architecture || "unknown")}

+

Displays: ${escapeHtml(input.displayInfo || "unknown")}

${attachmentMarkup}

Message

${escapeHtml(