diff --git a/.changeset/webshell-port.md b/.changeset/webshell-port.md new file mode 100644 index 0000000..169906b --- /dev/null +++ b/.changeset/webshell-port.md @@ -0,0 +1,5 @@ +--- +'solana-mobile': minor +--- + +Add `webshell init` and `webshell build`: generate and build an Android WebView wrapper around an existing web app or PWA (ports @solana-mobile/webshell-cli into this CLI). diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..69b47b5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.bat text eol=crlf diff --git a/.github/workflows/webshell.yml b/.github/workflows/webshell.yml new file mode 100644 index 0000000..ff530ad --- /dev/null +++ b/.github/workflows/webshell.yml @@ -0,0 +1,72 @@ +name: Webshell + +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} + +permissions: + contents: read + +on: + pull_request: + paths: + - .github/workflows/webshell.yml + - src/webshell/** + - templates/webshell-android/** + + # Gradle resolves the template's dependencies from live repositories, which can break with no + # pull request attached, so exercise the full build on a schedule too. + schedule: + - cron: "0 6 * * *" + + workflow_dispatch: + +jobs: + build-apk: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + # CI-only throwaway values: the keystore is generated fresh and discarded with the runner. + SOLANA_MOBILE_KEYSTORE_PASSWORD: ci-password + SOLANA_MOBILE_KEY_PASSWORD: ci-password + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Environment + uses: ./.github/actions/setup + + - name: Setup Java + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: temurin + java-version: "17" + + - name: Install Android packages + run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" 'platforms;android-37.0' 'build-tools;37.0.0' + + - name: Build the CLI + run: bun run build + + # Everything generated lands in $RUNNER_TEMP so the setup action's dirty-repo check stays + # meaningful. Fully flagged, so the init must complete without a single prompt. + - name: Generate a project + run: > + node dist/cli.mjs webshell init "$RUNNER_TEMP/smoke" + --app-name Smoke + --application-id com.example.smoke + --keystore-alias smoke + --keystore-path "$RUNNER_TEMP/smoke.keystore" + --skip-version-check + --url https://example.com + --version-code 1 + --version-name 1.0 + + - name: Build the APK + run: node dist/cli.mjs webshell build "$RUNNER_TEMP/smoke" --stacktrace --skip-version-check + + # The keystore and alias are configured, so the build signs and reports this exact path. + - name: Assert the release APK exists + run: test -f "$RUNNER_TEMP/smoke/app/build/outputs/apk/release/app-release.apk" diff --git a/README.md b/README.md index 22129df..379f87d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ CLI for Solana Mobile development. - **Emulator helpers** — create, delete, list, start, status, stop, and tune local Android emulators - **Local validator** — run surfpool or solana-test-validator in Docker and forward it to every connected device - **Template repository checks** — verify that a template repository's generated artifacts are up to date +- **Webshell projects** — wrap an existing web app or PWA in a native Android WebView project and build it to an APK ## Usage @@ -283,6 +284,37 @@ npx solana-mobile create --list-template-ids npx solana-mobile create --list-templates ``` +### Wrap a web app in an Android WebView shell + +Wraps an existing web app or PWA in a native Android WebView project. The generated app opens `solana-wallet:` links +in the installed wallet app, so Mobile Wallet Adapter flows keep working inside the shell. `--manifest` accepts a web +`manifest.json` or a Bubblewrap `twa-manifest.json` (local path or URL) and seeds the app name, application id, icons, +and colors from it. + +```bash +# Generate a project, answering prompts for the missing values +npx solana-mobile webshell init my-app --url https://example.com + +# Seed the project from a manifest instead +npx solana-mobile webshell init my-app --manifest https://example.com/manifest.json + +# Build the signed release APK +npx solana-mobile webshell build my-app + +# Build without password prompts +SOLANA_MOBILE_KEYSTORE_PASSWORD=secret SOLANA_MOBILE_KEY_PASSWORD=secret npx solana-mobile webshell build my-app + +# Show the full Gradle stack trace on failure +npx solana-mobile webshell build my-app --stacktrace +``` + +Every value resolves flag > manifest > prompt, so `init` also takes `--app-name`, `--application-id`, +`--version-code`, `--version-name`, `--keystore-path`, and `--keystore-alias` (plus `--force` to overwrite a non-empty +directory) — anything still missing is prompted for. The signing keystore is created when it does not exist yet, and +the passwords are read from `SOLANA_MOBILE_KEYSTORE_PASSWORD` and `SOLANA_MOBILE_KEY_PASSWORD` or prompted for; they +are never stored. Building runs the project's own Gradle wrapper and requires JDK 17+ and the Android SDK — the CLI +does not install them, so a missing toolchain surfaces as Gradle's own error. + ### Check your environment ```bash diff --git a/biome.json b/biome.json index 56f4184..e0481b3 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,8 @@ } }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!templates"] }, "formatter": { "attributePosition": "auto", diff --git a/package.json b/package.json index 2776304..3abfc0e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ } }, "files": [ - "dist" + "dist", + "templates" ], "homepage": "https://github.com/solana-mobile/solana-mobile-cli#readme", "license": "Apache-2.0", diff --git a/src/app.ts b/src/app.ts index caf36f2..b154a56 100644 --- a/src/app.ts +++ b/src/app.ts @@ -69,6 +69,12 @@ import { type TemplatesGenerateCommandOptions, type TemplatesSyncCommandOptions, } from './templates/templates-feature-index.ts' +import { + runWebshellBuild, + runWebshellInit, + type WebshellBuildCommandOptions, + type WebshellInitCommandOptions, +} from './webshell/webshell-feature-index.ts' export type AppOptions = { checkForNewerVersion?: (options: VersionCheckOptions) => Promise @@ -96,6 +102,8 @@ export type AppOptions = { runTemplatesCheck?: (options: TemplatesCheckCommandOptions) => Promise runTemplatesGenerate?: (options: TemplatesGenerateCommandOptions) => Promise runTemplatesSync?: (options: TemplatesSyncCommandOptions) => Promise + runWebshellBuild?: (options: WebshellBuildCommandOptions) => Promise + runWebshellInit?: (options: WebshellInitCommandOptions) => Promise } export function createApp({ @@ -124,6 +132,8 @@ export function createApp({ runTemplatesCheck: runTemplatesCheckCommand = runTemplatesCheck, runTemplatesGenerate: runTemplatesGenerateCommand = runTemplatesGenerate, runTemplatesSync: runTemplatesSyncCommand = runTemplatesSync, + runWebshellBuild: runWebshellBuildCommand = runWebshellBuild, + runWebshellInit: runWebshellInitCommand = runWebshellInit, }: AppOptions = {}) { const metadata = readPackageMetadata() const app = new Command() @@ -451,6 +461,38 @@ export function createApp({ await runTemplatesSyncCommand({ ...options, target }) }) + const webshellCommand = app.command('webshell').description('Wrap a web app in an Android WebView shell') + + webshellCommand.action(() => { + webshellCommand.outputHelp() + }) + + webshellCommand + .command('init [directory]') + .description('Generate an Android WebView project for a web app') + .option('--app-name ', 'Application display name') + .option('--application-id ', 'Android application id (e.g. com.example.app)') + .option('--force', 'Overwrite an existing directory') + .option('--keystore-alias ', 'Signing keystore alias') + .option('--keystore-path ', 'Signing keystore path (created when missing)') + .option('--manifest ', 'Web manifest.json or Bubblewrap twa-manifest.json') + .option('--url ', 'Web app URL to wrap') + .option('--version-code ', 'Android versionCode', parseIntegerOption) + .option('--version-name ', 'Android versionName') + .action(async (directory: string | undefined, options: Omit) => { + await runWebshellInitCommand({ ...options, directory }) + }) + + webshellCommand + .command('build [directory]') + .description('Build a release APK from a webshell project') + .option('--keystore-alias ', 'Signing keystore alias') + .option('--keystore-path ', 'Signing keystore path') + .option('--stacktrace', 'Pass --stacktrace to Gradle') + .action(async (directory: string | undefined, options: Omit) => { + await runWebshellBuildCommand({ ...options, directory }) + }) + // Positional options are enabled, so an option is only accepted where it is declared. The // root declaration alone would reject `solana-mobile emulator list --skip-version-check`; // every subcommand accepts the flag too, hidden there to keep help output focused. diff --git a/src/core/data-access/command-types.ts b/src/core/data-access/command-types.ts index 23bfe9f..afe0779 100644 --- a/src/core/data-access/command-types.ts +++ b/src/core/data-access/command-types.ts @@ -1,6 +1,14 @@ export type CommandRunner = (cmd: [string, ...string[]], options?: RunCommandOptions) => Promise -export type InteractiveCommandRunner = (cmd: [string, ...string[]]) => Promise +export type InteractiveCommandRunner = ( + cmd: [string, ...string[]], + options?: InteractiveRunCommandOptions, +) => Promise + +export interface InteractiveRunCommandOptions { + cwd?: string + env?: Record +} export interface RunCommandOptions { /** @@ -9,5 +17,6 @@ export interface RunCommandOptions { * drops half the diagnostics. */ combineOutput?: boolean + env?: Record stdin?: string } diff --git a/src/core/data-access/run-executable.ts b/src/core/data-access/run-executable.ts index 2710c4e..64bf3ba 100644 --- a/src/core/data-access/run-executable.ts +++ b/src/core/data-access/run-executable.ts @@ -2,9 +2,13 @@ import { spawn } from 'node:child_process' import { basename } from 'node:path' import type { InteractiveCommandRunner, RunCommandOptions } from './command-types.ts' -export const runInteractiveExecutable: InteractiveCommandRunner = async (cmd) => { +export const runInteractiveExecutable: InteractiveCommandRunner = async (cmd, options = {}) => { return new Promise((resolve, reject) => { - const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit' }) + const child = spawn(cmd[0], cmd.slice(1), { + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : undefined, + stdio: 'inherit', + }) child.on('error', reject) child.on('close', (exitCode) => { @@ -21,6 +25,7 @@ export const runInteractiveExecutable: InteractiveCommandRunner = async (cmd) => export async function runExecutable(cmd: [string, ...string[]], options: RunCommandOptions = {}): Promise { return new Promise((resolve, reject) => { const child = spawn(cmd[0], cmd.slice(1), { + env: options.env ? { ...process.env, ...options.env } : undefined, stdio: ['pipe', 'pipe', 'pipe'], }) const stderr: Buffer[] = [] diff --git a/src/webshell/data-access/apply-branding.ts b/src/webshell/data-access/apply-branding.ts new file mode 100644 index 0000000..824c2ff --- /dev/null +++ b/src/webshell/data-access/apply-branding.ts @@ -0,0 +1,280 @@ +import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { extname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { log } from '@clack/prompts' +import type { WebshellManifest, WebshellManifestIcon } from './read-manifest.ts' + +const DEFAULT_ICON_BACKGROUND = '#3DDC84' +const DEFAULT_SPLASH_BACKGROUND = '#3DDC84' +const FOREGROUND_RESOURCE_NAME = 'ic_launcher_foreground_inner' +const SUPPORTED_ICON_EXTENSIONS = new Set(['png', 'webp', 'jpg', 'jpeg']) + +export type WebshellBrandingSeed = Pick + +export interface ApplyWebshellBrandingDependencies { + fetchFn?: (url: URL) => Promise + logWarning?: (message: string) => void +} + +interface DownloadedIcon { + buffer: Buffer + extension: string +} + +/** + * Applies the web app's branding to a generated project: splash/launcher colors from the manifest's + * theme and background colors, and the preferred manifest icon as the adaptive launcher foreground. + * A missing or failing icon degrades to the template's default artwork with a warning — branding + * problems never fail init. + */ +export async function applyWebshellBranding( + projectDirectory: string, + manifest?: WebshellBrandingSeed, + { + fetchFn = (url) => fetch(url, { signal: AbortSignal.timeout(30_000) }), + logWarning = log.warn, + }: ApplyWebshellBrandingDependencies = {}, +): Promise { + const splashBackground = + normalizeAndroidColor(manifest?.backgroundColor) ?? + normalizeAndroidColor(manifest?.themeColor) ?? + DEFAULT_SPLASH_BACKGROUND + const iconBackground = + normalizeAndroidColor(manifest?.themeColor) ?? + normalizeAndroidColor(manifest?.backgroundColor) ?? + DEFAULT_ICON_BACKGROUND + + await writeColors(projectDirectory, splashBackground, iconBackground) + await writeLauncherBackground(projectDirectory) + + const selectedIcon = selectPreferredManifestIcon(manifest?.icons) + if (!selectedIcon) { + return + } + + const downloadedIcon = await downloadManifestIconSafely(selectedIcon.src, fetchFn, logWarning) + if (!downloadedIcon) { + logWarning(`Failed to import manifest icon ${selectedIcon.src}. Using the default Android launcher icon instead.`) + return + } + + await writeLauncherForeground(projectDirectory, downloadedIcon.extension) + await clearPreviousForegroundAssets(projectDirectory, downloadedIcon.extension) + + const drawableNodpiDirectory = join(projectDirectory, 'app', 'src', 'main', 'res', 'drawable-nodpi') + await mkdir(drawableNodpiDirectory, { recursive: true }) + await writeFile( + join(drawableNodpiDirectory, `${FOREGROUND_RESOURCE_NAME}.${downloadedIcon.extension}`), + downloadedIcon.buffer, + ) +} + +function selectPreferredManifestIcon(icons: WebshellManifestIcon[] | undefined): WebshellManifestIcon | undefined { + if (!icons?.length) { + return undefined + } + + const rankedIcons = [...icons] + .filter((icon) => isSupportedIconSource(icon.src, icon.type)) + .sort((left, right) => { + const purposeDelta = purposeScore(right.purpose) - purposeScore(left.purpose) + if (purposeDelta !== 0) { + return purposeDelta + } + + return largestDeclaredSize(right.sizes) - largestDeclaredSize(left.sizes) + }) + + return rankedIcons[0] +} + +function purposeScore(purpose: string[]): number { + // Adaptive icons and the native splash screen both crop the foreground asset, + // so prefer maskable artwork when it is available. + if (purpose.includes('maskable')) { + return 2 + } + if (purpose.length === 0 || purpose.includes('any')) { + return 1 + } + + return 0 +} + +function largestDeclaredSize(sizes: number[]): number { + return sizes.reduce((largest, size) => Math.max(largest, size), 0) +} + +function isSupportedIconSource(source: string, type?: string): boolean { + if (source.startsWith('data:')) { + return false + } + + return detectImageExtension(source, type) !== undefined +} + +async function downloadManifestIcon( + source: string, + fetchFn: (url: URL) => Promise, +): Promise { + if (source.startsWith('file://')) { + const extension = detectImageExtension(source) + if (!extension) { + return undefined + } + + return { + buffer: await readFile(fileURLToPath(source)), + extension, + } + } + + if (source.startsWith('http://') || source.startsWith('https://')) { + const response = await fetchFn(new URL(source)) + if (!response.ok) { + throw new Error(`Failed to fetch manifest icon ${source}: ${response.status} ${response.statusText}`) + } + + const extension = detectImageExtension(source, response.headers.get('content-type') ?? undefined) + if (!extension) { + return undefined + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + extension, + } + } + + return undefined +} + +async function downloadManifestIconSafely( + source: string, + fetchFn: (url: URL) => Promise, + logWarning: (message: string) => void, +): Promise { + try { + return await downloadManifestIcon(source, fetchFn) + } catch (error) { + if (error instanceof Error) { + logWarning(error.message) + return undefined + } + throw error + } +} + +function detectImageExtension(source: string, type?: string): string | undefined { + const normalizedType = type?.toLowerCase() + if (normalizedType) { + if (normalizedType.includes('png')) { + return 'png' + } + if (normalizedType.includes('webp')) { + return 'webp' + } + if (normalizedType.includes('jpeg') || normalizedType.includes('jpg')) { + return 'jpg' + } + } + + const withoutQuery = source.split('?')[0]?.split('#')[0] ?? source + const extension = extname(withoutQuery).replace('.', '').toLowerCase() + if (SUPPORTED_ICON_EXTENSIONS.has(extension)) { + return extension + } + + return undefined +} + +async function writeColors(projectDirectory: string, splashBackground: string, iconBackground: string): Promise { + const colorsPath = join(projectDirectory, 'app', 'src', 'main', 'res', 'values', 'colors.xml') + const contents = ` + + ${iconBackground} + ${splashBackground} + #FF000000 + #FFFFFFFF + +` + await writeFile(colorsPath, contents, 'utf8') +} + +async function writeLauncherBackground(projectDirectory: string): Promise { + const backgroundPath = join(projectDirectory, 'app', 'src', 'main', 'res', 'drawable', 'ic_launcher_background.xml') + const contents = ` + + + +` + await writeFile(backgroundPath, contents, 'utf8') +} + +async function writeLauncherForeground(projectDirectory: string, extension: string): Promise { + const foregroundXmlPath = join( + projectDirectory, + 'app', + 'src', + 'main', + 'res', + 'drawable', + 'ic_launcher_foreground.xml', + ) + const contents = ` + +` + await writeFile(foregroundXmlPath, contents, 'utf8') + + const foregroundAssetDirectory = join(projectDirectory, 'app', 'src', 'main', 'res', 'drawable-nodpi') + await mkdir(foregroundAssetDirectory, { recursive: true }) + + for (const candidateExtension of SUPPORTED_ICON_EXTENSIONS) { + if (candidateExtension === extension) { + continue + } + await rm(join(foregroundAssetDirectory, `${FOREGROUND_RESOURCE_NAME}.${candidateExtension}`), { force: true }) + } +} + +async function clearPreviousForegroundAssets(projectDirectory: string, currentExtension: string): Promise { + const drawableNodpiDirectory = join(projectDirectory, 'app', 'src', 'main', 'res', 'drawable-nodpi') + await mkdir(drawableNodpiDirectory, { recursive: true }) + const entries = await readdir(drawableNodpiDirectory) + await Promise.all( + entries + .filter((entry) => entry.startsWith(`${FOREGROUND_RESOURCE_NAME}.`) && !entry.endsWith(`.${currentExtension}`)) + .map((entry) => rm(join(drawableNodpiDirectory, entry), { force: true })), + ) +} + +function normalizeAndroidColor(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + + // CSS hex colors carry the alpha channel last (#RRGGBBAA, #RGBA); Android expects it first (#AARRGGBB). + const trimmed = value.trim() + if (/^#[0-9a-fA-F]{6}$/.test(trimmed)) { + return trimmed.toUpperCase() + } + if (/^#[0-9a-fA-F]{8}$/.test(trimmed)) { + const body = trimmed.slice(1) + return `#${body.slice(6, 8)}${body.slice(0, 6)}`.toUpperCase() + } + if (/^#[0-9a-fA-F]{3}$/.test(trimmed)) { + const [red, green, blue] = trimmed.slice(1).split('') + return `#${red}${red}${green}${green}${blue}${blue}`.toUpperCase() + } + if (/^#[0-9a-fA-F]{4}$/.test(trimmed)) { + const [red, green, blue, alpha] = trimmed.slice(1).split('') + return `#${alpha}${alpha}${red}${red}${green}${green}${blue}${blue}`.toUpperCase() + } + + return undefined +} diff --git a/src/webshell/data-access/copy-template.ts b/src/webshell/data-access/copy-template.ts new file mode 100644 index 0000000..edf241d --- /dev/null +++ b/src/webshell/data-access/copy-template.ts @@ -0,0 +1,56 @@ +import { chmod, cp, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export interface CopyWebshellTemplateOptions { + force?: boolean +} + +/** + * Copies the vendored Android template into a project directory. The template ships its root ignore + * file un-dotted (npm strips dotted ones from tarballs), so the copy renames it to `.gitignore`. The + * nested `app/.gitignore` is recreated unconditionally for the same reason — npm removes nested + * .gitignore files from published tarballs, so it may be missing from the template at runtime. + */ +export async function copyWebshellTemplate( + templateDirectory: string, + targetDirectory: string, + { force = false }: CopyWebshellTemplateOptions = {}, +): Promise { + if (!force && !(await isDirectoryEmpty(targetDirectory))) { + throw new Error(`Target directory ${targetDirectory} is not empty. Use --force to overwrite it.`) + } + + await mkdir(targetDirectory, { recursive: true }) + + for (const entry of await readdir(templateDirectory)) { + const destination = join(targetDirectory, entry === 'gitignore' ? '.gitignore' : entry) + if (force) { + await rm(destination, { force: true, recursive: true }) + } + await cp(join(templateDirectory, entry), destination, { errorOnExist: !force, force, recursive: true }) + } + + await mkdir(join(targetDirectory, 'app'), { recursive: true }) + await writeFile(join(targetDirectory, 'app', '.gitignore'), '/build\n', 'utf8') + await ensureFileExecutable(join(targetDirectory, 'gradlew')) +} + +async function ensureFileExecutable(filePath: string): Promise { + if (process.platform === 'win32') { + return + } + + const fileStats = await stat(filePath) + const executableMode = fileStats.mode | 0o111 + if (fileStats.mode !== executableMode) { + await chmod(filePath, executableMode) + } +} + +async function isDirectoryEmpty(directory: string): Promise { + try { + return (await readdir(directory)).length === 0 + } catch { + return true + } +} diff --git a/src/webshell/data-access/find-template-dir.ts b/src/webshell/data-access/find-template-dir.ts new file mode 100644 index 0000000..da91ce1 --- /dev/null +++ b/src/webshell/data-access/find-template-dir.ts @@ -0,0 +1,18 @@ +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Walks up from this module to the package root, in both the src (dev) and dist (published) layouts. */ +export function findWebshellTemplateDir(startDir = dirname(fileURLToPath(import.meta.url))): string { + let current = startDir + while (true) { + if (existsSync(join(current, 'package.json'))) { + return join(current, 'templates', 'webshell-android') + } + const parent = dirname(current) + if (parent === current) { + throw new Error('Could not locate the webshell template directory') + } + current = parent + } +} diff --git a/src/webshell/data-access/keystore.ts b/src/webshell/data-access/keystore.ts new file mode 100644 index 0000000..201b3aa --- /dev/null +++ b/src/webshell/data-access/keystore.ts @@ -0,0 +1,128 @@ +import { access, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { password } from '@clack/prompts' +import type { CommandRunner } from '../../core/data-access/command-types.ts' +import { runExecutable } from '../../core/data-access/run-executable.ts' + +export const WEBSHELL_KEY_PASSWORD_ENV = 'SOLANA_MOBILE_KEY_PASSWORD' +export const WEBSHELL_KEYSTORE_PASSWORD_ENV = 'SOLANA_MOBILE_KEYSTORE_PASSWORD' + +export type WebshellPasswordPrompt = (options: { message: string }) => Promise + +export interface WebshellSigningPasswords { + keyPassword: string + keystorePassword: string +} + +export interface ResolveWebshellSigningPasswordsDependencies { + env?: Partial> + promptPassword?: WebshellPasswordPrompt +} + +export interface EnsureKeystoreOptions { + appName: string + keyPassword: string + keystoreAlias: string + keystorePassword: string + keystorePath: string +} + +export interface EnsureKeystoreDependencies { + runCommand?: CommandRunner +} + +const defaultPasswordPrompt: WebshellPasswordPrompt = ({ message }) => + password({ message, validate: (value) => (value?.trim() ? undefined : 'A password is required.') }) + +/** + * Resolves the signing passwords from `SOLANA_MOBILE_KEYSTORE_PASSWORD` / `SOLANA_MOBILE_KEY_PASSWORD`, + * falling back to a hidden prompt for the keystore password. The key password defaults to the keystore + * password when its variable is unset. A cancelled prompt returns the clack cancel symbol for the + * caller to handle. + */ +export async function resolveWebshellSigningPasswords({ + env = process.env, + promptPassword = defaultPasswordPrompt, +}: ResolveWebshellSigningPasswordsDependencies = {}): Promise { + const keystorePassword = + env[WEBSHELL_KEYSTORE_PASSWORD_ENV]?.trim() || + (await promptPassword({ message: `Keystore password (${WEBSHELL_KEYSTORE_PASSWORD_ENV} is not set)` })) + if (typeof keystorePassword === 'symbol') { + return keystorePassword + } + + return { + keyPassword: env[WEBSHELL_KEY_PASSWORD_ENV]?.trim() || keystorePassword, + keystorePassword, + } +} + +/** + * Creates the signing keystore with `keytool -genkeypair` when it does not exist yet. Returns true + * when a new keystore was generated, false when the existing file is kept. + */ +export async function ensureKeystore( + options: EnsureKeystoreOptions, + { runCommand = runExecutable }: EnsureKeystoreDependencies = {}, +): Promise { + if (await exists(options.keystorePath)) { + return false + } + + await mkdir(dirname(options.keystorePath), { recursive: true }) + // Passwords must never appear in argv — keytool reads them from the child env via `:env`. + await runCommand( + [ + 'keytool', + '-genkeypair', + '-v', + '-keystore', + options.keystorePath, + '-alias', + options.keystoreAlias, + '-keyalg', + 'RSA', + '-keysize', + '2048', + '-validity', + '10000', + '-storepass:env', + WEBSHELL_KEYSTORE_PASSWORD_ENV, + '-keypass:env', + WEBSHELL_KEY_PASSWORD_ENV, + '-dname', + buildDname(options.appName), + '-noprompt', + ], + { + env: { + [WEBSHELL_KEY_PASSWORD_ENV]: options.keyPassword, + [WEBSHELL_KEYSTORE_PASSWORD_ENV]: options.keystorePassword, + }, + }, + ) + + return true +} + +function buildDname(appName: string): string { + const commonName = sanitizeDistinguishedNameValue(appName) || 'Solana Mobile Web Shell' + + return `CN=${commonName}, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=US` +} + +function sanitizeDistinguishedNameValue(value: string): string { + return value + .replace(/["+,;<>#=]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +async function exists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch { + return false + } +} diff --git a/src/webshell/data-access/project-config.ts b/src/webshell/data-access/project-config.ts new file mode 100644 index 0000000..d07ecae --- /dev/null +++ b/src/webshell/data-access/project-config.ts @@ -0,0 +1,106 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +export const WEBSHELL_PROJECT_CONFIG_FILENAME = 'twa-manifest.json' + +/** What init persists into a generated project. */ +export interface WebshellProjectConfig { + appName: string + applicationId: string + keystoreAlias?: string + keystorePath?: string + url: string + webManifestUrl?: string +} + +/** The subset the build flow reads back; `undefined` means the directory is not a webshell project. */ +export interface SavedWebshellProjectConfig { + keystoreAlias?: string + keystorePath?: string +} + +/** + * Writes the Bubblewrap-compatible `twa-manifest.json`, carrying over fields this CLI does not own so + * a project that started life under Bubblewrap keeps working there. + */ +export async function writeWebshellProjectConfig( + projectDirectory: string, + config: WebshellProjectConfig, +): Promise { + const configPath = join(projectDirectory, WEBSHELL_PROJECT_CONFIG_FILENAME) + const url = new URL(config.url) + + const merged: Record = { + ...(await readTwaManifest(configPath)), + fallbackType: 'webview', + generatorApp: 'solana-mobile', + host: url.host, + launcherName: config.appName, + name: config.appName, + packageId: config.applicationId, + startUrl: `${url.pathname}${url.search}`, + } + + const webManifestUrl = asRemoteUrl(config.webManifestUrl) + if (webManifestUrl) { + merged.webManifestUrl = webManifestUrl + } + + if (config.keystoreAlias || config.keystorePath) { + merged.signingKey = { alias: config.keystoreAlias, path: config.keystorePath } + } + + await writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8') +} + +/** Reads the keystore location back from `twa-manifest.json`; `undefined` when the file is absent. */ +export async function readWebshellProjectConfig( + projectDirectory: string, +): Promise { + const configPath = join(projectDirectory, WEBSHELL_PROJECT_CONFIG_FILENAME) + const parsed = await readTwaManifest(configPath) + if (!parsed) { + return undefined + } + + const signingKey = asObjectOrUndefined(parsed.signingKey) + + return { + keystoreAlias: asString(signingKey?.alias), + keystorePath: asString(signingKey?.path), + } +} + +async function readTwaManifest(configPath: string): Promise | undefined> { + let contents: string + try { + contents = await readFile(configPath, 'utf8') + } catch { + return undefined + } + + let parsed: unknown + try { + parsed = JSON.parse(contents) + } catch (error) { + throw new Error(`Failed to parse JSON from ${configPath}: ${error instanceof Error ? error.message : error}`) + } + + return asObjectOrUndefined(parsed) +} + +function asRemoteUrl(value: string | undefined): string | undefined { + return value?.startsWith('http://') || value?.startsWith('https://') ? value : undefined +} + +function asObjectOrUndefined(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + return value as Record +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} diff --git a/src/webshell/data-access/read-manifest.ts b/src/webshell/data-access/read-manifest.ts new file mode 100644 index 0000000..0528a24 --- /dev/null +++ b/src/webshell/data-access/read-manifest.ts @@ -0,0 +1,325 @@ +import { readFile as readFileUtf8 } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +/** Bubblewrap truncates launcher names to this length; matching it keeps generated projects interoperable. */ +const LAUNCHER_NAME_MAX_LENGTH = 12 + +export interface WebshellManifestIcon { + purpose: string[] + sizes: number[] + src: string + type?: string +} + +/** Normalized view of a web manifest.json or Bubblewrap twa-manifest.json, as consumed by the init flow. */ +export interface WebshellManifest { + appName?: string + applicationId?: string + backgroundColor?: string + icons?: WebshellManifestIcon[] + keystoreAlias?: string + keystorePath?: string + kind: 'bubblewrap' | 'web' + source: string + themeColor?: string + url?: string + versionCode?: number + versionName?: string + webManifestUrl?: string +} + +export interface ReadWebshellManifestDependencies { + fetchFn?: (url: URL) => Promise + readFile?: (path: string) => Promise +} + +type JsonObject = Record + +interface LoadedJsonSource { + baseUrl?: string + data: unknown + location: string +} + +/** Reads a web manifest.json or Bubblewrap twa-manifest.json from a local path or URL and normalizes it. */ +export async function readWebshellManifest( + source: string, + { + fetchFn = (url) => fetch(url, { signal: AbortSignal.timeout(30_000) }), + readFile = (path) => readFileUtf8(path, 'utf8'), + }: ReadWebshellManifestDependencies = {}, +): Promise { + const loaded = await loadJsonSource(source, { fetchFn, readFile }) + const manifest = asObject(loaded.data) + + return isBubblewrapManifest(manifest) ? parseBubblewrapManifest(loaded, manifest) : parseWebManifest(loaded, manifest) +} + +function parseWebManifest(loaded: LoadedJsonSource, manifest: JsonObject): WebshellManifest { + return { + appName: resolveLauncherName(manifest), + backgroundColor: asString(manifest.background_color), + icons: parseManifestIcons(manifest.icons, loaded.baseUrl), + kind: 'web', + source: loaded.location, + themeColor: asString(manifest.theme_color), + url: resolveWebManifestUrl(manifest, loaded.baseUrl), + webManifestUrl: loaded.location, + } +} + +function parseBubblewrapManifest(loaded: LoadedJsonSource, manifest: JsonObject): WebshellManifest { + const signingKey = asObjectOrUndefined(manifest.signingKey) + + return { + applicationId: asString(manifest.packageId) ?? asString(manifest.applicationId), + appName: + asString(manifest.launcherName) ?? + asString(manifest.shortName) ?? + asString(manifest.short_name) ?? + asString(manifest.name), + keystoreAlias: asString(signingKey?.alias), + keystorePath: asString(signingKey?.path) ?? asString(signingKey?.file), + kind: 'bubblewrap', + source: loaded.location, + url: resolveBubblewrapUrl(manifest, loaded.baseUrl), + versionCode: asPositiveInteger(manifest.versionCode ?? manifest.appVersionCode ?? manifest.androidVersionCode), + versionName: + asString(manifest.versionName) ?? asString(manifest.appVersionName) ?? asString(manifest.androidVersionName), + webManifestUrl: resolveAssetUrl(asString(manifest.webManifestUrl), loaded.baseUrl), + } +} + +function isBubblewrapManifest(manifest: JsonObject): boolean { + return Boolean( + asString(manifest.packageId) ?? + asString(manifest.applicationId) ?? + asString(manifest.launcherName) ?? + asString(manifest.host) ?? + asString(manifest.webManifestUrl) ?? + asString(manifest.generatorApp) ?? + asString(manifest.fallbackType) ?? + (asObjectOrUndefined(manifest.signingKey) ? 'signingKey' : undefined), + ) +} + +async function loadJsonSource( + source: string, + { fetchFn, readFile }: Required, +): Promise { + const trimmed = source.trim() + const parsedUrl = parseUrl(trimmed) + + if (parsedUrl?.protocol === 'http:' || parsedUrl?.protocol === 'https:') { + const response = await fetchFn(parsedUrl) + if (!response.ok) { + throw new Error(`Failed to fetch ${parsedUrl}: ${response.status} ${response.statusText}`) + } + const location = parsedUrl.toString() + + return { baseUrl: location, data: parseJson(await response.text(), location), location } + } + + const absolutePath = parsedUrl?.protocol === 'file:' ? fileURLToPath(parsedUrl) : resolve(trimmed) + + return { + baseUrl: pathToFileURL(absolutePath).toString(), + data: parseJson(await readFile(absolutePath), absolutePath), + location: absolutePath, + } +} + +function parseJson(contents: string, location: string): unknown { + try { + return JSON.parse(contents) as unknown + } catch (error) { + throw new Error(`Failed to parse JSON from ${location}: ${error instanceof Error ? error.message : error}`) + } +} + +function parseUrl(value: string): URL | undefined { + try { + return new URL(value) + } catch { + return undefined + } +} + +function resolveStartUrl(startUrl: string | undefined, baseUrl?: string): string | undefined { + if (!startUrl) { + return undefined + } + + if (startUrl.startsWith('http://') || startUrl.startsWith('https://')) { + return normalizeHttpUrl(startUrl) + } + + if (!baseUrl?.startsWith('http://') && !baseUrl?.startsWith('https://')) { + return undefined + } + + return normalizeHttpUrl(new URL(startUrl, baseUrl).toString()) +} + +function resolveWebManifestUrl(manifest: JsonObject, baseUrl?: string): string | undefined { + const explicitStartUrl = asString(manifest.start_url) ?? asString(manifest.startUrl) + + const resolvedStartUrl = resolveStartUrl(explicitStartUrl, baseUrl) + if (resolvedStartUrl) { + return resolvedStartUrl + } + + if (!baseUrl?.startsWith('http://') && !baseUrl?.startsWith('https://')) { + return undefined + } + + return normalizeHttpUrl(new URL('/', baseUrl).toString()) +} + +function resolveBubblewrapUrl(manifest: JsonObject, baseUrl?: string): string | undefined { + const explicitStartUrl = asString(manifest.startUrl) ?? asString(manifest.start_url) + + if (explicitStartUrl?.startsWith('http://') || explicitStartUrl?.startsWith('https://')) { + return normalizeHttpUrl(explicitStartUrl) + } + + const host = asString(manifest.host) + if (host) { + const hostUrl = host.startsWith('http://') || host.startsWith('https://') ? host : `https://${host}` + + return normalizeHttpUrl(new URL(explicitStartUrl ?? '/', hostUrl).toString()) + } + + return resolveStartUrl(explicitStartUrl, baseUrl) +} + +function parseManifestIcons(value: unknown, baseUrl?: string): WebshellManifestIcon[] | undefined { + if (!Array.isArray(value)) { + return undefined + } + + const icons: WebshellManifestIcon[] = [] + for (const entry of value) { + const icon = asObjectOrUndefined(entry) + const src = resolveAssetUrl(asString(icon?.src), baseUrl) + if (!src) { + continue + } + + icons.push({ + purpose: parsePurpose(asString(icon?.purpose)), + sizes: parseSizes(asString(icon?.sizes)), + src, + type: asString(icon?.type), + }) + } + + return icons.length > 0 ? icons : undefined +} + +function resolveAssetUrl(value: string | undefined, baseUrl?: string): string | undefined { + if (!value) { + return undefined + } + + if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('file://')) { + return value + } + + if (!baseUrl) { + return undefined + } + + return new URL(value, baseUrl).toString() +} + +function resolveLauncherName(manifest: JsonObject): string | undefined { + const shortName = asString(manifest.short_name) ?? asString(manifest.shortName) + if (shortName) { + return shortName + } + + return asString(manifest.name)?.slice(0, LAUNCHER_NAME_MAX_LENGTH) +} + +function normalizeHttpUrl(value: string): string { + let parsed: URL + try { + parsed = new URL(value) + } catch { + throw new Error(`Invalid URL: ${value}`) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`URL must use http or https: ${value}`) + } + + return parsed.toString() +} + +function asObject(value: unknown): JsonObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Manifest must be a JSON object') + } + + return value as JsonObject +} + +function asObjectOrUndefined(value: unknown): JsonObject | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined + } + + return value as JsonObject +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function asPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value + } + + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10) + if (Number.isInteger(parsed) && parsed > 0) { + return parsed + } + } + + return undefined +} + +function parsePurpose(value: string | undefined): string[] { + if (!value) { + return [] + } + + return value + .split(/\s+/) + .map((part) => part.trim().toLowerCase()) + .filter(Boolean) +} + +function parseSizes(value: string | undefined): number[] { + if (!value || value === 'any') { + return [] + } + + return value + .split(/\s+/) + .map((part) => { + const [width, height] = part.toLowerCase().split('x') + const widthValue = Number.parseInt(width ?? '', 10) + const heightValue = Number.parseInt(height ?? '', 10) + if (!Number.isFinite(widthValue) || !Number.isFinite(heightValue)) { + return undefined + } + + return Math.max(widthValue, heightValue) + }) + .filter((size): size is number => size !== undefined) +} diff --git a/src/webshell/data-access/rename-android-package.ts b/src/webshell/data-access/rename-android-package.ts new file mode 100644 index 0000000..2928cd1 --- /dev/null +++ b/src/webshell/data-access/rename-android-package.ts @@ -0,0 +1,436 @@ +import { access, mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' + +/** The hardcoded Kotlin package the vendored template ships with. */ +export const WEBSHELL_TEMPLATE_PACKAGE_NAME = 'com.example.webshell' + +/** + * Package segments that are valid in an Android application id but reserved words in Kotlin/Java, + * so they cannot appear in a package declaration without being rewritten. + */ +const RESERVED_PACKAGE_SEGMENTS = new Set([ + 'abstract', + 'annotation', + 'as', + 'assert', + 'boolean', + 'break', + 'byte', + 'case', + 'catch', + 'char', + 'class', + 'companion', + 'const', + 'constructor', + 'continue', + 'data', + 'default', + 'do', + 'double', + 'dynamic', + 'else', + 'enum', + 'exports', + 'extends', + 'external', + 'false', + 'field', + 'final', + 'finally', + 'float', + 'for', + 'fun', + 'get', + 'goto', + 'if', + 'implements', + 'import', + 'in', + 'infix', + 'init', + 'instanceof', + 'int', + 'interface', + 'internal', + 'is', + 'java', + 'long', + 'module', + 'native', + 'new', + 'null', + 'object', + 'open', + 'operator', + 'out', + 'override', + 'package', + 'private', + 'protected', + 'public', + 'record', + 'reified', + 'requires', + 'return', + 'sealed', + 'set', + 'short', + 'static', + 'strictfp', + 'super', + 'suspend', + 'switch', + 'synchronized', + 'this', + 'throw', + 'throws', + 'transient', + 'transitive', + 'true', + 'try', + 'typealias', + 'typeof', + 'val', + 'var', + 'void', + 'volatile', + 'when', + 'while', + 'yield', +]) + +export interface RenameAndroidPackageOptions { + applicationId: string + appName: string + keystoreAlias?: string + keystorePath?: string + projectName: string + url: string + versionCode: number + versionName: string +} + +export interface DerivedWebshellPackageName { + note?: string + packageName: string +} + +export function validateWebshellApplicationId(value: string): string | undefined { + if (!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(value)) { + return 'Application ID must look like com.example.app. Use lowercase package segments with letters, numbers, or underscores only; dashes (-) are not allowed.' + } + + return undefined +} + +/** + * The Android application id and the Kotlin package usually match, but an application id may contain + * segments that are reserved words in Kotlin/Java (`fun.cfl.www` is a valid id). Those segments are + * prefixed with an underscore in the package/namespace while the application id stays untouched. + */ +export function deriveWebshellPackageName(applicationId: string): DerivedWebshellPackageName { + const segmentResults = applicationId + .trim() + .split('.') + .map((segment) => normalizeApplicationIdSegment(segment)) + const segments = segmentResults + .map((result) => result.normalized) + .filter((segment): segment is string => Boolean(segment)) + + const packageName = segments.join('.') + const reservedRewrite = segmentResults.find((result) => result.reason === 'reserved') + if (reservedRewrite?.original) { + return { + note: + `The Kotlin package/namespace will use ${packageName} because ` + + `"${reservedRewrite.original}" is a reserved word in Kotlin/Java.`, + packageName, + } + } + + const normalizedRewrite = segmentResults.some((result) => result.adjusted) + if (normalizedRewrite) { + return { + note: `The Kotlin package/namespace will use ${packageName} to keep it code-safe.`, + packageName, + } + } + + return { packageName } +} + +/** + * Rewrites the copied template for the requested application id: Gradle properties (`SOLANA_MOBILE_*` + * keys), root project name, `android.namespace`, the launcher label, Kotlin package declarations and + * imports, and the Kotlin source tree location. Also writes the generated project's README. + */ +export async function renameAndroidPackage( + projectDirectory: string, + options: RenameAndroidPackageOptions, +): Promise { + const validationError = validateWebshellApplicationId(options.applicationId) + if (validationError) { + throw new Error(validationError) + } + + const { packageName } = deriveWebshellPackageName(options.applicationId) + + await rewriteGradleProperties(projectDirectory, options) + await rewriteSettings(projectDirectory, options.projectName) + await rewriteAppBuildScript(projectDirectory, packageName) + await rewriteStrings(projectDirectory, options.appName) + await rewritePackageDeclarations(projectDirectory, packageName) + await relocatePackageDirectories(projectDirectory, packageName) + await writeProjectReadme(projectDirectory, options, packageName) +} + +async function rewriteGradleProperties(projectDirectory: string, options: RenameAndroidPackageOptions): Promise { + const gradlePropertiesPath = join(projectDirectory, 'gradle.properties') + let contents = await readFile(gradlePropertiesPath, 'utf8') + contents = updateGradleProperty(contents, 'SOLANA_MOBILE_URL', options.url) + contents = updateGradleProperty(contents, 'SOLANA_MOBILE_APPLICATION_ID', options.applicationId) + contents = updateGradleProperty(contents, 'SOLANA_MOBILE_VERSION_CODE', String(options.versionCode)) + contents = updateGradleProperty(contents, 'SOLANA_MOBILE_VERSION_NAME', options.versionName) + await writeFile(gradlePropertiesPath, contents, 'utf8') +} + +async function rewriteSettings(projectDirectory: string, projectName: string): Promise { + const settingsPath = join(projectDirectory, 'settings.gradle.kts') + const contents = await readFile(settingsPath, 'utf8') + const escapedName = projectName.replaceAll('\\', '\\\\').replaceAll('"', '\\"') + await writeFile( + settingsPath, + contents.replace(/rootProject\.name\s*=\s*"[^"]*"/, `rootProject.name = "${escapedName}"`), + 'utf8', + ) +} + +async function rewriteAppBuildScript(projectDirectory: string, packageName: string): Promise { + const buildScriptPath = join(projectDirectory, 'app', 'build.gradle.kts') + const contents = await readFile(buildScriptPath, 'utf8') + await writeFile(buildScriptPath, contents.replace(/namespace\s*=\s*"[^"]+"/, `namespace = "${packageName}"`), 'utf8') +} + +async function rewriteStrings(projectDirectory: string, appName: string): Promise { + const stringsPath = join(projectDirectory, 'app', 'src', 'main', 'res', 'values', 'strings.xml') + const contents = await readFile(stringsPath, 'utf8') + await writeFile( + stringsPath, + contents.replace( + /.*?<\/string>/, + `${escapeXmlText(appName)}`, + ), + 'utf8', + ) +} + +async function rewritePackageDeclarations(projectDirectory: string, packageName: string): Promise { + for (const sourceRoot of kotlinSourceRoots(projectDirectory)) { + if (!(await exists(sourceRoot))) { + continue + } + + for (const filePath of await walkFiles(sourceRoot)) { + if (!filePath.endsWith('.kt')) { + continue + } + const contents = await readFile(filePath, 'utf8') + await writeFile(filePath, contents.replaceAll(WEBSHELL_TEMPLATE_PACKAGE_NAME, packageName), 'utf8') + } + } +} + +async function relocatePackageDirectories(projectDirectory: string, packageName: string): Promise { + if (packageName === WEBSHELL_TEMPLATE_PACKAGE_NAME) { + return + } + + for (const sourceRoot of kotlinSourceRoots(projectDirectory)) { + const sourceDirectory = join(sourceRoot, ...WEBSHELL_TEMPLATE_PACKAGE_NAME.split('.')) + if (!(await exists(sourceDirectory))) { + continue + } + + // Staged outside every package path first: a destination that equals or contains the source + // (e.g. an applicationId of com.example) would otherwise delete the sources before the rename. + const stagingDirectory = join(sourceRoot, '.webshell-staging') + await rename(sourceDirectory, stagingDirectory) + + const destinationDirectory = join(sourceRoot, ...packageName.split('.')) + if (await exists(destinationDirectory)) { + await rm(destinationDirectory, { force: true, recursive: true }) + } + await mkdir(dirname(destinationDirectory), { recursive: true }) + await rename(stagingDirectory, destinationDirectory) + await removeEmptyParents(dirname(sourceDirectory), sourceRoot) + } +} + +async function writeProjectReadme( + projectDirectory: string, + options: RenameAndroidPackageOptions, + packageName: string, +): Promise { + const signingSection = + options.keystorePath && options.keystoreAlias + ? `## Release Signing + +Saved from CLI configuration: + +- Keystore path: \`${options.keystorePath}\` +- Key alias: \`${options.keystoreAlias}\` +- Store password env: \`SOLANA_MOBILE_KEYSTORE_PASSWORD\` +- Key password env: \`SOLANA_MOBILE_KEY_PASSWORD\` + +Export the password environment variables before running the CLI release build. + +` + : '' + + const contents = `# ${options.appName} + +Generated by the Solana Mobile CLI. + +## Configuration + +- Web URL: \`${options.url}\` +- Application ID: \`${options.applicationId}\` +- Version code: \`${options.versionCode}\` +- Version name: \`${options.versionName}\` +- Kotlin package / namespace: \`${packageName}\` + +## Build + +\`\`\`bash +solana-mobile webshell build . +adb install -r app/build/outputs/apk/release/app-release.apk +\`\`\` + +${signingSection}## Notes + +- This project is a WebView-based Android shell, not a Trusted Web Activity. +- External links outside the configured host open in the system browser. +- Solana wallet intents are handled natively by the app shell. +` + + await writeFile(join(projectDirectory, 'README.md'), contents, 'utf8') +} + +function kotlinSourceRoots(projectDirectory: string): string[] { + return [ + join(projectDirectory, 'app', 'src', 'main', 'java'), + join(projectDirectory, 'app', 'src', 'test', 'java'), + join(projectDirectory, 'app', 'src', 'androidTest', 'java'), + ] +} + +function updateGradleProperty(contents: string, key: string, value: string): string { + const escapedValue = value.replaceAll('\\', '\\\\') + const pattern = new RegExp(`^${escapeRegExp(key)}=.*$`, 'm') + const line = `${key}=${escapedValue}` + if (pattern.test(contents)) { + return contents.replace(pattern, line) + } + + return `${contents.trimEnd()}\n${line}\n` +} + +function escapeXmlText(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +async function exists(filePath: string): Promise { + try { + await access(filePath) + return true + } catch { + return false + } +} + +async function walkFiles(directory: string): Promise { + const output: string[] = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + output.push(...(await walkFiles(entryPath))) + } else { + output.push(entryPath) + } + } + + return output +} + +async function removeEmptyParents(startDirectory: string, stopDirectory: string): Promise { + let current = startDirectory + const normalizedStop = resolve(stopDirectory) + + while (resolve(current) === normalizedStop || resolve(current).startsWith(`${normalizedStop}${sep}`)) { + if (resolve(current) === normalizedStop) { + return + } + + const entries = await readdir(current) + if (entries.length > 0) { + return + } + + await rmdir(current) + current = dirname(current) + } +} + +interface NormalizedApplicationIdSegment { + adjusted: boolean + normalized?: string + original: string + reason?: 'normalized' | 'reserved' +} + +function normalizeApplicationIdSegment(value: string): NormalizedApplicationIdSegment { + let normalized = value + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + + if (!normalized) { + return { + adjusted: true, + original: value, + } + } + + let adjusted = normalized !== value + + if (!/^[a-z]/.test(normalized)) { + normalized = `app${normalized}` + adjusted = true + } + + if (RESERVED_PACKAGE_SEGMENTS.has(normalized)) { + return { + adjusted: true, + normalized: `_${normalized}`, + original: value, + reason: 'reserved', + } + } + + return { + adjusted, + normalized, + original: value, + reason: adjusted ? 'normalized' : undefined, + } +} diff --git a/src/webshell/data-access/webshell-types.ts b/src/webshell/data-access/webshell-types.ts new file mode 100644 index 0000000..b908ab0 --- /dev/null +++ b/src/webshell/data-access/webshell-types.ts @@ -0,0 +1,19 @@ +export interface WebshellInitCommandOptions { + applicationId?: string + appName?: string + directory?: string + force?: boolean + keystoreAlias?: string + keystorePath?: string + manifest?: string + url?: string + versionCode?: number + versionName?: string +} + +export interface WebshellBuildCommandOptions { + directory?: string + keystoreAlias?: string + keystorePath?: string + stacktrace?: boolean +} diff --git a/src/webshell/ui/webshell-ui-prompts.ts b/src/webshell/ui/webshell-ui-prompts.ts new file mode 100644 index 0000000..2268df9 --- /dev/null +++ b/src/webshell/ui/webshell-ui-prompts.ts @@ -0,0 +1,377 @@ +import { confirm, log, password, text } from '@clack/prompts' +import { resolvePromptCancellation, type TextPrompt } from '../../emulator/ui/emulator-ui-prompt-types.ts' +import { + WEBSHELL_KEY_PASSWORD_ENV, + WEBSHELL_KEYSTORE_PASSWORD_ENV, + type WebshellSigningPasswords, +} from '../data-access/keystore.ts' +import { validateWebshellApplicationId } from '../data-access/rename-android-package.ts' + +/** Google Play rejects anything above this versionCode. */ +const MAX_ANDROID_VERSION_CODE = 2_100_000_000 +const MIN_KEYSTORE_PASSWORD_LENGTH = 6 + +export const WEBSHELL_DEFAULT_KEYSTORE_ALIAS = 'android' +export const WEBSHELL_DEFAULT_KEYSTORE_FILENAME = 'android.keystore' +export const WEBSHELL_DEFAULT_VERSION_CODE = 1 +export const WEBSHELL_DEFAULT_VERSION_NAME = '1.0' + +export type ConfirmPrompt = (options: { initialValue?: boolean; message: string }) => Promise + +export type PasswordPrompt = (options: { + message: string + validate?: (value: string) => string | undefined +}) => Promise + +/** Parses and re-serializes the URL so the rest of the flow only ever sees a canonical http(s) URL. */ +export function normalizeWebshellUrl(value: string): string { + let parsed: URL + try { + parsed = new URL(value.trim()) + } catch { + throw new Error(`Invalid URL: ${value}`) + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`URL must use http or https: ${value}`) + } + + return parsed.toString() +} + +export function validateWebshellUrl(value: string): string | undefined { + if (!value.trim()) { + return 'Web app URL is required.' + } + + try { + normalizeWebshellUrl(value) + return undefined + } catch (error) { + return error instanceof Error ? error.message : 'Invalid URL.' + } +} + +export interface WebshellApplicationIdSuggestion { + applicationId?: string + note?: string +} + +/** + * Suggests an Android application id by reversing the host of the web app URL, the same way Bubblewrap + * seeds its default. Reserved Kotlin words are left untouched here — they are legal in an application + * id, and the package rename step rewrites them for the Kotlin namespace separately. + */ +export function deriveWebshellApplicationIdSuggestion(url: string): WebshellApplicationIdSuggestion { + let parsed: URL + try { + parsed = new URL(normalizeWebshellUrl(url)) + } catch { + return {} + } + + const host = parsed.hostname.trim().toLowerCase() + if (!host || host === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(host)) { + return {} + } + + const segmentResults = host + .split('.') + .reverse() + .map((segment) => normalizeSuggestionSegment(segment)) + const segments = segmentResults + .map((result) => result.normalized) + .filter((segment): segment is string => Boolean(segment)) + + if (segments.length < 2) { + return {} + } + + const applicationId = segments.join('.') + if (validateWebshellApplicationId(applicationId)) { + return {} + } + + if (segmentResults.some((result) => result.adjusted)) { + return { + applicationId, + note: `Adjusted the default application ID to ${applicationId} to keep it Android-safe.`, + } + } + + return { applicationId } +} + +export async function promptWebshellUrl( + defaultValue?: string, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue, + initialValue: defaultValue, + message: 'Web app URL', + placeholder: defaultValue ? undefined : 'https://example.com', + validate: validateWebshellUrl, + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return normalizeWebshellUrl(entered) +} + +export async function promptWebshellApplicationId( + defaultValue?: string, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue, + initialValue: defaultValue, + message: 'Android application ID', + placeholder: defaultValue ? undefined : 'com.example.app', + validate: (value) => (value.trim() ? validateWebshellApplicationId(value.trim()) : 'Application ID is required.'), + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return entered.trim() +} + +export async function promptWebshellAppName( + defaultValue?: string, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue, + initialValue: defaultValue, + message: 'App name', + validate: (value) => (value.trim() ? undefined : 'App name is required.'), + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return entered.trim() +} + +export function validateWebshellVersionCode(value: string): string | undefined { + const trimmed = value.trim() + if (!trimmed) { + return 'Android version code is required.' + } + + if (!/^\d+$/.test(trimmed) || Number.parseInt(trimmed, 10) <= 0) { + return 'Android version code must be a positive integer.' + } + + if (Number.parseInt(trimmed, 10) > MAX_ANDROID_VERSION_CODE) { + return `Android version code must be ${MAX_ANDROID_VERSION_CODE} or lower.` + } + + return undefined +} + +export async function promptWebshellVersionCode( + defaultValue: number = WEBSHELL_DEFAULT_VERSION_CODE, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue: String(defaultValue), + initialValue: String(defaultValue), + message: 'Android version code', + validate: validateWebshellVersionCode, + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return Number.parseInt(entered.trim(), 10) +} + +export async function promptWebshellVersionName( + defaultValue: string = WEBSHELL_DEFAULT_VERSION_NAME, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue, + initialValue: defaultValue, + message: 'Android version name', + validate: (value) => (value.trim() ? undefined : 'Android version name is required.'), + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return entered.trim() +} + +export async function promptWebshellKeystorePath( + defaultValue?: string, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue, + initialValue: defaultValue, + message: 'Signing keystore path (created when missing)', + validate: (value) => (value.trim() ? undefined : 'Signing keystore path is required.'), + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return entered.trim() +} + +export async function promptWebshellKeystoreAlias( + defaultValue: string = WEBSHELL_DEFAULT_KEYSTORE_ALIAS, + runText: TextPrompt = text as TextPrompt, +): Promise { + const entered = await runText({ + defaultValue, + initialValue: defaultValue, + message: 'Signing key alias', + validate: (value) => (value.trim() ? undefined : 'Signing key alias is required.'), + }) + + if (typeof entered === 'symbol') { + return resolvePromptCancellation(entered) + } + + return entered.trim() +} + +export interface ResolveWebshellCreatePasswordsDependencies { + env?: Partial> + logError?: (message: string) => void + runConfirm?: ConfirmPrompt + runPassword?: PasswordPrompt +} + +/** + * Resolves the passwords used to create a brand-new keystore. The environment variables win so + * automated runs never prompt; interactive runs get a confirmed-password flow because a typo here + * produces a keystore nobody can ever sign an update with again. A cancelled prompt returns the + * cancel symbol for the caller to handle. + */ +export async function resolveWebshellCreatePasswords({ + env = process.env, + logError = log.error, + runConfirm = confirm as ConfirmPrompt, + runPassword = password as PasswordPrompt, +}: ResolveWebshellCreatePasswordsDependencies = {}): Promise { + const envKeyPassword = env[WEBSHELL_KEY_PASSWORD_ENV]?.trim() + const envKeystorePassword = env[WEBSHELL_KEYSTORE_PASSWORD_ENV]?.trim() + + // keytool -genkeypair rejects passwords shorter than 6 characters; catching env values here fails + // before any project files are written. + if (envKeystorePassword && envKeystorePassword.length < MIN_KEYSTORE_PASSWORD_LENGTH) { + throw new Error(`${WEBSHELL_KEYSTORE_PASSWORD_ENV} must be at least ${MIN_KEYSTORE_PASSWORD_LENGTH} characters.`) + } + if (envKeyPassword && envKeyPassword.length < MIN_KEYSTORE_PASSWORD_LENGTH) { + throw new Error(`${WEBSHELL_KEY_PASSWORD_ENV} must be at least ${MIN_KEYSTORE_PASSWORD_LENGTH} characters.`) + } + + if (envKeystorePassword) { + return { keyPassword: envKeyPassword || envKeystorePassword, keystorePassword: envKeystorePassword } + } + + const keystorePassword = await promptConfirmedPassword('Keystore password', { logError, runPassword }) + if (typeof keystorePassword === 'symbol') { + return keystorePassword + } + + if (envKeyPassword) { + return { keyPassword: envKeyPassword, keystorePassword } + } + + const useSamePassword = await runConfirm({ + initialValue: true, + message: 'Use the same password for the signing key?', + }) + if (typeof useSamePassword === 'symbol') { + return useSamePassword + } + + if (useSamePassword) { + return { keyPassword: keystorePassword, keystorePassword } + } + + const keyPassword = await promptConfirmedPassword('Signing key password', { logError, runPassword }) + if (typeof keyPassword === 'symbol') { + return keyPassword + } + + return { keyPassword, keystorePassword } +} + +async function promptConfirmedPassword( + label: string, + { logError, runPassword }: { logError: (message: string) => void; runPassword: PasswordPrompt }, +): Promise { + while (true) { + const entered = await runPassword({ message: label, validate: validateCreatePassword(label) }) + if (typeof entered === 'symbol') { + return entered + } + + const confirmed = await runPassword({ + message: `Confirm ${label.toLowerCase()}`, + validate: validateCreatePassword(label), + }) + if (typeof confirmed === 'symbol') { + return confirmed + } + + if (entered === confirmed) { + return entered + } + + logError('Passwords do not match. Try again.') + } +} + +function validateCreatePassword(label: string): (value: string) => string | undefined { + return (value) => { + if (!value.trim()) { + return `${label} is required.` + } + + if (value.trim().length < MIN_KEYSTORE_PASSWORD_LENGTH) { + return `${label} must be at least ${MIN_KEYSTORE_PASSWORD_LENGTH} characters.` + } + + return undefined + } +} + +interface NormalizedSuggestionSegment { + adjusted: boolean + normalized?: string +} + +function normalizeSuggestionSegment(value: string): NormalizedSuggestionSegment { + let normalized = value + .replace(/[^a-z0-9_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, '') + + if (!normalized) { + return { adjusted: true } + } + + let adjusted = normalized !== value + + if (!/^[a-z]/.test(normalized)) { + normalized = `app${normalized}` + adjusted = true + } + + return { adjusted, normalized } +} diff --git a/src/webshell/webshell-feature-build.ts b/src/webshell/webshell-feature-build.ts new file mode 100644 index 0000000..5028fee --- /dev/null +++ b/src/webshell/webshell-feature-build.ts @@ -0,0 +1,124 @@ +import { join, resolve } from 'node:path' +import { cancel, log as clackLog, intro, outro } from '@clack/prompts' +import type { InteractiveCommandRunner } from '../core/data-access/command-types.ts' +import { runInteractiveExecutable } from '../core/data-access/run-executable.ts' +import { formatCliCommand } from '../core/util/format-cli-command.ts' +import { + resolveWebshellSigningPasswords, + WEBSHELL_KEY_PASSWORD_ENV, + WEBSHELL_KEYSTORE_PASSWORD_ENV, + type WebshellPasswordPrompt, +} from './data-access/keystore.ts' +import { readWebshellProjectConfig, WEBSHELL_PROJECT_CONFIG_FILENAME } from './data-access/project-config.ts' +import type { WebshellBuildCommandOptions } from './data-access/webshell-types.ts' + +export interface RunWebshellBuildDependencies { + cancel?: (message: string) => void + env?: Partial> + formatCommand?: typeof formatCliCommand + intro?: (message: string) => void + log?: (message: string) => void + outro?: (message: string) => void + platform?: NodeJS.Platform + promptPassword?: WebshellPasswordPrompt + readProjectConfig?: typeof readWebshellProjectConfig + resolvePasswords?: typeof resolveWebshellSigningPasswords + runInteractiveCommand?: InteractiveCommandRunner +} + +/** + * Builds the release APK of a generated webshell project with its own Gradle wrapper. There is + * deliberately no toolchain preflight: Gradle's stdio is inherited, so a missing JDK or SDK surfaces + * as Gradle's own error, unfiltered and unwrapped. + */ +export async function runWebshellBuild( + options: WebshellBuildCommandOptions = {}, + dependencies: RunWebshellBuildDependencies = {}, +) { + const { + cancel: showCancel = cancel, + env = process.env, + formatCommand = formatCliCommand, + intro: showIntro = intro, + log = clackLog.message, + outro: showOutro = outro, + platform = process.platform, + promptPassword, + readProjectConfig = readWebshellProjectConfig, + resolvePasswords = resolveWebshellSigningPasswords, + runInteractiveCommand = runInteractiveExecutable, + } = dependencies + + try { + showIntro('solana-mobile webshell build') + + const projectDirectory = resolve(options.directory ?? '.') + const config = await readProjectConfig(projectDirectory) + if (config === undefined) { + throw new Error( + `${projectDirectory} is not a webshell project: no ${WEBSHELL_PROJECT_CONFIG_FILENAME} found. Run ${formatCommand('webshell init')} first.`, + ) + } + + // Flags override the saved config. A relative saved path is relative to the project — that is how + // Bubblewrap writes it — while a relative flag is relative to the caller's cwd. + const keystorePath = options.keystorePath?.trim() + ? resolve(options.keystorePath) + : config.keystorePath + ? resolve(projectDirectory, config.keystorePath) + : undefined + const keystoreAlias = options.keystoreAlias?.trim() || config.keystoreAlias + + // gradlew.bat is not an executable — Windows can only run it through cmd.exe, and cmd.exe parses + // the command text itself, so page- or manifest-controlled values must carry no metacharacters. + if (platform === 'win32') { + for (const [name, value] of Object.entries({ keystoreAlias, keystorePath, projectDirectory })) { + if (value && /[&|<>^"%\r\n]/.test(value)) { + throw new Error(`The ${name} contains characters cmd.exe would interpret: ${value}`) + } + } + } + const command: [string, ...string[]] = + platform === 'win32' + ? ['cmd.exe', '/c', join(projectDirectory, 'gradlew.bat'), 'assembleRelease'] + : [join(projectDirectory, 'gradlew'), 'assembleRelease'] + const childEnv: Record = {} + const signed = Boolean(keystorePath && keystoreAlias) + + if (keystorePath && keystoreAlias) { + const passwords = await resolvePasswords({ env, ...(promptPassword ? { promptPassword } : {}) }) + if (typeof passwords === 'symbol') { + showCancel('Cancelled') + process.exitCode = 1 + return + } + + // Passwords must never appear in argv — only in the child env. + command.push(`-PSOLANA_MOBILE_KEYSTORE_PATH=${keystorePath}`, `-PSOLANA_MOBILE_KEYSTORE_ALIAS=${keystoreAlias}`) + childEnv[WEBSHELL_KEYSTORE_PASSWORD_ENV] = passwords.keystorePassword + childEnv[WEBSHELL_KEY_PASSWORD_ENV] = passwords.keyPassword + } else { + log('No signing keystore configured. Gradle will produce an unsigned release APK.') + } + + if (options.stacktrace) { + command.push('--stacktrace') + } + + await runInteractiveCommand(command, { cwd: projectDirectory, env: childEnv }) + + const apkPath = join( + projectDirectory, + 'app', + 'build', + 'outputs', + 'apk', + 'release', + signed ? 'app-release.apk' : 'app-release-unsigned.apk', + ) + showOutro(`Built ${apkPath}`) + } catch (error) { + showCancel(`${error}`) + process.exitCode = 1 + } +} diff --git a/src/webshell/webshell-feature-index.ts b/src/webshell/webshell-feature-index.ts new file mode 100644 index 0000000..413403c --- /dev/null +++ b/src/webshell/webshell-feature-index.ts @@ -0,0 +1,3 @@ +export type { WebshellBuildCommandOptions, WebshellInitCommandOptions } from './data-access/webshell-types.ts' +export { runWebshellBuild } from './webshell-feature-build.ts' +export { runWebshellInit } from './webshell-feature-init.ts' diff --git a/src/webshell/webshell-feature-init.ts b/src/webshell/webshell-feature-init.ts new file mode 100644 index 0000000..9fabb1f --- /dev/null +++ b/src/webshell/webshell-feature-init.ts @@ -0,0 +1,276 @@ +import { access } from 'node:fs/promises' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { cancel, log as clackLog, intro, outro } from '@clack/prompts' +import type { CommandRunner } from '../core/data-access/command-types.ts' +import { formatCliCommand } from '../core/util/format-cli-command.ts' +import type { TextPrompt } from '../emulator/ui/emulator-ui-prompt-types.ts' +import { applyWebshellBranding } from './data-access/apply-branding.ts' +import { copyWebshellTemplate } from './data-access/copy-template.ts' +import { findWebshellTemplateDir } from './data-access/find-template-dir.ts' +import { + ensureKeystore, + WEBSHELL_KEY_PASSWORD_ENV, + WEBSHELL_KEYSTORE_PASSWORD_ENV, + type WebshellSigningPasswords, +} from './data-access/keystore.ts' +import { writeWebshellProjectConfig } from './data-access/project-config.ts' +import { readWebshellManifest, type WebshellManifest } from './data-access/read-manifest.ts' +import { + deriveWebshellPackageName, + renameAndroidPackage, + validateWebshellApplicationId, +} from './data-access/rename-android-package.ts' +import type { WebshellInitCommandOptions } from './data-access/webshell-types.ts' +import { + deriveWebshellApplicationIdSuggestion, + normalizeWebshellUrl, + promptWebshellApplicationId, + promptWebshellAppName, + promptWebshellKeystoreAlias, + promptWebshellKeystorePath, + promptWebshellUrl, + promptWebshellVersionCode, + promptWebshellVersionName, + resolveWebshellCreatePasswords, + WEBSHELL_DEFAULT_KEYSTORE_FILENAME, +} from './ui/webshell-ui-prompts.ts' + +export interface RunWebshellInitDependencies { + applyBranding?: typeof applyWebshellBranding + cancel?: (message: string) => void + copyTemplate?: typeof copyWebshellTemplate + createKeystore?: typeof ensureKeystore + env?: Partial> + fetchFn?: (url: URL) => Promise + fileExists?: (path: string) => Promise + findTemplateDir?: typeof findWebshellTemplateDir + formatCommand?: typeof formatCliCommand + intro?: (message: string) => void + log?: (message: string) => void + outro?: (message: string) => void + readManifest?: typeof readWebshellManifest + renamePackage?: typeof renameAndroidPackage + resolvePasswords?: typeof resolveWebshellCreatePasswords + runCommand?: CommandRunner + runText?: TextPrompt + warn?: (message: string) => void + writeProjectConfig?: typeof writeWebshellProjectConfig +} + +/** + * Generates an Android WebView-shell project for a web app. Every value resolves flag > manifest > + * prompt, so a fully flagged invocation (or one seeded by a complete Bubblewrap manifest) never + * prompts — that is what lets CI drive it. + */ +export async function runWebshellInit( + options: WebshellInitCommandOptions = {}, + dependencies: RunWebshellInitDependencies = {}, +) { + const { + applyBranding = applyWebshellBranding, + cancel: showCancel = cancel, + copyTemplate = copyWebshellTemplate, + createKeystore = ensureKeystore, + env = process.env, + fetchFn = (url: URL) => fetch(url, { signal: AbortSignal.timeout(30_000) }), + fileExists = defaultFileExists, + findTemplateDir = findWebshellTemplateDir, + formatCommand = formatCliCommand, + intro: showIntro = intro, + log = clackLog.message, + outro: showOutro = outro, + readManifest = readWebshellManifest, + renamePackage = renameAndroidPackage, + resolvePasswords = resolveWebshellCreatePasswords, + runCommand, + runText, + warn = clackLog.warn, + writeProjectConfig = writeWebshellProjectConfig, + } = dependencies + + try { + showIntro('solana-mobile webshell init') + + const targetDirectory = resolve(options.directory ?? '.') + + // A Bubblewrap manifest's linked web manifest is loaded too; a broken link only costs branding, never the init. + const manifest = options.manifest ? await readManifest(options.manifest, { fetchFn }) : undefined + let webManifest = manifest?.kind === 'web' ? manifest : undefined + if (manifest && !webManifest && manifest.webManifestUrl) { + try { + webManifest = await readManifest(manifest.webManifestUrl, { fetchFn }) + } catch (error) { + warn(`Skipping the linked web manifest: ${error instanceof Error ? error.message : error}`) + } + } + if (manifest) { + log(`Loaded ${manifest.kind === 'bubblewrap' ? 'Bubblewrap' : 'web'} manifest from ${manifest.source}`) + } + + const seededUrl = trimmedOrUndefined(options.url) ?? manifest?.url ?? webManifest?.url + const url = seededUrl ? normalizeWebshellUrl(seededUrl) : await promptWebshellUrl(undefined, runText) + if (url === undefined) { + return + } + + let applicationId = trimmedOrUndefined(options.applicationId) ?? manifest?.applicationId + if (applicationId) { + const validationError = validateWebshellApplicationId(applicationId) + if (validationError) { + throw new Error(validationError) + } + } else { + const suggestion = deriveWebshellApplicationIdSuggestion(url) + if (suggestion.note) { + log(suggestion.note) + } + applicationId = await promptWebshellApplicationId(suggestion.applicationId, runText) + if (applicationId === undefined) { + return + } + } + + const packageName = deriveWebshellPackageName(applicationId) + if (packageName.note) { + log(packageName.note) + } + + const appName = + trimmedOrUndefined(options.appName) ?? + manifest?.appName ?? + webManifest?.appName ?? + (await promptWebshellAppName(undefined, runText)) + if (appName === undefined) { + return + } + + const versionCode = + options.versionCode ?? manifest?.versionCode ?? (await promptWebshellVersionCode(undefined, runText)) + if (versionCode === undefined) { + return + } + + const versionName = + trimmedOrUndefined(options.versionName) ?? + manifest?.versionName ?? + (await promptWebshellVersionName(undefined, runText)) + if (versionName === undefined) { + return + } + + const enteredKeystorePath = + trimmedOrUndefined(options.keystorePath) ?? + resolveManifestKeystorePath(manifest) ?? + (await promptWebshellKeystorePath(join(targetDirectory, WEBSHELL_DEFAULT_KEYSTORE_FILENAME), runText)) + if (enteredKeystorePath === undefined) { + return + } + // A relative keystore path belongs to the project being generated, not to wherever init was invoked. + const keystorePath = resolve(targetDirectory, enteredKeystorePath) + + const keystoreAlias = + trimmedOrUndefined(options.keystoreAlias) ?? + manifest?.keystoreAlias ?? + (await promptWebshellKeystoreAlias(undefined, runText)) + if (keystoreAlias === undefined) { + return + } + + // Resolved before any project files are written, so a cancelled password prompt leaves no + // half-generated directory behind. + let keystorePasswords: WebshellSigningPasswords | undefined + if (!(await fileExists(keystorePath))) { + const passwords = await resolvePasswords({ env }) + if (typeof passwords === 'symbol') { + showCancel('Cancelled') + process.exitCode = 1 + return + } + keystorePasswords = passwords + } + + await copyTemplate(findTemplateDir(), targetDirectory, { force: options.force }) + await renamePackage(targetDirectory, { + applicationId, + appName, + keystoreAlias, + keystorePath, + projectName: basename(targetDirectory) || appName, + url, + versionCode, + versionName, + }) + await applyBranding(targetDirectory, webManifest, { fetchFn, logWarning: warn }) + + if (keystorePasswords === undefined) { + log(`Using the existing signing keystore at ${keystorePath}`) + } else { + log( + `Creating a signing keystore at ${keystorePath}. Keep the file and its passwords safe: app updates must be signed with the same key.`, + ) + await createKeystore( + { + appName, + keyPassword: keystorePasswords.keyPassword, + keystoreAlias, + keystorePassword: keystorePasswords.keystorePassword, + keystorePath, + }, + runCommand ? { runCommand } : {}, + ) + } + + // Stored Bubblewrap-style: project-relative when the keystore lives inside the project, so the + // generated project stays portable across machines. + const keystorePathInProject = relative(targetDirectory, keystorePath) + const savedKeystorePath = + keystorePathInProject.startsWith('..') || isAbsolute(keystorePathInProject) ? keystorePath : keystorePathInProject + + await writeProjectConfig(targetDirectory, { + applicationId, + appName, + keystoreAlias, + keystorePath: savedKeystorePath, + url, + webManifestUrl: webManifest?.webManifestUrl ?? manifest?.webManifestUrl, + }) + + log(`Set ${WEBSHELL_KEYSTORE_PASSWORD_ENV} and ${WEBSHELL_KEY_PASSWORD_ENV} to skip password prompts during builds`) + showOutro(`Generated ${appName} in ${targetDirectory}. Next: ${formatCommand(`webshell build ${targetDirectory}`)}`) + } catch (error) { + showCancel(`${error}`) + process.exitCode = 1 + } +} + +function defaultFileExists(path: string): Promise { + return access(path).then( + () => true, + () => false, + ) +} + +/** A relative signingKey path in a local twa-manifest.json is relative to that file, not to our cwd. */ +function resolveManifestKeystorePath(manifest?: WebshellManifest): string | undefined { + const candidate = manifest?.keystorePath?.trim() + if (!candidate) { + return undefined + } + + if (isAbsolute(candidate)) { + return candidate + } + + const source = manifest?.source + if (source && !source.startsWith('http://') && !source.startsWith('https://')) { + return resolve(dirname(source), candidate) + } + + return candidate +} + +function trimmedOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim() + + return trimmed ? trimmed : undefined +} diff --git a/templates/webshell-android/app/.gitignore b/templates/webshell-android/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/templates/webshell-android/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/templates/webshell-android/app/build.gradle.kts b/templates/webshell-android/app/build.gradle.kts new file mode 100644 index 0000000..7125197 --- /dev/null +++ b/templates/webshell-android/app/build.gradle.kts @@ -0,0 +1,121 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +fun String.escapeForBuildConfig(): String = replace("\\", "\\\\").replace("\"", "\\\"") + +val webShellUrl = + (findProperty("SOLANA_MOBILE_URL") as String?) + ?.trim() + ?.ifBlank { null } + ?: "https://example.com/" +val webShellApplicationId = + (findProperty("SOLANA_MOBILE_APPLICATION_ID") as String?) + ?.trim() + ?.ifBlank { null } + ?: "com.example.webshell" +val webShellVersionCode = + (findProperty("SOLANA_MOBILE_VERSION_CODE") as String?) + ?.trim() + ?.ifBlank { null } + ?.toIntOrNull() + ?: 1 +val webShellVersionName = + (findProperty("SOLANA_MOBILE_VERSION_NAME") as String?) + ?.trim() + ?.ifBlank { null } + ?: "1.0" +val webShellSigningStoreFile = + (findProperty("SOLANA_MOBILE_KEYSTORE_PATH") as String?) + ?.trim() + ?.ifBlank { null } +val webShellSigningStorePassword = + (findProperty("SOLANA_MOBILE_KEYSTORE_PASSWORD") as String?) + ?.trim() + ?.ifBlank { null } + ?: System + .getenv("SOLANA_MOBILE_KEYSTORE_PASSWORD") + ?.trim() + ?.ifBlank { null } +val webShellSigningKeyAlias = + (findProperty("SOLANA_MOBILE_KEYSTORE_ALIAS") as String?) + ?.trim() + ?.ifBlank { null } +val webShellSigningKeyPassword = + (findProperty("SOLANA_MOBILE_KEY_PASSWORD") as String?) + ?.trim() + ?.ifBlank { null } + ?: System + .getenv("SOLANA_MOBILE_KEY_PASSWORD") + ?.trim() + ?.ifBlank { null } +val hasReleaseSigning = + webShellSigningStoreFile != null && + webShellSigningStorePassword != null && + webShellSigningKeyAlias != null + +android { + namespace = "com.example.webshell" + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = webShellApplicationId + minSdk = 28 + targetSdk = 37 + versionCode = webShellVersionCode + versionName = webShellVersionName + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "SOLANA_MOBILE_URL", "\"${webShellUrl.escapeForBuildConfig()}\"") + } + + signingConfigs { + if (hasReleaseSigning) { + create("webShellRelease") { + storeFile = file(webShellSigningStoreFile!!) + storePassword = webShellSigningStorePassword + keyAlias = webShellSigningKeyAlias + keyPassword = webShellSigningKeyPassword ?: webShellSigningStorePassword + } + } + } + + buildTypes { + release { + isMinifyEnabled = true + if (hasReleaseSigning) { + signingConfig = signingConfigs.getByName("webShellRelease") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.core.splashscreen) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.swiperefreshlayout) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/templates/webshell-android/app/proguard-rules.pro b/templates/webshell-android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/templates/webshell-android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/templates/webshell-android/app/src/main/AndroidManifest.xml b/templates/webshell-android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9769528 --- /dev/null +++ b/templates/webshell-android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/templates/webshell-android/app/src/main/java/com/example/webshell/MainActivity.kt b/templates/webshell-android/app/src/main/java/com/example/webshell/MainActivity.kt new file mode 100644 index 0000000..3dbcfd9 --- /dev/null +++ b/templates/webshell-android/app/src/main/java/com/example/webshell/MainActivity.kt @@ -0,0 +1,408 @@ +package com.example.webshell + +import android.annotation.SuppressLint +import android.os.Bundle +import android.util.Log +import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.example.webshell.ui.theme.WebShellTheme +import org.json.JSONObject + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + enableEdgeToEdge() + WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG) + setContent { + WebShellTheme { + WebShellScreen() + } + } + } +} + +@SuppressLint("SetJavaScriptEnabled") +@Composable +fun WebShellScreen() { + val context = LocalContext.current + val startUrl = remember { normalizeHttpUrl() } + if (startUrl == null) { + Log.e(TAG, "SOLANA_MOBILE_URL is not a valid http(s) URL") + return + } + val scopeHost = remember(startUrl) { startUrl.toUri().host.orEmpty() } + val refreshIndicatorColor = MaterialTheme.colorScheme.primary.toArgb() + val refreshIndicatorBackgroundColor = MaterialTheme.colorScheme.surface.toArgb() + + var progress by remember { mutableFloatStateOf(0f) } + var isLoading by remember { mutableStateOf(true) } + var isRefreshing by remember { mutableStateOf(false) } + var hasError by remember { mutableStateOf(false) } + var showSplash by remember { mutableStateOf(true) } + + val webView = + remember { + WebView(context).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.databaseEnabled = true + settings.loadWithOverviewMode = false + settings.useWideViewPort = false + settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW + settings.builtInZoomControls = true + settings.displayZoomControls = false + settings.setSupportZoom(true) + settings.javaScriptCanOpenWindowsAutomatically = true + settings.setSupportMultipleWindows(true) + settings.offscreenPreRaster = true + + val originalUa = settings.userAgentString + settings.userAgentString = + appendUserAgentMarker( + baseUserAgent = originalUa, + ) + + if (BuildConfig.DEBUG) { + Log.i(TAG, "UA original: $originalUa") + Log.i(TAG, "UA verify: ${settings.userAgentString}") + } + + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + + webChromeClient = + WebShellChromeClient( + onProgressChanged = { newProgress -> + progress = newProgress / 100f + if (newProgress > 0) showSplash = false + isLoading = newProgress < 100 + }, + isDebug = BuildConfig.DEBUG, + ) + + webViewClient = + object : WebShellViewClient(context, scopeHostProvider = { scopeHost }) { + override fun onPageFinished( + view: WebView, + url: String?, + ) { + super.onPageFinished(view, url) + hasError = false + isRefreshing = false + probeViewportAndMaybePatch(view, BuildConfig.DEBUG) + } + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + if (request?.isForMainFrame == true) { + hasError = true + isRefreshing = false + } + } + } + + loadUrl(startUrl) + } + } + val swipeRefreshLayout = + remember(webView, refreshIndicatorColor, refreshIndicatorBackgroundColor) { + SwipeRefreshLayout(context).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setColorSchemeColors( + refreshIndicatorColor, + ) + setProgressBackgroundColorSchemeColor(refreshIndicatorBackgroundColor) + setOnChildScrollUpCallback { _, _ -> webView.canScrollVertically(-1) } + setOnRefreshListener { + hasError = false + isLoading = true + isRefreshing = true + webView.reload() + } + addView(webView) + } + } + + DisposableEffect(Unit) { + onDispose { + swipeRefreshLayout.removeView(webView) + webView.destroy() + } + } + + BackHandler(enabled = webView.canGoBack()) { + webView.goBack() + } + + WebViewLayer( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.systemBars), + swipeRefreshLayout = swipeRefreshLayout, + isRefreshing = isRefreshing, + isLoading = isLoading, + progress = progress, + hasError = hasError, + showSplash = showSplash, + onRetry = { + hasError = false + isLoading = true + isRefreshing = false + webView.reload() + }, + ) +} + +@Composable +private fun WebViewLayer( + modifier: Modifier, + swipeRefreshLayout: SwipeRefreshLayout, + isRefreshing: Boolean, + isLoading: Boolean, + progress: Float, + hasError: Boolean, + showSplash: Boolean, + onRetry: () -> Unit, +) { + Box(modifier = modifier) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { swipeRefreshLayout }, + update = { view -> + view.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + view.isEnabled = !hasError + view.isRefreshing = isRefreshing + }, + ) + + if (isLoading && !hasError) { + LinearProgressIndicator( + progress = { progress }, + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + ) + } + + if (hasError) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Unable to load page", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text("Retry") + } + } + } + } + + AnimatedVisibility( + visible = showSplash, + exit = fadeOut(), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } +} + +private fun probeViewportAndMaybePatch( + webView: WebView, + isDebug: Boolean, +) { + webView.evaluateJavascript(VIEWPORT_PROBE_AND_PATCH_SCRIPT) { rawResult -> + val decoded = decodeJavascriptStringResult(rawResult) + val parsed = runCatching { JSONObject(decoded) }.getOrNull() + val isBroken = parsed?.optBoolean("broken") == true + if (isDebug || isBroken) { + Log.i(TAG, "[VP] ${parsed?.toString() ?: decoded}") + } + } +} + +private fun decodeJavascriptStringResult(rawResult: String?): String { + if (rawResult.isNullOrBlank() || rawResult == "null") return "" + return runCatching { JSONObject("{\"value\":$rawResult}").getString("value") } + .getOrDefault(rawResult) +} + +private fun appendUserAgentMarker(baseUserAgent: String): String { + val marker = "Solana Mobile Web Shell" + if (marker.isEmpty()) return baseUserAgent.trim() + return if (baseUserAgent.contains(marker)) { + baseUserAgent.trim() + } else { + "${baseUserAgent.trim()} $marker".trim() + } +} + +private fun normalizeHttpUrl(): String? { + val trimmed = BuildConfig.SOLANA_MOBILE_URL.trim() + if (trimmed.isEmpty()) return null + val withScheme = + if ("://" in trimmed) { + trimmed + } else { + "https://$trimmed" + } + val uri = withScheme.toUri() + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return null + if (uri.host.isNullOrBlank()) return null + return uri.toString() +} + +private const val TAG = "WebShell" + +private val VIEWPORT_PROBE_AND_PATCH_SCRIPT = + """ + (function () { + function measureViewport() { + var probe = document.createElement('div'); + probe.style.cssText = 'position:fixed;top:0;left:0;width:0;visibility:hidden;pointer-events:none;'; + document.documentElement.appendChild(probe); + probe.style.height = '100vh'; + var vh = probe.getBoundingClientRect().height; + probe.style.height = '100dvh'; + var dvh = probe.getBoundingClientRect().height; + document.documentElement.removeChild(probe); + return { + innerHeight: window.innerHeight || 0, + visualViewportHeight: window.visualViewport ? window.visualViewport.height : 0, + vh: vh, + dvh: dvh + }; + } + + function updateViewportVars() { + var px = Math.max(window.innerHeight || 0, 1) + 'px'; + document.documentElement.style.setProperty('--webshell-vh-px', px); + document.documentElement.style.setProperty('--webshell-dvh-px', px); + } + + function applyFallbackPatch() { + updateViewportVars(); + if (!window.__webshell_viewport_resize_hook__) { + window.__webshell_viewport_resize_hook__ = true; + window.addEventListener('resize', updateViewportVars); + window.addEventListener('orientationchange', updateViewportVars); + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', updateViewportVars); + } + } + + var style = document.getElementById('__webshell_viewport_patch_style__'); + if (!style) { + style = document.createElement('style'); + style.id = '__webshell_viewport_patch_style__'; + style.textContent = [ + ':root { --webshell-vh-px: 100vh; --webshell-dvh-px: 100vh; }', + 'html, body, #root, #app { min-height: var(--webshell-dvh-px) !important; height: auto !important; }', + '[class~="h-screen"], [class~="h-dvh"], [class*="h-screen"], [class*="h-dvh"] { height: var(--webshell-dvh-px) !important; }', + '[class~="min-h-screen"], [class~="min-h-dvh"], [class*="min-h-screen"], [class*="min-h-dvh"] { min-height: var(--webshell-dvh-px) !important; }', + '[class~="max-h-screen"], [class~="max-h-dvh"], [class*="max-h-screen"], [class*="max-h-dvh"] { max-height: var(--webshell-dvh-px) !important; }' + ].join('\\n'); + document.documentElement.appendChild(style); + } + + var classElements = document.querySelectorAll('[class]'); + for (var i = 0; i < classElements.length; i++) { + var className = classElements[i].className; + if (typeof className !== 'string') continue; + if (className.indexOf('max-h-[calc(100dvh-1rem)]') !== -1 || className.indexOf('max-h-[calc(100vh-1rem)]') !== -1) { + classElements[i].style.maxHeight = 'calc(var(--webshell-dvh-px) - 1rem)'; + } + } + } + + var before = measureViewport(); + var broken = before.innerHeight > 0 && (before.vh <= 1 || before.dvh <= 1); + if (broken) { + applyFallbackPatch(); + } + var after = measureViewport(); + return JSON.stringify({ + broken: broken, + patched: broken, + before: before, + after: after + }); + })(); + """.trimIndent() diff --git a/templates/webshell-android/app/src/main/java/com/example/webshell/WebShellChromeClient.kt b/templates/webshell-android/app/src/main/java/com/example/webshell/WebShellChromeClient.kt new file mode 100644 index 0000000..23b7be5 --- /dev/null +++ b/templates/webshell-android/app/src/main/java/com/example/webshell/WebShellChromeClient.kt @@ -0,0 +1,96 @@ +package com.example.webshell + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Message +import android.util.Log +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +class WebShellChromeClient( + private val onProgressChanged: (Int) -> Unit, + private val isDebug: Boolean, +) : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + onProgressChanged.invoke(newProgress) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + // Console messages are page-controlled and may contain sensitive + // data — never write them to logcat in release builds. + if (!isDebug) { + return true + } + val level = + when (consoleMessage.messageLevel()) { + ConsoleMessage.MessageLevel.ERROR -> Log.ERROR + ConsoleMessage.MessageLevel.WARNING -> Log.WARN + ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG + else -> Log.INFO + } + Log.println( + level, + TAG, + "${consoleMessage.message()} — ${consoleMessage.sourceId()}:${consoleMessage.lineNumber()}", + ) + return true + } + + override fun onCreateWindow( + view: WebView, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message, + ): Boolean { + // Handle window.open() and target="_blank" links (used by OAuth + // flows like Privy, social logins, etc.). Open the URL in the + // system browser so the user can complete the flow there. The + // temporary WebView exists only to capture the popup's target URL + // and is destroyed once the URL has been handed off. + val newWebView = WebView(view.context) + newWebView.webViewClient = + object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + // The popup URL is page-controlled: only browsable http(s) targets may launch, + // matching the main frame's intent policy in WebShellViewClient. + val scheme = request.url.scheme?.lowercase() + if (scheme == "http" || scheme == "https") { + try { + val intent = Intent(Intent.ACTION_VIEW, request.url) + intent.addCategory(Intent.CATEGORY_BROWSABLE) + view.context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + if (isDebug) { + Log.w(TAG, "No activity found to handle popup URL: ${request.url}") + } + } + } + view.post { view.destroy() } + return true + } + } + newWebView.webChromeClient = + object : WebChromeClient() { + override fun onCloseWindow(window: WebView) { + window.destroy() + } + } + val transport = resultMsg.obj as WebView.WebViewTransport + transport.webView = newWebView + resultMsg.sendToTarget() + return true + } + + private companion object { + const val TAG = "WebShell" + } +} diff --git a/templates/webshell-android/app/src/main/java/com/example/webshell/WebShellViewClient.kt b/templates/webshell-android/app/src/main/java/com/example/webshell/WebShellViewClient.kt new file mode 100644 index 0000000..6125f44 --- /dev/null +++ b/templates/webshell-android/app/src/main/java/com/example/webshell/WebShellViewClient.kt @@ -0,0 +1,112 @@ +package com.example.webshell + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.util.Log +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.core.net.toUri + +open class WebShellViewClient( + private val context: Context, + private val scopeHostProvider: () -> String, +) : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest, + ): Boolean { + val url = request.url + val scheme = url.scheme ?: return false + + // Never intercept subframe (iframe) navigation — this breaks + // embedded SDKs like Privy that use cross-origin iframes. + if (!request.isForMainFrame) return false + + return when (scheme) { + "solana-wallet" -> { + if (launchExternal(Intent(Intent.ACTION_VIEW, url))) { + // The wallet protocol library uses window.blur to detect that the + // wallet app opened. In a WebView the blur event never fires + // naturally, so we dispatch a synthetic one to unblock the + // detection promise (3-second timeout in startSession.ts). + view.evaluateJavascript("window.dispatchEvent(new Event('blur'))", null) + } + true + } + + "intent" -> { + handleIntentScheme(url.toString()) + true + } + + "blob", "javascript" -> { + false + } + + "http", "https" -> { + if (url.host.equals(scopeHostProvider.invoke(), ignoreCase = true)) { + false + } else { + launchExternal(Intent(Intent.ACTION_VIEW, url)) + true + } + } + + else -> { + launchExternal(Intent(Intent.ACTION_VIEW, url)) + true + } + } + } + + private fun handleIntentScheme(url: String) { + try { + val intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME) + // Sanitize the page-controlled intent: only implicit, browsable + // targets may launch — never explicit components, selector + // intents, or URI permission grants. + intent.addCategory(Intent.CATEGORY_BROWSABLE) + intent.component = null + intent.selector = null + intent.removeFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION, + ) + if (!launchExternal(intent)) { + val fallback = intent.getStringExtra("browser_fallback_url")?.toUri() + if (fallback != null) { + // Only honor http/https fallback URLs — anything else + // (javascript:, intent:, file:) is an injection vector. + val fallbackScheme = fallback.scheme?.lowercase() + if (fallbackScheme == "http" || fallbackScheme == "https") { + launchExternal(Intent(Intent.ACTION_VIEW, fallback)) + } + } + } + } catch (_: Exception) { + // Malformed intent URL — silently ignore + } + } + + private fun launchExternal(intent: Intent): Boolean { + if (context !is Activity) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + Log.w(TAG, "No activity found to handle URL with scheme: ${intent.data?.scheme}") + false + } + } + + private companion object { + const val TAG = "WebShell" + } +} diff --git a/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Color.kt b/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Color.kt new file mode 100644 index 0000000..52d45cc --- /dev/null +++ b/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.webshell.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) diff --git a/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Theme.kt b/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Theme.kt new file mode 100644 index 0000000..85fb82f --- /dev/null +++ b/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.webshell.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun WebShellTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Type.kt b/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Type.kt new file mode 100644 index 0000000..1ff4c98 --- /dev/null +++ b/templates/webshell-android/app/src/main/java/com/example/webshell/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.webshell.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) diff --git a/templates/webshell-android/app/src/main/res/drawable/ic_launcher_background.xml b/templates/webshell-android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..80580cb --- /dev/null +++ b/templates/webshell-android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/templates/webshell-android/app/src/main/res/drawable/ic_launcher_foreground.xml b/templates/webshell-android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..7706ab9 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/templates/webshell-android/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/templates/webshell-android/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/webshell-android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/templates/webshell-android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..b3e26b4 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/templates/webshell-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/templates/webshell-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/templates/webshell-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/templates/webshell-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/templates/webshell-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/templates/webshell-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/templates/webshell-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/templates/webshell-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/templates/webshell-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/templates/webshell-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/templates/webshell-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/templates/webshell-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/templates/webshell-android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/templates/webshell-android/app/src/main/res/values/colors.xml b/templates/webshell-android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1287356 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #3DDC84 + #3DDC84 + #FF000000 + #FFFFFFFF + diff --git a/templates/webshell-android/app/src/main/res/values/strings.xml b/templates/webshell-android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7972130 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Solana Mobile Web Shell + diff --git a/templates/webshell-android/app/src/main/res/values/themes.xml b/templates/webshell-android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..cc3c657 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/templates/webshell-android/app/src/main/res/xml/backup_rules.xml b/templates/webshell-android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..d763a5f --- /dev/null +++ b/templates/webshell-android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/templates/webshell-android/app/src/main/res/xml/data_extraction_rules.xml b/templates/webshell-android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..8ed0b66 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/templates/webshell-android/app/src/main/res/xml/network_security_config.xml b/templates/webshell-android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..b565b61 --- /dev/null +++ b/templates/webshell-android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + + + + + + 127.0.0.1 + localhost + + diff --git a/templates/webshell-android/build.gradle.kts b/templates/webshell-android/build.gradle.kts new file mode 100644 index 0000000..b546c74 --- /dev/null +++ b/templates/webshell-android/build.gradle.kts @@ -0,0 +1,5 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.compose) apply false +} diff --git a/templates/webshell-android/gitignore b/templates/webshell-android/gitignore new file mode 100644 index 0000000..44ac267 --- /dev/null +++ b/templates/webshell-android/gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +.local/ + +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties + + +.idea diff --git a/templates/webshell-android/gradle.properties b/templates/webshell-android/gradle.properties new file mode 100644 index 0000000..2f56289 --- /dev/null +++ b/templates/webshell-android/gradle.properties @@ -0,0 +1,33 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true + +# Solana Mobile Web Shell template configuration +# Default URL opened by the WebView. +SOLANA_MOBILE_URL=https://example.com/ +# Application id for the generated APK package. +SOLANA_MOBILE_APPLICATION_ID=com.example.webshell +# Android versionCode for releases and updates. +SOLANA_MOBILE_VERSION_CODE=1 +# Android versionName shown in app metadata. +SOLANA_MOBILE_VERSION_NAME=1.0 diff --git a/templates/webshell-android/gradle/libs.versions.toml b/templates/webshell-android/gradle/libs.versions.toml new file mode 100644 index 0000000..2a81082 --- /dev/null +++ b/templates/webshell-android/gradle/libs.versions.toml @@ -0,0 +1,34 @@ +[versions] +agp = "9.3.2" +coreKtx = "1.19.0" +coreSplashscreen = "1.2.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.11.0" +activityCompose = "1.13.0" +kotlin = "2.4.10" +composeBom = "2026.08.00" +swipeRefreshLayout = "1.2.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/templates/webshell-android/gradle/wrapper/gradle-wrapper.jar b/templates/webshell-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/templates/webshell-android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/templates/webshell-android/gradle/wrapper/gradle-wrapper.properties b/templates/webshell-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..235fa4e --- /dev/null +++ b/templates/webshell-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Mon Feb 16 14:26:41 MYT 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/templates/webshell-android/gradlew b/templates/webshell-android/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/templates/webshell-android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/templates/webshell-android/gradlew.bat b/templates/webshell-android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/templates/webshell-android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/templates/webshell-android/settings.gradle.kts b/templates/webshell-android/settings.gradle.kts new file mode 100644 index 0000000..c9b9123 --- /dev/null +++ b/templates/webshell-android/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WebShell" +include(":app") diff --git a/test/core.test.ts b/test/core.test.ts index f3497fe..f4f6c09 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -283,6 +283,7 @@ describe('app', () => { 'emulator', 'localnet', 'templates', + 'webshell', ]) }) diff --git a/test/device.test.ts b/test/device.test.ts index 12245bf..8f09ae1 100644 --- a/test/device.test.ts +++ b/test/device.test.ts @@ -286,7 +286,7 @@ describe('runDeviceOpen', () => { const { calls, runCommand } = recordingRunner(oneDeviceWorld) const { dependencies } = openDependencies(runCommand) - await runDeviceOpen({ url: 'https://solanamobile.com' }, dependencies) + await runDeviceOpen({ url: 'https://example.com' }, dependencies) expect(commandsMatching(calls, 'reverse')).toEqual([]) expect(commandsMatching(calls, 'am')).toHaveLength(1) @@ -306,7 +306,7 @@ describe('runDeviceOpen', () => { const runSelect: SelectPrompt = async () => 'emulator-5554' const { dependencies } = openDependencies(runCommand, { runSelect }) - await runDeviceOpen({ url: 'https://solanamobile.com' }, dependencies) + await runDeviceOpen({ url: 'https://example.com' }, dependencies) expect(commandsMatching(calls, 'am').at(0)?.at(2)).toBe('emulator-5554') }) @@ -344,8 +344,8 @@ describe('runDeviceOpen', () => { world: oneDeviceWorld, }, { - expected: 'Not forwarding: https://solanamobile.com/ does not name an explicit localhost port', - options: { url: 'https://solanamobile.com/', verbose: true }, + expected: 'Not forwarding: https://example.com/ does not name an explicit localhost port', + options: { url: 'https://example.com/', verbose: true }, world: oneDeviceWorld, }, { diff --git a/test/fixtures/webshell/manifest.json b/test/fixtures/webshell/manifest.json new file mode 100644 index 0000000..98c96dc --- /dev/null +++ b/test/fixtures/webshell/manifest.json @@ -0,0 +1,21 @@ +{ + "background_color": "#abcdef", + "display": "standalone", + "icons": [ + { + "purpose": "any maskable", + "sizes": "192x192", + "src": "icons/icon-192.png", + "type": "image/png" + }, + { + "sizes": "512x512", + "src": "icons/icon-512.png", + "type": "image/png" + } + ], + "name": "Solana Mobile Example Application", + "short_name": "Example", + "start_url": "/app/start", + "theme_color": "#123456" +} diff --git a/test/fixtures/webshell/twa-manifest.json b/test/fixtures/webshell/twa-manifest.json new file mode 100644 index 0000000..911324c --- /dev/null +++ b/test/fixtures/webshell/twa-manifest.json @@ -0,0 +1,21 @@ +{ + "appVersionCode": 12, + "appVersionName": "1.2.0", + "backgroundColor": "#ffffff", + "display": "standalone", + "fallbackType": "customtabs", + "generatorApp": "bubblewrap-cli", + "host": "app.example.com", + "iconUrl": "https://app.example.com/icons/icon-512.png", + "launcherName": "Wallet Shell", + "name": "Wallet Shell for Android", + "orientation": "default", + "packageId": "com.example.walletshell", + "signingKey": { + "alias": "release", + "path": "./release.keystore" + }, + "startUrl": "/launch?mode=prod", + "themeColor": "#9945ff", + "webManifestUrl": "https://app.example.com/manifest.json" +} diff --git a/test/webshell.test.ts b/test/webshell.test.ts new file mode 100644 index 0000000..91c39b0 --- /dev/null +++ b/test/webshell.test.ts @@ -0,0 +1,1451 @@ +import { describe, expect, test } from 'bun:test' +import { existsSync } from 'node:fs' +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createApp } from '../src/app.ts' +import type { CommandRunner, InteractiveRunCommandOptions } from '../src/core/data-access/command-types.ts' +import { runInteractiveExecutable } from '../src/core/data-access/run-executable.ts' +import type { TextPrompt } from '../src/emulator/ui/emulator-ui-prompt-types.ts' +import { applyWebshellBranding } from '../src/webshell/data-access/apply-branding.ts' +import { copyWebshellTemplate } from '../src/webshell/data-access/copy-template.ts' +import { findWebshellTemplateDir } from '../src/webshell/data-access/find-template-dir.ts' +import { ensureKeystore, resolveWebshellSigningPasswords } from '../src/webshell/data-access/keystore.ts' +import { + readWebshellProjectConfig, + type WebshellProjectConfig, + writeWebshellProjectConfig, +} from '../src/webshell/data-access/project-config.ts' +import { readWebshellManifest } from '../src/webshell/data-access/read-manifest.ts' +import { + renameAndroidPackage, + validateWebshellApplicationId, +} from '../src/webshell/data-access/rename-android-package.ts' +import type { + WebshellBuildCommandOptions, + WebshellInitCommandOptions, +} from '../src/webshell/data-access/webshell-types.ts' +import { + deriveWebshellApplicationIdSuggestion, + resolveWebshellCreatePasswords, +} from '../src/webshell/ui/webshell-ui-prompts.ts' +import { type RunWebshellBuildDependencies, runWebshellBuild } from '../src/webshell/webshell-feature-build.ts' +import { type RunWebshellInitDependencies, runWebshellInit } from '../src/webshell/webshell-feature-init.ts' + +const webshellFixtures = join(import.meta.dir, 'fixtures', 'webshell') + +async function withTempDir(run: (directory: string) => Promise) { + const tempDirectory = await mkdtemp(join(tmpdir(), 'webshell-test-')) + + try { + await run(tempDirectory) + } finally { + await rm(tempDirectory, { force: true, recursive: true }) + } +} + +/** Copies the real vendored template into a scratch project directory, then hands it to the test. */ +async function withGeneratedProject(run: (projectDirectory: string) => Promise) { + await withTempDir(async (directory) => { + const projectDirectory = join(directory, 'generated') + await copyWebshellTemplate(findWebshellTemplateDir(), projectDirectory) + await run(projectDirectory) + }) +} + +async function walkFiles(directory: string): Promise { + const files: string[] = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name) + if (entry.isDirectory()) { + files.push(...(await walkFiles(entryPath))) + } else { + files.push(entryPath) + } + } + + return files +} + +const tinyPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aM6kAAAAASUVORK5CYII=', + 'base64', +) + +/** Records every command so tests can assert on the exact invocations without spawning anything. */ +function recordingRunner(): { + calls: string[][] + envs: (Record | undefined)[] + runCommand: CommandRunner +} { + const calls: string[][] = [] + const envs: (Record | undefined)[] = [] + const runCommand: CommandRunner = async (cmd, options) => { + calls.push([...cmd]) + envs.push(options?.env) + return '' + } + + return { calls, envs, runCommand } +} + +/** Serves a JSON payload for any URL while recording what was requested — tests never hit the network. */ +function fakeFetch(payload: unknown): { calls: string[]; fetchFn: (url: URL) => Promise } { + const calls: string[] = [] + const fetchFn = async (url: URL) => { + calls.push(url.toString()) + return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }) + } + + return { calls, fetchFn } +} + +describe('findWebshellTemplateDir', () => { + test('resolves the vendored Android template', () => { + const dir = findWebshellTemplateDir() + expect(existsSync(join(dir, 'settings.gradle.kts'))).toBe(true) + expect(existsSync(join(dir, 'gradle/wrapper/gradle-wrapper.jar'))).toBe(true) + }) +}) + +describe('readWebshellManifest', () => { + test('reads a local web manifest.json', async () => { + const manifest = await readWebshellManifest(join(webshellFixtures, 'manifest.json')) + + expect(manifest.kind).toBe('web') + expect(manifest.appName).toBe('Example') + expect(manifest.backgroundColor).toBe('#abcdef') + expect(manifest.themeColor).toBe('#123456') + // A relative start_url cannot resolve against a local file, so no web URL is derived. + expect(manifest.url).toBeUndefined() + expect(manifest.icons).toEqual([ + { + purpose: ['any', 'maskable'], + sizes: [192], + src: pathToFileURL(join(webshellFixtures, 'icons/icon-192.png')).toString(), + type: 'image/png', + }, + { + purpose: [], + sizes: [512], + src: pathToFileURL(join(webshellFixtures, 'icons/icon-512.png')).toString(), + type: 'image/png', + }, + ]) + }) + + test('maps the fields of a local Bubblewrap twa-manifest.json', async () => { + const manifest = await readWebshellManifest(join(webshellFixtures, 'twa-manifest.json')) + + expect(manifest.kind).toBe('bubblewrap') + expect(manifest.appName).toBe('Wallet Shell') + expect(manifest.applicationId).toBe('com.example.walletshell') + expect(manifest.keystoreAlias).toBe('release') + expect(manifest.keystorePath).toBe('./release.keystore') + expect(manifest.url).toBe('https://app.example.com/launch?mode=prod') + expect(manifest.versionCode).toBe(12) + expect(manifest.versionName).toBe('1.2.0') + expect(manifest.webManifestUrl).toBe('https://app.example.com/manifest.json') + }) + + test('fetches a web manifest from a URL and resolves relative values against it', async () => { + const { calls, fetchFn } = fakeFetch({ + background_color: '#abcdef', + icons: [{ sizes: '512x512', src: '/icons/launcher.png', type: 'image/png' }], + name: 'Trepa', + start_url: '/app/start', + theme_color: '#123456', + }) + + const manifest = await readWebshellManifest('https://trepa.app/manifest.json', { fetchFn }) + + expect(calls).toEqual(['https://trepa.app/manifest.json']) + expect(manifest.kind).toBe('web') + expect(manifest.appName).toBe('Trepa') + expect(manifest.backgroundColor).toBe('#abcdef') + expect(manifest.themeColor).toBe('#123456') + expect(manifest.url).toBe('https://trepa.app/app/start') + expect(manifest.icons).toEqual([ + { purpose: [], sizes: [512], src: 'https://trepa.app/icons/launcher.png', type: 'image/png' }, + ]) + }) + + test('falls back to the site origin and truncates a long name', async () => { + const { fetchFn } = fakeFetch({ name: 'Jupiter Aggregator' }) + + const manifest = await readWebshellManifest('https://jup.ag/manifest.json', { fetchFn }) + + expect(manifest.appName).toBe('Jupiter Aggr') + expect(manifest.url).toBe('https://jup.ag/') + }) + + test('reports a failed manifest download', async () => { + const fetchFn = async () => new Response('missing', { status: 404, statusText: 'Not Found' }) + + await expect(readWebshellManifest('https://trepa.app/manifest.json', { fetchFn })).rejects.toThrow( + 'Failed to fetch https://trepa.app/manifest.json: 404 Not Found', + ) + }) + + test('rejects malformed JSON with the source in the error', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'webshell-manifest-')) + + try { + const manifestPath = join(tempDirectory, 'manifest.json') + await writeFile(manifestPath, 'not json {{', 'utf8') + + await expect(readWebshellManifest(manifestPath)).rejects.toThrow(`Failed to parse JSON from ${manifestPath}`) + } finally { + await rm(tempDirectory, { force: true, recursive: true }) + } + }) + + test('rejects a manifest that is not a JSON object', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'webshell-manifest-')) + + try { + const manifestPath = join(tempDirectory, 'manifest.json') + await writeFile(manifestPath, '["not", "an", "object"]', 'utf8') + + await expect(readWebshellManifest(manifestPath)).rejects.toThrow('Manifest must be a JSON object') + } finally { + await rm(tempDirectory, { force: true, recursive: true }) + } + }) +}) + +describe('webshell project config', () => { + test('round-trips the config through twa-manifest.json', async () => { + await withTempDir(async (directory) => { + await writeWebshellProjectConfig(directory, { + applicationId: 'com.example.walletshell', + appName: 'Wallet Shell', + keystoreAlias: 'release', + keystorePath: '/tmp/release.keystore', + url: 'https://app.example.com/launch?mode=prod', + webManifestUrl: 'https://app.example.com/manifest.json', + }) + + const contents = JSON.parse(await readFile(join(directory, 'twa-manifest.json'), 'utf8')) + expect(contents.fallbackType).toBe('webview') + expect(contents.generatorApp).toBe('solana-mobile') + expect(contents.host).toBe('app.example.com') + expect(contents.launcherName).toBe('Wallet Shell') + expect(contents.name).toBe('Wallet Shell') + expect(contents.packageId).toBe('com.example.walletshell') + expect(contents.signingKey).toEqual({ alias: 'release', path: '/tmp/release.keystore' }) + expect(contents.startUrl).toBe('/launch?mode=prod') + expect(contents.webManifestUrl).toBe('https://app.example.com/manifest.json') + + expect(await readWebshellProjectConfig(directory)).toEqual({ + keystoreAlias: 'release', + keystorePath: '/tmp/release.keystore', + }) + }) + }) + + test('returns undefined when the directory has no twa-manifest.json', async () => { + await withTempDir(async (directory) => { + expect(await readWebshellProjectConfig(directory)).toBeUndefined() + }) + }) + + test('never serializes passwords', async () => { + await withTempDir(async (directory) => { + await writeWebshellProjectConfig(directory, { + applicationId: 'com.example.walletshell', + appName: 'Wallet Shell', + keyPassword: 'hunter2-key', + keystoreAlias: 'release', + keystorePassword: 'hunter2-store', + keystorePath: '/tmp/release.keystore', + url: 'https://app.example.com/', + } as WebshellProjectConfig) + + const raw = await readFile(join(directory, 'twa-manifest.json'), 'utf8') + expect(raw).not.toContain('hunter2') + expect(raw.toLowerCase()).not.toContain('password') + }) + }) + + test('keeps unknown fields of an existing Bubblewrap-authored file across a rewrite', async () => { + await withTempDir(async (directory) => { + await writeFile( + join(directory, 'twa-manifest.json'), + JSON.stringify({ + display: 'standalone', + enableNotifications: true, + generatorApp: 'bubblewrap-cli', + host: 'old.example.com', + orientation: 'portrait', + packageId: 'com.example.old', + shortcuts: [{ name: 'Send', url: '/send' }], + signingKey: { alias: 'android', path: './android.keystore' }, + themeColor: '#9945ff', + }), + 'utf8', + ) + + await writeWebshellProjectConfig(directory, { + applicationId: 'com.example.walletshell', + appName: 'Wallet Shell', + url: 'https://app.example.com/launch', + }) + + const contents = JSON.parse(await readFile(join(directory, 'twa-manifest.json'), 'utf8')) + // Fields this CLI owns are overwritten. + expect(contents.generatorApp).toBe('solana-mobile') + expect(contents.host).toBe('app.example.com') + expect(contents.packageId).toBe('com.example.walletshell') + // Bubblewrap fields this CLI does not understand survive, keeping the file interoperable. + expect(contents.display).toBe('standalone') + expect(contents.enableNotifications).toBe(true) + expect(contents.orientation).toBe('portrait') + expect(contents.shortcuts).toEqual([{ name: 'Send', url: '/send' }]) + expect(contents.signingKey).toEqual({ alias: 'android', path: './android.keystore' }) + expect(contents.themeColor).toBe('#9945ff') + }) + }) +}) + +describe('copyWebshellTemplate', () => { + test('copies the template with corrected gitignore files and an executable gradlew', async () => { + await withGeneratedProject(async (projectDirectory) => { + expect(existsSync(join(projectDirectory, 'settings.gradle.kts'))).toBe(true) + expect(existsSync(join(projectDirectory, 'gradle/wrapper/gradle-wrapper.jar'))).toBe(true) + + // The template ships its root ignore file un-dotted (npm strips dotted ones); the output must dot it. + expect(existsSync(join(projectDirectory, 'gitignore'))).toBe(false) + expect(await readFile(join(projectDirectory, '.gitignore'), 'utf8')).toContain('local.properties') + + // npm also strips nested .gitignore files from tarballs, so this one is recreated, not copied. + expect(await readFile(join(projectDirectory, 'app', '.gitignore'), 'utf8')).toBe('/build\n') + + expect((await stat(join(projectDirectory, 'gradlew'))).mode & 0o111).not.toBe(0) + }) + }) + + test('refuses a non-empty target directory without force', async () => { + await withTempDir(async (directory) => { + await writeFile(join(directory, 'existing.txt'), 'keep', 'utf8') + + await expect(copyWebshellTemplate(findWebshellTemplateDir(), directory)).rejects.toThrow('is not empty') + expect(await readFile(join(directory, 'existing.txt'), 'utf8')).toBe('keep') + }) + }) + + test('overwrites an existing project with force', async () => { + await withTempDir(async (directory) => { + await writeFile(join(directory, 'settings.gradle.kts'), 'stale contents', 'utf8') + + await copyWebshellTemplate(findWebshellTemplateDir(), directory, { force: true }) + + expect(await readFile(join(directory, 'settings.gradle.kts'), 'utf8')).toContain('rootProject.name') + }) + }) +}) + +describe('renameAndroidPackage', () => { + const baseOptions = { + applicationId: 'com.example.myapp', + appName: 'Wallet & Shell', + projectName: 'generated', + url: 'https://app.example.com/launch', + versionCode: 7, + versionName: '1.2.3', + } + + test('configures the project for a new application id', async () => { + await withGeneratedProject(async (projectDirectory) => { + await renameAndroidPackage(projectDirectory, baseOptions) + + const gradleProperties = await readFile(join(projectDirectory, 'gradle.properties'), 'utf8') + expect(gradleProperties).toContain('SOLANA_MOBILE_URL=https://app.example.com/launch') + expect(gradleProperties).toContain('SOLANA_MOBILE_APPLICATION_ID=com.example.myapp') + expect(gradleProperties).toContain('SOLANA_MOBILE_VERSION_CODE=7') + expect(gradleProperties).toContain('SOLANA_MOBILE_VERSION_NAME=1.2.3') + expect(gradleProperties).not.toContain('com.example.webshell') + + const settings = await readFile(join(projectDirectory, 'settings.gradle.kts'), 'utf8') + expect(settings).toContain('rootProject.name = "generated"') + + const buildScript = await readFile(join(projectDirectory, 'app', 'build.gradle.kts'), 'utf8') + expect(buildScript).toContain('namespace = "com.example.myapp"') + + const strings = await readFile(join(projectDirectory, 'app/src/main/res/values/strings.xml'), 'utf8') + expect(strings).toContain('Wallet & Shell') + + // The Kotlin tree moved to the new package, and no source file references the template package. + const mainActivity = await readFile( + join(projectDirectory, 'app/src/main/java/com/example/myapp/MainActivity.kt'), + 'utf8', + ) + expect(mainActivity).toMatch(/^package com\.example\.myapp$/m) + expect(mainActivity).toContain('import com.example.myapp.ui.theme.WebShellTheme') + expect(existsSync(join(projectDirectory, 'app/src/main/java/com/example/webshell'))).toBe(false) + for (const file of await walkFiles(join(projectDirectory, 'app/src/main/java'))) { + expect(await readFile(file, 'utf8')).not.toContain('com.example.webshell') + } + + const readme = await readFile(join(projectDirectory, 'README.md'), 'utf8') + expect(readme).toContain('com.example.myapp') + expect(readme).toContain('solana-mobile webshell build .') + + expect((await stat(join(projectDirectory, 'gradlew'))).mode & 0o111).not.toBe(0) + }) + }) + + test('keeps the application id but sanitizes reserved Kotlin keywords in the package', async () => { + await withGeneratedProject(async (projectDirectory) => { + await renameAndroidPackage(projectDirectory, { ...baseOptions, applicationId: 'fun.cfl.www' }) + + const gradleProperties = await readFile(join(projectDirectory, 'gradle.properties'), 'utf8') + expect(gradleProperties).toContain('SOLANA_MOBILE_APPLICATION_ID=fun.cfl.www') + + const buildScript = await readFile(join(projectDirectory, 'app', 'build.gradle.kts'), 'utf8') + expect(buildScript).toContain('namespace = "_fun.cfl.www"') + + expect(existsSync(join(projectDirectory, 'app/src/main/java/_fun/cfl/www/MainActivity.kt'))).toBe(true) + }) + }) + + test('survives an application id that is an ancestor of the template package', async () => { + await withGeneratedProject(async (projectDirectory) => { + await renameAndroidPackage(projectDirectory, { ...baseOptions, applicationId: 'com.example' }) + + expect(existsSync(join(projectDirectory, 'app/src/main/java/com/example/MainActivity.kt'))).toBe(true) + expect(existsSync(join(projectDirectory, 'app/src/main/java/com/example/webshell'))).toBe(false) + }) + }) + + test('survives an application id equal to the template package', async () => { + await withGeneratedProject(async (projectDirectory) => { + await renameAndroidPackage(projectDirectory, { ...baseOptions, applicationId: 'com.example.webshell' }) + + expect(existsSync(join(projectDirectory, 'app/src/main/java/com/example/webshell/MainActivity.kt'))).toBe(true) + }) + }) + + test('rejects invalid application ids without touching the project', async () => { + await withGeneratedProject(async (projectDirectory) => { + for (const invalid of ['com.1example', 'com.example-app', 'Com.Example.App', 'com..example', 'example']) { + expect(validateWebshellApplicationId(invalid)).toContain('Application ID must look like com.example.app') + await expect( + renameAndroidPackage(projectDirectory, { ...baseOptions, applicationId: invalid }), + ).rejects.toThrow('Application ID must look like com.example.app') + } + + // A failed rename leaves the template defaults untouched. + const gradleProperties = await readFile(join(projectDirectory, 'gradle.properties'), 'utf8') + expect(gradleProperties).toContain('SOLANA_MOBILE_APPLICATION_ID=com.example.webshell') + }) + }) +}) + +describe('applyWebshellBranding', () => { + /** Serves the tiny PNG for any URL while recording what was fetched. */ + function fakeIconFetch(): { calls: string[]; fetchFn: (url: URL) => Promise } { + const calls: string[] = [] + const fetchFn = async (url: URL) => { + calls.push(url.toString()) + return new Response(new Uint8Array(tinyPng), { headers: { 'content-type': 'image/png' } }) + } + + return { calls, fetchFn } + } + + const rejectingFetch = async (): Promise => { + throw new Error('unexpected fetch') + } + + test('downloads the preferred manifest icon and writes the launcher artwork', async () => { + await withGeneratedProject(async (projectDirectory) => { + const { calls, fetchFn } = fakeIconFetch() + + await applyWebshellBranding( + projectDirectory, + { + backgroundColor: '#F5F5F5', + icons: [ + // The larger "any" icon loses to maskable artwork, which survives adaptive-icon cropping. + { purpose: [], sizes: [1024], src: 'https://app.example.com/icons/any.png', type: 'image/png' }, + { + purpose: ['maskable'], + sizes: [512], + src: 'https://app.example.com/icons/maskable.png', + type: 'image/png', + }, + ], + themeColor: '#123456', + }, + { fetchFn }, + ) + + expect(calls).toEqual(['https://app.example.com/icons/maskable.png']) + + const colors = await readFile(join(projectDirectory, 'app/src/main/res/values/colors.xml'), 'utf8') + expect(colors).toContain('#123456') + expect(colors).toContain('#F5F5F5') + + const foreground = await readFile( + join(projectDirectory, 'app/src/main/res/drawable/ic_launcher_foreground.xml'), + 'utf8', + ) + expect(foreground).toContain('android:drawable="@drawable/ic_launcher_foreground_inner"') + + const icon = await readFile( + join(projectDirectory, 'app/src/main/res/drawable-nodpi/ic_launcher_foreground_inner.png'), + ) + expect(icon.equals(tinyPng)).toBe(true) + }) + }) + + test('copies a local file icon without fetching', async () => { + await withGeneratedProject(async (projectDirectory) => { + const iconPath = join(projectDirectory, 'local-icon.png') + await writeFile(iconPath, tinyPng) + + await applyWebshellBranding( + projectDirectory, + { icons: [{ purpose: [], sizes: [512], src: pathToFileURL(iconPath).toString(), type: 'image/png' }] }, + { fetchFn: rejectingFetch }, + ) + + const icon = await readFile( + join(projectDirectory, 'app/src/main/res/drawable-nodpi/ic_launcher_foreground_inner.png'), + ) + expect(icon.equals(tinyPng)).toBe(true) + }) + }) + + test('keeps the template launcher artwork when the icon download fails', async () => { + await withGeneratedProject(async (projectDirectory) => { + const warnings: string[] = [] + const fetchFn = async () => new Response('missing', { status: 404, statusText: 'Not Found' }) + + await applyWebshellBranding( + projectDirectory, + { + backgroundColor: '#FAFAFA', + icons: [{ purpose: [], sizes: [512], src: 'https://app.example.com/icon.png', type: 'image/png' }], + themeColor: '#abc', + }, + { fetchFn, logWarning: (message) => warnings.push(message) }, + ) + + expect(warnings.length).toBeGreaterThan(0) + expect(warnings.join('\n')).toContain('https://app.example.com/icon.png') + + // Colors are still applied — a short hex color expands to the Android six-digit form. + const colors = await readFile(join(projectDirectory, 'app/src/main/res/values/colors.xml'), 'utf8') + expect(colors).toContain('#AABBCC') + expect(colors).toContain('#FAFAFA') + + // The template's default vector foreground stays in place. + expect( + existsSync(join(projectDirectory, 'app/src/main/res/drawable-nodpi/ic_launcher_foreground_inner.png')), + ).toBe(false) + const foreground = await readFile( + join(projectDirectory, 'app/src/main/res/drawable/ic_launcher_foreground.xml'), + 'utf8', + ) + expect(foreground).toContain(' { + await withGeneratedProject(async (projectDirectory) => { + await applyWebshellBranding( + projectDirectory, + { + icons: [ + { purpose: ['maskable'], sizes: [512], src: 'https://app.example.com/icon.svg', type: 'image/svg+xml' }, + ], + }, + { fetchFn: rejectingFetch }, + ) + + expect( + existsSync(join(projectDirectory, 'app/src/main/res/drawable-nodpi/ic_launcher_foreground_inner.png')), + ).toBe(false) + const foreground = await readFile( + join(projectDirectory, 'app/src/main/res/drawable/ic_launcher_foreground.xml'), + 'utf8', + ) + expect(foreground).toContain(' { + await withGeneratedProject(async (projectDirectory) => { + await applyWebshellBranding( + projectDirectory, + // CSS carries alpha last (#RRGGBBAA, #RGBA); Android colors.xml expects it first (#AARRGGBB). + { backgroundColor: '#123456ff', themeColor: '#abcd' }, + { fetchFn: rejectingFetch }, + ) + + const colors = await readFile(join(projectDirectory, 'app/src/main/res/values/colors.xml'), 'utf8') + expect(colors).toContain('#FF123456') + expect(colors).toContain('#DDAABBCC') + }) + }) + + test('falls back to the default Android green without a manifest', async () => { + await withGeneratedProject(async (projectDirectory) => { + await applyWebshellBranding(projectDirectory, undefined, { fetchFn: rejectingFetch }) + + const colors = await readFile(join(projectDirectory, 'app/src/main/res/values/colors.xml'), 'utf8') + expect(colors).toContain('#3DDC84') + expect(colors).toContain('#3DDC84') + }) + }) +}) + +describe('webshell keystore', () => { + const baseOptions = { + appName: 'Wallet Shell', + keyPassword: 'key-secret', + keystoreAlias: 'release', + keystorePassword: 'store-secret', + } + + test('generates a missing keystore via keytool', async () => { + await withTempDir(async (directory) => { + const keystorePath = join(directory, 'keys', 'release.keystore') + const { calls, envs, runCommand } = recordingRunner() + + const created = await ensureKeystore({ ...baseOptions, keystorePath }, { runCommand }) + + expect(created).toBe(true) + expect(calls).toEqual([ + [ + 'keytool', + '-genkeypair', + '-v', + '-keystore', + keystorePath, + '-alias', + 'release', + '-keyalg', + 'RSA', + '-keysize', + '2048', + '-validity', + '10000', + '-storepass:env', + 'SOLANA_MOBILE_KEYSTORE_PASSWORD', + '-keypass:env', + 'SOLANA_MOBILE_KEY_PASSWORD', + '-dname', + 'CN=Wallet Shell, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=US', + '-noprompt', + ], + ]) + // The passwords stay out of argv (visible in process listings) and travel via the child env. + expect(calls[0]).not.toContain('store-secret') + expect(calls[0]).not.toContain('key-secret') + expect(envs).toEqual([ + { SOLANA_MOBILE_KEY_PASSWORD: 'key-secret', SOLANA_MOBILE_KEYSTORE_PASSWORD: 'store-secret' }, + ]) + // The parent directory is created ahead of time so keytool can write the file. + expect(existsSync(join(directory, 'keys'))).toBe(true) + }) + }) + + test('sanitizes the app name in the certificate distinguished name', async () => { + await withTempDir(async (directory) => { + const { calls, runCommand } = recordingRunner() + + await ensureKeystore( + { ...baseOptions, appName: 'Wallet, "Shell" +', keystorePath: join(directory, 'a.keystore') }, + { runCommand }, + ) + await ensureKeystore( + { ...baseOptions, appName: ' ', keystorePath: join(directory, 'b.keystore') }, + { runCommand }, + ) + + const dnames = calls.map((cmd) => cmd[cmd.indexOf('-dname') + 1]) + expect(dnames).toEqual([ + 'CN=Wallet Shell Dev, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=US', + 'CN=Solana Mobile Web Shell, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=US', + ]) + }) + }) + + test('skips generation when the keystore already exists', async () => { + await withTempDir(async (directory) => { + const keystorePath = join(directory, 'release.keystore') + await writeFile(keystorePath, 'existing') + const { calls, runCommand } = recordingRunner() + + const created = await ensureKeystore({ ...baseOptions, keystorePath }, { runCommand }) + + expect(created).toBe(false) + expect(calls).toEqual([]) + }) + }) + + test('environment variables short-circuit password prompting', async () => { + const promptPassword = async (): Promise => { + throw new Error('prompt should not be called') + } + + const passwords = await resolveWebshellSigningPasswords({ + env: { SOLANA_MOBILE_KEY_PASSWORD: 'key-secret', SOLANA_MOBILE_KEYSTORE_PASSWORD: 'store-secret' }, + promptPassword, + }) + + expect(passwords).toEqual({ keyPassword: 'key-secret', keystorePassword: 'store-secret' }) + }) + + test('prompts for the keystore password and reuses it for the key', async () => { + const messages: string[] = [] + const promptPassword = async ({ message }: { message: string }): Promise => { + messages.push(message) + return 'prompted-secret' + } + + const passwords = await resolveWebshellSigningPasswords({ env: {}, promptPassword }) + + expect(messages).toEqual(['Keystore password (SOLANA_MOBILE_KEYSTORE_PASSWORD is not set)']) + expect(passwords).toEqual({ keyPassword: 'prompted-secret', keystorePassword: 'prompted-secret' }) + }) + + test('a dedicated key password from the environment overrides the prompted keystore password', async () => { + const promptPassword = async (): Promise => 'prompted-secret' + + const passwords = await resolveWebshellSigningPasswords({ + env: { SOLANA_MOBILE_KEY_PASSWORD: 'key-only' }, + promptPassword, + }) + + expect(passwords).toEqual({ keyPassword: 'key-only', keystorePassword: 'prompted-secret' }) + }) + + test('a cancelled password prompt is passed through for the caller to handle', async () => { + const cancelled = Symbol('clack:cancel') + const promptPassword = async (): Promise => cancelled + + expect(await resolveWebshellSigningPasswords({ env: {}, promptPassword })).toBe(cancelled) + }) +}) + +describe('deriveWebshellApplicationIdSuggestion', () => { + test('reverses the URL host into an application id', () => { + expect(deriveWebshellApplicationIdSuggestion('https://app.example.com/launch')).toEqual({ + applicationId: 'com.example.app', + }) + }) + + test('normalizes host segments that are not valid package segments', () => { + expect(deriveWebshellApplicationIdSuggestion('https://my-app.example.com')).toEqual({ + applicationId: 'com.example.my_app', + note: 'Adjusted the default application ID to com.example.my_app to keep it Android-safe.', + }) + }) + + test('keeps reserved Kotlin words: they are valid in an application id', () => { + expect(deriveWebshellApplicationIdSuggestion('https://www.cfl.fun')).toEqual({ + applicationId: 'fun.cfl.www', + }) + }) + + test('gives no suggestion for localhost, raw IPs, short hosts, or junk', () => { + expect(deriveWebshellApplicationIdSuggestion('http://localhost:3000')).toEqual({}) + expect(deriveWebshellApplicationIdSuggestion('http://192.168.1.10/')).toEqual({}) + expect(deriveWebshellApplicationIdSuggestion('https://example')).toEqual({}) + expect(deriveWebshellApplicationIdSuggestion('not a url')).toEqual({}) + }) +}) + +describe('resolveWebshellCreatePasswords', () => { + const throwingConfirm = async (): Promise => { + throw new Error('confirm prompt should not be called') + } + const throwingPassword = async (): Promise => { + throw new Error('password prompt should not be called') + } + + test('rejects environment passwords shorter than the keytool minimum', async () => { + await expect( + resolveWebshellCreatePasswords({ + env: { SOLANA_MOBILE_KEYSTORE_PASSWORD: 'short' }, + runConfirm: throwingConfirm, + runPassword: throwingPassword, + }), + ).rejects.toThrow('SOLANA_MOBILE_KEYSTORE_PASSWORD must be at least 6 characters.') + }) + + test('environment variables win without prompting', async () => { + const passwords = await resolveWebshellCreatePasswords({ + env: { SOLANA_MOBILE_KEY_PASSWORD: 'key-secret', SOLANA_MOBILE_KEYSTORE_PASSWORD: 'store-secret' }, + runConfirm: throwingConfirm, + runPassword: throwingPassword, + }) + + expect(passwords).toEqual({ keyPassword: 'key-secret', keystorePassword: 'store-secret' }) + }) + + test('prompts for a confirmed password and reuses it for the key', async () => { + const confirms: string[] = [] + const messages: string[] = [] + + const passwords = await resolveWebshellCreatePasswords({ + env: {}, + runConfirm: async ({ message }) => { + confirms.push(message) + return true + }, + runPassword: async ({ message }) => { + messages.push(message) + return 'secret-1' + }, + }) + + expect(passwords).toEqual({ keyPassword: 'secret-1', keystorePassword: 'secret-1' }) + expect(messages).toEqual(['Keystore password', 'Confirm keystore password']) + expect(confirms).toEqual(['Use the same password for the signing key?']) + }) + + test('re-prompts on a mismatch, then accepts a separate key password', async () => { + const errors: string[] = [] + const responses = ['store-a', 'store-b', 'store-c', 'store-c', 'key-1', 'key-1'] + + const passwords = await resolveWebshellCreatePasswords({ + env: {}, + logError: (message) => { + errors.push(message) + }, + runConfirm: async () => false, + runPassword: async () => responses.shift() ?? Symbol('exhausted'), + }) + + expect(passwords).toEqual({ keyPassword: 'key-1', keystorePassword: 'store-c' }) + expect(errors).toEqual(['Passwords do not match. Try again.']) + }) + + test('a cancelled prompt is passed through for the caller to handle', async () => { + const cancelled = Symbol('cancelled') + + const passwords = await resolveWebshellCreatePasswords({ + env: {}, + runConfirm: throwingConfirm, + runPassword: async () => cancelled, + }) + + expect(passwords).toBe(cancelled) + }) +}) + +describe('runWebshellInit', () => { + const throwingText: TextPrompt = async () => { + throw new Error('text prompt should not be called') + } + + const completeInitOptions = { + applicationId: 'com.example.smoke', + appName: 'Smoke', + directory: '/tmp/webshell-smoke', + keystoreAlias: 'smoke', + keystorePath: '/tmp/webshell-smoke/smoke.keystore', + url: 'https://example.com', + versionCode: 7, + versionName: '1.2.3', + } + + function initDependencies(overrides: RunWebshellInitDependencies = {}) { + const state = { + branding: [] as { directory: string; manifest: unknown }[], + cancelled: undefined as string | undefined, + configs: [] as { config: WebshellProjectConfig; projectDirectory: string }[], + copies: [] as { force: boolean | undefined; targetDirectory: string; templateDirectory: string }[], + keystores: [] as unknown[], + logs: [] as string[], + outro: undefined as string | undefined, + renames: [] as { options: unknown; projectDirectory: string }[], + } + + const dependencies: RunWebshellInitDependencies = { + applyBranding: async (directory, manifest) => { + state.branding.push({ directory, manifest }) + }, + cancel: (message) => { + state.cancelled = message + }, + copyTemplate: async (templateDirectory, targetDirectory, copyOptions) => { + state.copies.push({ force: copyOptions?.force, targetDirectory, templateDirectory }) + }, + createKeystore: async (keystoreOptions) => { + state.keystores.push(keystoreOptions) + return true + }, + fileExists: async () => false, + findTemplateDir: () => '/fake/template', + intro: () => {}, + log: (message) => { + state.logs.push(message) + }, + outro: (message) => { + state.outro = message + }, + renamePackage: async (projectDirectory, renameOptions) => { + state.renames.push({ options: renameOptions, projectDirectory }) + }, + resolvePasswords: async () => ({ keyPassword: 'key-secret', keystorePassword: 'store-secret' }), + runText: throwingText, + warn: (message) => { + state.logs.push(message) + }, + writeProjectConfig: async (projectDirectory, config) => { + state.configs.push({ config, projectDirectory }) + }, + ...overrides, + } + + return { dependencies, state } + } + + test('runs the full pipeline without prompting when every option is provided', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = initDependencies() + + await runWebshellInit(completeInitOptions, dependencies) + + expect(state.cancelled).toBeUndefined() + expect(state.copies).toEqual([ + { force: undefined, targetDirectory: '/tmp/webshell-smoke', templateDirectory: '/fake/template' }, + ]) + expect(state.renames).toEqual([ + { + options: { + applicationId: 'com.example.smoke', + appName: 'Smoke', + keystoreAlias: 'smoke', + keystorePath: '/tmp/webshell-smoke/smoke.keystore', + projectName: 'webshell-smoke', + url: 'https://example.com/', + versionCode: 7, + versionName: '1.2.3', + }, + projectDirectory: '/tmp/webshell-smoke', + }, + ]) + expect(state.branding).toEqual([{ directory: '/tmp/webshell-smoke', manifest: undefined }]) + expect(state.keystores).toEqual([ + { + appName: 'Smoke', + keyPassword: 'key-secret', + keystoreAlias: 'smoke', + keystorePassword: 'store-secret', + keystorePath: '/tmp/webshell-smoke/smoke.keystore', + }, + ]) + expect(state.configs).toEqual([ + { + config: { + applicationId: 'com.example.smoke', + appName: 'Smoke', + keystoreAlias: 'smoke', + keystorePath: 'smoke.keystore', + url: 'https://example.com/', + webManifestUrl: undefined, + }, + projectDirectory: '/tmp/webshell-smoke', + }, + ]) + expect(state.outro).toContain('webshell build /tmp/webshell-smoke') + expect(process.exitCode).toBe(previousExitCode) + }) + + test('resolves a relative --keystore-path against the project directory, not the cwd', async () => { + const { dependencies, state } = initDependencies() + + await runWebshellInit({ ...completeInitOptions, keystorePath: 'release.keystore' }, dependencies) + + expect(state.cancelled).toBeUndefined() + expect(state.keystores[0]).toMatchObject({ keystorePath: '/tmp/webshell-smoke/release.keystore' }) + expect(state.configs[0]?.config.keystorePath).toBe('release.keystore') + }) + + test('fills missing values from a manifest without prompting', async () => { + const { dependencies, state } = initDependencies({ + readManifest: async (source) => ({ + appName: 'Trepa', + backgroundColor: '#abcdef', + kind: 'web', + source, + themeColor: '#123456', + url: 'https://trepa.app/start', + webManifestUrl: 'https://trepa.app/manifest.json', + }), + }) + + await runWebshellInit( + { + applicationId: 'com.example.trepa', + directory: '/tmp/webshell-trepa', + keystoreAlias: 'trepa', + keystorePath: '/tmp/trepa.keystore', + manifest: 'https://trepa.app/manifest.json', + versionCode: 1, + versionName: '1.0', + }, + dependencies, + ) + + expect(state.cancelled).toBeUndefined() + expect(state.renames).toEqual([ + { + options: expect.objectContaining({ appName: 'Trepa', url: 'https://trepa.app/start' }), + projectDirectory: '/tmp/webshell-trepa', + }, + ]) + expect(state.branding).toEqual([ + { + directory: '/tmp/webshell-trepa', + manifest: expect.objectContaining({ backgroundColor: '#abcdef', themeColor: '#123456' }), + }, + ]) + expect(state.configs[0]?.config.webManifestUrl).toBe('https://trepa.app/manifest.json') + }) + + test('exits quietly when a prompt is cancelled', async () => { + const { dependencies, state } = initDependencies({ runText: async () => Symbol('cancelled') }) + + await runWebshellInit({ directory: '/tmp/webshell-cancel' }, dependencies) + + expect(state.copies).toEqual([]) + expect(state.configs).toEqual([]) + expect(state.outro).toBeUndefined() + }) + + test('cancels with exit code 1 when password resolution is cancelled', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = initDependencies({ resolvePasswords: async () => Symbol('cancelled') }) + + await runWebshellInit(completeInitOptions, dependencies) + + expect(state.cancelled).toBe('Cancelled') + expect(state.keystores).toEqual([]) + expect(state.configs).toEqual([]) + expect(process.exitCode).toBe(1) + process.exitCode = previousExitCode + }) + + test('rejects an invalid application id before touching anything', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = initDependencies() + + await runWebshellInit({ ...completeInitOptions, applicationId: 'com.example-app' }, dependencies) + + expect(state.cancelled).toContain('Application ID must look like com.example.app') + expect(state.copies).toEqual([]) + expect(process.exitCode).toBe(1) + process.exitCode = previousExitCode + }) + + test('generates a real project end to end from a fetched manifest', async () => { + await withTempDir(async (directory) => { + const projectDirectory = join(directory, 'shell') + const keystorePath = join(directory, 'keys', 'release.keystore') + const { calls, envs, runCommand } = recordingRunner() + const state = { cancelled: undefined as string | undefined, outro: undefined as string | undefined } + + const fetchFn = async (url: URL) => { + if (url.toString() === 'https://trepa.app/manifest.json') { + const payload = { + background_color: '#abcdef', + icons: [{ purpose: 'maskable', sizes: '512x512', src: '/icons/maskable.png', type: 'image/png' }], + name: 'Trepa Predictions', + short_name: 'Trepa', + start_url: '/', + theme_color: '#123456', + } + + return new Response(JSON.stringify(payload), { headers: { 'content-type': 'application/json' } }) + } + + return new Response(new Uint8Array(tinyPng), { headers: { 'content-type': 'image/png' } }) + } + + await runWebshellInit( + { + applicationId: 'com.example.trepa', + directory: projectDirectory, + keystoreAlias: 'release', + keystorePath, + manifest: 'https://trepa.app/manifest.json', + versionCode: 2, + versionName: '1.1', + }, + { + cancel: (message) => { + state.cancelled = message + }, + env: { SOLANA_MOBILE_KEYSTORE_PASSWORD: 'store-secret' }, + fetchFn, + intro: () => {}, + log: () => {}, + outro: (message) => { + state.outro = message + }, + runCommand, + runText: throwingText, + warn: () => {}, + }, + ) + + expect(state.cancelled).toBeUndefined() + + // The template was copied and configured for the requested identity. + const gradleProperties = await readFile(join(projectDirectory, 'gradle.properties'), 'utf8') + expect(gradleProperties).toContain('SOLANA_MOBILE_URL=https://trepa.app/') + expect(gradleProperties).toContain('SOLANA_MOBILE_APPLICATION_ID=com.example.trepa') + expect(gradleProperties).toContain('SOLANA_MOBILE_VERSION_CODE=2') + expect(gradleProperties).toContain('SOLANA_MOBILE_VERSION_NAME=1.1') + expect(existsSync(join(projectDirectory, 'app/src/main/java/com/example/trepa/MainActivity.kt'))).toBe(true) + + // The app name came from the manifest's short_name. + const strings = await readFile(join(projectDirectory, 'app/src/main/res/values/strings.xml'), 'utf8') + expect(strings).toContain('Trepa') + + // Branding was applied from the manifest colors and icon. + const colors = await readFile(join(projectDirectory, 'app/src/main/res/values/colors.xml'), 'utf8') + expect(colors).toContain('#123456') + expect( + existsSync(join(projectDirectory, 'app/src/main/res/drawable-nodpi/ic_launcher_foreground_inner.png')), + ).toBe(true) + + // Keystore creation went through keytool with the password in the child env, never in argv. + expect(calls).toHaveLength(1) + expect(calls[0]?.[0]).toBe('keytool') + expect(calls[0]).toContain(keystorePath) + expect(calls[0]).toContain('release') + expect(calls[0]).not.toContain('store-secret') + expect(envs[0]).toEqual({ + SOLANA_MOBILE_KEY_PASSWORD: 'store-secret', + SOLANA_MOBILE_KEYSTORE_PASSWORD: 'store-secret', + }) + + // The Bubblewrap-compatible project config points back at everything. + const config = JSON.parse(await readFile(join(projectDirectory, 'twa-manifest.json'), 'utf8')) + expect(config.generatorApp).toBe('solana-mobile') + expect(config.packageId).toBe('com.example.trepa') + expect(config.signingKey).toEqual({ alias: 'release', path: keystorePath }) + expect(config.webManifestUrl).toBe('https://trepa.app/manifest.json') + + expect(state.outro).toContain('webshell build') + }) + }) +}) + +describe('runWebshellBuild', () => { + interface InteractiveCall { + cmd: string[] + options?: InteractiveRunCommandOptions + } + + function buildDependencies(overrides: RunWebshellBuildDependencies = {}) { + const state = { + calls: [] as InteractiveCall[], + cancelled: undefined as string | undefined, + logs: [] as string[], + outro: undefined as string | undefined, + } + + const dependencies: RunWebshellBuildDependencies = { + cancel: (message) => { + state.cancelled = message + }, + intro: () => {}, + log: (message) => { + state.logs.push(message) + }, + outro: (message) => { + state.outro = message + }, + readProjectConfig: async () => ({ keystoreAlias: 'release', keystorePath: './release.keystore' }), + resolvePasswords: async () => ({ keyPassword: 'key-secret', keystorePassword: 'store-secret' }), + runInteractiveCommand: async (cmd, runOptions) => { + state.calls.push({ cmd: [...cmd], options: runOptions }) + }, + ...overrides, + } + + return { dependencies, state } + } + + test('runs gradle in the project directory with signing properties and passwords in the child env', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = buildDependencies() + + await runWebshellBuild({ directory: '/tmp/webshell-app' }, dependencies) + + expect(state.cancelled).toBeUndefined() + expect(state.calls).toEqual([ + { + cmd: [ + '/tmp/webshell-app/gradlew', + 'assembleRelease', + '-PSOLANA_MOBILE_KEYSTORE_PATH=/tmp/webshell-app/release.keystore', + '-PSOLANA_MOBILE_KEYSTORE_ALIAS=release', + ], + options: { + cwd: '/tmp/webshell-app', + env: { SOLANA_MOBILE_KEY_PASSWORD: 'key-secret', SOLANA_MOBILE_KEYSTORE_PASSWORD: 'store-secret' }, + }, + }, + ]) + expect(state.outro).toContain('/tmp/webshell-app/app/build/outputs/apk/release/app-release.apk') + expect(process.exitCode).toBe(previousExitCode) + }) + + test('invokes gradlew.bat through cmd.exe on Windows', async () => { + const { dependencies, state } = buildDependencies({ platform: 'win32' }) + + await runWebshellBuild({ directory: '/tmp/webshell-app' }, dependencies) + + expect(state.cancelled).toBeUndefined() + expect(state.calls[0]?.cmd).toEqual([ + 'cmd.exe', + '/c', + '/tmp/webshell-app/gradlew.bat', + 'assembleRelease', + '-PSOLANA_MOBILE_KEYSTORE_PATH=/tmp/webshell-app/release.keystore', + '-PSOLANA_MOBILE_KEYSTORE_ALIAS=release', + ]) + }) + + test('rejects cmd.exe metacharacters in signing values on Windows', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = buildDependencies({ platform: 'win32' }) + + await runWebshellBuild({ directory: '/tmp/webshell-app', keystorePath: 'evil&calc.keystore' }, dependencies) + + expect(state.cancelled).toContain('cmd.exe') + expect(state.calls).toHaveLength(0) + expect(process.exitCode).toBe(1) + process.exitCode = previousExitCode + }) + + test('errors clearly when the directory is not a webshell project', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = buildDependencies({ readProjectConfig: async () => undefined }) + + await runWebshellBuild({ directory: '/tmp/not-a-project' }, dependencies) + + expect(state.cancelled).toContain('is not a webshell project') + expect(state.cancelled).toContain('webshell init') + expect(state.calls).toEqual([]) + expect(process.exitCode).toBe(1) + process.exitCode = previousExitCode + }) + + test('keystore flags override the saved config', async () => { + const { dependencies, state } = buildDependencies() + + await runWebshellBuild( + { directory: '/tmp/webshell-app', keystoreAlias: 'override', keystorePath: '/keys/other.keystore' }, + dependencies, + ) + + expect(state.calls[0]?.cmd).toContain('-PSOLANA_MOBILE_KEYSTORE_PATH=/keys/other.keystore') + expect(state.calls[0]?.cmd).toContain('-PSOLANA_MOBILE_KEYSTORE_ALIAS=override') + }) + + test('appends --stacktrace when flagged', async () => { + const { dependencies, state } = buildDependencies() + + await runWebshellBuild({ directory: '/tmp/webshell-app', stacktrace: true }, dependencies) + + expect(state.calls[0]?.cmd.at(-1)).toBe('--stacktrace') + }) + + test('builds unsigned without keystore configuration and never resolves passwords', async () => { + const { dependencies, state } = buildDependencies({ + readProjectConfig: async () => ({}), + resolvePasswords: async () => { + throw new Error('passwords should not be resolved') + }, + }) + + await runWebshellBuild({ directory: '/tmp/webshell-app' }, dependencies) + + expect(state.cancelled).toBeUndefined() + expect(state.calls).toEqual([ + { cmd: ['/tmp/webshell-app/gradlew', 'assembleRelease'], options: { cwd: '/tmp/webshell-app', env: {} } }, + ]) + expect(state.logs.join('\n')).toContain('unsigned') + expect(state.outro).toContain('app-release-unsigned.apk') + }) + + test('propagates a gradle failure as exit code 1 without advice', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = buildDependencies({ + runInteractiveCommand: async () => { + throw new Error('gradlew exited with code 1') + }, + }) + + await runWebshellBuild({ directory: '/tmp/webshell-app' }, dependencies) + + expect(state.cancelled).toBe('Error: gradlew exited with code 1') + expect(state.outro).toBeUndefined() + expect(process.exitCode).toBe(1) + process.exitCode = previousExitCode + }) + + test('cancels before gradle when the password prompt is cancelled', async () => { + const previousExitCode = process.exitCode + const { dependencies, state } = buildDependencies({ resolvePasswords: async () => Symbol('cancelled') }) + + await runWebshellBuild({ directory: '/tmp/webshell-app' }, dependencies) + + expect(state.cancelled).toBe('Cancelled') + expect(state.calls).toEqual([]) + expect(process.exitCode).toBe(1) + process.exitCode = previousExitCode + }) + + test('runInteractiveExecutable forwards cwd and env to the child process', async () => { + await withTempDir(async (directory) => { + // Writing through a relative path proves the cwd; the file contents prove the env made it through. + await runInteractiveExecutable(['sh', '-c', 'printf "%s" "$WEBSHELL_TEST_VALUE" > marker.txt'], { + cwd: directory, + env: { WEBSHELL_TEST_VALUE: 'from-env' }, + }) + + expect(await readFile(join(directory, 'marker.txt'), 'utf8')).toBe('from-env') + }) + }) +}) + +describe('webshell command registration', () => { + test('delegates webshell init command options', async () => { + const initOptions: WebshellInitCommandOptions[] = [] + const app = createApp({ + runWebshellInit: async (options) => { + initOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'webshell', + 'init', + 'my-app', + '--app-name', + 'My App', + '--application-id', + 'com.example.myapp', + '--force', + '--keystore-alias', + 'release', + '--keystore-path', + 'keys/release.keystore', + '--manifest', + 'https://example.com/manifest.json', + '--url', + 'https://example.com', + '--version-code', + '7', + '--version-name', + '1.2.3', + ]) + + expect(initOptions).toEqual([ + { + applicationId: 'com.example.myapp', + appName: 'My App', + directory: 'my-app', + force: true, + keystoreAlias: 'release', + keystorePath: 'keys/release.keystore', + manifest: 'https://example.com/manifest.json', + url: 'https://example.com', + versionCode: 7, + versionName: '1.2.3', + }, + ]) + }) + + test('delegates webshell build command options', async () => { + const buildOptions: WebshellBuildCommandOptions[] = [] + const app = createApp({ + runWebshellBuild: async (options) => { + buildOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'webshell', + 'build', + 'my-app', + '--keystore-alias', + 'release', + '--keystore-path', + 'keys/release.keystore', + '--stacktrace', + ]) + + expect(buildOptions).toEqual([ + { + directory: 'my-app', + keystoreAlias: 'release', + keystorePath: 'keys/release.keystore', + stacktrace: true, + }, + ]) + }) + + test('rejects a webshell init --version-code that is not a positive integer', async () => { + const app = createApp({ runWebshellInit: async () => {} }) + + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + + const webshellCommand = app.commands.find((command) => command.name() === 'webshell') + + webshellCommand?.exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} }) + webshellCommand?.commands + .find((command) => command.name() === 'init') + ?.exitOverride() + .configureOutput({ writeErr: () => {}, writeOut: () => {} }) + + await expect( + app.parseAsync(['node', 'solana-mobile', 'webshell', 'init', '--version-code', 'abc']), + ).rejects.toThrow('Expected a positive integer, received: abc') + }) + + test('prints webshell help without delegating when no subcommand is given', async () => { + const output: string[] = [] + let initCalled = false + const app = createApp({ + runWebshellInit: async () => { + initCalled = true + }, + }) + + app.commands + .find((command) => command.name() === 'webshell') + ?.configureOutput({ + writeErr: () => {}, + writeOut: (chunk: string) => { + output.push(chunk) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'webshell']) + + expect(output.join('')).toContain('Commands:') + expect(output.join('')).toContain('init') + expect(output.join('')).toContain('build') + expect(initCalled).toBe(false) + }) +})