Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/webshell-port.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.bat text eol=crlf
72 changes: 72 additions & 0 deletions .github/workflows/webshell.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
--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"
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
}
},
"files": {
"ignoreUnknown": false
"ignoreUnknown": false,
"includes": ["**", "!templates"]
},
"formatter": {
"attributePosition": "auto",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
}
},
"files": [
"dist"
"dist",
"templates"
],
"homepage": "https://github.com/solana-mobile/solana-mobile-cli#readme",
"license": "Apache-2.0",
Expand Down
42 changes: 42 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VersionCheckResult | undefined>
Expand Down Expand Up @@ -96,6 +102,8 @@ export type AppOptions = {
runTemplatesCheck?: (options: TemplatesCheckCommandOptions) => Promise<void>
runTemplatesGenerate?: (options: TemplatesGenerateCommandOptions) => Promise<void>
runTemplatesSync?: (options: TemplatesSyncCommandOptions) => Promise<void>
runWebshellBuild?: (options: WebshellBuildCommandOptions) => Promise<void>
runWebshellInit?: (options: WebshellInitCommandOptions) => Promise<void>
}

export function createApp({
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 <name>', 'Application display name')
.option('--application-id <id>', 'Android application id (e.g. com.example.app)')
.option('--force', 'Overwrite an existing directory')
.option('--keystore-alias <alias>', 'Signing keystore alias')
.option('--keystore-path <path>', 'Signing keystore path (created when missing)')
.option('--manifest <path-or-url>', 'Web manifest.json or Bubblewrap twa-manifest.json')
.option('--url <url>', 'Web app URL to wrap')
.option('--version-code <number>', 'Android versionCode', parseIntegerOption)
.option('--version-name <name>', 'Android versionName')
.action(async (directory: string | undefined, options: Omit<WebshellInitCommandOptions, 'directory'>) => {
await runWebshellInitCommand({ ...options, directory })
})

webshellCommand
.command('build [directory]')
.description('Build a release APK from a webshell project')
.option('--keystore-alias <alias>', 'Signing keystore alias')
.option('--keystore-path <path>', 'Signing keystore path')
.option('--stacktrace', 'Pass --stacktrace to Gradle')
.action(async (directory: string | undefined, options: Omit<WebshellBuildCommandOptions, 'directory'>) => {
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.
Expand Down
11 changes: 10 additions & 1 deletion src/core/data-access/command-types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
export type CommandRunner = (cmd: [string, ...string[]], options?: RunCommandOptions) => Promise<string>

export type InteractiveCommandRunner = (cmd: [string, ...string[]]) => Promise<void>
export type InteractiveCommandRunner = (
cmd: [string, ...string[]],
options?: InteractiveRunCommandOptions,
) => Promise<void>

export interface InteractiveRunCommandOptions {
cwd?: string
env?: Record<string, string>
}

export interface RunCommandOptions {
/**
Expand All @@ -9,5 +17,6 @@ export interface RunCommandOptions {
* drops half the diagnostics.
*/
combineOutput?: boolean
env?: Record<string, string>
stdin?: string
}
9 changes: 7 additions & 2 deletions src/core/data-access/run-executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -21,6 +25,7 @@ export const runInteractiveExecutable: InteractiveCommandRunner = async (cmd) =>
export async function runExecutable(cmd: [string, ...string[]], options: RunCommandOptions = {}): Promise<string> {
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[] = []
Expand Down
Loading
Loading