diff options
| author | Agando <roduccomm@gmail.com> | 2026-06-09 06:00:43 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-06-09 06:00:43 +0200 |
| commit | 9f60030e39feecc76f11671a4b9d82303c83670c (patch) | |
| tree | cd4d35985f98aef0f4e688e4bcc283a476c8cd3b /modules/ext | |
| parent | b562e1491513163921593985f3542d4ee8ae0e75 (diff) | |
auto: 2026-06-09_06:00 changes
Diffstat (limited to 'modules/ext')
| -rw-r--r-- | modules/ext/cryptogzip.js | 321 | ||||
| -rw-r--r-- | modules/ext/filemgr.js | 3 | ||||
| -rw-r--r-- | modules/ext/floating.js | 50 | ||||
| -rw-r--r-- | modules/ext/skill-pack-core.js | 6 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 8 | ||||
| -rw-r--r-- | modules/ext/wpm.js | 9 |
6 files changed, 379 insertions, 18 deletions
diff --git a/modules/ext/cryptogzip.js b/modules/ext/cryptogzip.js index b67e067..200abdd 100644 --- a/modules/ext/cryptogzip.js +++ b/modules/ext/cryptogzip.js @@ -1,6 +1,7 @@ class CryptoGzipModule { - // CryptoGzipModule — VFS-level gzip/encrypt commands for WebX11 newOS4 - // Commands: gzip <path>, gunzip <path.gz>, encrypt <path> <passphrase>, decrypt <path.enc> <passphrase> + // CryptoGzipModule — VFS-level gzip/encrypt/tar commands for WebX11 newOS4 + // Commands: gzip <path>, gunzip <path.gz>, encrypt <path> <passphrase>, decrypt <path.enc> <passphrase>, + // tar <src-dir> <out.tar[.gz]>, untar <path.tar[.gz]> [dest-dir] constructor() { this.commands = {}; this.terminal = null; @@ -9,9 +10,12 @@ class CryptoGzipModule { getHelpInfo() { return { 'gzip': 'gzip <path> — Compress a VFS file to .gz', - 'gunzip': 'gunzip <path.gz> — Decompress a .gz file', - 'encrypt': 'encrypt <path> <passphrase> — AES-256-GCM encrypt a VFS file', - 'decrypt': 'decrypt <path.enc> <passphrase> — Decrypt a .enc file' + 'gunzip': 'gunzip <path.gz> — Decompress a .gz file (binary-safe)', + 'encrypt': 'encrypt <path> <passphrase> — AES-256-GCM encrypt a file', + 'decrypt': 'decrypt <path.enc> <passphrase> — Decrypt a .enc file', + 'tar': 'tar <src-dir> <out.tar[.gz]> — Bundle a VFS directory into a tar archive (gzip if .gz)', + 'untar': 'untar <path.tar[.gz]> [dest-dir] — Extract a tar archive into VFS (auto-detects gzip)', + 'wget': 'wget <url> [filename] — Download a URL and save to VFS' }; } @@ -20,6 +24,17 @@ class CryptoGzipModule { const normalized = path.replace(/^\/+/, ''); return this.terminal.fs.readFile(normalized) !== null; } + _vfsDirExists(path) { + // Check if a path is a directory (or empty string for root) + const normalized = path.replace(/^\/+/, '').replace(/\/+$/, ''); + if (!normalized) return true; // root always exists + try { + const items = this.terminal.fs.readDir(normalized); + return Array.isArray(items); + } catch (e) { + return false; + } + } _vfsRead(path) { const normalized = path.replace(/^\/+/, ''); @@ -152,6 +167,290 @@ class CryptoGzipModule { return new TextDecoder().decode(decompressed); } + // Binary-safe variants: return raw Uint8Array, store as base64 in VFS + async _gzipBytes(bytes) { + const cs = new CompressionStream('gzip'); + const writer = cs.writable.getWriter(); + writer.write(bytes); + writer.close(); + const compressed = await new Response(cs.readable).arrayBuffer(); + return new Uint8Array(compressed); + } + async _gunzipBytes(bytes) { + const ds = new DecompressionStream('gzip'); + const writer = ds.writable.getWriter(); + writer.write(bytes); + writer.close(); + const decompressed = await new Response(ds.readable).arrayBuffer(); + return new Uint8Array(decompressed); + } + _bytesToBase64(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); + } + _base64ToBytes(b64) { + const s = atob(b64); + const u8 = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) u8[i] = s.charCodeAt(i); + return u8; + } +// Save binary file to VFS (stored as Latin-1 string — each byte → one char, round-trips via charCodeAt) + _vfsWriteBytes(path, bytes) { + const normalized = path.replace(/^\/+/, ''); + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + this.terminal.fs.writeFile(normalized, s); + } + // Load binary file from VFS (auto-detects format: base64 via heuristic, or raw Latin-1 bytes) + _vfsReadBytes(path) { + const normalized = path.replace(/^\/+/, ''); + const content = this.terminal.fs.readFile(normalized); + if (content === null) throw new Error(`File not found: ${path}`); +// Heuristic: if content looks like base64 (only [A-Za-z0-9+/\n\r]) and length is multiple of 4, decode + const looksLikeBase64 = /^[A-Za-z0-9+/\n\r]+=*$/.test(content.slice(0, 200)); + if (looksLikeBase64 && content.length % 4 === 0) { + try { return this._base64ToBytes(content); } catch (e) {} + } + // Fallback: treat as Latin-1 bytes (each charCode is a byte) + const u8 = new Uint8Array(content.length); + for (let i = 0; i < content.length; i++) u8[i] = content.charCodeAt(i); + return u8; + } + // Detect gzip magic bytes (1f 8b) + _isGzip(bytes) { + return bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b; + } + + // ============================================================ + // TAR / UNTAR — POSIX ustar parser (pure JS, ~120 lines) + // Supports: ustar + gnu tar formats, gzip auto-detect + // ============================================================ + + _parseOctal(str, len) { + // Parse octal string (null/space-padded) from tar header field + const s = (str || '').replace(/[\x00\s]+$/g, '').replace(/^0+/, '') || '0'; + return parseInt(s, 8) || 0; + } + _padOctal(num, len) { + // Format number as octal, null-terminated, len bytes + const oct = num.toString(8); + return oct + '\0'.repeat(len - oct.length - 1); + } + + // Parse a tar buffer (Uint8Array) into [{name, type, size, data, mode, mtime}] + _tarParse(bytes) { + const entries = []; + const decoder = new TextDecoder(); + let off = 0; + while (off < bytes.length) { + // Read 512-byte header + if (off + 512 > bytes.length) break; + const header = bytes.slice(off, off + 512); + // Empty block = end of archive + if (header.every(b => b === 0)) break; + const nameRaw = decoder.decode(header.slice(0, 100)).replace(/\0+$/, ''); + const size = this._parseOctal(decoder.decode(header.slice(124, 136)), 12); + const typeFlag = String.fromCharCode(header[156]); + const magic = decoder.decode(header.slice(257, 263)).replace(/\0+$/, ''); + // ustar prefix (POSIX): name split across header.name + header.prefix + let name = nameRaw; + if (magic === 'ustar') { + const prefix = decoder.decode(header.slice(345, 500)).replace(/\0+$/, ''); + if (prefix) name = prefix + '/' + nameRaw; + } + off += 512; + // Read file data (padded to 512) + const data = bytes.slice(off, off + size); + off += Math.ceil(size / 512) * 512; + entries.push({ name, type: typeFlag, size, data, mode: this._parseOctal(decoder.decode(header.slice(100, 108)), 8) }); + } + return entries; + } + + // Build a tar buffer from a list of {name, type, size, data, mode} entries + _tarBuild(entries) { + const blocks = []; + const encoder = new TextEncoder(); + for (const e of entries) { + const header = new Uint8Array(512); + // Name (100 bytes, null-padded) + const nameBytes = encoder.encode(e.name.slice(0, 99)); + header.set(nameBytes, 0); + // Mode (8 bytes, octal null-terminated) - default 0644 + const modeStr = (e.mode || 0o644).toString(8).padStart(7, '0') + '\0'; + header.set(encoder.encode(modeStr), 100); + // uid/gid (8 bytes each, "0000000\0") + header.set(encoder.encode('0000000\0'), 108); + header.set(encoder.encode('0000000\0'), 116); + // Size (12 bytes, octal) + const sizeStr = e.size.toString(8).padStart(11, '0') + '\0'; + header.set(encoder.encode(sizeStr), 124); + // Mtime (12 bytes, octal) - epoch + const mtimeStr = Math.floor(Date.now() / 1000).toString(8).padStart(11, '0') + '\0'; + header.set(encoder.encode(mtimeStr), 136); + // Type flag at offset 156 + header[156] = e.type.charCodeAt(0); + // Magic "ustar\0" at 257 + header.set(encoder.encode('ustar\0'), 257); + // Version "00" at 263 + header.set(encoder.encode('00'), 263); + blocks.push(header); + // Data block(s), padded to 512 + const data = e.data; + if (data && data.length > 0) { + const padded = new Uint8Array(Math.ceil(data.length / 512) * 512); + padded.set(data, 0); + blocks.push(padded); + } + } + // End-of-archive: two 512-byte zero blocks + blocks.push(new Uint8Array(512)); + blocks.push(new Uint8Array(512)); + // Concatenate + const total = blocks.reduce((s, b) => s + b.length, 0); + const out = new Uint8Array(total); + let pos = 0; + for (const b of blocks) { out.set(b, pos); pos += b.length; } + return out; + } + + // Collect all files in a VFS directory recursively + _vfsCollectFiles(dirPath) { + const normalized = dirPath.replace(/^\/+/, '').replace(/\/+$/, ''); + const entries = []; + const walk = (rel, absParts) => { + const items = this.terminal.fs.readDir(absParts.join('/')); + for (const item of items) { + const itemPath = absParts.concat([item.name]).join('/'); + const relPath = rel ? `${rel}/${item.name}` : item.name; + if (item.type === 'dir') { + walk(relPath, absParts.concat([item.name])); + } else { + const content = this.terminal.fs.readFile(itemPath); + entries.push({ vfsPath: itemPath, archivePath: relPath, content: content || '' }); + } + } + }; + // Handle root case + if (!normalized) { + walk('', []); + } else { + walk(normalized.split('/').pop() || '', normalized.split('/')); + } + return entries; + } + + // ============================================================ + // WGET — fetch URL and save to VFS (binary-safe) + // ============================================================ + + async _cmdWget(args) { + if (args.length < 1) { + this.terminal.print('Usage: wget <url> [filename] — Download a URL and save to VFS', 'warning'); + return; + } + const url = args[0]; + const filename = args[1] || url.split('/').pop() || 'download.bin'; + this.terminal.print(`Downloading ${url}...`, 'info'); + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + const buf = await resp.arrayBuffer(); + const bytes = new Uint8Array(buf); + this._vfsWriteBytes(filename, bytes); + const sizeKb = (bytes.length / 1024).toFixed(1); + this.terminal.print(`✅ wget: ${filename} (${sizeKb} KB, ${bytes.length} bytes)`, 'success'); + } catch (e) { + this.terminal.print(`wget error: ${e.message}`, 'error'); + } + } + + // ============================================================ + // TAR / UNTAR COMMANDS + // ============================================================ + + async _cmdTar(args) { + if (args.length < 2) { + this.terminal.print('Usage: tar <src-dir> <out.tar[.gz]>', 'warning'); + return; + } + const [srcPath, outPath] = args; + if (!this._vfsDirExists(srcPath)) { + this.terminal.print(`Source not found (or not a directory): ${srcPath}`, 'error'); + return; + } + this.terminal.print(`Bundling ${srcPath} → ${outPath}...`, 'info'); + const files = this._vfsCollectFiles(srcPath); + if (files.length === 0) { + this.terminal.print('No files found in source directory', 'warning'); + return; + } + // Build tar entries (all '0' = regular file) + const entries = files.map(f => ({ + name: f.archivePath, + type: '0', + size: f.content.length, + data: new TextEncoder().encode(f.content), + mode: 0o644 + })); + let tarBytes = this._tarBuild(entries); + // Gzip if .gz suffix + if (outPath.endsWith('.gz')) { + tarBytes = await this._gzipBytes(tarBytes); + } + this._vfsWriteBytes(outPath, tarBytes); + const sizeKb = (tarBytes.length / 1024).toFixed(1); + this.terminal.print(`✅ tar: ${files.length} files → ${outPath} (${sizeKb} KB)`, 'success'); + } + + async _cmdUntar(args) { + if (args.length < 1) { + this.terminal.print('Usage: untar <path.tar[.gz]> [dest-dir]', 'warning'); + return; + } + const srcPath = args[0]; + const destDir = (args[1] || '/').replace(/^\/+/, '').replace(/\/+$/, ''); + if (!this._vfsExists(srcPath)) { + this.terminal.print(`File not found: ${srcPath}`, 'error'); + return; + } + this.terminal.print(`Extracting ${srcPath} → /${destDir}...`, 'info'); + let bytes = this._vfsReadBytes(srcPath); + // Auto-detect and strip gzip + if (this._isGzip(bytes)) { + this.terminal.print(' detected gzip wrapper, decompressing...', 'info'); + bytes = await this._gunzipBytes(bytes); + } + const entries = this._tarParse(bytes); + if (entries.length === 0) { + this.terminal.print('No entries found in tar (empty or invalid format)', 'error'); + return; + } + // Write to VFS + let fileCount = 0, dirCount = 0; + for (const e of entries) { + // Clean name (strip leading ./, normalize slashes) + let cleanName = e.name.replace(/^\.?\//, '').replace(/\/+$/, ''); + const targetPath = destDir ? `${destDir}/${cleanName}` : cleanName; + if (e.type === '5' || e.type === 'd') { + // Directory + if (cleanName) { + this.terminal.fs.mkdirRecursive(targetPath); + dirCount++; + } + } else { + // Regular file (or type '0' or '\0') + const dir = targetPath.split('/').slice(0, -1).join('/'); + if (dir) this.terminal.fs.mkdirRecursive(dir); + const content = new TextDecoder('utf-8', { fatal: false }).decode(e.data); + this.terminal.fs.writeFile(targetPath, content); + fileCount++; + } + } + this.terminal.print(`✅ untar: ${fileCount} files, ${dirCount} dirs from ${srcPath}`, 'success'); + } + // ============================================================ // ENCRYPT / DECRYPT (AES-256-GCM) // ============================================================ @@ -280,14 +579,20 @@ class CryptoGzipModule { this.commands['gunzip'] = async (args) => self._cmdGunzip(args); this.commands['encrypt'] = async (args) => self._cmdEncrypt(args); this.commands['decrypt'] = async (args) => self._cmdDecrypt(args); + this.commands['tar'] = async (args) => self._cmdTar(args); + this.commands['untar'] = async (args) => self._cmdUntar(args); + this.commands['wget'] = async (args) => self._cmdWget(args); this.commandsMeta = { gzip: { argType: 'file', description: 'Compress a VFS file to .gz' }, - gunzip: { argType: 'file', description: 'Decompress a .gz file' }, + gunzip: { argType: 'file', description: 'Decompress a .gz file (binary-safe)' }, encrypt: { argType: 'file', description: 'Encrypt a file with passphrase' }, - decrypt: { argType: 'file', description: 'Decrypt a .enc file' } + decrypt: { argType: 'file', description: 'Decrypt a .enc file' }, + tar: { argType: 'file', description: 'Bundle a VFS directory into a tar archive' }, + untar: { argType: 'file', description: 'Extract a tar archive into VFS' }, + wget: { argType: 'url', description: 'Download a URL and save to VFS' } }; } } if (typeof window.webx11 !== 'undefined' && window.webx11) { - webx11.moduleManager.registerModule(new CryptoGzipModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/cryptogzip.js'); + webx11.moduleManager.registerModule(new CryptoGzipModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/cryptogzip.js?v=2026-06-08-v4.5.26'); } diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js index 4074f06..3cd2687 100644 --- a/modules/ext/filemgr.js +++ b/modules/ext/filemgr.js @@ -1,6 +1,9 @@ class FileManagerModule { constructor() { this.commands = {}; + this.commandsMeta = { + 'files': { argType: 'file' }, 'preview': { argType: 'file' }, + }; this.terminal = null; this.currentPath = []; this._overlay = null; diff --git a/modules/ext/floating.js b/modules/ext/floating.js index b6f5a93..f59aa29 100644 --- a/modules/ext/floating.js +++ b/modules/ext/floating.js @@ -5,6 +5,11 @@ class FloatingModule { constructor() { this.commands = {}; + this.commandsMeta = { + 'floating-remove': { argType: 'enum', enumFn: '_listElementIds' }, + 'floating-toggle': { argType: 'enum', enumFn: '_listElementIds' }, + 'floating-pos': { argType: 'enum', enumFn: '_listElementIds' }, + }; this.terminal = null; this._elements = {}; this._storageKey = 'webx11_floating_elements'; @@ -14,7 +19,7 @@ class FloatingModule { getHelpInfo() { return { - 'floating-add': 'floating-add btn|menu|panel <id> <label> [command|buttons...] — Add a floating UI element', + 'floating-add': 'floating-add btn|menu|panel <id> <label> [cmd|btn:Label:cmd...] — Add a floating UI element', 'floating-remove': 'floating-remove <id> — Remove a floating element', 'floating-list': 'floating-list — Show all floating elements', 'floating-pos': 'floating-pos <id> <x|auto> <y|auto> — Reposition a floating element', @@ -30,10 +35,11 @@ class FloatingModule { this.commands['floating-pos'] = (args) => this._cmdPos(args); this.commands['floating-clear'] = () => this._cmdClear(); this.commands['floating-toggle'] = (args) => this._cmdToggle(args); - } - - seed() { - this._restore(); + // Restore persisted elements at boot — registerCommands() is the only hook called by ModuleManager + // but defer to next tick so terminal/fs are ready + setTimeout(() => { + this._restore(); + }, 0); } // ── Persistence ── @@ -202,7 +208,7 @@ class FloatingModule { _cmdAdd(args) { if (args.length < 3) { - this.terminal.print('Usage: floating-add btn|menu|panel <id> <label> [command|btn:Label|cmd...]', 'warning'); + this.terminal.print('Usage: floating-add btn|menu|panel <id> <label> [cmd|btn:Label:cmd...]', 'warning'); return; } const type = args[0]; @@ -230,14 +236,32 @@ class FloatingModule { el.type = 'menu'; el.collapsed = true; el.buttons = []; + // Remaining args are btn:Label:command pairs. Quotes or colons separate them. + // Each btn: entry runs until next btn: or end + let btnBuf = ''; for (let i = 3; i < args.length; i++) { - const m = args[i].match(/^btn:(.+?)\|(.+)$/); - if (m) { - el.buttons.push({ label: m[1], command: m[2] }); + if (btnBuf) { + btnBuf += ' ' + args[i]; + } else { + btnBuf = args[i]; + } + // Check if this completes a btn: entry + // btn:Label: remaining text — the label is between first : and second : + const firstColon = btnBuf.indexOf(':'); + if (firstColon === -1) { btnBuf = ''; continue; } // prefix doesn't start with btn: + const secondColon = btnBuf.indexOf(':', firstColon + 1); + if (secondColon === -1) continue; // need more args to find command + const prefix = btnBuf.substring(0, firstColon); + if (prefix !== 'btn') { btnBuf = ''; continue; } + const label = btnBuf.substring(firstColon + 1, secondColon); + const command = btnBuf.substring(secondColon + 1); + if (label && command) { + el.buttons.push({ label, command }); } + btnBuf = ''; } if (el.buttons.length === 0) { - this.terminal.print('No valid sub-buttons. Use btn:Label|command format', 'warning'); + this.terminal.print('No valid sub-buttons. Use btn:Label:command format', 'warning'); return; } break; @@ -328,4 +352,10 @@ class FloatingModule { this._renderAll(); this.terminal.print('All floating elements cleared', 'success'); } + + // ── Enum helpers for autocomplete ── + + _listElementIds() { + return Object.keys(this._elements); + } }
\ No newline at end of file diff --git a/modules/ext/skill-pack-core.js b/modules/ext/skill-pack-core.js index abbc7ab..bb00672 100644 --- a/modules/ext/skill-pack-core.js +++ b/modules/ext/skill-pack-core.js @@ -1,6 +1,12 @@ class SkillPackCoreModule { constructor() { this.commands = {}; + this.commandsMeta = { + 'skill-list': { argType: 'dir' }, 'skill-view': { argType: 'file' }, + 'skill-create': { argType: 'dir' }, 'skill-patch': { argType: 'file' }, + 'skill-delete': { argType: 'file' }, 'skill-export': { argType: 'file' }, + 'skill-sync': { argType: 'file' }, + }; this.terminal = null; this._seeded = false; this._skills = this._buildSkills(); diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index a025cb2..8096b2e 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -1,6 +1,14 @@ class WebDAVModule { constructor() { this.commands = {}; + this.commandsMeta = { + 'webdav-ls': { argType: 'file' }, 'webdav-push': { argType: 'file' }, + 'webdav-pull': { argType: 'file' }, 'webdav-rm': { argType: 'file' }, + 'webdav-mkdir': { argType: 'dir' }, 'webdav-rename': { argType: 'file' }, + 'webdav-move': { argType: 'file' }, 'webdav-backup': { argType: 'file' }, + 'webdav-restore': { argType: 'file' }, 'webdav-browser': { argType: 'file' }, + 'webdav-config': { argType: 'file' }, + }; this.terminal = null; this.config = null; // { url, username, password } this._browsingPath = '/'; diff --git a/modules/ext/wpm.js b/modules/ext/wpm.js index 4dc9c29..314e124 100644 --- a/modules/ext/wpm.js +++ b/modules/ext/wpm.js @@ -1,6 +1,9 @@ class WpmModule { constructor() { this.commands = {}; + this.commandsMeta = { + 'wpm': { argType: 'enum', enumFn: '_listSubcommands' }, + }; this.terminal = null; this.indexUrl = 'https://webx11.duckdns.org/spaces/newos4/packages.json'; this.index = null; @@ -143,6 +146,12 @@ class WpmModule { if (installed) this.terminal.print(`Installed: ${installed.installed}`, 'success'); else this.terminal.print('Not installed. Use: wpm install ' + name, 'info'); } + + // ── Enum helpers for autocomplete ── + + _listSubcommands() { + return ['install', 'list', 'search', 'update', 'remove', 'info']; + } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new WpmModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/wpm.js'); |