diff --git a/.env.example b/.env.example index c66d9c26c7a..9399c7e84b8 100644 --- a/.env.example +++ b/.env.example @@ -274,6 +274,10 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # app launch while keeping the current identity and relay data. # VITE_BUZZ_FORCE_FRESH_ONBOARDING=true +# Protected internal builds only: selects the module graph that contains the +# default-off Bestie experiment. Official OSS builds must leave this unset. +# VITE_BUZZ_BESTIE=1 + # ── Subscription & filtering ───────────────────────────────────────────────── # Subscribe mode: "mentions" (default), "all", or "config" (rule-based). # BUZZ_ACP_SUBSCRIBE=mentions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44966c28de6..cd6a87dcf30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -304,7 +304,7 @@ jobs: name: Desktop runs-on: ubuntu-latest timeout-minutes: 5 - needs: [changes, desktop-core, desktop-smoke-e2e] + needs: [changes, desktop-core, desktop-smoke-e2e, desktop-windows-build] if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') permissions: contents: read @@ -319,6 +319,10 @@ jobs: echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" exit 1 fi + if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then + echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" + exit 1 + fi echo "Desktop jobs passed" desktop-e2e-relay: @@ -1121,6 +1125,36 @@ jobs: -p git-credential-nostr \ -p git-sign-nostr + desktop-windows-build: + name: Desktop Windows Build + runs-on: windows-latest + timeout-minutes: 20 + needs: [changes] + if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + - name: Install desktop dependencies + shell: bash + run: pnpm install --frozen-lockfile + - name: Build both protected-feature selections + shell: pwsh + run: | + Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $env:VITE_BUZZ_BESTIE = "1" + pnpm -C desktop build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) runs-on: windows-latest diff --git a/desktop/package.json b/desktop/package.json index 1e93fd76a85..14db248a134 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc && node ./scripts/build-protected-feature-artifacts.mjs", "build:e2e": "tsc && vite build --mode e2e", "typecheck": "tsc --noEmit", "check:file-sizes": "node ./scripts/check-file-sizes.mjs", @@ -16,13 +16,13 @@ "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", "preview": "vite preview", - "tauri": "tauri", + "tauri": "node ./scripts/tauri-command.mjs", "test:e2e": "pnpm build:e2e && playwright test", "test:e2e:smoke": "pnpm build:e2e && playwright test --project=smoke", "test:e2e:integration": "pnpm build:e2e && playwright test --project=integration", "test:e2e:release-smoke": "pnpm build:e2e && playwright test --config=playwright.release-smoke.config.ts", "test:e2e:report": "playwright show-report", - "tauri:build": "tauri build" + "tauri:build": "node ./scripts/tauri-command.mjs build" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/desktop/scripts/build-protected-feature-artifacts.mjs b/desktop/scripts/build-protected-feature-artifacts.mjs new file mode 100644 index 00000000000..3de4830ceeb --- /dev/null +++ b/desktop/scripts/build-protected-feature-artifacts.mjs @@ -0,0 +1,151 @@ +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { loadEnv } from "vite"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const vitePackageJsonPath = fileURLToPath( + import.meta.resolve("vite/package.json"), +); +const vitePackage = JSON.parse(readFileSync(vitePackageJsonPath, "utf8")); +const viteEntrypoint = path.resolve( + path.dirname(vitePackageJsonPath), + vitePackage.bin.vite, +); + +function buildVariant({ internal, output }) { + const env = { + ...process.env, + // Pin both children explicitly. Deleting the OSS value lets Vite reload + // `=1` from .env.local or a mode-specific env file. + VITE_BUZZ_BESTIE: internal ? "1" : "0", + }; + + const result = spawnSync( + process.execPath, + [viteEntrypoint, "build", "--outDir", output, "--emptyOutDir"], + { + cwd: desktopRoot, + env, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${internal ? "internal" : "OSS"} desktop build failed with status ${result.status}`, + ); + } +} + +function emittedText(root) { + const chunks = []; + const visit = (candidate) => { + const stat = statSync(candidate); + if (stat.isDirectory()) { + for (const child of readdirSync(candidate)) { + visit(path.join(candidate, child)); + } + return; + } + if (/\.(?:css|html|js|json)$/u.test(candidate)) { + chunks.push(readFileSync(candidate, "utf8")); + } + }; + visit(root); + return chunks.join("\n"); +} + +export function assertArtifactContract({ ossOutput, internalOutput }) { + const ossText = emittedText(ossOutput); + const internalText = emittedText(internalOutput); + const protectedContent = /\bbestie\b|chief of staff|builtin:bestie/iu; + const internalManifestMarker = + "Try a personal agent that is always close at hand"; + + if (protectedContent.test(ossText)) { + throw new Error( + "Official OSS desktop artifact contains protected Bestie/Chief content", + ); + } + if (!internalText.includes(internalManifestMarker)) { + throw new Error( + "Protected internal desktop artifact is missing the Bestie manifest", + ); + } +} + +/** Resolve the requested output with the same precedence used by Vite config. */ +export function selectInternalVariant({ processEnv, modeEnv }) { + return (processEnv.VITE_BUZZ_BESTIE ?? modeEnv.VITE_BUZZ_BESTIE) === "1"; +} + +/** Build and inspect both graphs, leaving the requested variant in dist. */ +export function buildArtifactMatrix({ + selectedInternalVariant, + selectedOutput, + alternateOutput, + build = buildVariant, +}) { + // Build the unselected variant outside dist first, then leave the requested + // variant in dist for Vite/Tauri's ordinary packaging contract. + build({ + internal: !selectedInternalVariant, + output: alternateOutput, + }); + build({ + internal: selectedInternalVariant, + output: selectedOutput, + }); + + assertArtifactContract({ + ossOutput: selectedInternalVariant ? alternateOutput : selectedOutput, + internalOutput: selectedInternalVariant ? selectedOutput : alternateOutput, + }); +} + +function main() { + const selectedInternalVariant = selectInternalVariant({ + processEnv: process.env, + modeEnv: loadEnv("production", desktopRoot, ""), + }); + const scratchRoot = mkdtempSync( + path.join(tmpdir(), "buzz-protected-feature-artifacts-"), + ); + const selectedOutput = process.env.BUZZ_PROTECTED_BUILD_OUTPUT + ? path.resolve(process.env.BUZZ_PROTECTED_BUILD_OUTPUT) + : path.join(desktopRoot, "dist"); + const alternateOutput = path.join(scratchRoot, "alternate"); + + try { + buildArtifactMatrix({ + selectedInternalVariant, + selectedOutput, + alternateOutput, + }); + } finally { + rmSync(scratchRoot, { recursive: true, force: true }); + } + + console.log( + `Protected feature artifact matrix passed; dist contains the ${selectedInternalVariant ? "internal" : "OSS"} variant.`, + ); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); +} diff --git a/desktop/scripts/tauri-command.mjs b/desktop/scripts/tauri-command.mjs new file mode 100644 index 00000000000..dc1d8691e96 --- /dev/null +++ b/desktop/scripts/tauri-command.mjs @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const tauriPackageJsonPath = fileURLToPath( + import.meta.resolve("@tauri-apps/cli/package.json"), +); +const tauriPackage = JSON.parse(readFileSync(tauriPackageJsonPath, "utf8")); +const defaultTauriEntrypoint = path.resolve( + path.dirname(tauriPackageJsonPath), + tauriPackage.bin.tauri, +); + +function runTauri(args, options = {}) { + const entrypoint = + process.env.BUZZ_TAURI_CLI_ENTRYPOINT ?? defaultTauriEntrypoint; + const result = spawnSync(process.execPath, [entrypoint, ...args], { + cwd: desktopRoot, + env: { ...process.env, ...options.env }, + stdio: "inherit", + }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +export function runTauriCommand(args) { + if (args[0] !== "build") return runTauri(args); + + // Tauri runs beforeBuildCommand and then consumes frontendDist. Give the + // entire invocation a private directory so concurrent OSS/internal packages + // cannot replace one another's assets between those two operations. + const invocationRoot = mkdtempSync( + path.join(tmpdir(), "buzz-tauri-package-assets-"), + ); + const frontendDist = path.join(invocationRoot, "dist"); + const outputOverride = JSON.stringify({ build: { frontendDist } }); + + try { + const delimiterIndex = args.indexOf("--"); + const configIndex = delimiterIndex === -1 ? args.length : delimiterIndex; + const tauriArgs = [...args]; + tauriArgs.splice(configIndex, 0, "--config", outputOverride); + return runTauri(tauriArgs, { + env: { BUZZ_PROTECTED_BUILD_OUTPUT: frontendDist }, + }); + } finally { + rmSync(invocationRoot, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + process.exitCode = runTauriCommand(process.argv.slice(2)); +} diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 2f9b2c36a1a..d242faf6189 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -7,11 +7,11 @@ import { canManageCommunityMembers, shouldWarnMissingMembershipSnapshot, } from "@/shared/api/relayMembers"; -import { getFeature } from "@/shared/features/manifest"; import { + getFeature, resolveEnabled, useFeatureSnapshot, -} from "@/shared/features/useFeatureEnabled"; +} from "@/shared/features"; import { topChromeBackdrop } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { @@ -137,7 +137,10 @@ export function SettingsView({ // stable and renders unconditionally (fail-open). if (s.featureGate) { const feature = getFeature(s.featureGate); - if (feature && !resolveEnabled(s.featureGate, featureState)) { + if ( + feature && + !resolveEnabled(s.featureGate, featureState, feature.defaultEnabled) + ) { return false; } } diff --git a/desktop/src/protectedFeatures/buildProtectedFeatureArtifacts.test.mjs b/desktop/src/protectedFeatures/buildProtectedFeatureArtifacts.test.mjs new file mode 100644 index 00000000000..ae330a6889e --- /dev/null +++ b/desktop/src/protectedFeatures/buildProtectedFeatureArtifacts.test.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { loadEnv } from "vite"; + +import { + buildArtifactMatrix, + selectInternalVariant, +} from "../../scripts/build-protected-feature-artifacts.mjs"; + +const INTERNAL_MARKER = "Try a personal agent that is always close at hand"; + +function fakeBuilder(calls) { + return ({ internal, output }) => { + calls.push(internal); + rmSync(output, { recursive: true, force: true }); + mkdirSync(output, { recursive: true }); + writeFileSync( + path.join(output, "index.js"), + internal ? INTERNAL_MARKER : "public desktop artifact", + ); + }; +} + +describe("protected feature production artifact selection", () => { + it("honors env-file selection while process overrides retain the requested dist", () => { + const root = mkdtempSync(path.join(tmpdir(), "buzz-protected-build-test-")); + const envRoot = path.join(root, "env"); + mkdirSync(envRoot); + writeFileSync(path.join(envRoot, ".env.local"), "VITE_BUZZ_BESTIE=1\n"); + + try { + const modeEnv = loadEnv("production", envRoot, ""); + const internalOutput = path.join(root, "internal-dist"); + const internalAlternate = path.join(root, "internal-alternate"); + const internalCalls = []; + const fileSelectedInternal = selectInternalVariant({ + processEnv: {}, + modeEnv, + }); + + assert.equal(fileSelectedInternal, true); + buildArtifactMatrix({ + selectedInternalVariant: fileSelectedInternal, + selectedOutput: internalOutput, + alternateOutput: internalAlternate, + build: fakeBuilder(internalCalls), + }); + assert.deepEqual(internalCalls, [false, true]); + assert.match( + readFileSync(path.join(internalOutput, "index.js"), "utf8"), + /personal agent/u, + ); + assert.doesNotMatch( + readFileSync(path.join(internalAlternate, "index.js"), "utf8"), + /personal agent/u, + ); + + const ossOutput = path.join(root, "oss-dist"); + const ossAlternate = path.join(root, "oss-alternate"); + const ossCalls = []; + const processSelectedOss = selectInternalVariant({ + processEnv: { VITE_BUZZ_BESTIE: "0" }, + modeEnv, + }); + + assert.equal(processSelectedOss, false); + buildArtifactMatrix({ + selectedInternalVariant: processSelectedOss, + selectedOutput: ossOutput, + alternateOutput: ossAlternate, + build: fakeBuilder(ossCalls), + }); + assert.deepEqual(ossCalls, [true, false]); + assert.doesNotMatch( + readFileSync(path.join(ossOutput, "index.js"), "utf8"), + /personal agent/u, + ); + assert.match( + readFileSync(path.join(ossAlternate, "index.js"), "utf8"), + /personal agent/u, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/desktop/src/protectedFeatures/internal.ts b/desktop/src/protectedFeatures/internal.ts new file mode 100644 index 00000000000..7f9f6b551e8 --- /dev/null +++ b/desktop/src/protectedFeatures/internal.ts @@ -0,0 +1,11 @@ +import type { FeatureDefinition } from "@/shared/features/types"; + +/** Definitions available only in the protected internal application build. */ +export const protectedFeatureDefinitions: FeatureDefinition[] = [ + { + id: "bestie", + name: "Bestie", + description: "Try a personal agent that is always close at hand", + platforms: ["desktop"], + }, +]; diff --git a/desktop/src/protectedFeatures/protectedFeatures.test.mjs b/desktop/src/protectedFeatures/protectedFeatures.test.mjs new file mode 100644 index 00000000000..20a6d469faa --- /dev/null +++ b/desktop/src/protectedFeatures/protectedFeatures.test.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { resolveEnabled } from "../shared/features/resolveEnabled.ts"; +import { protectedFeatureDefinitions as internalDefinitions } from "./internal.ts"; +import { protectedFeatureDefinitions as publicDefinitions } from "./public.ts"; + +describe("protected feature build variants", () => { + it("keeps protected definitions out of the OSS module", () => { + assert.deepEqual(publicDefinitions, []); + }); + + it("adds Bestie as a default-off experiment only through the internal module", () => { + assert.deepEqual( + internalDefinitions.map((feature) => feature.id), + ["bestie"], + ); + const bestie = internalDefinitions[0]; + assert.ok(bestie); + assert.equal(resolveEnabled(bestie.id, {}, bestie.defaultEnabled), false); + }); +}); diff --git a/desktop/src/protectedFeatures/public.ts b/desktop/src/protectedFeatures/public.ts new file mode 100644 index 00000000000..90c1e596242 --- /dev/null +++ b/desktop/src/protectedFeatures/public.ts @@ -0,0 +1,7 @@ +import type { FeatureDefinition } from "@/shared/features/types"; + +/** + * Protected feature definitions compiled into the official OSS application. + * Keep this module free of protected product names, metadata, and imports. + */ +export const protectedFeatureDefinitions: FeatureDefinition[] = []; diff --git a/desktop/src/protectedFeatures/tauriCommand.test.mjs b/desktop/src/protectedFeatures/tauriCommand.test.mjs new file mode 100644 index 00000000000..e3e1532af45 --- /dev/null +++ b/desktop/src/protectedFeatures/tauriCommand.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const desktopRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +const wrapper = path.join(desktopRoot, "scripts/tauri-command.mjs"); +const fakeCli = path.join(tmpdir(), `buzz-fake-tauri-${process.pid}.mjs`); + +writeFileSync( + fakeCli, + `import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +const args = process.argv.slice(2); +const configIndex = args.lastIndexOf("--config"); +const override = JSON.parse(args[configIndex + 1]); +const output = override.build.frontendDist; +mkdirSync(output, { recursive: true }); +writeFileSync(path.join(output, "variant.txt"), process.env.VITE_BUZZ_BESTIE); +await new Promise((resolve) => setTimeout(resolve, 100)); +const observed = readFileSync(path.join(output, "variant.txt"), "utf8"); +writeFileSync( + process.env.BUZZ_TEST_RESULT, + JSON.stringify({ args, output, observed }), +); +`, +); + +function packageVariant(variant, result, runnerArguments = []) { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [wrapper, "build", ...runnerArguments], + { + cwd: desktopRoot, + env: { + ...process.env, + BUZZ_TAURI_CLI_ENTRYPOINT: fakeCli, + BUZZ_TEST_RESULT: result, + VITE_BUZZ_BESTIE: variant, + }, + stdio: "inherit", + }, + ); + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`wrapper exited ${code}`)), + ); + }); +} + +test("opposite Tauri package variants own private frontend artifacts", async () => { + const resultRoot = path.join(tmpdir(), `buzz-tauri-results-${process.pid}`); + mkdirSync(resultRoot, { recursive: true }); + const ossResult = path.join(resultRoot, "oss.json"); + const internalResult = path.join(resultRoot, "internal.json"); + + await Promise.all([ + packageVariant("0", ossResult), + packageVariant("1", internalResult), + ]); + + const oss = JSON.parse(readFileSync(ossResult, "utf8")); + const internal = JSON.parse(readFileSync(internalResult, "utf8")); + assert.equal(oss.observed, "0"); + assert.equal(internal.observed, "1"); + assert.notEqual(oss.output, internal.output); +}); + +test("private config precedes Cargo runner arguments", async () => { + const result = path.join( + tmpdir(), + `buzz-tauri-runner-arguments-${process.pid}.json`, + ); + await packageVariant("0", result, [ + "--config", + '{"bundle":{"active":false}}', + "--", + "--locked", + ]); + + const invocation = JSON.parse(readFileSync(result, "utf8")); + const delimiterIndex = invocation.args.indexOf("--"); + const privateConfigIndex = invocation.args.lastIndexOf("--config"); + assert.ok(privateConfigIndex < delimiterIndex); + assert.equal(invocation.args[delimiterIndex + 1], "--locked"); + assert.equal( + JSON.parse(invocation.args[privateConfigIndex + 1]).build.frontendDist, + invocation.output, + ); +}); diff --git a/desktop/src/shared/features/manifest.ts b/desktop/src/shared/features/manifest.ts index 1e6f48ae017..423fbc3b36b 100644 --- a/desktop/src/shared/features/manifest.ts +++ b/desktop/src/shared/features/manifest.ts @@ -1,4 +1,5 @@ import manifestJson from "@features-manifest"; +import { protectedFeatureDefinitions } from "@protected-features"; import { z } from "zod"; import type { FeatureDefinition, FeaturesManifest } from "./types"; @@ -25,7 +26,10 @@ const FeaturesManifestSchema = z.object({ const EMPTY_MANIFEST: FeaturesManifest = { version: 1, features: [] }; function loadManifest(): FeaturesManifest { - const result = FeaturesManifestSchema.safeParse(manifestJson); + const result = FeaturesManifestSchema.safeParse({ + ...manifestJson, + features: [...manifestJson.features, ...protectedFeatureDefinitions], + }); if (!result.success) { console.warn( "[FeatureFlags] preview-features.json failed schema validation; falling back to empty manifest.", diff --git a/desktop/src/shared/features/useFeatureEnabled.ts b/desktop/src/shared/features/useFeatureEnabled.ts index b0c9878d0b7..1be1e5e30e4 100644 --- a/desktop/src/shared/features/useFeatureEnabled.ts +++ b/desktop/src/shared/features/useFeatureEnabled.ts @@ -105,6 +105,8 @@ export function useFeatureEnabled(featureId: string): boolean { return resolveEnabled(featureId, overrides, feature.defaultEnabled); } +export { resolveEnabled } from "./resolveEnabled"; + /** * Hook to toggle a feature override. Returns [enabled, toggle]. */ @@ -157,5 +159,3 @@ export function usePreviewFeatureWarning(featureId: string): void { }; }, [feature, enabled]); } - -export { resolveEnabled } from "./resolveEnabled"; diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs index 06c44ae2130..d473587adf3 100644 --- a/desktop/test-loader-hooks.mjs +++ b/desktop/test-loader-hooks.mjs @@ -89,6 +89,12 @@ export function resolve(specifier, context, nextResolve) { const resolved = path.join(repoRoot, "preview-features.json"); return nextResolve(toFileSpecifier(resolved), context); } + if (specifier === "@protected-features") { + const variant = + process.env.VITE_BUZZ_BESTIE === "1" ? "internal.ts" : "public.ts"; + const resolved = path.join(srcRoot, "protectedFeatures", variant); + return nextResolve(toFileSpecifier(resolved), context); + } if (specifier === "@model-capabilities-manifest") { const resolved = path.join(repoRoot, "scripts", "model-capabilities.json"); return nextResolve(toFileSpecifier(resolved), context); diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index a2a57c66efb..feb7e7590f2 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -8,6 +8,7 @@ "paths": { "@/*": ["./src/*"], "@features-manifest": ["../preview-features.json"], + "@protected-features": ["./src/protectedFeatures/public.ts"], "@model-capabilities-manifest": ["../scripts/model-capabilities.json"] }, diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index 5a5de191204..257c8382bbb 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -1,56 +1,71 @@ import path from "node:path"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; const host = process.env.TAURI_DEV_HOST; // https://vite.dev/config/ -export default defineConfig(async () => ({ - plugins: [ - tanstackRouter({ - target: "react", - routesDirectory: "./src/app/routes", - generatedRouteTree: "./src/app/routeTree.gen.ts", - virtualRouteConfig: "./src/app/routes.ts", - quoteStyle: "double", - semicolons: true, - routeTreeFileHeader: [ - "// biome-ignore-all lint: generated by TanStack Router", - ], - }), - react(), - ], - resolve: { - alias: { - "@": "/src", - "@features-manifest": path.resolve(__dirname, "../preview-features.json"), - "@model-capabilities-manifest": path.resolve( - __dirname, - "../scripts/model-capabilities.json", - ), +export default defineConfig(async ({ mode }) => { + const modeEnv = loadEnv(mode, __dirname, ""); + const protectedFeaturesEnabled = + (process.env.VITE_BUZZ_BESTIE ?? modeEnv.VITE_BUZZ_BESTIE) === "1"; + + return { + plugins: [ + tanstackRouter({ + target: "react", + routesDirectory: "./src/app/routes", + generatedRouteTree: "./src/app/routeTree.gen.ts", + virtualRouteConfig: "./src/app/routes.ts", + quoteStyle: "double", + semicolons: true, + routeTreeFileHeader: [ + "// biome-ignore-all lint: generated by TanStack Router", + ], + }), + react(), + ], + resolve: { + alias: { + "@": "/src", + "@features-manifest": path.resolve( + __dirname, + "../preview-features.json", + ), + "@protected-features": path.resolve( + __dirname, + protectedFeaturesEnabled + ? "./src/protectedFeatures/internal.ts" + : "./src/protectedFeatures/public.ts", + ), + "@model-capabilities-manifest": path.resolve( + __dirname, + "../scripts/model-capabilities.json", + ), + }, }, - }, - // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` - // - // 1. prevent Vite from obscuring rust errors - clearScreen: false, - // 2. tauri expects a fixed port, fail if that port is not available - server: { - port: parseInt(process.env.VITE_PORT || "1420", 10), - strictPort: true, - host: host || false, - hmr: host - ? { - protocol: "ws", - host, - port: parseInt(process.env.VITE_HMR_PORT || "1421", 10), - } - : undefined, - watch: { - // 3. tell Vite to ignore watching `src-tauri` - ignored: ["**/src-tauri/**"], + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` + // + // 1. prevent Vite from obscuring rust errors + clearScreen: false, + // 2. tauri expects a fixed port, fail if that port is not available + server: { + port: parseInt(process.env.VITE_PORT || "1420", 10), + strictPort: true, + host: host || false, + hmr: host + ? { + protocol: "ws", + host, + port: parseInt(process.env.VITE_HMR_PORT || "1421", 10), + } + : undefined, + watch: { + // 3. tell Vite to ignore watching `src-tauri` + ignored: ["**/src-tauri/**"], + }, }, - }, -})); + }; +});