feat: add Expo QR code generation and modal for mobile preview
Introduce Expo QR code functionality to allow users to preview their projects on mobile devices. Added a new QR code modal component, integrated it into the chat and preview components, and implemented Expo URL detection in the shell process. This enhances the mobile development workflow by providing a seamless way to test Expo projects directly on devices. - Clean up and consolidate Preview icon buttons while removing redundant ones.
This commit is contained in:
@@ -2,6 +2,7 @@ import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
|
||||
import type { ITerminal } from '~/types/terminal';
|
||||
import { withResolvers } from './promises';
|
||||
import { atom } from 'nanostores';
|
||||
import { expoUrlAtom } from '~/stores/qrCodeStore';
|
||||
|
||||
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
@@ -80,13 +81,96 @@ export class BoltShell {
|
||||
this.#webcontainer = webcontainer;
|
||||
this.#terminal = terminal;
|
||||
|
||||
const { process, output } = await this.newBoltShellProcess(webcontainer, terminal);
|
||||
// Use all three streams from tee: one for terminal, one for command execution, one for Expo URL detection
|
||||
const { process, commandStream, expoUrlStream } = await this.newBoltShellProcess(webcontainer, terminal);
|
||||
this.#process = process;
|
||||
this.#outputStream = output.getReader();
|
||||
this.#outputStream = commandStream.getReader();
|
||||
|
||||
// Start background Expo URL watcher immediately
|
||||
this._watchExpoUrlInBackground(expoUrlStream);
|
||||
|
||||
await this.waitTillOscCode('interactive');
|
||||
this.#initialized?.();
|
||||
}
|
||||
|
||||
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
|
||||
terminal: {
|
||||
cols: terminal.cols ?? 80,
|
||||
rows: terminal.rows ?? 15,
|
||||
},
|
||||
});
|
||||
|
||||
const input = process.input.getWriter();
|
||||
this.#shellInputStream = input;
|
||||
|
||||
// Tee the output so we can have three independent readers
|
||||
const [streamA, streamB] = process.output.tee();
|
||||
const [streamC, streamD] = streamB.tee();
|
||||
|
||||
const jshReady = withResolvers<void>();
|
||||
let isInteractive = false;
|
||||
streamA.pipeTo(
|
||||
new WritableStream({
|
||||
write(data) {
|
||||
if (!isInteractive) {
|
||||
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
|
||||
|
||||
if (osc === 'interactive') {
|
||||
isInteractive = true;
|
||||
jshReady.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
terminal.write(data);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.onData((data) => {
|
||||
if (isInteractive) {
|
||||
input.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
await jshReady.promise;
|
||||
|
||||
// Return all streams for use in init
|
||||
return { process, terminalStream: streamA, commandStream: streamC, expoUrlStream: streamD };
|
||||
}
|
||||
|
||||
// Dedicated background watcher for Expo URL
|
||||
private async _watchExpoUrlInBackground(stream: ReadableStream<string>) {
|
||||
const reader = stream.getReader();
|
||||
let buffer = '';
|
||||
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += value || '';
|
||||
|
||||
const expoUrlMatch = buffer.match(expoUrlRegex);
|
||||
|
||||
if (expoUrlMatch) {
|
||||
const cleanUrl = expoUrlMatch[1]
|
||||
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
|
||||
.replace(/[^\x20-\x7E]+$/g, '');
|
||||
expoUrlAtom.set(cleanUrl);
|
||||
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
|
||||
}
|
||||
|
||||
if (buffer.length > 2048) {
|
||||
buffer = buffer.slice(-2048);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return this.#terminal;
|
||||
}
|
||||
@@ -138,65 +222,17 @@ export class BoltShell {
|
||||
return resp;
|
||||
}
|
||||
|
||||
async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
|
||||
const args: string[] = [];
|
||||
|
||||
// we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
|
||||
const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
|
||||
terminal: {
|
||||
cols: terminal.cols ?? 80,
|
||||
rows: terminal.rows ?? 15,
|
||||
},
|
||||
});
|
||||
|
||||
const input = process.input.getWriter();
|
||||
this.#shellInputStream = input;
|
||||
|
||||
const [internalOutput, terminalOutput] = process.output.tee();
|
||||
|
||||
const jshReady = withResolvers<void>();
|
||||
|
||||
let isInteractive = false;
|
||||
terminalOutput.pipeTo(
|
||||
new WritableStream({
|
||||
write(data) {
|
||||
if (!isInteractive) {
|
||||
const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
|
||||
|
||||
if (osc === 'interactive') {
|
||||
// wait until we see the interactive OSC
|
||||
isInteractive = true;
|
||||
|
||||
jshReady.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
terminal.write(data);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
terminal.onData((data) => {
|
||||
// console.log('terminal onData', { data, isInteractive });
|
||||
|
||||
if (isInteractive) {
|
||||
input.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
await jshReady.promise;
|
||||
|
||||
return { process, output: internalOutput };
|
||||
}
|
||||
|
||||
async getCurrentExecutionResult(): Promise<ExecutionResult> {
|
||||
const { output, exitCode } = await this.waitTillOscCode('exit');
|
||||
return { output, exitCode };
|
||||
}
|
||||
|
||||
onQRCodeDetected?: (qrCode: string) => void;
|
||||
|
||||
async waitTillOscCode(waitCode: string) {
|
||||
let fullOutput = '';
|
||||
let exitCode: number = 0;
|
||||
let buffer = ''; // <-- Add a buffer to accumulate output
|
||||
|
||||
if (!this.#outputStream) {
|
||||
return { output: fullOutput, exitCode };
|
||||
@@ -204,6 +240,9 @@ export class BoltShell {
|
||||
|
||||
const tappedStream = this.#outputStream;
|
||||
|
||||
// Regex for Expo URL
|
||||
const expoUrlRegex = /(exp:\/\/[^\s]+)/;
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await tappedStream.read();
|
||||
|
||||
@@ -213,6 +252,21 @@ export class BoltShell {
|
||||
|
||||
const text = value || '';
|
||||
fullOutput += text;
|
||||
buffer += text; // <-- Accumulate in buffer
|
||||
|
||||
// Extract Expo URL from buffer and set store
|
||||
const expoUrlMatch = buffer.match(expoUrlRegex);
|
||||
|
||||
if (expoUrlMatch) {
|
||||
// Remove any trailing ANSI escape codes or non-printable characters
|
||||
const cleanUrl = expoUrlMatch[1]
|
||||
.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '')
|
||||
.replace(/[^\x20-\x7E]+$/g, '');
|
||||
expoUrlAtom.set(cleanUrl);
|
||||
|
||||
// Remove everything up to and including the URL from the buffer to avoid duplicate matches
|
||||
buffer = buffer.slice(buffer.indexOf(expoUrlMatch[1]) + expoUrlMatch[1].length);
|
||||
}
|
||||
|
||||
// Check if command completion signal with exit code
|
||||
const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
|
||||
|
||||
Reference in New Issue
Block a user