Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Dev server url e.g. example.developer.shellular.dev
DEV_SERVER=
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,18 @@ keystore.jks
/build.json
dev/terminals.config

# Apple build and signing artifacts
*.xcarchive
*.ipa
*.p12
*.p8
*.mobileprovision
platforms/ios/Runner 20??-??-?? ??-??-??/
xcuserdata
*.xcuserstate
.build

*.xcconfig

# Agents
.claude
.claude
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Use your dev machine from your phone. Connect to your Mac, PC, or VPS and ship f
## Features

- **Remote terminal** — xterm.js-based shell access over WebSocket
- **File browser & editor** — browse, edit, and diff files with CodeMirror 6
- **File browser & editor** — browse, edit, and diff files with Monaco on desktop and CodeMirror 6 on mobile
- **Project management** — Git integration for your remote projects
- **AI agents** — chat and task execution via ACP
- **System monitoring** — CPU, memory, and battery dashboards
Expand All @@ -23,6 +23,7 @@ Use your dev machine from your phone. Connect to your Mac, PC, or VPS and ship f
- Platform SDKs (for native builds):
- Android: JDK 17+, Android SDK
- iOS: Xcode 16+, CocoaPods
- macOS: Xcode with the macOS SDK

## Quick Start

Expand Down Expand Up @@ -53,9 +54,49 @@ Successful app joins are shown as read-only host and device history from the Acc
# Type-check and build for production
pnpm build android
pnpm build ios
pnpm build macos
pnpm build browser
```

### Signed macOS DMG

The macOS build can create a Developer ID-signed, notarized, and stapled DMG for distribution outside the Mac App Store:

```bash
pnpm build macos --package dmg
```

The completed package is written to `dist/Shellular-<version>.dmg`. The command only publishes the final file after signing, notarization, stapling, and Gatekeeper validation all succeed. A normal `pnpm build macos` continues to create only `platforms/macos/shellular.xcarchive`.

Before the first DMG build:

1. In Xcode, sign in to the company Apple Developer account and install a valid **Developer ID Application** certificate. The certificate and its private key must both be available in the login Keychain.
2. Create the local signing configuration:

```bash
cp platforms/macos/Signing.xcconfig.example platforms/macos/Signing.xcconfig
```

3. Set the company team ID and Keychain profile name in `Signing.xcconfig`:

```xcconfig
DEVELOPMENT_TEAM = COMPANY_TEAM_ID
SHELLULAR_NOTARY_PROFILE = shellular-notary
```

4. Store the notarization credentials in the Keychain. Use an app-specific password for the Apple ID:

```bash
xcrun notarytool store-credentials "shellular-notary" \
--apple-id "APPLE_ID" \
--team-id "COMPANY_TEAM_ID" \
--password "APP_SPECIFIC_PASSWORD"
```

`Signing.xcconfig`, Apple credentials, certificates, and private keys are not committed to Git. The DMG build uses the existing non-sandboxed direct-distribution entitlements so local CLI integration remains available, while the normal Release configuration remains unchanged.

If the build reports that no Developer ID identity exists, confirm that the certificate has not expired and that its private key appears beneath it in Keychain Access. If Keychain profile validation fails, run `notarytool store-credentials` again with the same profile name. When Apple rejects a submission, the build prints the notarization log and does not publish the failed DMG. Renew an expiring certificate through the company developer account, install it with its private key, and rerun the build; no source configuration should need to change when the team ID stays the same.

### iOS code signing

The iOS build archives with `xcodebuild`, which needs a development team. The project already references a `Config.xcconfig`, but that file is **gitignored** — so each machine creates it once at `platforms/ios/Config.xcconfig` with your signing settings:
Expand Down Expand Up @@ -87,7 +128,7 @@ That's it — no Xcode setup needed; the project is already wired to read this f

## Tech Stack

React 19 / TypeScript / Webpack 5 / Tailwind CSS 4 / xterm.js 6 / CodeMirror 6 / Chart.js / Framer Motion / libsodium / Bio
React 19 / TypeScript / Webpack 5 / Tailwind CSS 4 / xterm.js 6 / Monaco Editor / CodeMirror 6 (mobile) / Chart.js / Framer Motion / libsodium / Bio

## License

Expand Down
2 changes: 1 addition & 1 deletion dev/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"files": {
"ignoreUnknown": false,
"includes": ["*.js"]
"includes": ["**/*.js"]
},
"formatter": {
"enabled": true,
Expand Down
61 changes: 61 additions & 0 deletions dev/build-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const SUPPORTED_PLATFORMS = new Set(["android", "ios", "macos", "browser"]);
const SUPPORTED_PACKAGES = new Map([["macos", new Set(["dmg"])]]);

export function parseBuildArgs(args) {
let platform;
let packageType;
let packageOptionSeen = false;

for (let index = 0; index < args.length; index += 1) {
const rawArgument = args[index];
const argument = rawArgument.toLowerCase();

if (SUPPORTED_PLATFORMS.has(argument)) {
if (platform) {
throw new Error(`Multiple build platforms were provided: ${platform}, ${argument}`);
}
platform = argument;
continue;
}

if (argument === "--package" || argument.startsWith("--package=")) {
if (packageOptionSeen) {
throw new Error("The --package option may only be provided once");
}
packageOptionSeen = true;

if (argument === "--package") {
const value = args[index + 1];
if (!value || value.startsWith("-")) {
throw new Error("The --package option requires a value");
}
packageType = value.toLowerCase();
index += 1;
} else {
packageType = rawArgument.slice(rawArgument.indexOf("=") + 1).toLowerCase();
if (!packageType) {
throw new Error("The --package option requires a value");
}
}
continue;
}

throw new Error(`Unknown build argument: ${rawArgument}`);
}

if (!platform) {
throw new Error("Please specify a platform: android, ios, macos, or browser");
}

if (packageType) {
const supportedForPlatform = SUPPORTED_PACKAGES.get(platform);
if (!supportedForPlatform) {
throw new Error(`The --package option is not supported for ${platform}`);
}
if (!supportedForPlatform.has(packageType)) {
throw new Error(`Unsupported ${platform} package type: ${packageType}`);
}
}

return { platform, packageType };
}
38 changes: 38 additions & 0 deletions dev/build-args.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { parseBuildArgs } from "./build-args.js";

describe("parseBuildArgs", () => {
it.each(["android", "ios", "macos", "browser"])("parses an archive-only %s build", (platform) => {
expect(parseBuildArgs([platform])).toEqual({
platform,
packageType: undefined,
});
});

it("accepts a separate DMG package value", () => {
expect(parseBuildArgs(["macos", "--package", "dmg"])).toEqual({
platform: "macos",
packageType: "dmg",
});
});

it("accepts an equals-form DMG package value case-insensitively", () => {
expect(parseBuildArgs(["MacOS", "--package=DMG"])).toEqual({
platform: "macos",
packageType: "dmg",
});
});

it.each([
[[], "Please specify a platform"],
[["macos", "--package"], "requires a value"],
[["macos", "--package="], "requires a value"],
[["macos", "--package", "zip"], "Unsupported macos package type: zip"],
[["ios", "--package", "dmg"], "not supported for ios"],
[["macos", "--package", "dmg", "--package=dmg"], "only be provided once"],
[["macos", "ios"], "Multiple build platforms"],
[["macos", "--unknown"], "Unknown build argument"],
])("rejects invalid arguments %j", (args, message) => {
expect(() => parseBuildArgs(args)).toThrow(message);
});
});
31 changes: 15 additions & 16 deletions dev/build.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { type } from "node:os";
import { parseBuildArgs } from "./build-args.js";
import config from "./config.js";

config("production");

const args = process.argv.slice(2);
const platform = args.find((arg) => /(android|ios|macos|browser)/i.test(arg));

if (!platform) {
console.error("Please specify a platform: android, ios, macos, or browser");
let buildOptions;
try {
buildOptions = parseBuildArgs(process.argv.slice(2));
} catch (error) {
console.error(error?.message || error);
process.exit(1);
}

const { platform, packageType } = buildOptions;
config("production");

const { default: build } = await import(`./${platform}/build.js`);

const RED = type() === "Windows_NT" ? "\x1b[31m" : "\x1b[91m";
Expand All @@ -20,22 +22,19 @@ const GREEN = type() === "Windows_NT" ? "\x1b[32m" : "\x1b[92m";
const YELLOW = type() === "Windows_NT" ? "\x1b[33m" : "\x1b[93m";
const NC = type() === "Windows_NT" ? "\x1b[0m" : "\x1b[39m";

const buildCommand = `webpack --progress --mode production --env platform=${platform}`;

(async () => {
try {
// run pnpm install
console.log(`\n${YELLOW}pnpm install${NC}`);
execSync("pnpm install", { stdio: "inherit" });
execFileSync("pnpm", ["install"], { stdio: "inherit" });

console.log(`${YELLOW}${buildCommand}${NC}`);
execSync(buildCommand, { stdio: "inherit" });
console.log(`${YELLOW}webpack --progress --mode production --env platform=${platform}${NC}`);
execFileSync("webpack", ["--progress", "--mode", "production", "--env", `platform=${platform}`], { stdio: "inherit" });
console.log(`${YELLOW}-> Compiling console using${NC} ${BLUE}webpack${NC}`);
execSync(`webpack --mode production --env console=true --env platform=${platform}`);
execFileSync("webpack", ["--mode", "production", "--env", "console=true", "--env", `platform=${platform}`], { stdio: "inherit" });
console.log(`${GREEN}-> Console compiled successfully${NC}`);

console.log(`${YELLOW}Building for ${platform}...${NC}`);
await build();
await build({ packageType });
console.log(`${GREEN}Build completed successfully${NC}`);
} catch (error) {
console.error(error);
Expand Down
47 changes: 38 additions & 9 deletions dev/macos/build.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { exec } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { runCommand } from "./command.js";
import { createDmgPackage } from "./dmg.js";

export default async function build() {
const root = join(process.cwd(), "platforms/macos");
await new Promise((resolve, reject) => {
const child = exec(`xcodebuild archive -project "${join(root, "shellular.xcodeproj")}" -scheme shellular -configuration Release -destination 'generic/platform=macOS' -archivePath "${join(root, "shellular.xcarchive")}"`);
child.stdout?.pipe(process.stdout); child.stderr?.pipe(process.stderr);
child.on("close", code => code === 0 ? resolve() : reject(new Error(`xcodebuild exited ${code}`)));
child.on("error", reject);
});
export default async function build({ packageType } = {}) {
const appRoot = process.cwd();
const macosRoot = join(appRoot, "platforms", "macos");
const projectPath = join(macosRoot, "shellular.xcodeproj");

if (packageType === "dmg") {
const packageJson = JSON.parse(await readFile(join(appRoot, "package.json"), "utf8"));
await createDmgPackage({
appRoot,
macosRoot,
projectPath,
version: packageJson.version,
versionCode: packageJson.versionCode,
});
return;
}
if (packageType) {
throw new Error(`Unsupported macOS package type: ${packageType}`);
}

const archivePath = join(macosRoot, "shellular.xcarchive");
await runCommand("xcodebuild", [
"archive",
"-project",
projectPath,
"-scheme",
"shellular",
"-configuration",
"Release",
"-destination",
"generic/platform=macOS",
"-archivePath",
archivePath,
]);
console.log(`Archive created at: ${archivePath}`);
}
50 changes: 50 additions & 0 deletions dev/macos/command.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { spawn } from "node:child_process";

export class CommandExecutionError extends Error {
constructor(command, args, code, stdout, stderr) {
const detail = stderr.trim() || stdout.trim();
super(`${command} exited with code ${code}${detail ? `: ${detail}` : ""}`);
this.name = "CommandExecutionError";
this.command = command;
this.args = args;
this.code = code;
this.stdout = stdout;
this.stderr = stderr;
}
}

export function runCommand(command, args, options = {}) {
const { capture = false, cwd, env = process.env } = options;

return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env,
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
});
let stdout = "";
let stderr = "";

if (capture) {
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk) => {
stdout += chunk;
});
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
}

child.on("error", (error) => {
reject(new CommandExecutionError(command, args, "unavailable", stdout, stderr || error.message));
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new CommandExecutionError(command, args, code, stdout, stderr));
});
});
}
26 changes: 26 additions & 0 deletions dev/macos/command.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { CommandExecutionError, runCommand } from "./command.js";

describe("runCommand", () => {
it("captures stdout and stderr without a shell", async () => {
const result = await runCommand(process.execPath, ["-e", 'process.stdout.write("out"); process.stderr.write("err")'], { capture: true });

expect(result).toEqual({ stdout: "out", stderr: "err" });
});

it("returns structured output when a command fails", async () => {
const execution = runCommand(process.execPath, ["-e", 'process.stdout.write("out"); process.stderr.write("failure"); process.exit(7)'], {
capture: true,
});

await expect(execution).rejects.toMatchObject({
name: "CommandExecutionError",
code: 7,
stdout: "out",
stderr: "failure",
});
await execution.catch((error) => {
expect(error).toBeInstanceOf(CommandExecutionError);
});
});
});
Loading