diff --git a/Makefile b/Makefile index a354305..3ce1f60 100644 --- a/Makefile +++ b/Makefile @@ -125,11 +125,11 @@ build/assets/matter.css: apps/libfileview.lib/icons: apps/libfileview.lib/icons.json cd apps/libfileview.lib; bash geticons.sh -build/bin/chimerix.ajs: chimerix/src/* +bin/chimerix.ajs: chimerix/src/* mkdir -p build/bin cd chimerix; npm i cd chimerix; npx rollup -c rollup.config.js - cp chimerix/dist/chimerix.ajs build/bin/chimerix.ajs + cp chimerix/dist/chimerix.ajs bin/chimerix.ajs clean: rm -rf build @@ -193,6 +193,7 @@ static: all mkdir -p static/ cp -r aboutproxy/static/* static/ cp -r apps/ static/apps/ + cp -r bin/ static/bin/ cp -r build/* static/ cp -r public/* static/ diff --git a/apps/ashell.app/term.html b/apps/ashell.app/term.html index b91840a..675e9ae 100644 --- a/apps/ashell.app/term.html +++ b/apps/ashell.app/term.html @@ -1,7 +1,7 @@ - +
diff --git a/apps/ashell.app/term.js b/apps/ashell.app/term.js index ddba95f..676ddb7 100644 --- a/apps/ashell.app/term.js +++ b/apps/ashell.app/term.js @@ -1,7 +1,67 @@ const hterm = (await anura.import("anura.hterm")).default; +const exit = env.process.kill.bind(env.process); + +const url = new URL(window.location.href); +const argv = ExternalApp.deserializeArgs(url.searchParams.get("args")); + +let scan = "none"; + +const argmap = {}; + +console.log(argv); + +for (let i = 0; i < argv.length; i++) { + let arg = argv[i]; + + if (arg === "--" || arg === "") continue; + + if (arg.startsWith("--")) { + scan = arg.slice(2); + } else if (scan !== "none") { + argmap[scan] = arg; + scan = "none"; + } else { + console.error(`Unknown argument: ${arg}`); + window.postMessage({ + type: "stderr", + message: "\x1b[31mUnknown argument: " + arg, + }); + exit(1); + } +} + +if (scan !== "none") { + console.error(`Expected argument after ${scan}`); + window.postMessage({ + type: "stderr", + message: "\x1b[31mExpected argument after " + scan, + }); + exit(1); +} + +// detect if there are arguments that dont exist +const validArgs = ["cmd"]; +for (let key in argmap) { + if (!validArgs.includes(key)) { + console.error(`Unknown argument: ${key}`); + window.postMessage({ + type: "stderr", + message: "\x1b[31mUnknown argument: " + key, + }); + exit(1); + } +} + +console.log(argmap); + const shell = anura.settings.get("shell") || "/usr/bin/chimerix.ajs"; anura.settings.set("shell", shell); +if (!argmap.cmd) { + // If no command is provided, default to the system shell + argmap.cmd = shell; +} + const config = anura.settings.get("anura-shell-config") || {}; anura.settings.set("anura-shell-config", config); @@ -35,7 +95,17 @@ term.onTerminalReady = async () => { let io = term.io.push(); - const proc = await anura.processes.execute(shell); + const cmdline = (argmap.cmd.match(/(?:[^\s"]+|"[^"]*")+/g) || []).map( + (arg) => { + // Remove surrounding quotes if they exist + if (arg.startsWith('"') && arg.endsWith('"')) { + return arg.slice(1, -1); + } + return arg; + }, + ); + + const proc = await anura.processes.execute(cmdline[0], cmdline.slice(1)); const stdinWriter = proc.stdin.getWriter(); @@ -68,6 +138,9 @@ term.onTerminalReady = async () => { proc.stdout.pipeTo( new WritableStream({ write: (chunk) => { + if (typeof chunk === "string") { + chunk = encoder.encode(chunk); + } io.writeUTF8(LF_to_CRLF(chunk)); }, }), @@ -76,6 +149,9 @@ term.onTerminalReady = async () => { proc.stderr.pipeTo( new WritableStream({ write: (chunk) => { + if (typeof chunk === "string") { + chunk = encoder.encode(chunk); + } io.writeUTF8(LF_to_CRLF(chunk)); }, }), @@ -83,12 +159,12 @@ term.onTerminalReady = async () => { const oldProcKill = proc.kill.bind(proc); proc.kill = () => { - oldProcKill(); - instanceWindow.close(); + if (proc.alive) oldProcKill(); + if (instanceWindow.alive) instanceWindow.close(); }; instanceWindow.addEventListener("close", () => { - proc.kill(); + if (proc.alive) proc.kill(); }); proc.exit = proc.kill.bind(proc); diff --git a/apps/libfileview.lib/fileHandler.js b/apps/libfileview.lib/fileHandler.js index 1b81d19..38c65e2 100644 --- a/apps/libfileview.lib/fileHandler.js +++ b/apps/libfileview.lib/fileHandler.js @@ -1,5 +1,7 @@ import { createAppView, getAppIcon } from "./pages/appview/appview.js"; +const { ShortcutApp } = await anura.import("anura.globalscope"); + const icons = await (await fetch(localPathToURL("icons.json"))).json(); export function openFile(path) { @@ -118,6 +120,23 @@ export function openFile(path) { iframe.srcdoc = data; fileView.content.appendChild(iframe); } + + async function openApp(path) { + const stat = await fs.promises.stat(path); + if (stat.isDirectory()) { + console.error("TODO: Move special folder execution to libfileview"); + anura.dialog.alert( + "Special folder execution is not yet implemented in libfileview. You should not be seeing this message unless you are a developer, if you are, please fix it. If you are not, please report this issue.", + ); + } else { + // Shortcut file + const data = await fs.promises.readFile(path); + const app = new ShortcutApp(path, JSON.parse(data)); + console.log(app); + await app.open(); + } + } + switch (path.split(".").slice("-2").join(".")) { case "app.zip": createAppView(path, "app"); @@ -139,7 +158,10 @@ export function openFile(path) { openText(path); break; case "ajs": - anura.processes.execute(path); + // anura.processes.execute(path); + const shell = anura.settings.get("shell") || "/usr/bin/chimerix.ajs"; + anura.settings.set("shell", shell); + anura.processes.execute(shell, ["--cmd", path], true); break; case "mp3": openAudio(path, "audio/mpeg"); @@ -181,13 +203,15 @@ export function openFile(path) { case "html": openHTML(path); break; + case "app": + openApp(path); + break; default: openText(path); - break; } } -export function getIcon(path) { +export async function getIcon(path) { switch (path.split(".").slice("-2").join(".")) { case "app.zip": return getAppIcon(path); @@ -197,10 +221,30 @@ export function getIcon(path) { break; } let ext = path.split(".").slice("-1")[0]; + + if (ext === "app") { + const stat = await anura.fs.promises.stat(path); + if (stat.isDirectory()) { + console.error("TODO: Move special folder execution to libfileview"); + anura.dialog.alert( + "Special folder execution is not yet implemented in libfileview. You should not be seeing this message unless you are a developer, if you are, please fix it. If you are not, please report this issue.", + ); + } else { + // Shortcut file + const app = new ShortcutApp( + path, + JSON.parse(await anura.fs.promises.readFile(path)), + ); + + console.log(app); + return new URL(app.icon, top.location.href).href; + } + } let iconObject = icons.files.find((icon) => icon.ext === ext); if (iconObject) { return localPathToURL(iconObject.icon); } + return localPathToURL(icons.default); } @@ -213,8 +257,10 @@ export function getFileType(path) { default: break; } - let ext = path.split(".").slice("-1")[0]; - let iconObject = icons.files.find((icon) => icon.ext === ext); + + const ext = path.split(".").slice("-1")[0]; + + const iconObject = icons.files.find((icon) => icon.ext === ext); if (iconObject) { return iconObject.type; } diff --git a/apps/libfileview.lib/icons.json b/apps/libfileview.lib/icons.json index 7d2f19b..433d99b 100644 --- a/apps/libfileview.lib/icons.json +++ b/apps/libfileview.lib/icons.json @@ -131,6 +131,10 @@ "icon": "icons/css.svg", "source": "papirus/Papirus/16x16/mimetypes/text-css.svg", "type": "CSS Stylesheet" + }, + { + "ext": "app", + "type": "Anura Shortcut" } ], "default": "icons/txt.svg", diff --git a/bin/anuractrl.ajs b/bin/anuractrl.ajs new file mode 100644 index 0000000..1f0dae3 --- /dev/null +++ b/bin/anuractrl.ajs @@ -0,0 +1,91 @@ +#! {"lang":"module"} +console.warn(` +// AnuraCtrl - A command-line utility for controlling Anura apps +// Eventually this should have more tightly knit integration with Anurad, +// instead of just the raw process API. For now, this is a good start.`) + +export async function main(args) { + const validArgs = new Set(["open", "close", "info", "pid", "pkg"]); + const argmap = {}; + + args.forEach((arg, i) => { + if (arg.startsWith("--")) { + const key = arg.slice(2); + if (!validArgs.has(key)) { + eprintln(`Unknown argument: --${key}`); + exit(1); + } + argmap[key] = ["open", "close", "info"].includes(key) ? true : args[i + 1] || ""; + } + }); + + const commands = ["open", "close", "info"].filter((cmd) => argmap[cmd]); + if (commands.length !== 1) { + eprintln("Provide exactly one command: --open, --close, or --info."); + exit(1); + } + + const { + open, + close, + info, + pid, + pkg + } = argmap; + + if (open) { + await anura.apps[pkg].open(); + println(`Opened app with package name: ${pkg}`); + } else if (close) { + if (pid) { + anura.processes.procs[pid].deref()?.kill(); + println(`Closed process with PID: ${pid}`); + } else if (pkg) { + anura.processes.procs.forEach((p) => { + let proc = p?.deref(); + if (proc?.app?.package === pkg) { + proc.kill(); + println(`Closed process with PID: ${proc.pid} for app: ${pkg}`); + } + }); + } else { + eprintln("Please provide either --pid or --pkg to close a process or an app's windows."); + exit(1); + } + } else if (info) { + if (pid) { + const proc = anura.processes.procs[pid]?.deref(); + if (proc) { + const isApp = proc?.app + println(`Process info for PID ${pid}:`); + println(` Title: ${proc.title}`); + println(` Belongs to app: ${isApp}`); + if (isApp) { + println(` Package: ${proc.app?.package}`); + } + } else { + eprintln(`No process found with PID: ${pid}`); + } + } else if (pkg) { + let found = false; + anura.processes.procs.forEach((p) => { + let proc = p?.deref(); + if (proc?.app?.package === pkg) { + found = true; + println(`Process info for PID ${proc.pid}:`); + println(` Title: ${proc.title}`); + println(` Belongs to app: true`); + println(` Package: ${proc.app?.package}`); + } + }); + + if (!found) { + eprintln(`No process found for package: ${pkg}`); + } + } else { + eprintln("Please provide either --pid or --pkg to get process information."); + exit(1); + } + } + exit(0); +} \ No newline at end of file diff --git a/bin/vista.ajs b/bin/vista.ajs new file mode 100644 index 0000000..014fa33 --- /dev/null +++ b/bin/vista.ajs @@ -0,0 +1,46 @@ +#! {"lang":"module"} + +export async function main(args) { + const validArgs = new Set(["alert", "confirm", "prompt", "message", "title", "default"]); + const argmap = {}; + + args.forEach((arg, i) => { + if (arg.startsWith("--")) { + const key = arg.slice(2); + if (!validArgs.has(key)) { + eprintln(`Unknown argument: --${key}`); + exit(1); + } + argmap[key] = ["alert", "confirm", "prompt"].includes(key) ? true : args[i + 1] || ""; + } + }); + + const commands = ["alert", "confirm", "prompt"].filter((cmd) => argmap[cmd]); + if (commands.length !== 1) { + eprintln("Provide exactly one command: --alert, --confirm, or --prompt."); + exit(1); + } + + const { + alert, + confirm, + prompt, + message = "No message provided", + title = "Dialog", + default: defaultValue = null, + } = argmap; + + if (alert) { + anura.dialog.alert(message, title); + println(title); + println(message); + } else if (confirm) { + const result = await anura.dialog.confirm(message, title); + println((!!result) + ""); + } else if (prompt) { + const result = await anura.dialog.prompt(message, defaultValue); + println(result + ""); + } + + exit(0); +} \ No newline at end of file diff --git a/bin/x86-run.ajs b/bin/x86-run.ajs new file mode 100644 index 0000000..7e77a63 --- /dev/null +++ b/bin/x86-run.ajs @@ -0,0 +1,55 @@ +#! {"lang":"module"} + +export async function main(args) { + let cmd = args.slice(1).join(" "); + + console.log(args, cmd); + + if (!cmd || cmd === "") { + cmd = "/bin/bash --login"; + } + + if (anura.x86 === undefined) { + println( + "\u001b[33mThe Anura x86 subsystem is not enabled. Please enable it in Settings.\u001b[0m", + ); + return; + } + if (!anura.x86.ready) { + println( + "\u001b[33mThe Anura x86 subsystem has not yet booted. Please wait for the notification that it has booted and try again.\u001b[0m", + ); + return; + } + println( + "Welcome to the Anura x86 subsystem.\nTo access your Anura files within Linux, use the /root directory.", + ); + const pty = await anura.x86.openpty( + cmd, + 80, 24, + (data) => { + print(data); + }, + ); + + addEventListener("message", (event) => { + if (event.data.type === "ioctl.set") { + console.log(event.data.windowSize); + anura.x86.resizepty(pty, event.data.windowSize.cols, event.data.windowSize.rows); + } + }); + + addEventListener("message", (event) => { + if (event.data.type === "stdin") { + console.log(event.data.message); + anura.x86.writepty(pty, event.data.message); + } + }); + + const oldkill = env.process.kill.bind(env.process); + + env.process.kill = (signal) => { + anura.x86.closepty(pty); + oldkill(signal); + }; +} \ No newline at end of file diff --git a/config.default.json b/config.default.json index 9ddad17..1fb8916 100644 --- a/config.default.json +++ b/config.default.json @@ -12,7 +12,12 @@ "/apps/libpersist.lib", "/apps/libhterm.lib" ], - "bin": ["/bin/chimerix.ajs"], + "bin": [ + "/bin/chimerix.ajs", + "/bin/x86-run.ajs", + "/bin/vista.ajs", + "/bin/anuractrl.ajs" + ], "defaultsettings": { "use-sw-cache": false, "applist": ["anura.browser", "anura.settings", "anura.fsapp"], diff --git a/public/index.html b/public/index.html index bf1c7be..826dda7 100755 --- a/public/index.html +++ b/public/index.html @@ -60,6 +60,7 @@ + @@ -119,6 +120,7 @@ + diff --git a/server/server.js b/server/server.js index a4af875..db51bfa 100644 --- a/server/server.js +++ b/server/server.js @@ -55,6 +55,7 @@ if (debugAppFolder) { app.use(express.static(__dirname + "/public")); app.use(express.static(__dirname + "/build")); +app.use("/bin", express.static(__dirname + "/bin")); app.use("/apps", express.static(__dirname + "/apps")); app.use(express.static(__dirname + "/aboutproxy/static")); diff --git a/src/AliceWM.tsx b/src/AliceWM.tsx index 47ecd43..ad3277b 100755 --- a/src/AliceWM.tsx +++ b/src/AliceWM.tsx @@ -28,6 +28,7 @@ class WindowInformation { minwidth: number; minheight: number; resizable: boolean; + args?: string[]; } class WMWindow extends EventTarget implements Process { @@ -81,6 +82,12 @@ class WMWindow extends EventTarget implements Process { this.state.title = title; } + #args: string[]; + + get args() { + return this.#args; + } + maximizeImg: HTMLOrSVGElement; maximizeSvg: HTMLOrSVGElement; restoreSvg: HTMLOrSVGElement; @@ -89,6 +96,7 @@ class WMWindow extends EventTarget implements Process { public app?: App, ) { super(); + this.#args = wininfo.args || []; this.wininfo = wininfo; this.state = $state({ title: wininfo.title, diff --git a/src/Boot.tsx b/src/Boot.tsx index add0c9f..1c2e818 100644 --- a/src/Boot.tsx +++ b/src/Boot.tsx @@ -354,6 +354,8 @@ window.addEventListener("load", async () => { } } + anura.registerLib(new AnuraGlobalsLib()); + // Register built-in Node Polyfills anura.registerLib(new NodeFS()); anura.registerLib(new NodePrelude()); @@ -788,12 +790,25 @@ async function bootUserCustomizations() { const files = await anura.fs.promises.readdir(directories["apps"]); if (files) { for (const file of files) { - try { - await anura.registerExternalApp( - `/fs/${directories["apps"]}/${file}/`, + const { type } = await anura.fs.promises.stat( + `${directories["apps"]}/${file}`, + ); + if (type === "DIRECTORY") { + try { + await anura.registerExternalApp( + `/fs/${directories["apps"]}/${file}/`, + ); + } catch (e) { + anura.logger.error("Anura failed to load an app", e); + } + } else { + // This is a shortcut file + const shortcut = JSON.parse( + ( + await anura.fs.promises.readFile(`${directories["apps"]}/${file}`) + ).toString(), ); - } catch (e) { - anura.logger.error("Anura failed to load an app", e); + anura.registerApp(new ShortcutApp(file, shortcut)); } } } diff --git a/src/anurad.tsx b/src/anurad.tsx index ae8e0de..378fb43 100644 --- a/src/anurad.tsx +++ b/src/anurad.tsx @@ -46,6 +46,7 @@ class AnuradInitScript implements Process { frame: InitScriptFrame; window: InitScriptFrame["contentWindow"]; info?: InitScriptExports; + #args: string[]; get title() { return this.info?.name as string; @@ -58,8 +59,11 @@ class AnuradInitScript implements Process { constructor( script: string, public pid: number, + args: string[] = [], ) { this.script = script; + this.#args = args; + this.frame = ( - ) as HTMLIFrameElement; + ) as ModuleProcessFrame; anura.processes.processesDiv.appendChild(this.frame); @@ -145,12 +172,26 @@ class IframeProcess extends Process { message, }); }, + // Alias for printerr + eprint: (message: string) => { + this.window.postMessage({ + type: "stderr", + message, + }); + }, printlnerr: (message: string) => { this.window.postMessage({ type: "stderr", message: message + "\n", }); }, + // Alias for printlnerr + eprintln: (message: string) => { + this.window.postMessage({ + type: "stderr", + message: message + "\n", + }); + }, read: () => { return new Promise((resolve) => { this.window.addEventListener( @@ -180,6 +221,10 @@ class IframeProcess extends Process { this.window.addEventListener("message", listener); }); }, + // Exit codes are not implemented yet but it is good practice to include them anyways + exit: (code?: number) => { + this.kill(code); + }, env: { process: this, }, @@ -197,11 +242,17 @@ class IframeProcess extends Process { this.stderr = new ReadableStream({ start: (controller) => { this.window.addEventListener("error", (e) => { - controller.enqueue(e.error); + const en = new TextEncoder(); + controller.enqueue(en.encode(e.error.message + "\n")); }); this.window.addEventListener("message", (e) => { if (e.data.type === "stderr") { + if (typeof e.data.message === "string") { + const en = new TextEncoder(); + e.data.message = en.encode(e.data.message); + } + controller.enqueue(e.data.message); } }); @@ -212,6 +263,11 @@ class IframeProcess extends Process { start: (controller) => { this.window.addEventListener("message", (e) => { if (e.data.type === "stdout") { + if (typeof e.data.message === "string") { + const en = new TextEncoder(); + e.data.message = en.encode(e.data.message); + } + controller.enqueue(e.data.message); } }); @@ -219,13 +275,32 @@ class IframeProcess extends Process { }); } - kill() { - this.frame.remove(); - super.kill(); + #closing = false; + + kill(code?: number) { + // Make sure all messages are received by sending a dummy message and waiting + + if (code) { + console.warn("Exit codes are not implemented yet, ignoring"); + } + + this.#closing = true; + + this.window.addEventListener("message", (e) => { + if (e.data.type === "kill") { + this.frame.remove(); + super.kill(); + } + }); + this.window.postMessage({ type: "kill" }); + } + + get args() { + return this.#args; } get alive() { - return this.frame.isConnected; + return !this.#closing || !this.frame.isConnected; } get window() { diff --git a/src/coreapps/ShortcutApp.tsx b/src/coreapps/ShortcutApp.tsx new file mode 100644 index 0000000..f35fc43 --- /dev/null +++ b/src/coreapps/ShortcutApp.tsx @@ -0,0 +1,93 @@ +interface AnuraShortcut { + name: string; + command: string; + icon?: string; + console?: boolean; +} + +// mangle file path to a valid package id component +function b26(s: string) { + return [...s] + .map((c) => + [...c.charCodeAt(0).toString(26)] + .map((d) => String.fromCharCode(parseInt(d, 36) + 97)) + .join(""), + ) + .join(""); +} + +// Virtual app that represents a shortcut, used when a shortcut file is placed in the apps directory +class ShortcutApp extends App implements AnuraShortcut { + static async launchShortcut(props: AnuraShortcut) { + // Manually parse the cmdline string. Eventually we should have a proper + // system shell that can handle this, but for now we will just use a regex + // to split the command line into arguments. + const cmdline = (props.command!.match(/(?:[^\s"]+|"[^"]*")+/g) || []).map( + (arg) => { + // Remove surrounding quotes if they exist + if (arg.startsWith('"') && arg.endsWith('"')) { + return arg.slice(1, -1); + } + return arg; + }, + ); + + const streams = anura.logger.createStreams( + "Shortcut: " + props.name + " (" + props.command + ") ", + ); + + if (props.console) { + const terminal = anura.settings.get("terminal") || "anura.ashell"; + anura.settings.set("terminal", terminal); + + const proc = await anura.apps[terminal].open([ + "--cmd", + cmdline.join(" "), + ]); + if (proc instanceof WMWindow || proc instanceof Process) { + proc.stdout.pipeTo(streams.stdout); + proc.stderr.pipeTo(streams.stderr); + } + return proc; + } else { + anura.processes.execute(cmdline[0]!, cmdline.slice(1)).then((proc) => { + proc.stdout.pipeTo(streams.stdout); + proc.stderr.pipeTo(streams.stderr); + }); + } + } + + name = "Shortcut"; + package = "anura.shortcut"; + icon = "/assets/icons/generic.svg"; + console = false; + command = + '/usr/bin/vista.ajs --alert --message "Anura Shortcuts: This shortcut is not configured properly." --title Error'; + + constructor(filePath: string, props: AnuraShortcut) { + super(); + Object.assign(this, props); + this.package = "anura.shortcut." + b26(filePath); + if (anura.apps[this.package]) { + if (anura.apps[this.package] instanceof ShortcutApp) { + // If the app is already a shortcut app, just return it + return anura.apps[this.package]; + } + + this.package += "." + Date.now(); + console.warn( + "ShortcutApp: Mitigating package collision, please investigate as this is a bug.", + ); + anura.notifications.add({ + title: "ShortcutApp", + description: + "Package collision detected, renaming package, please investigate or report this.", + timeout: 10000, + }); + } + } + + async open() { + await ShortcutApp.launchShortcut(this); + } +} diff --git a/src/libs/AnuraGlobalsLib.tsx b/src/libs/AnuraGlobalsLib.tsx new file mode 100644 index 0000000..2bb5e66 --- /dev/null +++ b/src/libs/AnuraGlobalsLib.tsx @@ -0,0 +1,50 @@ +/** + * Export helpful global objects from the anura top level window + */ +class AnuraGlobalsLib extends Lib { + icon = "/assets/icons/generic.svg"; + package = "anura.globalscope"; + name = "Anura Global Objects"; + latestVersion = anura.version.pretty; + + versions = { + [anura.version.pretty]: { + /** + * Run a top level eval to get a global object, + * this is how you would get an object from the top level + * before this library was created but this helper method + * is more verbose and easier to explain. + */ + getWithPath: eval.bind(top), + }, + }; + + constructor() { + super(); + + this.versions[anura.version.pretty] = new Proxy( + this.versions[anura.version.pretty], + { + get: (target, prop) => { + if (prop in target) { + return target[prop]; + } else { + try { + return this.versions[anura.version.pretty]?.getWithPath(prop); + } catch (_) { + return undefined; + } + } + }, + }, + ); + } + + async getImport(version: string): Promise { + if (!version) version = this.latestVersion; + if (!this.versions[version]) { + throw new Error("Version not found"); + } + return this.versions[version]; + } +}