Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
Empty file added apps/web/.env.production
Empty file.
8 changes: 8 additions & 0 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ We use the [bun](https://bun.sh/) as a javascript package manager and runtime fo

The production application is compiled down to a static javascript bundle using a traditional SPA-like architecture.

## Production analytics configuration

For local production builds, set `VITE_POSTHOG_API_KEY` in the ignored
`apps/web/.env.production.local`. Vite loads this file automatically; use the
existing build commands. The shared value is stored in Doppler `web-release/prd`.
CI continues to inject the GitHub `POSTHOG_API_KEY` secret. Keep the value out of
tracked env files.

### Toolchain Management

If you have the [nix](https://nixos.org/learn/) package manager installed on your system then you can drop into a nix shell where everything you need is installed for you.
Expand Down
9 changes: 9 additions & 0 deletions apps/web/justfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
set positional-arguments

# Android: SDK/JDK setup and optional Firebase config are handled by the launcher.
android-dev *ARGS:
bash scripts/android.sh dev "$@"

android-build *ARGS:
bash scripts/android.sh build "$@"

# iOS: dev-facing, for testing in Simulator. Honors PORT like the web dev
# server (`PORT=3001 just ios-dev`): the vite server picks PORT up through
# beforeDevCommand's environment, and the --config merge points the webview's
Expand Down
16 changes: 16 additions & 0 deletions apps/web/scripts/android-assetlinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const fingerprints = process.argv.slice(2).map((value) => value.toUpperCase());
if (
fingerprints.length === 0 ||
fingerprints.some((value) => !/^(?:[A-F0-9]{2}:){31}[A-F0-9]{2}$/.test(value))
) {
throw new Error('Pass one or more SHA-256 signing fingerprints (32 colon-separated bytes)');
}

console.log(JSON.stringify([{
relation: ['delegate_permission/common.handle_all_urls'],
target: {
namespace: 'android_app',
package_name: 'com.macro.app.prod',
sha256_cert_fingerprints: [...new Set(fingerprints)],
},
}], null, 2));
102 changes: 102 additions & 0 deletions apps/web/scripts/android-firebase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { validateAndroidFirebase } from './android-firebase';

const config = (project: string, packageName = 'com.macro.app.prod') => ({
project_info: { project_id: project, project_number: '123456789' },
client: [
{
client_info: {
mobilesdk_app_id: '1:123456789:android:test',
android_client_info: { package_name: packageName },
},
api_key: [{ current_key: 'test-api-key' }],
},
],
});

describe('Android Firebase environment selection', () => {
it('accepts the matching existing dev and production projects', () => {
expect(() =>
validateAndroidFirebase(config('macro-app-dev-12ae0'), 'dev')
).not.toThrow();
expect(() =>
validateAndroidFirebase(config('macro-app-955f1'), 'build')
).not.toThrow();
});

it('rejects crossed environments even with the correct package', () => {
expect(() =>
validateAndroidFirebase(config('macro-app-dev-12ae0'), 'build')
).toThrow('must use macro-app-955f1');
expect(() =>
validateAndroidFirebase(config('macro-app-955f1'), 'dev')
).toThrow('must use macro-app-dev-12ae0');
});

it.each([undefined, ''])(
'rejects missing or empty Firebase fields (%s)',
(value) => {
const valid = config('macro-app-dev-12ae0');
const client = valid.client[0];
for (const incomplete of [
{
...valid,
project_info: { ...valid.project_info, project_number: value },
},
{
...valid,
client: [
{
...client,
client_info: { ...client.client_info, mobilesdk_app_id: value },
},
],
},
{ ...valid, client: [{ ...client, api_key: [{ current_key: value }] }] },
{ ...valid, client: [{ ...client, api_key: [] }] },
{ ...valid, client: [{ ...client, api_key: undefined }] },
]) {
expect(() => validateAndroidFirebase(incomplete, 'dev')).toThrow(
'Invalid Android Firebase configuration'
);
}
}
);

it('copies Firebase config before the Android project directory exists', async () => {
const root = await mkdtemp(join(tmpdir(), 'android-firebase-'));
try {
const source = join(root, 'google-services.json');
const destination = join(root, 'gen/android/app/google-services.json');
const contents = JSON.stringify(config('macro-app-dev-12ae0'));
await writeFile(source, contents);
const result = spawnSync('bun', [
fileURLToPath(new URL('./android-firebase.ts', import.meta.url)),
'dev',
source,
destination,
]);
expect(result.status, result.stderr.toString()).toBe(0);
expect(await readFile(destination, 'utf8')).toBe(contents);
} finally {
await rm(root, { recursive: true, force: true });
}
});

it('rejects old scaffold packages and malformed configurations', () => {
expect(() =>
validateAndroidFirebase(
config('macro-app-dev-12ae0', 'com.tauri.dev'),
'dev'
)
).toThrow('must contain com.macro.app.prod');
expect(() => validateAndroidFirebase(null, 'dev')).toThrow(
'Invalid Android Firebase configuration'
);
});
});
55 changes: 55 additions & 0 deletions apps/web/scripts/android-firebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { copyFile, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { z } from 'zod';

// These projects match the dev/prod FCM credentials used by notification-service.
const projects = { dev: 'macro-app-dev-12ae0', build: 'macro-app-955f1' };
const configSchema = z.object({
project_info: z.object({
project_id: z.string(),
project_number: z.string().min(1),
}),
client: z.array(
z.object({
client_info: z.object({
mobilesdk_app_id: z.string().min(1),
android_client_info: z.object({ package_name: z.string() }),
}),
api_key: z.array(z.object({ current_key: z.string().min(1) })).min(1),
})
Comment thread
peterchinman marked this conversation as resolved.
Resolved
),
});

export function validateAndroidFirebase(
config: unknown,
action: 'dev' | 'build'
) {
const parsed = configSchema.safeParse(config);
if (!parsed.success)
throw new Error('Invalid Android Firebase configuration');
if (
!parsed.data.client.some(
(client) =>
client.client_info.android_client_info.package_name ===
'com.macro.app.prod'
)
)
throw new Error('Firebase config must contain com.macro.app.prod');
if (parsed.data.project_info.project_id !== projects[action])
throw new Error(
`Firebase config for ${action} must use ${projects[action]}`
);
}

if (import.meta.main) {
const [action, source, destination] = process.argv.slice(2);
if ((action !== 'dev' && action !== 'build') || !source || !destination)
throw new Error(
'Usage: android-firebase.ts {dev|build} source destination'
);
validateAndroidFirebase(await Bun.file(source).json(), action);
if (resolve(source) !== resolve(destination)) {
await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
Comment thread
peterchinman marked this conversation as resolved.
Resolved
}
}
134 changes: 134 additions & 0 deletions apps/web/scripts/android-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { spawnSync } from 'node:child_process';
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

const web = dirname(dirname(fileURLToPath(import.meta.url)));

describe('Android launcher arguments', () => {
let root: string;
let app: string;
let caller: string;
let env: NodeJS.ProcessEnv;

beforeEach(() => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'macro android ')));
app = join(root, 'app');
caller = join(root, 'caller');
for (const path of [
'app/scripts',
'app/tauri/src-tauri',
'caller/config files',
'bin',
'sdk/platforms/android-36',
'ndk/toolchains/llvm',
]) {
mkdirSync(join(root, path), { recursive: true });
}
copyFileSync(join(web, 'justfile'), join(app, 'justfile'));
copyFileSync(
join(web, 'scripts/android.sh'),
join(app, 'scripts/android.sh')
);
writeFileSync(join(caller, 'config files/google-services.json'), '{}');
for (const command of ['bun', 'cargo']) {
writeFileSync(
join(root, 'bin', command),
`#!/bin/sh\nprintf '%s\\0' "$@" > "$CAPTURE_${command.toUpperCase()}"\n`,
{ mode: 0o700 }
);
}
env = {
...process.env,
// CI's Nix startup file resets PATH, hiding the fixture's bun/cargo stubs.
BASH_ENV: '',
PATH: `${join(root, 'bin')}:${process.env.PATH}`,
ANDROID_HOME: join(root, 'sdk'),
NDK_HOME: join(root, 'ndk'),
CAPTURE_BUN: join(root, 'bun.args'),
CAPTURE_CARGO: join(root, 'cargo.args'),
};
});

afterEach(() => rmSync(root, { recursive: true, force: true }));

const captured = (path: string) =>
readFileSync(path, 'utf8').split('\0').slice(0, -1);

for (const action of ['dev', 'build']) {
it(`forwards spaced paths and literal shell characters through android-${action}`, () => {
const config = join(caller, 'config files/google-services.json');
const extra = '{"label":"two words; $HOME"}';
const result = spawnSync(
'just',
[
'--justfile',
join(app, 'justfile'),
`android-${action}`,
'--firebase-config',
config,
'--config',
extra,
],
{ cwd: caller, env }
);
expect(result.stderr.toString()).not.toContain(
'Firebase configuration file not found'
);
expect(result.status, result.stderr.toString()).toBe(0);
expect(captured(join(root, 'bun.args'))).toEqual([
'scripts/android-firebase.ts',
action,
config,
'tauri/src-tauri/gen/android/app/google-services.json',
]);
const args = captured(join(root, 'cargo.args'));
expect(args.slice(0, 3)).toEqual(['tauri', 'android', action]);
expect(args).toContain(extra);
});
}

it('resolves a relative Firebase path against the direct caller directory', () => {
const result = spawnSync(
'bash',
[
join(app, 'scripts/android.sh'),
'build',
'--firebase-config',
'config files/google-services.json',
],
{ cwd: caller, env }
);
expect(result.status, result.stderr.toString()).toBe(0);
expect(captured(join(root, 'bun.args'))[2]).toBe(
join(caller, 'config files/google-services.json')
);
});

it('rejects missing Firebase input before invoking build tools', () => {
for (const args of [
['--firebase-config'],
['--firebase-config', 'missing.json'],
]) {
const result = spawnSync(
'bash',
[join(app, 'scripts/android.sh'), 'build', ...args],
{ cwd: caller, env }
);
expect(result.status).toBe(1);
expect(result.stderr.toString()).toContain(
'Firebase configuration file not found'
);
}
});
});
57 changes: 57 additions & 0 deletions apps/web/scripts/android.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail

action="${1:-dev}"
shift || true
case "$action" in
dev|build) ;;
*) echo "Usage: android.sh {dev|build} [--firebase-config /path/google-services.json] [Tauri arguments]" >&2; exit 1 ;;
esac

firebase_config=""
if [[ "${1:-}" == "--firebase-config" ]]; then
firebase_config="${2:-}"
[[ -n "$firebase_config" ]] || { echo "Firebase configuration file not found" >&2; exit 1; }
[[ "$firebase_config" = /* ]] || firebase_config="$PWD/$firebase_config"
shift 2
fi

script_dir="$(\cd "$(dirname "$0")" && pwd)"
\cd "$script_dir/.."
if [[ -n "$firebase_config" ]]; then
[[ -f "$firebase_config" ]] || { echo "Firebase configuration file not found" >&2; exit 1; }
fi

firebase_environment=prod
[[ "$action" != "dev" ]] || firebase_environment=dev
firebase_destination=tauri/src-tauri/gen/android/app/google-services.json
firebase_default="tauri/src-tauri/firebase/$firebase_environment/google-services.json"
if [[ -z "$firebase_config" && -f "$firebase_default" ]]; then
firebase_config="$firebase_default"
elif [[ -z "$firebase_config" && -f "$firebase_destination" ]]; then
firebase_config="$firebase_destination"
fi
if [[ -n "$firebase_config" ]]; then
bun scripts/android-firebase.ts "$action" "$firebase_config" "$firebase_destination"
fi

if [[ "$(uname)" == "Darwin" ]]; then
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
if [[ -z "${JAVA_HOME:-}" && -d /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ]]; then
export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
fi
else
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
fi
export NDK_HOME="${NDK_HOME:-$ANDROID_HOME/ndk/30.0.16248370}"
[[ -d "$NDK_HOME/toolchains/llvm" ]] || { echo "Install NDK 30.0.16248370 or set NDK_HOME" >&2; exit 1; }
[[ -d "$ANDROID_HOME/platforms/android-36" ]] || { echo "Install Android SDK Platform 36" >&2; exit 1; }
export PATH="${JAVA_HOME:+$JAVA_HOME/bin:}$ANDROID_HOME/platform-tools:$PATH"

\cd tauri/src-tauri
if [[ "$action" == "dev" ]]; then
export PORT="${PORT:-3000}"
exec cargo tauri android dev --config "{\"build\":{\"devUrl\":\"http://localhost:${PORT}\"}}" "$@" -- --no-default-features
else
exec cargo tauri android build --target aarch64 "$@"
fi
Loading
Loading