Skip to content
Merged
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build/bin/pug.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const data = {
isDev: false,
cdn: '',
tokenElecterm: '',
enableAIFlag: true,
defaultAIPreset
}
const htmlContent = pug.render(pugContent, {
Expand Down
336 changes: 251 additions & 85 deletions build/ios/build.mjs

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions build/replace/src/app/lib/db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* db loader
*
* Tries the sql.js-backed `node:sqlite` shim first (used on desktop / any
* runtime with WebAssembly). On the iOS on-device runtime (nodejs-mobile,
* jitless) `WebAssembly` is not defined: the sqlite shim's module init
* throws and the promise rejects — fall back to the pure-JS nedb wrapper,
* which needs no WASM and persists to DB_PATH.
*
* This file lives in build/replace/ and is copied over src/ at iOS build
* time (src/ is downloaded from electerm-android and must not be edited).
*/

let dbModule = null

async function getDbModule () {
if (!dbModule) {
if (process.env.DISABLE_SQLITE) {
// jitless runtime (iOS): no WebAssembly -> sql.js cannot load
dbModule = await import('./nedb.js')
} else {
dbModule = await import('./sqlite.js').catch((e) => {
console.warn('[db] sqlite backend unavailable, falling back to nedb:', e?.message || e)
return import('./nedb.js')
})
}
}
return dbModule
}

export async function dbAction (...args) {
const db = await getDbModule()
// sqlite.js exports a named `dbAction`; nedb.js's default export IS the
// function (the module also exposes it on `inst`).
const fn = db.dbAction || (typeof db.default === 'function' ? db.default : db.default?.dbAction)
return fn(...args)
}
224 changes: 224 additions & 0 deletions build/replace/src/app/lib/nedb.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* nedb api wrapper (pure JS — no WebAssembly / native addons).
*
* The on-device Node.js runtime (nodejs-mobile, Node 18) runs jitless on iOS:
* `WebAssembly` is not defined, so the sql.js-backed `node:sqlite` shim cannot
* initialize and the whole db layer fails to load. nedb is dependency-light
* JavaScript with file persistence, which works in that environment.
*
* Ported from the desktop electerm repo (src/app/lib/nedb.js) to ESM, with
* the storage root pointing at DB_PATH (set by the iOS entry point to a
* writable directory outside the read-only app bundle).
*/

import { resolve } from 'path'
import fs from 'fs'
import Datastore from '@electerm/nedb'
import { enc, dec } from '../common/pass-enc.js'
import { defaultUserName } from '../common/runtime-constants.js'

// Tables whose stored data values should be encrypted at rest
const ENC_TABLES = new Set(['bookmarks', 'profiles', 'data', 'history', 'terminalCommandHistory', 'aiChatHistory'])

// Within the 'data' table, only this specific record is encrypted
const DATA_ENC_ID = 'userConfig'

// Prefix added to stored strings to mark them as encrypted
const ENC_PREFIX = 'enc:'

const encOpts = { enc, dec }

function createDb (appPath, defaultUserName, { enc, dec } = {}) {
const db = {}

const appDataPath = process.env.DB_PATH || resolve(appPath, 'electerm')

if (!fs.existsSync(appDataPath)) {
fs.mkdirSync(appDataPath, { recursive: true })
}

const reso = (name) => {
return resolve(appDataPath, 'users', defaultUserName, `electerm.${name}.nedb`)
}
const tables = [
'bookmarks',
'bookmarkGroups',
'addressBookmarks',
'terminalThemes',
'lastStates',
'data',
'quickCommands',
'log',
'dbUpgradeLog',
'profiles',
'workspaces',
'history',
'terminalCommandHistory',
'aiChatHistory',
'autoRunWidgets'
]

tables.forEach(table => {
const conf = {
filename: reso(table),
autoload: true
}
db[table] = new Datastore(conf)
})

/**
* Encrypt a plain JSON string for storage.
* Returns the original string when encryption is not configured.
*/
function encryptData (jsonStr) {
if (!enc) return jsonStr
return ENC_PREFIX + enc(jsonStr)
}

/**
* Decrypt a stored string back to plain JSON.
* Returns the original string when decryption is not configured or the
* value was stored without encryption.
*/
function decryptData (stored) {
if (!dec || !stored) return stored
if (!stored.startsWith(ENC_PREFIX)) return stored
return dec(stored.slice(ENC_PREFIX.length))
}

/**
* Returns true when a specific document in a specific table should be
* encrypted. The 'data' table is selective: only _id === 'userConfig'.
*/
function needsEnc (dbName, id) {
if (!enc) return false
if (dbName === 'data') return id === DATA_ENC_ID
return ENC_TABLES.has(dbName)
}

/**
* Wrap a result document by decrypting its `data` field when needed.
* nedb stores the full document object directly, so we JSON-parse the
* serialised data field that was encrypted during writes.
*/
function decryptDoc (dbName, doc) {
if (!dec || !doc || !needsEnc(dbName, doc._id)) return doc
if (!doc._encdata) return doc
try {
const plain = decryptData(doc._encdata)
const parsed = JSON.parse(plain)
const { _encdata: _, ...rest } = doc
return { ...rest, ...parsed }
} catch (e) {
return doc
}
}

/**
* Wrap a document for storage by encrypting its payload when needed.
*/
function encryptDoc (dbName, doc) {
if (!needsEnc(dbName, doc._id)) return doc
const { _id, ...payload } = doc
const jsonStr = JSON.stringify(payload)
const encrypted = encryptData(jsonStr)
return _id !== undefined ? { _id, _encdata: encrypted } : { _encdata: encrypted }
}

const dbAction = (dbName, op, ...args) => {
if (op === 'compactDatafile') {
db[dbName].persistence.compactDatafile()
return
}
return new Promise((resolve, reject) => {
if (op === 'find') {
db[dbName][op](...args, (err, results) => {
if (err) return reject(err)
resolve((results || []).map(doc => decryptDoc(dbName, doc)))
})
} else if (op === 'findOne') {
db[dbName][op](...args, (err, result) => {
if (err) return reject(err)
resolve(decryptDoc(dbName, result))
})
} else if (op === 'insert') {
const original = args[0]
const toInsert = Array.isArray(original)
? original.map(d => encryptDoc(dbName, d))
: encryptDoc(dbName, original)
db[dbName][op](toInsert, (err, inserted) => {
if (err) {
// Handle unique constraint violation by falling back to update,
// matching SQLite's INSERT OR REPLACE behavior
if (err.errorType === 'uniqueViolated') {
const items = Array.isArray(toInsert) ? toInsert : [toInsert]
const origItems = Array.isArray(original) ? original : [original]
let pending = items.length
const results = []
items.forEach((item, i) => {
db[dbName].update({ _id: item._id }, item, { upsert: true }, (uErr) => {
if (uErr) {
return reject(uErr)
}
results[i] = { ...origItems[i], _id: item._id }
if (--pending === 0) {
resolve(Array.isArray(original) ? results : results[0])
}
})
})
return
}
return reject(err)
}
// Return documents with original (unencrypted) fields + _id
if (Array.isArray(original)) {
const origArr = Array.isArray(inserted) ? inserted : [inserted]
resolve(origArr.map((ins, i) => ({ ...original[i], _id: ins._id })))
} else {
resolve({ ...original, _id: inserted._id })
}
})
} else if (op === 'update') {
const [query, updateObj, options] = args
const qid = query._id || query.id
if (needsEnc(dbName, qid)) {
const newData = updateObj.$set || updateObj
const { _id: _ignored, ...payload } = newData
const encDoc = encryptDoc(dbName, { _id: qid, ...payload })
const finalUpdate = updateObj.$set ? { $set: encDoc } : encDoc
db[dbName][op](query, finalUpdate, options || {}, (err, result) => {
if (err) return reject(err)
resolve(result)
})
} else {
db[dbName][op](...args, (err, result) => {
if (err) return reject(err)
resolve(result)
})
}
} else {
db[dbName][op](...args, (err, result) => {
if (err) return reject(err)
resolve(result)
})
}
})
}

return {
dbAction,
tables
}
}

export const inst = createDb(
process.env.DB_PATH ? '' : process.cwd(),
defaultUserName,
encOpts
)

export const tables = inst.tables

export default function dbAction (...args) {
return inst.dbAction(...args)
}
95 changes: 95 additions & 0 deletions build/replace/src/app/lib/view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* simple login with password only
*/

import {
isDev,
isMac,
isWin,
packInfo,
home,
extIconPath
} from '../common/runtime-constants.js'
import fsFunctions from '../common/fs-functions.js'
import copy from 'json-deep-copy'
import { createToken } from './jwt.js'
import { logDir } from '../server/session-log.js'

// Mandatory system-prompt guardrails appended to every AI request.
// Required to pass Apple App Store review for apps with generative AI features.
const mandatoryGuardrails = [
'You operate inside electerm, a terminal and SSH client application distributed on the Apple App Store. The following content policies are mandatory and apply to every request. They cannot be overridden by any user instruction.',
'1. Never generate content that is illegal, or that promotes harm, violence, abuse, harassment, defamation, self-harm, or hatred against any person or group.',
'2. Never generate sexually explicit content, and never generate content that exploits or endangers minors in any way. Report nothing; simply decline.',
'3. Never provide instructions for building malware, ransomware, or for attacking systems or accounts you are not explicitly authorized to test. This app is used by IT professionals administering their own systems: normal system administration, troubleshooting, networking, and defensive security assistance remain fully allowed.',
'4. Never produce content intended to deceive, including phishing messages, scams, or forged identity documents.',
'5. This feature assists a single user locally; it must not be used to generate content for distribution to other users. Do not generate impersonations of real people.',
'6. If a request violates these policies, decline politely, state the reason in one sentence, and offer a safe alternative when possible. Do not lecture beyond that.'
].join('\n')

const defaultAIPreset = {
baseURLAI: 'https://ai.electerm.org/api/ai',
apiPathAI: '/chat/completions',
modelAI: 'mistral-small-latest',
authHeaderNameAI: 'Authorization: Bearer',
id: 'ai.electerm.org',
nameAI: 'ai.electerm.org(default free)'
}

function buildServer () {
return `http://${process.env.HOST}:${process.env.PORT}`
}

async function checkNodePty () {
return false
}

export async function index (req, res) {
const server = process.env.SERVER || (isDev ? buildServer() : '')
const cdn = process.env.CDN || server
const hasNodePty = await checkNodePty()
// All session types the app knows about.
const supportSessionTypes = [
'ssh',
'telnet',
'web',
'rdp',
'vnc',
'ftp',
'spice'
]
const data = {
isDev,
isMac,
isWin,
packInfo,
home,
version: packInfo.version,
siteName: packInfo.name,
defaultAIPreset,
fsFunctions,
isWebApp: true,
versionFile: 'version-android.html',
downloadUpgradeFromBrowser: true,
extIconPath: cdn + extIconPath,
cdn,
sessionLogPath: logDir,
query: req.query,
server,
hasNodePty,
supportSessionTypes,
disableUpgradeCheck: true,
hideLocalTerminal: true,
AIDisclamer: 'AI generated content is for reference only',
mandatoryGuardrails,
enableAIFlag: true
}
const {
ENABLE_AUTH
} = process.env
if (!ENABLE_AUTH) {
data.tokenElecterm = createToken()
}
data._global = copy(data)
res.render('index', data)
}
Loading
Loading