Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .cursor/rules/native-env.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ alwaysApply: false

**Do not use `process.env` or `import.meta.env` anywhere except `apps/native/src/lib/env.ts`.**

All app code reads deployment settings through exports from that module (`settings`, `nixmacEnvironment`, `getProfileValue`, etc.).
All app code reads deployment settings through exports from that module (`settings`, `nixmacEnvironment`, `nixmacVersion`, `isE2eProfile`).

Benefits: single validation path, typed profile JSON, no scattered env reads, and build-time profile baking stays consistent with Rust (`build.rs`).

Expand Down
42 changes: 40 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ jobs:
- name: Cargo clippy
run: cargo clippy --workspace --all-targets --features nixmac/codegen -- -D warnings
env:
NIXMAC_ENV: prod
NIXMAC_ENV: production

# Decide what kind of build this is:
# - tag: push of refs/tags/v* → ship that exact version on stable
Expand Down Expand Up @@ -251,11 +251,49 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Pass DSNs and build metadata into the tauri action step so build.rs can read them
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
NIXMAC_ENV: prod
NIXMAC_ENV: production
NIXMAC_VERSION: ${{ steps.sync-version.outputs.build_version }}
VITE_SERVER_URL: ${{ secrets.VITE_SERVER_URL }}
SUBMITTED_FEEDBACK_DSN: ${{ secrets.SUBMITTED_FEEDBACK_DSN }}

# An unset NIXMAC_ENV selects the development profile, which turns the
# permissions gate off. Check the artifacts themselves name production,
# rather than trusting that the env above reached both bakers: the
# frontend bundle carries its profile as a Vite define, the binary as a
# build.rs embed, and they are baked by separate code paths.
#
# esbuild may drop the quotes on the object key, so both greps accept the
# key quoted or bare. The second one is the important half: a release
# bundle that carries a non-production profile at all has a path back to
# one, whether or not it also carries the right one.
- name: Verify artifacts carry the production profile
run: |
set -euo pipefail

if [ ! -d apps/native/dist ]; then
echo "ERROR: apps/native/dist is missing; the frontend was not built"
exit 1
fi
if ! grep -rqE '"?NIXMAC_ENV"?:"production"' apps/native/dist; then
echo "ERROR: frontend bundle in apps/native/dist does not carry the production profile"
exit 1
fi
if grep -rqE '"?NIXMAC_ENV"?:"(development|e2e)"' apps/native/dist; then
echo "ERROR: frontend bundle in apps/native/dist also carries a non-production profile"
grep -rhoE '"?NIXMAC_ENV"?:"(development|e2e)"' apps/native/dist | sort -u
exit 1
fi

APP_PATH=$(find target/release/bundle/macos -name "*.app" -type d 2>/dev/null | sed -n '1p' || true)
if [ -z "$APP_PATH" ]; then
echo "ERROR: no .app bundle found under target/release/bundle/macos"
exit 1
fi
if ! grep -qa '"NIXMAC_ENV":"production"' "$APP_PATH/Contents/MacOS/nixmac"; then
echo "ERROR: $APP_PATH does not embed the production profile"
exit 1
fi

- name: Unit Test frontend
working-directory: apps/native
run: bun run test:unit
Expand Down
77 changes: 57 additions & 20 deletions apps/native/nixmac-profile.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { EnvProfileSchema, NIXMAC_ENVS, type NixmacEnv } from "./src/lib/env-profile-schema";

type NixmacProfileName = "development" | "release" | "e2e";

Expand All @@ -8,16 +9,28 @@ function readProfileJson(nativeAppDir: string, name: NixmacProfileName): Record<
return JSON.parse(raw) as Record<string, unknown>;
}

/** Profile file selection — keep in sync with `apps/native/src-tauri/build.rs`. */
function resolveNixmacProfile(): NixmacProfileName {
switch (process.env.NIXMAC_ENV ?? "development") {
case "prod":
/**
* Profile file selection — keep in sync with `apps/native/src-tauri/build.rs`.
*
* Unset means development. Any other value is a mistake in the build command,
* not a request for the default.
*
* Returns the selector as well as the file, so the caller can check that the
* file it picked names the same environment.
*/
function resolveNixmacProfile(): { selector: NixmacEnv; file: NixmacProfileName } {
const selector = process.env.NIXMAC_ENV ?? "development";
switch (selector) {
case "development":
return { selector, file: "development" };
case "production":
return "release";
return { selector, file: "release" };
case "e2e":
return "e2e";
return { selector, file: "e2e" };
default:
return "development";
throw new Error(
`NIXMAC_ENV must be unset or one of ${NIXMAC_ENVS.join(", ")}; got ${JSON.stringify(selector)}`,
);
}
}

Expand Down Expand Up @@ -65,6 +78,15 @@ const OVERRIDABLE_PREFIXES = [
"NIX_INSTALLED_",
] as const;

/**
* Keys process env must never overwrite.
*
* `NIXMAC_ENV` picks which profile file to read, and each profile names the same
* environment in its own `NIXMAC_ENV` key. A selector is not a setting: letting
* process env write that key too would let it drift from the file it selected.
*/
const NON_OVERRIDABLE_KEYS = new Set(["$schema", "NIXMAC_ENV"]);

function isOverridableKey(key: string): boolean {
return OVERRIDABLE_PREFIXES.some((prefix) => key.startsWith(prefix));
}
Expand All @@ -80,7 +102,8 @@ function mergeProfileWithProcessEnv(
const merged: Record<string, unknown> = { ...base };

for (const [key, envValue] of Object.entries(process.env)) {
if (key === "$schema" || envValue === undefined || envValue.trim() === "") continue;
if (NON_OVERRIDABLE_KEYS.has(key) || envValue === undefined || envValue.trim() === "")
continue;
if (!(key in merged) && !isOverridableKey(key)) continue;
merged[key] = coerceEnvOverride(merged[key], envValue);
}
Expand All @@ -89,23 +112,37 @@ function mergeProfileWithProcessEnv(
return merged;
}

function loadCommittedProfile(
function resolveMergedProfile(
nativeAppDir: string,
name: NixmacProfileName,
file: NixmacProfileName,
): Record<string, unknown> {
return readProfileJson(nativeAppDir, name);
}

function resolveMergedProfile(nativeAppDir: string): Record<string, unknown> {
const base = loadCommittedProfile(nativeAppDir, resolveNixmacProfile());
return mergeProfileWithProcessEnv(base, nativeAppDir);
return mergeProfileWithProcessEnv(readProfileJson(nativeAppDir, file), nativeAppDir);
}

/**
* Vite `define` entries that bake the selected profile into the bundle.
*
* A define is raw text substitution, so this emits a JavaScript object literal
* that `src/lib/env.ts` consumes directly: no string to parse, and therefore no
* parse for a bad profile to fall back from. Validating and coercing here means
* an invalid profile fails the build instead of the app.
*
* The schema only checks that `NIXMAC_ENV` is one of the three names, so the
* file could name an environment other than the one the selector asked for.
* `mayBypassUserGates` in `src/lib/env.ts` reads that name to decide whether the
* skip-permissions and nix-installed bypasses are allowed at all, so a profile
* mislabelled `development` would switch that lockout off in a release build.
* Checking the two agree is what keeps the name honest.
*/
export function nixmacBuildDefines(nativeAppDir: string): Record<string, string> {
const profileName = resolveNixmacProfile();
const merged = resolveMergedProfile(nativeAppDir);
const { selector, file } = resolveNixmacProfile();
const profile = EnvProfileSchema.parse(resolveMergedProfile(nativeAppDir, file));
if (profile.NIXMAC_ENV !== selector) {
throw new Error(
`env.${file}.json declares NIXMAC_ENV ${JSON.stringify(profile.NIXMAC_ENV)}, but this build selected ${JSON.stringify(selector)}; the two must name the same environment`,
);
}
return {
__NIXMAC_PROFILE__: JSON.stringify(profileName),
__NIXMAC_PROFILE_JSON__: JSON.stringify(merged),
__NIXMAC_PROFILE_DATA__: JSON.stringify(profile),
};
}
44 changes: 35 additions & 9 deletions apps/native/src-tauri/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ use std::path::Path;
use std::process::Command;

/// Embed `apps/native/env.{development,release,e2e}.json` selected by `NIXMAC_ENV`.
///

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is another comment that can probably be cut down to just what's needed later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I've adjusted the comments to focus on the future maintenance

/// Accepted values must stay in sync with `apps/native/nixmac-profile.ts`.
/// Unset means development; anything else stops the build rather than falling
/// through to the development profile.
fn embed_build_profile() {
let native_app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let profile = std::env::var("NIXMAC_ENV").unwrap_or_else(|_| "development".to_string());
let file = match profile.as_str() {
"prod" | "production" => "env.release.json",
let selector = std::env::var("NIXMAC_ENV").unwrap_or_else(|_| "development".to_string());
let file = match selector.as_str() {
"development" => "env.development.json",
"production" => "env.release.json",
"e2e" => "env.e2e.json",
_ => "env.development.json",
other => {
panic!("NIXMAC_ENV must be unset or one of development, production, e2e; got {other:?}")
}
};
let path = native_app_dir.join(file);

Expand All @@ -25,11 +32,30 @@ fn embed_build_profile() {
);
}

let json = std::fs::read_to_string(&path).unwrap_or_else(|_| "{}".to_string());
let minified = serde_json::from_str::<serde_json::Value>(&json)
.ok()
.and_then(|value| serde_json::to_string(&value).ok())
.unwrap_or_else(|| "{}".to_string());
// An unreadable or malformed profile stops the build. Degrading to "{}"
// compiles an app whose every setting silently falls back to its default.
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
let value: serde_json::Value = serde_json::from_str(&json)
.unwrap_or_else(|error| panic!("cannot parse {}: {error}", path.display()));
// The selector picks the file; the file names the same environment in its own

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

After seeing related comments in several places I wonder if maybe they should just all be consolidated to a single location in README or another md file as design documentation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm more afraid of those docs becoming stale

// `NIXMAC_ENV` key. That key is what `crate::env::nixmac_env()` reports for
// telemetry and the startup log, unless `NIXMAC_ENV` is set in the running
// process, which takes precedence over the embed. A profile mislabelled
// `development` would therefore have a release build report itself as a
// development one. The frontend is baked from its own copy of the profile
// and makes the same check there (`apps/native/nixmac-profile.ts`).
let declared = value.get("NIXMAC_ENV");
if declared.and_then(serde_json::Value::as_str) != Some(selector.as_str()) {
panic!(
"{} declares NIXMAC_ENV {}, but this build selected {selector:?}; the two must name the same environment",
path.display(),
declared.map_or_else(|| "no value at all".to_string(), ToString::to_string)
);
}

let minified = serde_json::to_string(&value)
.unwrap_or_else(|error| panic!("cannot re-encode {}: {error}", path.display()));
println!("cargo:rustc-env=NIXMAC_ENV_PROFILE_JSON={minified}");
}

Expand Down
20 changes: 8 additions & 12 deletions apps/native/src-tauri/configurable-derive/src/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ fn build_scope_methods(
}

fn __resolve_string(
profile: Option<&serde_json::Value>,
profile: &serde_json::Value,
key: &str,
env_var: &str,
build_embed: bool,
Expand All @@ -205,30 +205,26 @@ fn build_scope_methods(
return value;
}
}
if let Some(profile) = profile {
if let Some(value) = profile.get(key).and_then(|value| value.as_str()) {
let value = value.trim();
if !value.is_empty() {
return value.to_string();
}
if let Some(value) = profile.get(key).and_then(|value| value.as_str()) {
let value = value.trim();
if !value.is_empty() {
return value.to_string();
}
}
default.to_string()
}

fn __resolve_bool(
profile: Option<&serde_json::Value>,
profile: &serde_json::Value,
key: &str,
env_var: &str,
default: bool,
) -> bool {
if let Some(value) = crate::env::sources::trimmed_env(env_var) {
return crate::env::sources::env_is_truthy(&value);
}
if let Some(profile) = profile {
if let Some(value) = profile.get(key).and_then(|value| value.as_bool()) {
return value;
}
if let Some(value) = profile.get(key).and_then(|value| value.as_bool()) {
return value;
}
default
}
Expand Down
4 changes: 2 additions & 2 deletions apps/native/src-tauri/configurable-derive/src/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,15 @@ fn generate_field(field: &syn::Field, scope: StoreScope) -> syn::Result<FieldCod
Some(match type_name.as_str() {
"bool" => quote! {
#ident: Self::__resolve_bool(
__build_profile.as_ref(),
&__build_profile,
#profile_key_lit,
#env_var_lit,
#default,
),
},
"String" => quote! {
#ident: Self::__resolve_string(
__build_profile.as_ref(),
&__build_profile,
#profile_key_lit,
#env_var_lit,
#build_embed,
Expand Down
2 changes: 1 addition & 1 deletion apps/native/src-tauri/resources/schemas/env.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"type": "boolean"
},
"NIXMAC_ENV": {
"default": "prod",
"default": "production",
"title": "Deployment environment",
"type": "string"
},
Expand Down
6 changes: 4 additions & 2 deletions apps/native/src-tauri/src/env/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ pub struct NixmacEnvSettings {
)]
pub sentry_dsn: String,

/// No `build_embed`: this is the selector for which profile gets embedded, so
/// it resolves from the process environment, then from that profile's own
/// `NIXMAC_ENV` key — never from a separate embedded copy of the selector.
#[config(
default = "prod",
build_embed = true,
default = "production",
env_var = "NIXMAC_ENV",
label = "Deployment environment"
)]
Expand Down
9 changes: 4 additions & 5 deletions apps/native/src-tauri/src/env/sources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ pub fn build_embed(name: &str) -> Option<String> {
"SENTRY_DSN" => option_env!("SENTRY_DSN").map(str::to_string),
"VITE_SERVER_URL" => option_env!("VITE_SERVER_URL").map(str::to_string),
"SUBMITTED_FEEDBACK_DSN" => option_env!("SUBMITTED_FEEDBACK_DSN").map(str::to_string),
"NIXMAC_ENV" => option_env!("NIXMAC_ENV").map(str::to_string),
"NIXMAC_VERSION" => option_env!("NIXMAC_VERSION").map(str::to_string),
_ => None,
};
Expand All @@ -25,8 +24,9 @@ pub fn build_embed(name: &str) -> Option<String> {
}

/// JSON profile from `apps/native/env.{development,release,e2e}.json`, embedded at compile time.
pub fn build_profile() -> Option<serde_json::Value> {
option_env!("NIXMAC_ENV_PROFILE_JSON").and_then(|raw| serde_json::from_str(raw).ok())
pub fn build_profile() -> serde_json::Value {
serde_json::from_str(env!("NIXMAC_ENV_PROFILE_JSON"))
.expect("NIXMAC_ENV_PROFILE_JSON is minified by build.rs and must parse")
}

#[cfg(test)]
Expand All @@ -35,8 +35,7 @@ mod tests {

#[test]
fn embedded_build_profile_parses_and_uses_env_var_keys() {
let profile =
build_profile().expect("NIXMAC_ENV_PROFILE_JSON should parse at compile time");
let profile = build_profile();
assert_eq!(
profile
.get("VITE_SERVER_URL")
Expand Down
10 changes: 4 additions & 6 deletions apps/native/src-tauri/src/env_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@

#[allow(dead_code)]
/// Environment variables embedded at build time via `build.rs` (`cargo:rustc-env`).
pub const BUILD_EMBED_KEYS: &[&str] = &[
"SENTRY_DSN",
"VITE_SERVER_URL",
"SUBMITTED_FEEDBACK_DSN",
"NIXMAC_ENV",
];
/// `NIXMAC_ENV` is deliberately absent: it selects which profile to embed, so
/// embedding it as a value too let the selector overwrite the selected file's
/// own `NIXMAC_ENV`. The value comes from the embedded profile JSON instead.
pub const BUILD_EMBED_KEYS: &[&str] = &["SENTRY_DSN", "VITE_SERVER_URL", "SUBMITTED_FEEDBACK_DSN"];

/// Application environment variable names.
#[allow(dead_code)]
Expand Down
Loading
Loading