Developers

Termal App Studio.

Build, translate, test and package your own Termal apps.

Introduction

Termal App Studio is the in-desktop IDE for creating third-party Termal apps. You write plain HTML / CSS / JavaScript; the Studio gives you a code editor, a component palette, a manifest editor (icon & permissions), a per-app translation manager, a one-click sandboxed test, and a packager that produces an installable .tapp bundle.

Every app runs inside an isolated sandbox (an opaque-origin iframe). It renders its own UI locally and talks to the desktop through the Termal SDK. Privileged actions (file system, network, storage, clipboard, webview) cross a message bridge that enforces the permissions declared in your manifest - so an app can only do what it asked for.

The Termal App Studio IDE: a file explorer with app.css, app.html, app.js and manifest.json, the code editor, and a Components palette listing toast, translation, storage, prompt, confirm, menu and file operations
The Studio: your four project files on the left, the editor in the middle, and the Components palette on the right - click an entry to insert the matching SDK call at the cursor. Run tests it in the sandbox, Build produces the .tapp.

New to the desktop itself? Why a desktop over SSH explains what it is for and what the built-in apps do - this page is the API reference.

Quick start

  1. Open Termal App Studio from the Start menu.
  2. Click + to scaffold a fresh project - or click Examples to open a ready-made, fully commented app you can read and adapt (see Examples).
  3. Edit app.js / app.html / app.css. Use the Components palette on the right to insert SDK snippets at the cursor.
  4. Open the Manifest tab to set the icon, color, window size and permissions.
  5. Open the Translations tab to add languages and key/value strings.
  6. Press ▶ Run to test in the sandbox. The Console at the bottom shows your app's logs and errors.
  7. Press 📦 Build to produce a .tapp in Downloads, then install it. The app appears in the Start menu and under My apps in the Termal App store.

Examples

The Studio ships with ready-made, heavily commented example apps. Click Examples in the toolbar and pick one - it is copied into a new project you can run, read and adapt (the original is never touched).

Browse the SDK & example apps on GitHub →

ExampleShows
File ExplorerThe mediated file model: pick a file or folder, list it, and open items in the desktop (dialog.openFile/openFolderfs.list/readopen.path).
Web ViewA cookie-capable webview embedded in the app window (Termal.webview).
SDK TourOne button per API: toast, dialog, menu, clipboard, storage.
Text EditorRead / write / list / delete files in the app's sandbox folder.
Notes & TodoPersist the whole app state with key/value storage.
HTTP ClientTermal.net.fetch with a local or server exit.
Server WidgetPoll a URL from your SSH server, with auto-refresh.
Window OptionsSize, maximize, resizable, title - plus a fixed mobile web view.

Project structure

Projects live on the connected server, under ~/.termal/users/<you>/.apps/studio/projects/<key>/:

manifest.json     app metadata (key, name, icon, permissions, …)
app.html          your markup (rendered inside the app window)
app.js            your logic - defines function main(Termal)
app.css           your styles
i18n/<lang>.json   translations, one file per language
assets/…          images (e.g. an icon you imported)

At runtime the desktop loads app.html, injects your app.css, evaluates your app.js, and exposes the SDK as a global Termal object.

Manifest reference

manifest.json describes your app. You can edit it directly, or use the visual Manifest tab.

FieldTypeDescription
keystringUnique id (the folder name). Lowercase letters, digits, dashes.
namestringDisplay name (shown in the Start menu and store).
versionstringe.g. 0.1.0. Part of the built filename.
iconobject{ "type": "ti", "value": "ti-rocket" } for a Tabler icon, or { "type": "img", "value": "assets/icon.png" }.
colorstringAccent color, e.g. #62d0a3.
windowobjectWindow size & behavior:
{ "w": 520, "h": 440, "resizable": true, "maximizable": true, "minimizable": true }.
Set a flag to false to lock it (e.g. a fixed, mobile-sized window). See Window control.
permissionsstring[]Capabilities your app may use. See Permissions.
defaultLangstringFallback language code, e.g. en.
langsstring[]Languages you provide, e.g. ["en","fr"].
entrystringHTML entry file. Defaults to app.html.
authorstringYour name or handle. Shown in the installer and in the store.
categorystringStore category slug (e.g. utilities, developer). Required - the build fails until you pick one.
descriptionstringShort description shown in the installer and the store.
tagsstring[]Keywords used to find your app in the Start-menu search and the store.
disclaimerstringOptional terms / EULA. If set, they are shown at install time with a checkbox the user must tick before Install is enabled. See Build & install.

Runtime & entry point

Define a global main(Termal) function - it is called once after the runtime is initialized. Alternatively, listen for the termal:ready event.

// app.js
function main(Termal) {
  document.getElementById('go').onclick = () => Termal.toast('Hello!');
}

// equivalent:
window.addEventListener('termal:ready', () => { /* … */ });
Termal.on('ready', () => { /* … */ });

Your UI is the iframe's <body> - manipulate the DOM as usual. All privileged SDK calls return Promises (use await). console.log / errors are forwarded to the Studio Console.

Core & internationalization

MemberReturnsDescription
Termal.t(key, fallback?)stringTranslated string for the current language (falls back to defaultLang, then fallback, then key).
Termal.langstringCurrent language code.
Termal.manifestobjectYour manifest.
Termal.on(name, fn)-Subscribe to events: 'ready', 'lang'.
const label = Termal.t('greeting', 'Hello');   // uses i18n/<lang>.json
Termal.on('lang', ({ lang }) => console.log('language is now', lang));

Dialogs, menu & toast no permission

MethodReturnsDescription
Termal.dialog.prompt(title, def?)string | nullAsk for text input.
Termal.dialog.confirm(msg, ok?)booleanYes/no confirmation.
Termal.dialog.alert(msg)-Single-button message.
Termal.menu(x, y, items)any | nullContext menu at screen (x,y); resolves to the chosen item's value, or null if dismissed without a choice.
Termal.toast(msg, kind?)-Transient notification (kind: 'info', 'success', 'warn', 'error').
const name = await Termal.dialog.prompt('Your name?');
if (await Termal.dialog.confirm('Delete this item?')) { /* … */ }

const choice = await Termal.menu(120, 80, [
  { label: 'Rename', value: 'rename', icon: 'ti-pencil' },
  { sep: true },
  { label: 'Delete', value: 'delete', danger: true },
]);

File system fs.readfs.write

By default the file system is sandboxed to your app's private data folder. Relative paths resolve inside it; .. is always rejected. Your app cannot read the rest of the server on its own.

To reach a file or folder outside that box, the user must hand it to you through a picker - the user's choice is the grant. An absolute path is accepted only after it (or its parent folder) came back from a picker in this window; anything else raises “path not granted”. Grants last for the app window's lifetime and are never persisted.

MethodPermissionDescription
Termal.fs.list(path?)fs.readList entries: [{ name, type, size, mtime }].
Termal.fs.read(path)fs.readRead a text file (returns a string).
Termal.fs.write(path, content)fs.writeWrite a text file (creates folders as needed).
Termal.fs.mkdir(path)fs.writeCreate a folder.
Termal.fs.remove(path, dir?)fs.writeDelete a file (or folder when dir is true).
Termal.fs.newFile(path)fs.writeCreate an empty file.
Termal.fs.copy(from, to)fs.writeCopy a file or folder.
Termal.fs.move(from, to)fs.writeMove / rename across folders.
Termal.fs.rename(from, to)fs.writeRename in place.
// Inside your data folder - always allowed:
await Termal.fs.write('notes.txt', 'remember the milk');
const text = await Termal.fs.read('notes.txt');

// Outside - only after the user picked it:
const p = await Termal.dialog.openFile();
if (p) {
  const content = await Termal.fs.read(p); // p is now granted
}

File & folder pickers no permission

These open the desktop's real file / folder dialogs. Showing a picker needs no permission - it reveals nothing until the user chooses. The returned absolute path (and, for a folder, its whole tree) becomes usable with fs.* and open.*; nothing else does. Reading or writing still requires fs.read / fs.write. They return null when the user cancels.

MethodReturnsDescription
Termal.dialog.openFile(opts?)string | nullPick a file. opts: { title, dir }.
Termal.dialog.openFolder(opts?)string | nullPick a folder (grants its whole subtree).
Termal.dialog.saveFile(opts?)string | nullChoose a save location. opts: { name, dir }.
const dir = await Termal.dialog.openFolder({ title: 'Pick a project' });
if (dir) {
  for (const entry of await Termal.fs.list(dir)) console.log(entry.name);
}

const dest = await Termal.dialog.saveFile({ name: 'export.csv' });
if (dest) await Termal.fs.write(dest, csv);

Open with the desktop no permission

Hand a path to the desktop's own apps instead of reading it yourself. open.path routes by file type - image → viewer, archive → Termal Zip, .html → Termal Browser, otherwise the text editor - exactly like a double-click in the Explorer. Same scope as fs: the path must be inside your data folder or granted by a picker.

MethodDescription
Termal.open.path(path)Open a file with the matching desktop app.
Termal.open.folder(path)Open a folder in a new Explorer window.
const p = await Termal.dialog.openFile();
if (p) await Termal.open.path(p); // opens in the right desktop app

Storage storage

Simple key/value store, persisted per app on the server (survives restarts).

MethodReturnsDescription
Termal.storage.get(key, def?)anyRead a value (or def if missing).
Termal.storage.set(key, value)-Store any JSON-serializable value.
Termal.storage.del(key)-Remove a key.
const count = (await Termal.storage.get('count', 0)) + 1;
await Termal.storage.set('count', count);

Network net

MethodReturnsDescription
Termal.net.fetch(url, opts?){ status, body }HTTP request via the backend. opts: { exit, method }.

exit: 'server' routes the request through the SSH server (it goes out via the server's IP, using curl); exit: 'local' (default) fetches from the machine running Termal. See Network exit.

const res = await Termal.net.fetch('https://api.example.com/data', { exit: 'server' });
console.log(res.status, res.body);

Webview webview

Open a URL in a real Electron webview - a proper browser context on a persistent partition, so cookies and sessions work (logins behave normally). By default it opens as its own Termal window; pass embed: true to render it inside your app's window instead.

OptionDefaultDescription
url-Required. http / https only.
embedfalsetrue → the webview takes over the calling app's window (instead of a new one).
exit'local''server' (via the server's IP / SSH tunnel) or 'local' (this machine).
titlepage titleWindow title. If omitted, follows the page's own title.
bartrueShow the info bar (network badge + current URL).
navfalseShow navigation controls (back / forward / reload).
resizable, maximizable, minimizabletrueSet any to false to lock it (e.g. a fixed, phone-shaped window).
icon, w, h-Window icon and size.
// A cookie-capable site, embedded in the app window:
await Termal.webview({ url: 'https://example.com/app', embed: true, nav: true });

// A fixed, phone-sized window:
await Termal.webview({ url: 'https://example.com', w: 400, h: 780, resizable: false, maximizable: false });
Cookies - a plain <iframe> in your app.html is inside the app's sandbox (opaque origin) and cannot use cookies, so login-based sites won't work there. Use Termal.webview for anything that needs a session.

Inside the webview, normal links navigate in place (keeping the chosen network exit). Links that open a new window (target="_blank" / window.open) are opened in Termal Browser.

Downloads - when a site inside the webview generates a file (image, video, audio, export…) and triggers a download, Termal saves it to the connected server's Downloads folder - exactly like Termal Browser. Nothing to wire: it works automatically for any Termal.webview.

Clipboard clipboard

MethodReturnsDescription
Termal.clipboard.write(text)-Copy text to the clipboard.
Termal.clipboard.read()stringRead the clipboard's text (empty string if unavailable - never rejects).
await Termal.clipboard.write('copied text');
const pasted = await Termal.clipboard.read();

Progress bar no permission

Drive the desktop's progress bar during a long operation. One bar per app window.

MethodDescription
Termal.ui.progress(label, pct)Show / update the bar (pct 0-100).
Termal.ui.progressDone()Finish and dismiss it.
await Termal.ui.progress('Processing…', 0);
for (let i = 0; i < files.length; i++) {
  await handle(files[i]);
  await Termal.ui.progress('Processing…', Math.round((i + 1) / files.length * 100));
}
await Termal.ui.progressDone();

Window control no permission

Set the initial size and locks in manifest.window. Control the window at runtime with Termal.win.*:

MethodDescription
Termal.win.setSize(w, h)Resize the window now.
Termal.win.maximize()Fill the desktop.
Termal.win.restore()Back to the normal size.
Termal.win.minimize()Send to the dock.
Termal.win.center()Center on screen.
Termal.win.setResizable(bool)Lock / unlock resizing.
Termal.win.setTitle(text)Rename the title bar.
Termal.win.close()Close the window.

Termal.setTitle(text) and Termal.close() remain available as shortcuts.

The Window Options example app driving its own window: buttons for mobile and desktop sizes, maximize, restore, minimize, resizable on and off, next to a phone-sized webview window it opened
The example-window-options app driving its own window, and opening a fixed phone-sized webview beside it. Note the Console line: the app runs in the sandbox with perms: [webview] - the only permission its manifest asked for.
Termal.win.setSize(390, 760);   // phone-sized
Termal.win.setResizable(false); // lock it
Termal.win.maximize();

Permissions

Declare the capabilities your app needs in manifest.permissions (or tick the boxes in the Manifest tab). A call to a capability you did not declare is rejected with Permission denied. The user reviews granted permissions when installing your app.

PermissionGrants access to
fs.readReading files - in the app's data folder, or in any file/folder the user granted via a picker.
fs.writeWriting / creating / deleting / moving files, in the same scope.
storageThe key/value store.
netTermal.net.fetch.
clipboardTermal.clipboard.write and Termal.clipboard.read.
webviewTermal.webview.
No permission required: dialogs, context menu, toast, the file/folder pickers, Termal.open.*, the progress bar, window control (Termal.win.*), and core/i18n. Pickers reveal nothing until the user chooses, and open.* hands a path to the desktop rather than to your app - so neither needs a declared capability.

Translations

Provide one JSON file per language under i18n/. Manage them visually in the Translations tab (add languages, edit key/value pairs).

// i18n/en.json            // i18n/fr.json
{ "greeting": "Hello" }    { "greeting": "Bonjour" }

At runtime, Termal.t('greeting') resolves against the current desktop language, falling back to your defaultLang. The current language is available as Termal.lang, and changes emit a 'lang' event.

Network exit

Both Termal.webview and Termal.net.fetch accept an exit option:

ValueBehavior
'local'Traffic leaves from this machine (the device running Termal). Default. For safety, 'local' requests to private / internal addresses (localhost, 10/8, 172.16/12, 192.168/16, link-local…) are blocked (anti-SSRF) - use 'server' to reach the server's private network.
'server'Traffic is tunneled through the SSH server and exits via its IP. Useful to reach the server's private network or to appear "as the server".
Note - the server exit reuses the same SSH SOCKS tunnel as Termal Browser and requires AllowTcpForwarding yes on the server. net.fetch with exit: 'server' uses curl on the server.

Build & install

Press 📦 Build in the Studio. The project folder is zipped on the server into <key>-<version>.tapp inside your Downloads folder, and you're offered to install it.

Installing registers the app: it asks the user to grant the declared permissions, then the app appears in the Start menu, on the desktop's app list, and under My apps in the Termal App store, where it can also be uninstalled.

What your recipient sees for a .tapp installed outside the store (a file you shared and they double-clicked): an “unofficial app” warning above the permission review, and - if your manifest sets a disclaimer - its text with a mandatory checkbox that keeps the Install button disabled until it is accepted. Keep permissions minimal and fill in author / description so the installer looks trustworthy.

Requirement - building needs the zip utility installed on the server (apt install zip).

Limitations & tips

  • The app runs in an opaque-origin sandbox: no access to the parent page, cookies or Node. All host interaction goes through the Termal SDK.
  • A plain <iframe> in your app.html is sandboxed and cannot use cookies. For anything that needs a login/session, use Termal.webview (real webview, persistent cookies) - with embed: true to keep it inside your app window.
  • The file system is confined to your app's data folder - design around relative paths.
  • Use the Console panel while developing; console.error and uncaught errors are reported there.
  • Keep your permissions minimal - users see them at install time.