Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,37 @@ bun install
bun run dev
```

Headless/browser dev mode skips the Electron window and serves the app through Vite:

```bash
bun run dev:headless
```

To try it from another device on your network:

```bash
bun run dev:headless:host
# open http://<this-machine-ip>:5173/#token=<printed-token>
```

Remote dev mode requires the printed access token before the browser can use the local desktop bridge.
Keep it on a trusted network.

Installed builds can also run without opening the desktop window:

```bash
bunx howcode --headless
bunx howcode --headless --host 0.0.0.0 --port 4173
bunx howcode --headless --host 0.0.0.0 --port 4173 --token my-secret
```

Open `http://<server-ip>:4173/#token=<printed-token>` from another device.

The default headless host is `127.0.0.1`. Remote headless mode requires a token; pass `--token`,
set `HOWCODE_HEADLESS_TOKEN`, or use the random one printed at startup. Use `--host 0.0.0.0` only on a trusted network.
In browser mode, files picked, pasted, or dropped from the browser device are uploaded to a temp
folder on the host and attached from there. Host file browsing still uses the host filesystem.

## Issues and updates

> [!IMPORTANT]
Expand Down
19 changes: 19 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Added Pi project trust prompts in desktop, backed by Pi's trust store.
- Updated Pi SDK/runtime packages to 0.79.1.
- Bumped app/build dependencies, including Electron 41.7.2.
- Added headless/browser mode, including LAN access with token auth and browser-side file uploads.

#### 0.1.66 Hotfixes (because .67 has to be more special)

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
"dev:web": "bun ./scripts/dev-web.ts",
"dev:runtime": "bun ./scripts/build-electron-runtime.ts --watch",
"dev:desktop": "bun ./scripts/dev-electron.ts",
"dev:headless": "bun run dev:clean && concurrently -k \"bun run dev:web\" \"bun run dev:runtime\"",
"dev:headless:host": "HOWCODE_DEV_SERVER_HOST=0.0.0.0 bun run dev:headless",
"build:runtime": "bun ./scripts/build-electron-runtime.ts",
"build": "vite build && bun run build:runtime && electron-builder --dir --config electron-builder.yml",
"build:release": "vite build && bun run build:runtime && electron-builder --config electron-builder.yml --publish never",
Expand Down Expand Up @@ -69,6 +71,7 @@
"@pierre/trees": "1.0.0-beta.4",
"@tanstack/react-pacer": "^0.22.1",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-router": "^1.170.15",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0",
Expand Down
14 changes: 14 additions & 0 deletions packages/howcode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ npm i -g howcode
howcode
```

Headless/browser mode:

```bash
bunx howcode --headless
bunx howcode --headless --host 0.0.0.0 --port 4173
bunx howcode --headless --host 0.0.0.0 --port 4173 --token my-secret
```

Open `http://<server-ip>:4173/#token=<printed-token>` from another device.

Remote host mode requires an access token before the browser can use the local desktop bridge. Pass `--token`,
set `HOWCODE_HEADLESS_TOKEN`, or use the random token printed at startup. Keep it on a trusted network.
Files picked, pasted, or dropped from a browser device are uploaded to a temp folder on the host before attaching.

This npm package is a small launcher.

On first run, it downloads the matching desktop app for your platform from GitHub Releases and caches it locally.
Expand Down
80 changes: 70 additions & 10 deletions packages/howcode/lib/howcode.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,10 +162,10 @@ function getLinuxSetsidPath() {
return null
}

function spawnLinuxDetachedLauncher(executablePath, env) {
function spawnLinuxDetachedLauncher(executablePath, args, env) {
const setsidPath = getLinuxSetsidPath()
if (setsidPath) {
return spawn(setsidPath, ['-f', executablePath], {
return spawn(setsidPath, ['-f', executablePath, ...args], {
detached: true,
stdio: 'ignore',
windowsHide: true,
Expand All @@ -176,7 +176,10 @@ function spawnLinuxDetachedLauncher(executablePath, env) {

return spawn(
'/bin/sh',
['-c', `nohup ${shellSingleQuote(executablePath)} >/dev/null 2>&1 </dev/null &`],
[
'-c',
`nohup ${[executablePath, ...args].map(shellSingleQuote).join(' ')} >/dev/null 2>&1 </dev/null &`,
],
{
detached: true,
stdio: 'ignore',
Expand All @@ -187,13 +190,32 @@ function spawnLinuxDetachedLauncher(executablePath, env) {
)
}

function isHeadlessLaunchArgs(args) {
return args.includes('--headless') || process.env.HOWCODE_HEADLESS === '1'
}

function getAppLaunchArgs(args) {
const appArgs = args.map((arg) => (arg === '--headless' ? '--howcode-headless' : arg))
if (isHeadlessLaunchArgs(args) && !appArgs.some((arg) => arg.startsWith('--ozone-platform'))) {
appArgs.push('--ozone-platform=headless')
}
return appArgs
}

async function writeLinuxCommandLauncher(paths) {
const launcherPath = getLinuxCommandLauncherPath()
const launcherDirectory = path.dirname(launcherPath)
const shellParameterExpansionStart = '${'
const launcherContents = [
'#!/bin/sh',
`export HOWCODE_REPO_ROOT=${shellParameterExpansionStart}HOWCODE_REPO_ROOT:-$(pwd)}`,
'if [ "$1" = "--headless" ] || [ "$HOWCODE_HEADLESS" = "1" ]; then',
' if [ "$1" = "--headless" ]; then',
' shift',
` exec ${shellSingleQuote(paths.executablePath)} --howcode-headless --ozone-platform=headless "$@"`,
' fi',
` exec ${shellSingleQuote(paths.executablePath)} --ozone-platform=headless "$@"`,
'fi',
'if command -v setsid >/dev/null 2>&1; then',
` setsid -f ${shellSingleQuote(paths.executablePath)} "$@" >/dev/null 2>&1 </dev/null`,
'else',
Expand Down Expand Up @@ -260,7 +282,16 @@ async function writeWindowsCommandLauncher(paths) {
` echo Run npx ${APP_NAME} to repair the local install.`,
' exit /b 1',
')',
'start "" /D "%HOWCODE_REPO_ROOT%" "%HOWCODE_EXE%"',
'if "%~1"=="--headless" (',
' shift /1',
' "%HOWCODE_EXE%" --howcode-headless --ozone-platform=headless %*',
' exit /b %ERRORLEVEL%',
')',
'if "%HOWCODE_HEADLESS%"=="1" (',
' "%HOWCODE_EXE%" --ozone-platform=headless %*',
' exit /b %ERRORLEVEL%',
')',
'start "" /D "%HOWCODE_REPO_ROOT%" "%HOWCODE_EXE%" %*',
'endlocal',
'',
].join('\r\n')
Expand Down Expand Up @@ -563,18 +594,29 @@ async function pruneOldVersions(cacheRoot, keepDir) {
}

function spawnLauncherProcess(executablePath, options = {}) {
const args = options.args || []
const env = {
...process.env,
HOWCODE_REPO_ROOT: process.env.HOWCODE_REPO_ROOT || process.cwd(),
...(options.env || {}),
}
Reflect.deleteProperty(env, 'NODE_TLS_REJECT_UNAUTHORIZED')

if (options.foreground) {
return spawn(executablePath, args, {
detached: false,
stdio: options.stdio || 'inherit',
windowsHide: false,
cwd: path.dirname(executablePath),
env,
})
}

if (process.platform === 'linux') {
return spawnLinuxDetachedLauncher(executablePath, env)
return spawnLinuxDetachedLauncher(executablePath, args, env)
}

return spawn(executablePath, [], {
return spawn(executablePath, args, {
detached: true,
stdio: options.stdio || 'ignore',
windowsHide: true,
Expand All @@ -583,13 +625,31 @@ function spawnLauncherProcess(executablePath, options = {}) {
})
}

async function launch(executablePath) {
const child = spawnLauncherProcess(executablePath)
async function launch(executablePath, args) {
const foreground = isHeadlessLaunchArgs(args)
const child = spawnLauncherProcess(executablePath, { args: getAppLaunchArgs(args), foreground })

if (foreground) {
await new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => {
if (signal) {
reject(new Error(`${APP_NAME} exited with signal ${signal}`))
return
}
resolve(code || 0)
})
}).then((code) => {
process.exitCode = code
})
return
}

child.unref()
}

async function main() {
const launchArgs = process.argv.slice(2)
const target = getTarget()
const cacheRoot = getCacheRoot()
await fsp.mkdir(cacheRoot, { recursive: true })
Expand Down Expand Up @@ -620,7 +680,7 @@ async function main() {
await ensureCommandLaunchIntegration(target, {
...currentPaths,
})
await launch(current.executablePath)
await launch(current.executablePath, launchArgs)
return
}

Expand All @@ -638,7 +698,7 @@ async function main() {
console.log(`${APP_NAME}: installed. You can relaunch it from the Windows Start Menu.`)
}
await pruneOldVersions(cacheRoot, paths.installDir)
await launch(paths.executablePath)
await launch(paths.executablePath, launchArgs)
}

module.exports = {
Expand Down
Loading