Skip to content
Open
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
39 changes: 39 additions & 0 deletions memory-bank/activeContext.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
# Active Context

> ## 🔧 IN FLIGHT 2026-08-10: `fix/rosetta2-arch-detection` (committed + pushed, PR pending)
>
> **Issue #158** — a user installed the **Intel (x64) dmg on Apple Silicon**, so
> the app runs under Rosetta 2; every spawned `brew` (arm `/opt/homebrew`) fails
> *"Cannot install under Rosetta 2 in ARM default prefix."* Terminal works
> because that `brew` runs natively. Apple retires Rosetta 2 in a future macOS
> release, so the native build is the durable fix.
>
> **Fix (both shells), committed `1d5e461`, pushed, all green (Rust 688 · native
> 201 · svelte-check 0 · vitest 57):**
>
> - **Detect** via `sysctl.proc_translated` — a *runtime* check (the compile-time
> `#if arch(arm64)` / `cfg!(target_arch)` can't see translation). Cached; a
> pure helper is unit-tested. `SystemProfile.is_translated()` (Rust) /
> `SystemProfile.isTranslated()` (Swift). A `BREWBROWSER_FAKE_ROSETTA=1`
> override (mirrors `BREWBROWSER_FAKE_RAM_GB`) exercises the path on native-arm.
> - **Bridge**: every brew spawn routes through `arch -arm64 <brew>` when
> translated **and** the prefix is `/opt/homebrew` (Intel brew at `/usr/local`
> is left alone), so operations keep working. One helper per shell —
> `exec.rs brew_command()` / `BrewService.brewInvocation()` — which also closed
> a gap where `brew bundle check` skipped the analytics-off env.
> - **Notice**: a Dashboard **card** (NOT a top-of-window banner), same
> `.card`/`GroupBox` + warning-row language as the Exposure card, steering to
> the Apple Silicon build. `RosettaCard` in `DashboardView.swift`; a `.card` in
> `Dashboard.svelte`. `SystemStatus.rosettaTranslated` carries the flag to the
> Tauri frontend at startup.
>
> **Lesson (SwiftUI window sizing):** the first attempt was a top banner wrapping
> `NavigationSplitView` in a `VStack`; that broke free window resizing under the
> scene's `.windowResizability(.contentMinSize)`. The true culprit was
> `.fixedSize(vertical: true)` on the banner text — it locked the window's
> minimum height — compounded by a corrupted frame persisted under the
> `NSWindow Frame SwiftUI.WindowGroup<…>` defaults key. Resolved by moving to a
> Dashboard card (window chrome untouched). Full record to land in a task doc.
>
> **Next:** open PR (closes #158) → cut release **Tauri 0.7.3 / native 0.3.3** →
> reply on #158 → issue-triage sweep (brew-not-app class + #128/#139/#159/#160,
> label #161 `enhancement`).

> ## 🔧 IN FLIGHT 2026-06-10/11: `feat/intel-builds-and-onboarding` (committed + pushed, PR pending)
>
> Triggered by post-release "corrupt download" reports → root cause was Intel
Expand Down
8 changes: 8 additions & 0 deletions native/Sources/BrewBrowserKit/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,14 @@ public final class AppModel {
/// can force Marginal/Blocked states on a high-RAM dev Mac.
var systemProfile = SystemProfile.detect()

/// True when the app itself is running under Rosetta 2 — an Intel build on
/// an Apple Silicon Mac. Drives `RosettaBanner`, which steers the user to
/// the native Apple Silicon build (brew can't install into the arm
/// `/opt/homebrew` prefix from a translated process without the
/// `arch -arm64` stopgap). Read once at construction — translation state is
/// fixed for the process lifetime. See issue #158.
let rosettaTranslated = SystemProfile.isTranslated()

/// Capability verdict for a bundle on this host (M3). Pure — just routes the
/// bundle's `requires`/`capabilityNotes` and the cached profile through the
/// M1 readiness function.
Expand Down
26 changes: 22 additions & 4 deletions native/Sources/BrewBrowserKit/BrewService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,29 @@ struct BrewService: Sendable {
return env
}

/// Build the (executable, arguments) pair for a `brew` spawn, routing
/// through `/usr/bin/arch -arm64` when this process runs under Rosetta 2
/// against an arm-native Homebrew (`/opt/homebrew`). Without this, a
/// translated (x86_64) process spawning arm `brew` fails with "Cannot
/// install under Rosetta 2 in ARM default prefix" (issue #158). An Intel
/// brew at `/usr/local` already matches the translated process, so it's
/// left alone. Every brew spawn site goes through this.
static func brewInvocation(brew: String, args: [String])
-> (executable: URL, arguments: [String])
{
if SystemProfile.isTranslated() && brew.hasPrefix("/opt/homebrew") {
return (URL(fileURLWithPath: "/usr/bin/arch"), ["-arm64", brew] + args)
}
return (URL(fileURLWithPath: brew), args)
}

private func runCapture(_ args: [String]) async throws -> String {
guard let brew = Self.resolveBrewPath() else { throw BrewError.brewNotFound }

let process = Process()
process.executableURL = URL(fileURLWithPath: brew)
process.arguments = args
let invocation = Self.brewInvocation(brew: brew, args: args)
process.executableURL = invocation.executable
process.arguments = invocation.arguments
process.currentDirectoryURL = URL(fileURLWithPath: "/")
process.environment = Self.brewEnvironment()

Expand Down Expand Up @@ -314,8 +331,9 @@ struct BrewService: Sendable {
return
}
let process = Process()
process.executableURL = URL(fileURLWithPath: brew)
process.arguments = args
let invocation = Self.brewInvocation(brew: brew, args: args)
process.executableURL = invocation.executable
process.arguments = invocation.arguments
process.currentDirectoryURL = URL(fileURLWithPath: "/")
// No TTY/stdin: a sudo/interactive prompt gets EOF → brew errors out
// visibly instead of blocking on a read that never returns.
Expand Down
58 changes: 58 additions & 0 deletions native/Sources/BrewBrowserKit/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,22 @@ struct DashboardView: View {
/// True when the content pane is wide enough to pair cards two-across.
@State private var wide = false

/// Issue #158 — session-only dismissal of the Rosetta 2 notice card.
@State private var rosettaDismissed = false

var body: some View {
ScrollView {
if !model.dashboardLoaded {
ProgressView("Reading your Homebrew setup…")
.frame(maxWidth: .infinity, minHeight: 300)
} else {
VStack(alignment: .leading, spacing: 16) {
// Issue #158 — Rosetta 2 notice, at the top of the stack when
// the app is running under Rosetta 2 (Intel build on Apple
// Silicon). Same card language as the other dashboard cards.
if model.rosettaTranslated && !rosettaDismissed {
RosettaCard { rosettaDismissed = true }
}
HeroStrip(model: model)
CatalogFreshnessStrip(model: model)
if model.outdatedCount > 0 { UpdatesCard(model: model) }
Expand Down Expand Up @@ -809,6 +818,55 @@ struct GitHubCard: View {
}
}

/// Issue #158 — Rosetta 2 notice card. Shown at the top of the Dashboard when
/// the app is running under Rosetta 2 (an Intel build on an Apple Silicon Mac).
/// `BrewService.brewInvocation` routes `brew` through `arch -arm64` so it keeps
/// working, but Apple is retiring Rosetta 2, so the durable fix is the native
/// Apple Silicon build. Same `GroupBox` + warning-`Label` language as
/// `ExposureCard`. Visibility + session dismissal are owned by `DashboardView`.
struct RosettaCard: View {
/// Called when the user dismisses the notice for this session.
let onDismiss: () -> Void

private let releasesURL = URL(string: "https://github.com/msitarzewski/brew-browser/releases/latest")!

var body: some View {
GroupBox {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image(systemName: "cpu").foregroundStyle(.orange)
Text("Rosetta 2").font(.headline)
Spacer()
Link(destination: releasesURL) {
Label("Apple Silicon Build", systemImage: "arrow.down.circle")
}
.controlSize(.small)
Button {
onDismiss()
} label: {
Image(systemName: "xmark").imageScale(.small)
}
.buttonStyle(.borderless)
.foregroundStyle(.secondary)
.help("Dismiss for this session")
.accessibilityLabel("Dismiss Rosetta 2 notice")
}

Label {
VStack(alignment: .leading, spacing: 2) {
Text("Running under Rosetta 2").fontWeight(.semibold)
Text("You installed the Intel build on an Apple Silicon Mac. It keeps working — Homebrew runs through arch -arm64 — but Apple is retiring Rosetta 2. Switch to the Apple Silicon build for full, future-proof support.")
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}

#if DEBUG
#Preview("Dashboard") {
DashboardView(model: .preview())
Expand Down
33 changes: 33 additions & 0 deletions native/Sources/BrewBrowserKit/SystemProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ public struct SystemProfile: Sendable, Codable, Equatable {
}
}

// MARK: - Rosetta 2 detection (issue #158)

extension SystemProfile {
/// True when this process is running under Rosetta 2 — an x86_64 build
/// translated on Apple Silicon. A translated process shelling out to
/// arm-native `brew` (`/opt/homebrew`) fails with "Cannot install under
/// Rosetta 2 in ARM default prefix", so we detect this both to warn the
/// user (install the Apple Silicon build) and to re-exec `brew` through
/// `arch -arm64`. Reads the per-process `sysctl.proc_translated` flag:
/// `1` translated, `0`/absent (Macs without Rosetta) native.
///
/// Note: this is a *runtime* check — the compile-time `#if arch(arm64)`
/// in `detect()` can't see translation, since an x86_64 build reports
/// itself as Intel even on Apple Silicon hardware.
public static func isTranslated() -> Bool {
// Debug/QA override — mirrors `BREWBROWSER_FAKE_RAM_GB` in `detect()`.
// Lets the Rosetta banner + `arch -arm64` bridge be exercised on a
// native Apple Silicon Mac (where `proc_translated` is always 0). Safe:
// `arch -arm64` on an already-arm `brew` is a no-op wrapper.
if let fake = ProcessInfo.processInfo.environment["BREWBROWSER_FAKE_ROSETTA"] {
return fake == "1"
}
return translatedFromSysctl(sysctlInt("sysctl.proc_translated"))
}
}

/// Pure mapping behind `isTranslated()`, split out so the flag semantics are
/// unit-testable without a real Rosetta process. `1` → translated; `0`, `nil`
/// (key absent), and anything else → native.
func translatedFromSysctl(_ value: Int?) -> Bool {
value == 1
}

// MARK: - sysctl helpers

/// Read a string-valued `sysctl` (e.g. `machdep.cpu.brand_string`).
Expand Down
49 changes: 49 additions & 0 deletions native/Tests/BrewBrowserKitTests/RosettaDetectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation
import Testing
@testable import BrewBrowserKit

/// Rosetta 2 detection + `arch -arm64` re-exec gating (issue #158). Parity with
/// the Tauri suite (`profile.rs::translated_only_when_sysctl_reads_one` and
/// `exec.rs::arm64_reexec_only_for_translated_arm_prefix`).
@Suite("RosettaDetection")
struct RosettaDetectionTests {

@Test("sysctl.proc_translated maps to translated only when it reads 1")
func translatedFlagMapping() {
// 1 = Rosetta 2, 0 = native, nil = key absent (Macs without Rosetta).
#expect(translatedFromSysctl(1) == true)
#expect(translatedFromSysctl(0) == false)
#expect(translatedFromSysctl(nil) == false)
// Defensive: any other value is treated as native, never "translated".
#expect(translatedFromSysctl(2) == false)
#expect(translatedFromSysctl(-1) == false)
}

@Test("brewInvocation re-execs via arch -arm64 only for translated + /opt/homebrew")
func brewInvocationGating() {
// The gating logic mirrors the Rust `should_reexec_arm64`. We can't
// fake Rosetta at runtime, but we can pin the wrapping shape: an
// arm-prefix brew under `arch -arm64` must run `arch -arm64 <brew> …`,
// and a direct invocation must run `<brew> …` verbatim.
let args = ["upgrade", "wget"]

let direct = BrewService.brewInvocation(brew: "/opt/homebrew/bin/brew", args: args)
let wrapped = (URL(fileURLWithPath: "/usr/bin/arch"), ["-arm64", "/opt/homebrew/bin/brew"] + args)

// On a native (non-translated) machine — which the CI/dev host is —
// brewInvocation must NOT wrap, regardless of prefix.
if SystemProfile.isTranslated() {
#expect(direct.executable == wrapped.0)
#expect(direct.arguments == wrapped.1)
} else {
#expect(direct.executable == URL(fileURLWithPath: "/opt/homebrew/bin/brew"))
#expect(direct.arguments == args)
}

// An Intel brew (/usr/local) already matches a translated process, so it
// is never wrapped — direct invocation on every host.
let intel = BrewService.brewInvocation(brew: "/usr/local/bin/brew", args: args)
#expect(intel.executable == URL(fileURLWithPath: "/usr/local/bin/brew"))
#expect(intel.arguments == args)
}
}
69 changes: 65 additions & 4 deletions src-tauri/src/brew/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,55 @@ pub(crate) fn apply_brew_env(cmd: &mut Command) {
cmd.env("PATH", new_path);
}

/// True when brew must be launched through `arch -arm64`: this process is
/// running under Rosetta 2 (an Intel build on Apple Silicon) *and* the resolved
/// `brew` lives in the arm-native prefix (`/opt/homebrew`). In that state a
/// translated (x86_64) process spawning arm `brew` fails with
/// "Cannot install under Rosetta 2 in ARM default prefix" (issue #158), so we
/// re-exec it natively. An Intel Homebrew at `/usr/local` already matches the
/// translated process, so we leave those untouched.
fn needs_arm64_reexec(brew_path: &Path) -> bool {
should_reexec_arm64(crate::system::profile::is_translated(), brew_path)
}

/// Pure gating logic behind [`needs_arm64_reexec`], split out so the
/// `translated` + arm-prefix combination is unit-testable without a real
/// Rosetta process.
fn should_reexec_arm64(translated: bool, brew_path: &Path) -> bool {
translated && brew_path.starts_with("/opt/homebrew")
}

/// Construct the `brew` command with the analytics-off env + Homebrew PATH
/// prepend applied ([`apply_brew_env`]), transparently routing through
/// `/usr/bin/arch -arm64` when [`needs_arm64_reexec`] is true. Every brew spawn
/// site goes through this so the Rosetta 2 stopgap and the env policy stay in
/// one place. Subsequent `.args(..)` on the returned command append the brew
/// arguments as normal (after the `brew` path when re-execing via `arch`).
pub(crate) fn brew_command(brew_path: &Path) -> Command {
if needs_arm64_reexec(brew_path) {
let mut cmd = Command::new("/usr/bin/arch");
cmd.arg("-arm64").arg(brew_path);
apply_brew_env(&mut cmd);
cmd
} else {
let mut cmd = Command::new(brew_path);
apply_brew_env(&mut cmd);
cmd
}
}

pub async fn run_brew_capture(
brew_path: &Path,
args: &[&str],
display_command: &str,
) -> Result<String, BrewError> {
let mut cmd = Command::new(brew_path);
let mut cmd = brew_command(brew_path);
cmd.args(args)
.current_dir(BREW_SPAWN_CWD)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
apply_brew_env(&mut cmd);

let output = cmd.output().await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => BrewError::BrewNotFound,
Expand Down Expand Up @@ -151,14 +187,13 @@ pub async fn run_brew_streaming(

let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect();

let mut cmd = Command::new(brew_path);
let mut cmd = brew_command(brew_path);
cmd.args(&str_args)
.current_dir(BREW_SPAWN_CWD)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
apply_brew_env(&mut cmd);

let mut child = match cmd.spawn() {
Ok(c) => c,
Expand Down Expand Up @@ -548,6 +583,32 @@ mod tests {
);
}

#[test]
fn arm64_reexec_only_for_translated_arm_prefix() {
use std::path::Path;
// Under Rosetta 2, an arm-native brew (/opt/homebrew) must be re-execed
// via `arch -arm64` (issue #158)...
assert!(should_reexec_arm64(
true,
Path::new("/opt/homebrew/bin/brew")
));
// ...but an Intel brew (/usr/local) already matches the translated
// process — leave it alone.
assert!(!should_reexec_arm64(
true,
Path::new("/usr/local/bin/brew")
));
// Native (non-translated) process: never re-exec, regardless of prefix.
assert!(!should_reexec_arm64(
false,
Path::new("/opt/homebrew/bin/brew")
));
assert!(!should_reexec_arm64(
false,
Path::new("/usr/local/bin/brew")
));
}

#[test]
fn stderr_ring_keeps_lines_under_cap() {
let mut r = StderrRing::new(100);
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/commands/brewfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ pub async fn brewfile_check(
let display = format!("brew bundle check --file={} --verbose", target_str);
// `brew bundle check` exits non-zero when packages are missing —
// we want to read the output even then, so capture via plain output.
let mut cmd = tokio::process::Command::new(&path);
// `brew_command` applies the analytics-off env + PATH prepend and routes
// through `arch -arm64` under Rosetta 2 (issue #158).
let mut cmd = crate::brew::exec::brew_command(&path);
cmd.args([
"bundle",
"check",
Expand Down
Loading