From d3e6c6b3b969780bd82f5416c20faa837ed690bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:50:08 +0000 Subject: [PATCH 1/5] feat: add android-app Capacitor port (JS backend, Canvas API) Agent-Logs-Url: https://github.com/com55/AtlasToolkit/sessions/63d32cf1-ee63-4c69-9e95-c2020b8ea60c Co-authored-by: com55 <33087300+com55@users.noreply.github.com> --- android-app/.gitignore | 4 + android-app/README.md | 122 ++++ android-app/capacitor.config.json | 14 + android-app/package.json | 18 + android-app/www/index.html | 126 +++++ android-app/www/js/atlas-api.js | 381 +++++++++++++ android-app/www/js/atlas-converter.js | 139 +++++ android-app/www/js/atlas-extracter.js | 280 ++++++++++ android-app/www/js/atlas-modifier.js | 426 ++++++++++++++ android-app/www/script.js | 765 ++++++++++++++++++++++++++ android-app/www/style.css | 663 ++++++++++++++++++++++ 11 files changed, 2938 insertions(+) create mode 100644 android-app/.gitignore create mode 100644 android-app/README.md create mode 100644 android-app/capacitor.config.json create mode 100644 android-app/package.json create mode 100644 android-app/www/index.html create mode 100644 android-app/www/js/atlas-api.js create mode 100644 android-app/www/js/atlas-converter.js create mode 100644 android-app/www/js/atlas-extracter.js create mode 100644 android-app/www/js/atlas-modifier.js create mode 100644 android-app/www/script.js create mode 100644 android-app/www/style.css diff --git a/android-app/.gitignore b/android-app/.gitignore new file mode 100644 index 0000000..6a669f5 --- /dev/null +++ b/android-app/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +android/ +ios/ +dist/ diff --git a/android-app/README.md b/android-app/README.md new file mode 100644 index 0000000..da07476 --- /dev/null +++ b/android-app/README.md @@ -0,0 +1,122 @@ +# AtlasToolkit Android App + +Capacitor-based Android port of AtlasToolkit. +The Python/pywebview desktop backend has been fully rewritten in JavaScript using the Canvas API. + +## Architecture + +``` +android-app/ +├── www/ ← Web assets (served by Capacitor) +│ ├── index.html ← App shell (same UI, ES-module script) +│ ├── style.css ← Unchanged from desktop version +│ ├── script.js ← UI logic (pywebview.api → AtlasAPI) +│ └── js/ +│ ├── atlas-converter.js ← Port of atlas_converter.py +│ ├── atlas-extracter.js ← Port of atlas_extracter.py (Canvas API) +│ ├── atlas-modifier.js ← Port of atlas_modifier.py (Canvas API) +│ └── atlas-api.js ← Drop-in replacement for pywebview.api +├── package.json +└── capacitor.config.json +``` + +### What changed vs the desktop app + +| Desktop (Python) | Android (JavaScript) | +|-------------------------------|------------------------------------------| +| `pywebview` window | Capacitor WebView (Android Activity) | +| `Pillow` image processing | HTML5 Canvas API | +| `pywebview.api.*` calls | `AtlasAPI.*` calls in `atlas-api.js` | +| File dialogs (pywebview) | `` / download links | +| Auto-update / self-update | Not applicable (Play Store or side-load) | + +--- + +## Prerequisites + +- **Node.js** ≥ 18 and **npm** ≥ 9 +- **Android Studio** (latest stable) with Android SDK ≥ 24 +- **Java 17** (required by Gradle) +- Android device or emulator (API 24+) + +--- + +## Build Instructions + +### 1. Install dependencies + +```bash +cd android-app +npm install +``` + +### 2. Add the Android platform + +```bash +npx cap add android +``` + +This generates the `android/` folder with a native Android project. + +### 3. Sync web assets into the native project + +```bash +npx cap sync android +``` + +Run this command every time you modify files inside `www/`. + +### 4. Open in Android Studio + +```bash +npx cap open android +``` + +In Android Studio: +- Select **Build → Build Bundle(s) / APK(s) → Build APK(s)** +- The signed/debug APK will be in `android/app/build/outputs/apk/debug/` + +### 5. Run on device / emulator + +In Android Studio press the **▶ Run** button, or from the CLI: + +```bash +npx cap run android +``` + +--- + +## Required Android Permissions + +The app needs these permissions (already included in the generated `AndroidManifest.xml` after `cap add android`, but verify): + +```xml + + + +``` + +For Android 13+ (API 33), `READ_MEDIA_IMAGES` is needed instead of `READ_EXTERNAL_STORAGE`. + +--- + +## Usage on Android + +1. **Open atlas**: Tap **Open** → select the `.atlas` file *and* its associated PNG image(s) together in the multi-file picker. +2. **Extract regions**: Select regions in the list and tap **Extract Selected** or **Extract All**. Files are saved to the **Downloads** folder. +3. **Modify mode**: Tap **Modify** → select regions → tap **Modify Selected** → pick your mod PNG → optionally enable **Repack** → tap **Save As...** (saves to Downloads). + +--- + +## Development (browser testing) + +The app works in a regular browser too (no native features required for core functionality): + +```bash +# Serve www/ with any static file server, e.g.: +npx serve www +# then open http://localhost:3000 +``` + +> Note: `` and `` are used for all file I/O, so no Capacitor plugins are strictly required for basic use. diff --git a/android-app/capacitor.config.json b/android-app/capacitor.config.json new file mode 100644 index 0000000..cbbd655 --- /dev/null +++ b/android-app/capacitor.config.json @@ -0,0 +1,14 @@ +{ + "appId": "com.atlastoolkit.app", + "appName": "AtlasToolkit", + "webDir": "www", + "plugins": { + "SplashScreen": { + "launchShowDuration": 0 + } + }, + "android": { + "allowMixedContent": false, + "backgroundColor": "#2b2b2b" + } +} diff --git a/android-app/package.json b/android-app/package.json new file mode 100644 index 0000000..56a9123 --- /dev/null +++ b/android-app/package.json @@ -0,0 +1,18 @@ +{ + "name": "atlastoolkit-android", + "version": "0.2.2", + "description": "AtlasToolkit – Spine/LibGDX atlas tool for Android", + "private": true, + "scripts": { + "build": "npx cap sync android", + "open:android": "npx cap open android" + }, + "dependencies": { + "@capacitor/android": "^7.0.0", + "@capacitor/core": "^7.0.0", + "@capacitor/filesystem": "^7.0.0" + }, + "devDependencies": { + "@capacitor/cli": "^7.0.0" + } +} diff --git a/android-app/www/index.html b/android-app/www/index.html new file mode 100644 index 0000000..1ced78c --- /dev/null +++ b/android-app/www/index.html @@ -0,0 +1,126 @@ + + + + + + Atlas Toolkit + + + + + + + + Regions (0) + + + + + + Open + + + + + + Modify + + + + + + + + + + Back + + Modify Mode + + + + + + + + + + + + Ready + + Extract Selected + + + Extract All + + + + + + Modify Selected + + No mod image loaded + + Save As... + + + + + + + Repack + + + + + + + + + + + + + + + + Confirm + Are you sure? + + Cancel + Confirm + + + + + + + + + + + Drop .atlas file here to load + + + + + + + + + + Copy Image + + + + + + + + + + diff --git a/android-app/www/js/atlas-api.js b/android-app/www/js/atlas-api.js new file mode 100644 index 0000000..cc855cc --- /dev/null +++ b/android-app/www/js/atlas-api.js @@ -0,0 +1,381 @@ +/** + * atlas-api.js + * Replaces pywebview.api with a pure-JS implementation. + * Exposes the same async interface that script.js expects. + */ + +import { autoConvertAtlas } from './atlas-converter.js'; +import { AtlasProcessor, _loadImage } from './atlas-extracter.js'; +import { AtlasModifier, parseAtlas } from './atlas-modifier.js'; + +// ─── State ──────────────────────────────────────────────────────────────────── + +let _processor = null; +let _modifier = null; +let _currentAtlasFilename = ''; +let _currentAtlasText = ''; +let _mergedCanvas = null; +let _mergedAtlasText = null; +let _preRepackCanvas = null; +let _preRepackText = null; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function _readFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = e => resolve(e.target.result); + reader.onerror = reject; + reader.readAsText(file); + }); +} + +/** Extract page image filenames required by an atlas text. */ +function _extractRequiredPages(atlasText) { + const proc = new AtlasProcessor(atlasText); + return proc.pages.map(p => p.filename); +} + +/** + * Download data as a file. + * @param {string} filename + * @param {Blob} blob + */ +async function _downloadBlob(filename, blob) { + // If running in Capacitor, use Filesystem plugin to write to Documents + if (window.Capacitor && window.Capacitor.isNativePlatform() && window.Capacitor.Plugins.Filesystem) { + const { Filesystem, Directory } = window.Capacitor.Plugins; + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + await Filesystem.writeFile({ path: filename, data: base64, directory: Directory.Documents }); + return; + } + // Fallback: browser download + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); +} + +function _canvasToBlob(canvas) { + return new Promise((resolve, reject) => { + canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('toBlob failed')), 'image/png'); + }); +} + +/** Pick one or more files using a hidden file input triggered by a user gesture. */ +function _pickFiles({ accept = '', multiple = false } = {}) { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.multiple = multiple; + // listen for change; also handle cancel gracefully + const onchange = (e) => { + input.removeEventListener('change', onchange); + resolve(Array.from(e.target.files)); + }; + input.addEventListener('change', onchange); + input.click(); + // Safari/some Android may not fire 'cancel' event; resolve with [] after long delay + setTimeout(() => { input.removeEventListener('change', onchange); resolve([]); }, 60000); + }); +} + +// ─── Core: load an atlas File + associated image Files ─────────────────────── + +/** + * Load an atlas from File objects. + * @param {File} atlasFile + * @param {Object.} imageFileMap { pageName: File } (may be partial) + * @returns {Promise} + */ +async function _loadAtlasFiles(atlasFile, imageFileMap) { + try { + const rawText = await _readFileAsText(atlasFile); + const convertedText = autoConvertAtlas(rawText); + const requiredPages = _extractRequiredPages(convertedText); + + const finalMap = { ...imageFileMap }; + + // For any missing page images, prompt the user + for (const pageName of requiredPages) { + if (finalMap[pageName]) continue; + // Ask user to locate the file + const msg = `Please select the image file for: "${pageName}"`; + // Simple prompt via toast + file picker + if (typeof window.showToast === 'function') window.showToast(msg, 'info'); + const files = await _pickFiles({ accept: 'image/png,image/jpeg,image/webp,image/bmp,image/gif', multiple: false }); + if (files.length === 0) return false; + finalMap[pageName] = files[0]; + } + + _processor = new AtlasProcessor(convertedText); + await _processor.loadImages(finalMap); + + _currentAtlasFilename = atlasFile.name; + _currentAtlasText = convertedText; + + // Clear modify state + _modifier = null; + _mergedCanvas = null; + _mergedAtlasText = null; + _preRepackCanvas = null; + _preRepackText = null; + + return true; + } catch (e) { + console.error('load_atlas error:', e); + return false; + } +} + +// ─── Public API (mirrors pywebview.api) ─────────────────────────────────────── + +export const AtlasAPI = { + + /** Restore preferences from localStorage. */ + get_pref(key, defaultValue = null) { + try { + const raw = localStorage.getItem(`atlastoolkit.${key}`); + return raw !== null ? JSON.parse(raw) : defaultValue; + } catch (_) { return defaultValue; } + }, + + set_pref(key, value) { + try { localStorage.setItem(`atlastoolkit.${key}`, JSON.stringify(value)); } catch (_) {} + }, + + /** Called on startup; returns false (no file pre-loaded on Android). */ + async startup_check() { + return false; + }, + + /** Open a file picker that accepts .atlas and image files. */ + async choose_file() { + const files = await _pickFiles({ + accept: '.atlas,image/png,image/jpeg,image/webp,image/bmp', + multiple: true, + }); + if (files.length === 0) return false; + + const atlasFile = files.find(f => f.name.endsWith('.atlas')); + if (!atlasFile) { + if (typeof window.showToast === 'function') window.showToast('Please select an .atlas file.', 'error'); + return false; + } + + const imageFileMap = {}; + for (const f of files) { + if (!f.name.endsWith('.atlas')) imageFileMap[f.name] = f; + } + + return _loadAtlasFiles(atlasFile, imageFileMap); + }, + + /** Load atlas directly from a File object (used by drag-and-drop). */ + async load_atlas_from_file(atlasFile, imageFileMap = {}) { + return _loadAtlasFiles(atlasFile, imageFileMap); + }, + + get_region_names() { + if (!_processor) return []; + return Object.keys(_processor.regions); + }, + + async get_preview(names) { + if (!_processor || !names || names.length === 0) return null; + try { + return _processor.getPreviewDataURL(names); + } catch (e) { + console.error('get_preview error:', e); + return null; + } + }, + + /** + * Extract regions to files (downloads). + * @param {string[]|null} regionNames null = extract all + */ + async extract_files(regionNames) { + if (!_processor) return 'No atlas loaded.'; + const targets = regionNames || Object.keys(_processor.regions); + if (targets.length === 0) return 'No regions to extract.'; + + let count = 0; + for (const name of targets) { + const canvas = _processor.extractRegion(name); + if (!canvas) continue; + try { + const safeName = name.replace(/[^\w.\- ]/g, '_'); + const blob = await _canvasToBlob(canvas); + await _downloadBlob(`${safeName}.png`, blob); + count++; + } catch (e) { + console.error(`Failed to extract ${name}:`, e); + } + } + return `Successfully extracted ${count} image${count !== 1 ? 's' : ''}.`; + }, + + // ── Modify Mode ──────────────────────────────────────────────────────────── + + async enter_modify_mode() { + if (!_processor) return null; + try { + const baseImg = _processor.getPageImage(); + if (!baseImg) return null; + + _modifier = new AtlasModifier(_currentAtlasText, _currentAtlasFilename, baseImg); + _mergedCanvas = null; + _mergedAtlasText = null; + + // Build region bounds for overlay: { name: [x, y, w, h, rotate] } + const regionBounds = {}; + for (const [name, info] of Object.entries(_modifier.regions)) { + regionBounds[name] = [...info.bounds, info.rotate]; + } + + // Convert base image to data URL for preview + const baseCanvas = document.createElement('canvas'); + baseCanvas.width = baseImg.naturalWidth || baseImg.width; + baseCanvas.height = baseImg.naturalHeight || baseImg.height; + baseCanvas.getContext('2d').drawImage(baseImg, 0, 0); + + return { image: baseCanvas.toDataURL('image/png'), regions: regionBounds }; + } catch (e) { + console.error('enter_modify_mode error:', e); + return null; + } + }, + + exit_modify_mode() { + _modifier = null; + _mergedCanvas = null; + _mergedAtlasText = null; + _preRepackCanvas = null; + _preRepackText = null; + }, + + /** Pick a mod PNG and process it. */ + async select_mod_image(selectedNames, repack = false) { + if (!_modifier) return null; + const files = await _pickFiles({ accept: 'image/png,image/jpeg,image/webp', multiple: false }); + if (files.length === 0) return null; + return AtlasAPI.process_mod_image(files[0], selectedNames, repack); + }, + + /** Process a mod image (File or canvas/img) for the selected regions. */ + async process_mod_image(source, selectedNames, repack = false) { + if (!_modifier) return null; + try { + const img = source instanceof File ? await _loadImage(source) : source; + const { mergedCanvas, atlasText } = _modifier.mergeModImage(img, selectedNames); + + _preRepackCanvas = mergedCanvas; + _preRepackText = atlasText; + + let finalCanvas = mergedCanvas; + let finalText = atlasText; + + if (repack) { + const repacked = await _modifier.repack(mergedCanvas, atlasText); + finalCanvas = repacked.canvas; + finalText = repacked.atlasText; + } + + _mergedCanvas = finalCanvas; + _mergedAtlasText = finalText; + + const { regions: mergedRegions } = parseAtlas(finalText); + const regionBounds = {}; + for (const [name, info] of Object.entries(mergedRegions)) { + regionBounds[name] = [...info.bounds, info.rotate]; + } + + return { image: finalCanvas.toDataURL('image/png'), regions: regionBounds }; + } catch (e) { + console.error('process_mod_image error:', e); + if (typeof window.showToast === 'function') window.showToast(`Error: ${e.message}`, 'error'); + return null; + } + }, + + /** Save the merged atlas files (downloads PNG + atlas text). */ + async save_modified() { + if (!_mergedCanvas || !_mergedAtlasText) return 'Error: No merged data to save.'; + try { + const base = _currentAtlasFilename.replace(/\.[^.]+$/, ''); + const pngName = `${base}.png`; + const atlasName = _currentAtlasFilename; + + const pngBlob = await _canvasToBlob(_mergedCanvas); + await _downloadBlob(pngName, pngBlob); + + const textBlob = new Blob([_mergedAtlasText], { type: 'text/plain' }); + await _downloadBlob(atlasName, textBlob); + + return `Saved: ${pngName} and ${atlasName}`; + } catch (e) { + return `Error: ${e.message}`; + } + }, + + /** Toggle repack on/off using the pre-repack cached state. */ + async toggle_repack(repack) { + if (!_modifier || !_preRepackCanvas || !_preRepackText) return null; + try { + let canvas = _preRepackCanvas, text = _preRepackText; + if (repack) { + const repacked = await _modifier.repack(_preRepackCanvas, _preRepackText); + canvas = repacked.canvas; + text = repacked.atlasText; + } + _mergedCanvas = canvas; + _mergedAtlasText = text; + + const { regions: mergedRegions } = parseAtlas(text); + const regionBounds = {}; + for (const [name, info] of Object.entries(mergedRegions)) { + regionBounds[name] = [...info.bounds, info.rotate]; + } + return { image: canvas.toDataURL('image/png'), regions: regionBounds }; + } catch (e) { + console.error('toggle_repack error:', e); + return null; + } + }, + + /** Open a URL in the browser (or system browser on Android via Capacitor). */ + async open_url(url) { + try { + const parsed = new URL(String(url)); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + return { ok: false, error: 'Invalid URL scheme.' }; + if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Browser) { + await window.Capacitor.Plugins.Browser.open({ url: parsed.toString() }); + } else { + window.open(parsed.toString(), '_blank'); + } + return { ok: true }; + } catch (e) { + return { ok: false, error: e.message }; + } + }, + + // Stub: update features not applicable to Android app + async get_update_download_progress() { return { status: 'idle', percent: 0 }; }, + async download_update() { return { ok: false, error: 'Not supported on Android.' }; }, + async restart_and_install_update() { return { ok: false, error: 'Not supported on Android.' }; }, + async open_update_log() { return { ok: false, error: 'Not supported on Android.' }; }, +}; + +export { _loadAtlasFiles }; diff --git a/android-app/www/js/atlas-converter.js b/android-app/www/js/atlas-converter.js new file mode 100644 index 0000000..58b615e --- /dev/null +++ b/android-app/www/js/atlas-converter.js @@ -0,0 +1,139 @@ +/** + * atlas-converter.js + * Port of atlas_converter.py + * Converts LibGDX atlas format → Spine atlas format. + */ + +const PAGE_KEYS = new Set(['size', 'format', 'filter', 'repeat', 'pma']); + +function _isPageLine(line) { + return /^.+\.(png|jpg|jpeg|webp|bmp|gif)$/i.test(line.trim()); +} + +function _parseKV(line) { + if (!line.includes(':')) return null; + const idx = line.indexOf(':'); + const key = line.slice(0, idx).trim().toLowerCase(); + const values = line.slice(idx + 1).split(',').map(v => v.trim()); + return { key, values }; +} + +export function isOldFormat(atlasText) { + for (const line of atlasText.split('\n')) { + const s = line.trim().toLowerCase(); + if (s.startsWith('xy:') || s.startsWith('orig:') || s.startsWith('offset:')) return true; + } + return false; +} + +export function convertAtlasToNewFormat(atlasText) { + const lines = atlasText.split('\n'); + const result = []; + + let inPageHeader = false; + let inRegion = false; + let regionNameLine = ''; + let regionKV = {}; + let regionExtraLines = []; + + function flushRegion() { + if (!regionNameLine) return; + result.push(regionNameLine); + + if ('index' in regionKV) result.push(` index: ${regionKV['index'][0]}`); + + const rotateVal = (regionKV['rotate'] || ['false'])[0].trim().toLowerCase(); + result.push(` rotate: ${rotateVal}`); + + if ('bounds' in regionKV) { + const b = regionKV['bounds']; + result.push(` bounds: ${b[0]}, ${b[1]}, ${b[2]}, ${b[3]}`); + } else { + const xy = regionKV['xy'] || ['0', '0']; + const sz = regionKV['size'] || ['0', '0']; + result.push(` bounds: ${xy[0]}, ${xy[1]}, ${sz[0]}, ${sz[1]}`); + } + + if ('offsets' in regionKV) { + const o = regionKV['offsets']; + result.push(` offsets: ${o[0]}, ${o[1]}, ${o[2]}, ${o[3]}`); + } else if ('orig' in regionKV) { + const orig = regionKV['orig']; + const offset = regionKV['offset'] || ['0', '0']; + result.push(` offsets: ${offset[0]}, ${offset[1]}, ${orig[0]}, ${orig[1]}`); + } + + for (const k of ['split', 'pad']) { + if (k in regionKV) result.push(` ${k}: ${regionKV[k].join(', ')}`); + } + + result.push(...regionExtraLines); + inRegion = false; + } + + for (const rawLine of lines) { + const stripped = rawLine.trim(); + + if (!stripped) { + if (inRegion) { + flushRegion(); + regionNameLine = ''; + regionKV = {}; + regionExtraLines = []; + } + inPageHeader = false; + result.push(rawLine); + continue; + } + + if (_isPageLine(stripped)) { + if (inRegion) { + flushRegion(); + regionNameLine = ''; + regionKV = {}; + regionExtraLines = []; + } + inPageHeader = true; + inRegion = false; + result.push(rawLine); + continue; + } + + const parsed = _parseKV(rawLine); + if (parsed) { + const { key, values } = parsed; + if (inPageHeader) { + result.push(rawLine); + if (!PAGE_KEYS.has(key)) inPageHeader = false; + continue; + } + if (inRegion) { + regionKV[key] = values; + } else { + result.push(rawLine); + } + continue; + } + + // No colon → new region name + if (inRegion) { + flushRegion(); + regionNameLine = ''; + regionKV = {}; + regionExtraLines = []; + } + inPageHeader = false; + inRegion = true; + regionNameLine = rawLine; + regionKV = {}; + regionExtraLines = []; + } + + if (inRegion) flushRegion(); + + return result.join('\n'); +} + +export function autoConvertAtlas(atlasText) { + return isOldFormat(atlasText) ? convertAtlasToNewFormat(atlasText) : atlasText; +} diff --git a/android-app/www/js/atlas-extracter.js b/android-app/www/js/atlas-extracter.js new file mode 100644 index 0000000..15f13dd --- /dev/null +++ b/android-app/www/js/atlas-extracter.js @@ -0,0 +1,280 @@ +/** + * atlas-extracter.js + * Port of atlas_extracter.py using Canvas API. + */ + +class AtlasPage { + constructor(filename) { + this.filename = filename; + this.width = 0; + this.height = 0; + this.format = 'RGBA8888'; + this.filter = ['Nearest', 'Nearest']; + this.repeat = 'none'; + this.scaleX = 1.0; + this.scaleY = 1.0; + } +} + +class AtlasRegion { + constructor(name, pageFilename) { + this.name = name; + this.pageFilename = pageFilename; + this.index = -1; + this.x = 0; + this.y = 0; + this.w = 0; + this.h = 0; + this.offsets = null; // [off_x, off_y, orig_w, orig_h] + this.rotate = 0; + } +} + +export class AtlasProcessor { + constructor(atlasContent) { + this.atlasContent = atlasContent; + this.pages = []; + this.regions = {}; // name → AtlasRegion (ordered by insertion) + this._loadedImages = {}; // pageName → HTMLImageElement + this._pageMap = {}; // pageName → AtlasPage + this._parse(); + } + + _parse() { + const lines = this.atlasContent.split('\n').map(l => l.trim()); + let currentPage = null; + let currentRegion = null; + + for (const line of lines) { + if (!line) continue; + + if (line.endsWith('.png')) { + currentPage = new AtlasPage(line); + this.pages.push(currentPage); + this._pageMap[line] = currentPage; + currentRegion = null; + continue; + } + + if (line.includes(':')) { + const idx = line.indexOf(':'); + const key = line.slice(0, idx).trim().toLowerCase(); + const vals = line.slice(idx + 1).split(',').map(v => v.trim()); + + if (currentRegion) { + if (key === 'bounds' && vals.length >= 4) { + currentRegion.x = parseInt(vals[0]); + currentRegion.y = parseInt(vals[1]); + currentRegion.w = parseInt(vals[2]); + currentRegion.h = parseInt(vals[3]); + } else if (key === 'xy') { + currentRegion.x = parseInt(vals[0]); + currentRegion.y = parseInt(vals[1]); + } else if (key === 'size' && currentRegion.w === 0) { + // Only apply size to region if we haven't got bounds yet + currentRegion.w = parseInt(vals[0]); + currentRegion.h = parseInt(vals[1]); + } else if (key === 'rotate') { + const v = vals[0].toLowerCase(); + if (v === 'true') currentRegion.rotate = 90; + else if (v === 'false') currentRegion.rotate = 0; + else { const n = parseInt(v); currentRegion.rotate = isNaN(n) ? 0 : n; } + } else if (key === 'offsets' && vals.length >= 4) { + currentRegion.offsets = vals.map(Number); + } else if (key === 'index') { + currentRegion.index = parseInt(vals[0]); + } + } else if (currentPage) { + if (key === 'size') { + currentPage.width = parseInt(vals[0]); + currentPage.height = parseInt(vals[1]); + } else if (key === 'format') { + currentPage.format = vals[0]; + } else if (key === 'filter') { + currentPage.filter = [vals[0], vals[1]]; + } else if (key === 'repeat') { + currentPage.repeat = vals[0]; + } + } + continue; + } + + // No colon and not a .png line → region name + if (!currentPage) continue; + currentRegion = new AtlasRegion(line, currentPage.filename); + this.regions[line] = currentRegion; + } + } + + /** + * Load images from a map of { pageName: File | string(dataURL|url) }. + * Must be called before extracting regions. + */ + async loadImages(imageFileMap) { + for (const [pageName, source] of Object.entries(imageFileMap)) { + try { + const img = await _loadImage(source); + const page = this._pageMap[pageName]; + if (page && page.width !== 0 && page.height !== 0) { + if (img.naturalWidth !== page.width || img.naturalHeight !== page.height) { + page.scaleX = img.naturalWidth / page.width; + page.scaleY = img.naturalHeight / page.height; + } + } + this._loadedImages[pageName] = img; + } catch (e) { + console.error(`Failed to load image ${pageName}:`, e); + } + } + } + + getPageImage(pageName) { + if (pageName) return this._loadedImages[pageName] || null; + const keys = Object.keys(this._loadedImages); + return keys.length > 0 ? this._loadedImages[keys[0]] : null; + } + + /** + * Crop a region from img (HTMLImageElement or canvas) and undo atlas rotation. + * Returns a canvas element (w × h) with the sprite in its original orientation. + */ + static cropAndRotate(img, x, y, w, h, rotate) { + const isSwapped = rotate === 90 || rotate === 270; + const cropW = isSwapped ? h : w; + const cropH = isSwapped ? w : h; + + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + + if (rotate === 0) { + ctx.drawImage(img, x, y, cropW, cropH, 0, 0, w, h); + } else if (rotate === 90) { + // Stored in atlas as h×w; un-rotate 90° CW → w×h + ctx.translate(w, 0); + ctx.rotate(Math.PI / 2); + ctx.drawImage(img, x, y, cropW, cropH, 0, 0, cropW, cropH); + } else if (rotate === 270) { + // Stored in atlas as h×w; un-rotate 90° CCW → w×h + ctx.translate(0, h); + ctx.rotate(-Math.PI / 2); + ctx.drawImage(img, x, y, cropW, cropH, 0, 0, cropW, cropH); + } else if (rotate === 180) { + ctx.translate(w, h); + ctx.rotate(Math.PI); + ctx.drawImage(img, x, y, w, h, 0, 0, w, h); + } + + return canvas; + } + + /** Extract a single region as a canvas (includes offset padding). */ + extractRegion(name) { + const region = this.regions[name]; + if (!region) return null; + const baseImg = this._loadedImages[region.pageFilename]; + if (!baseImg) return null; + + let { x, y, w, h, rotate, offsets } = region; + const page = this._pageMap[region.pageFilename]; + + if (page && (page.scaleX !== 1.0 || page.scaleY !== 1.0)) { + x = Math.round(x * page.scaleX); + y = Math.round(y * page.scaleY); + w = Math.round(w * page.scaleX); + h = Math.round(h * page.scaleY); + } + + const sprite = AtlasProcessor.cropAndRotate(baseImg, x, y, w, h, rotate); + const currentW = sprite.width; + const currentH = sprite.height; + + if (offsets) { + let [offX, offY, origW, origH] = offsets; + if (page && (page.scaleX !== 1.0 || page.scaleY !== 1.0)) { + offX = Math.round(offX * page.scaleX); + offY = Math.round(offY * page.scaleY); + origW = Math.round(origW * page.scaleX); + origH = Math.round(origH * page.scaleY); + } + const canvas = document.createElement('canvas'); + canvas.width = origW; + canvas.height = origH; + const ctx = canvas.getContext('2d'); + ctx.drawImage(sprite, offX, origH - offY - currentH); + return canvas; + } + + return sprite; + } + + /** Extract a single region as a base64 PNG data URI. */ + extractRegionAsDataURL(name) { + const canvas = this.extractRegion(name); + return canvas ? canvas.toDataURL('image/png') : null; + } + + /** + * Get a composite preview of one or more region names as a data URI. + * Regions are composited (alpha-blended) on a max-size canvas. + */ + getPreviewDataURL(names) { + const images = names + .filter(n => n in this.regions) + .map(n => this.extractRegion(n)) + .filter(Boolean); + + if (images.length === 0) return null; + if (images.length === 1) return images[0].toDataURL('image/png'); + + const maxW = Math.max(...images.map(c => c.width)); + const maxH = Math.max(...images.map(c => c.height)); + + const canvas = document.createElement('canvas'); + canvas.width = maxW; + canvas.height = maxH; + const ctx = canvas.getContext('2d'); + + // Composite in reverse order (last on top matches Python's alpha_composite) + for (const img of [...images].reverse()) { + ctx.drawImage(img, 0, 0); + } + + return canvas.toDataURL('image/png'); + } + + /** + * Extract all regions. Returns { name: canvas }. + */ + extractAll() { + const results = {}; + for (const name of Object.keys(this.regions)) { + try { + const canvas = this.extractRegion(name); + if (canvas) results[name] = canvas; + } catch (e) { + console.error(`Failed to extract ${name}:`, e); + } + } + return results; + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/** Load an image from a File object or a URL/data-URL string. */ +export function _loadImage(source) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load image: ${source}`)); + if (source instanceof File) { + const url = URL.createObjectURL(source); + img.onload = () => { resolve(img); }; + img.src = url; + } else { + img.src = source; + } + }); +} diff --git a/android-app/www/js/atlas-modifier.js b/android-app/www/js/atlas-modifier.js new file mode 100644 index 0000000..639e24a --- /dev/null +++ b/android-app/www/js/atlas-modifier.js @@ -0,0 +1,426 @@ +/** + * atlas-modifier.js + * Port of atlas_modifier.py using Canvas API. + */ + +import { AtlasProcessor } from './atlas-extracter.js'; + +// ─── Parse atlas text using AtlasProcessor ────────────────────────────────── + +export function parseAtlas(atlasText) { + const processor = new AtlasProcessor(atlasText); + const pageInfo = {}; + if (processor.pages.length > 0) { + const p = processor.pages[0]; + pageInfo.page = p.filename; + pageInfo.size = `${p.width},${p.height}`; + pageInfo.format = p.format; + pageInfo.filter = `${p.filter[0]}, ${p.filter[1]}`; + pageInfo.repeat = p.repeat; + } + const regionNames = Object.keys(processor.regions); + const regions = {}; + for (const [name, r] of Object.entries(processor.regions)) { + regions[name] = { name, bounds: [r.x, r.y, r.w, r.h], offsets: r.offsets, rotate: r.rotate }; + } + return { pageInfo, regionNames, regions }; +} + +// ─── Atlas text manipulation ───────────────────────────────────────────────── + +function _formatRotate(val) { + if (val === 90) return 'true'; + if (val === 180) return '180'; + if (val === 270) return '270'; + return null; +} + +/** + * Rebuild atlas text with updated bounds/offsets for specific regions. + * updatedRegions: { name: [[x,y,w,h], offsets|null, rotateVal] } + */ +export function updateAtlasText(atlasText, newSize, updatedRegions) { + const lines = atlasText.split('\n'); + const result = []; + let currentRegion = null; + let inPageHeader = false; + let rotateWritten = false; + let offsetsWritten = false; + + function flushPendingRotate() { + if (!currentRegion || !(currentRegion in updatedRegions) || rotateWritten) return; + const [, , rv] = updatedRegions[currentRegion]; + const rs = _formatRotate(rv); + if (rs !== null) result.push(` rotate: ${rs}`); + } + + function flushPendingOffsets() { + if (!currentRegion || !(currentRegion in updatedRegions) || offsetsWritten) return; + const [, off] = updatedRegions[currentRegion]; + if (off) result.push(` offsets: ${off[0]}, ${off[1]}, ${off[2]}, ${off[3]}`); + } + + for (const line of lines) { + const s = line.trim(); + + if (s.endsWith('.png')) { + flushPendingOffsets(); + flushPendingRotate(); + result.push(line); + inPageHeader = true; + currentRegion = null; + rotateWritten = false; + offsetsWritten = false; + continue; + } + + if (inPageHeader) { + if (s.startsWith('size:')) { result.push(`size: ${newSize[0]},${newSize[1]}`); continue; } + if (!s.includes(':') && s) inPageHeader = false; + } + + if (!s.includes(':') && s && !s.endsWith('.png')) { + flushPendingOffsets(); + flushPendingRotate(); + currentRegion = s; + rotateWritten = false; + offsetsWritten = false; + result.push(line); + continue; + } + + if (currentRegion && currentRegion in updatedRegions) { + const [nb, no, rv] = updatedRegions[currentRegion]; + if (s.startsWith('bounds:')) { + result.push(` bounds: ${nb[0]}, ${nb[1]}, ${nb[2]}, ${nb[3]}`); + continue; + } + if (s.startsWith('offsets:')) { + if (no) result.push(` offsets: ${no[0]}, ${no[1]}, ${no[2]}, ${no[3]}`); + else result.push(line); + offsetsWritten = true; + continue; + } + if (s.startsWith('rotate:')) { + const rs = _formatRotate(rv); + result.push(rs !== null ? ` rotate: ${rs}` : ' rotate: false'); + rotateWritten = true; + continue; + } + } + + result.push(line); + } + + flushPendingOffsets(); + flushPendingRotate(); + return result.join('\n'); +} + +/** Build a complete atlas text from scratch. */ +export function rebuildAtlasText(pageInfo, newSize, regionNames, regionData) { + const lines = ['', pageInfo.page || 'atlas.png', `size: ${newSize[0]},${newSize[1]}`]; + for (const [k, v] of Object.entries(pageInfo)) { + if (k === 'page' || k === 'size') continue; + lines.push(`${k}: ${v}`); + } + for (const name of regionNames) { + if (!(name in regionData)) continue; + const [bounds, offsets, rv] = regionData[name]; + lines.push(name); + const rs = _formatRotate(rv); + if (rs) lines.push(` rotate: ${rs}`); + lines.push(` bounds: ${bounds[0]}, ${bounds[1]}, ${bounds[2]}, ${bounds[3]}`); + if (offsets) lines.push(` offsets: ${offsets[0]}, ${offsets[1]}, ${offsets[2]}, ${offsets[3]}`); + else lines.push(` offsets: 0, 0, ${bounds[2]}, ${bounds[3]}`); + } + return lines.join('\n'); +} + +// ─── Placement strategy ────────────────────────────────────────────────────── + +function _findBestPlacement(baseW, baseH, modW, modH) { + const rotW = modH, rotH = modW; + const candidates = [ + { label: 'right', canvasW: baseW + modW, canvasH: Math.max(baseH, modH), pasteX: baseW, pasteY: 0, rotated: false }, + { label: 'right+rotated', canvasW: baseW + rotW, canvasH: Math.max(baseH, rotH), pasteX: baseW, pasteY: 0, rotated: true }, + { label: 'below', canvasW: Math.max(baseW, modW), canvasH: baseH + modH, pasteX: 0, pasteY: baseH, rotated: false }, + { label: 'below+rotated', canvasW: Math.max(baseW, rotW), canvasH: baseH + rotH, pasteX: 0, pasteY: baseH, rotated: true }, + ]; + return candidates.reduce((best, c) => c.canvasW * c.canvasH < best.canvasW * best.canvasH ? c : best); +} + +// ─── Shelf packing ─────────────────────────────────────────────────────────── + +function _shelfPack(items) { + // items: [{ name, w, h }] + if (items.length === 0) return { canvasW: 0, canvasH: 0, placements: [] }; + + function packWithWidth(rects, stripW, allowRotate) { + const sorted = [...rects].sort((a, b) => Math.max(b.w, b.h) - Math.max(a.w, a.h)); + const placements = []; + let shelfY = 0, shelfH = 0, cursorX = 0, usedW = 0; + + for (const { name, w, h } of sorted) { + let pw = w, ph = h, rotated = false; + if (allowRotate) { + if (shelfH > 0) { + const wasteA = h > shelfH ? h - shelfH : 0; + const wasteB = w > shelfH ? w - shelfH : 0; + if (wasteB < wasteA) { pw = h; ph = w; rotated = true; } + } else if (h > w) { pw = h; ph = w; rotated = true; } + } + if (cursorX + pw > stripW && cursorX > 0) { shelfY += shelfH; cursorX = 0; shelfH = 0; } + placements.push({ name, x: cursorX, y: shelfY, pw, ph, rotated }); + cursorX += pw; + usedW = Math.max(usedW, cursorX); + shelfH = Math.max(shelfH, ph); + } + return { usedW, canvasH: shelfY + shelfH, placements }; + } + + const maxSingle = Math.max(...items.map(i => Math.max(i.w, i.h))); + const totalArea = items.reduce((s, i) => s + i.w * i.h, 0); + const sqrtArea = Math.floor(Math.sqrt(totalArea)); + const totalW = items.reduce((s, i) => s + Math.max(i.w, i.h), 0); + + const candidateWidths = new Set([ + maxSingle, totalW, + Math.max(maxSingle, sqrtArea), + Math.max(maxSingle, Math.floor(sqrtArea * 0.8)), + Math.max(maxSingle, Math.floor(sqrtArea * 1.2)), + Math.max(maxSingle, Math.floor(sqrtArea * 1.5)), + Math.max(maxSingle, Math.floor(sqrtArea * 2.0)), + ...Array.from({ length: 5 }, (_, i) => maxSingle * (i + 1)), + ]); + + let best = null, bestArea = Infinity; + for (const stripW of [...candidateWidths].sort((a, b) => a - b)) { + for (const allowRot of [false, true]) { + const { usedW, canvasH, placements } = packWithWidth(items, stripW, allowRot); + const area = usedW * canvasH; + if (area < bestArea) { bestArea = area; best = { canvasW: usedW, canvasH, placements }; } + } + } + return best; +} + +// ─── Pixel hashing ─────────────────────────────────────────────────────────── + +async function _canvasHash(canvas) { + const ctx = canvas.getContext('2d'); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + try { + const buf = await crypto.subtle.digest('SHA-256', data.buffer); + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); + } catch (_) { + // Fallback: djb2 + let h = 5381; + for (let i = 0; i < data.length; i++) h = (Math.imul(h, 31) + data[i]) | 0; + return `${canvas.width}x${canvas.height}_${(h >>> 0).toString(16)}`; + } +} + +// ─── AtlasModifier class ───────────────────────────────────────────────────── + +export class AtlasModifier { + /** + * @param {string} atlasText Spine-format atlas text (already auto-converted) + * @param {string} atlasFilename Base filename of the atlas (e.g. "hero.atlas") + * @param {HTMLImageElement|HTMLCanvasElement} baseImage First page image + */ + constructor(atlasText, atlasFilename, baseImage) { + this.atlasFilename = atlasFilename; + this.baseCanvas = _toCanvas(baseImage); + this.atlasText = this._scaleAtlasText(atlasText); + const { regionNames, regions } = parseAtlas(this.atlasText); + this.regionNames = regionNames; + this.regions = regions; + } + + _scaleAtlasText(atlasText) { + const { pageInfo, regions } = parseAtlas(atlasText); + const sizeStr = pageInfo.size; + if (!sizeStr) return atlasText; + const [atlasW, atlasH] = sizeStr.split(',').map(s => parseInt(s.trim())); + if (atlasW === 0 || atlasH === 0) return atlasText; + const realW = this.baseCanvas.width; + const realH = this.baseCanvas.height; + if (realW === atlasW && realH === atlasH) return atlasText; + + const sx = realW / atlasW, sy = realH / atlasH; + const updated = {}; + for (const [name, info] of Object.entries(regions)) { + const [x, y, w, h] = info.bounds; + const nb = [Math.round(x * sx), Math.round(y * sy), Math.round(w * sx), Math.round(h * sy)]; + let no = null; + if (info.offsets) { + const [ox, oy, ow, oh] = info.offsets; + no = [Math.round(ox * sx), Math.round(oy * sy), Math.round(ow * sx), Math.round(oh * sy)]; + } + updated[name] = [nb, no, info.rotate]; + } + return updateAtlasText(atlasText, [realW, realH], updated); + } + + /** + * Merge a mod image (canvas/img) into the atlas for the selected regions. + * Returns { mergedCanvas, atlasText }. + */ + mergeModImage(modImage, selectedRegions) { + if (!selectedRegions || selectedRegions.length === 0) + throw new Error('No regions selected for modification'); + + const modCanvas = _toCanvas(modImage); + const baseW = this.baseCanvas.width, baseH = this.baseCanvas.height; + let modW = modCanvas.width, modH = modCanvas.height; + + // Determine original canvas dimensions from offsets of first region + let origCanvasW = modW, origCanvasH = modH; + const firstRegion = this.regions[selectedRegions[0]]; + if (firstRegion && firstRegion.offsets) { + origCanvasW = firstRegion.offsets[2]; + origCanvasH = firstRegion.offsets[3]; + } + + // Detect proportional scale (e.g. user supplied 2× mod) + if (origCanvasW > 0 && origCanvasH > 0 && (modW !== origCanvasW || modH !== origCanvasH)) { + const ratioW = modW / origCanvasW, ratioH = modH / origCanvasH; + if (Math.abs(ratioW - ratioH) < 0.05 && !(0.95 < ratioW && ratioW < 1.05)) { + const scale = (ratioW + ratioH) / 2; + origCanvasW = Math.round(origCanvasW * scale); + origCanvasH = Math.round(origCanvasH * scale); + } + } + + // Pad mod image to canvas size if needed + let finalMod = modCanvas; + if (modW !== origCanvasW || modH !== origCanvasH) { + finalMod = document.createElement('canvas'); + finalMod.width = origCanvasW; + finalMod.height = origCanvasH; + finalMod.getContext('2d').drawImage(modCanvas, 0, origCanvasH - modH); + modW = origCanvasW; modH = origCanvasH; + } + + const best = _findBestPlacement(baseW, baseH, modW, modH); + + // Rotate mod if best strategy requires it (PIL ROTATE_90 = 90° CCW) + let pastedMod = finalMod; + if (best.rotated) { + pastedMod = document.createElement('canvas'); + pastedMod.width = modH; // after 90° CCW: width=oldHeight + pastedMod.height = modW; + const rCtx = pastedMod.getContext('2d'); + rCtx.translate(0, modW); + rCtx.rotate(-Math.PI / 2); + rCtx.drawImage(finalMod, 0, 0); + } + + // Create merged canvas + const merged = document.createElement('canvas'); + merged.width = best.canvasW; merged.height = best.canvasH; + const ctx = merged.getContext('2d'); + ctx.drawImage(this.baseCanvas, 0, 0); + ctx.drawImage(pastedMod, best.pasteX, best.pasteY); + + // Build atlas update data + // Bounds always store ORIGINAL (pre-rotation) dimensions + const rotateVal = best.rotated ? 90 : 0; + const updatedRegions = {}; + for (const name of selectedRegions) { + updatedRegions[name] = [[best.pasteX, best.pasteY, modW, modH], [0, 0, modW, modH], rotateVal]; + } + const newAtlasText = updateAtlasText(this.atlasText, [best.canvasW, best.canvasH], updatedRegions); + + return { mergedCanvas: merged, atlasText: newAtlasText }; + } + + _extractRawSprite(imageCanvas, region) { + const [x, y, w, h] = region.bounds; + return AtlasProcessor.cropAndRotate(imageCanvas, x, y, w, h, region.rotate); + } + + /** + * Repack all regions from mergedCanvas into a compact atlas. + * Returns { canvas, atlasText }. + */ + async repack(mergedCanvas, atlasText) { + const { pageInfo, regionNames, regions } = parseAtlas(atlasText); + + // 1. Extract raw sprites + const sprites = {}; + for (const [name, info] of Object.entries(regions)) { + sprites[name] = this._extractRawSprite(mergedCanvas, info); + } + + // 2. Deduplicate by pixel hash + const hashToCanonical = {}, canonicalMap = {}; + for (const name of regionNames) { + if (!(name in sprites)) continue; + const hash = await _canvasHash(sprites[name]); + if (hash in hashToCanonical) { + canonicalMap[name] = hashToCanonical[hash]; + } else { + hashToCanonical[hash] = name; + canonicalMap[name] = name; + } + } + const uniqueNames = Object.values(hashToCanonical); + + // 3. Bin-pack unique sprites + const packItems = uniqueNames.map(n => ({ name: n, w: sprites[n].width, h: sprites[n].height })); + const { canvasW, canvasH, placements } = _shelfPack(packItems); + + // 4. Build placement lookup + const placementMap = {}; + for (const p of placements) placementMap[p.name] = p; + + // 5. Paste sprites onto new canvas + const canvas = document.createElement('canvas'); + canvas.width = canvasW; canvas.height = canvasH; + const ctx = canvas.getContext('2d'); + + for (const name of uniqueNames) { + const { x, y, rotated } = placementMap[name]; + const sprite = sprites[name]; + if (rotated) { + // PIL ROTATE_90 = 90° CCW + const rotCanvas = document.createElement('canvas'); + rotCanvas.width = sprite.height; rotCanvas.height = sprite.width; + const rCtx = rotCanvas.getContext('2d'); + rCtx.translate(0, sprite.width); + rCtx.rotate(-Math.PI / 2); + rCtx.drawImage(sprite, 0, 0); + ctx.drawImage(rotCanvas, x, y); + } else { + ctx.drawImage(sprite, x, y); + } + } + + // 6. Build region data for atlas text + const regionData = {}; + for (const name of regionNames) { + if (!(name in canonicalMap)) continue; + const canonical = canonicalMap[name]; + const { x, y, rotated } = placementMap[canonical]; + const orig = sprites[name]; + const bounds = [x, y, orig.width, orig.height]; + regionData[name] = [bounds, regions[name].offsets, rotated ? 90 : 0]; + } + + const newAtlasText = rebuildAtlasText(pageInfo, [canvasW, canvasH], regionNames, regionData); + return { canvas, atlasText: newAtlasText }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function _toCanvas(img) { + if (img instanceof HTMLCanvasElement) return img; + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + canvas.getContext('2d').drawImage(img, 0, 0); + return canvas; +} diff --git a/android-app/www/script.js b/android-app/www/script.js new file mode 100644 index 0000000..8c90a38 --- /dev/null +++ b/android-app/www/script.js @@ -0,0 +1,765 @@ +/** + * script.js – AtlasToolkit Android / Web front-end + * + * All pywebview.api.* calls have been replaced with AtlasAPI.* calls from + * js/atlas-api.js. File I/O is handled via HTML5 File API and download links. + */ + +import { AtlasAPI, _loadAtlasFiles } from './js/atlas-api.js'; + +// ─── Data State ─────────────────────────────────────────────────────────────── +let regionsData = []; +let selectedIndices = new Set(); +let lastClickIndex = -1; +let isDragSelecting = false; +let dragStartIndex = -1; +let currentMode = 'extract'; // 'extract' | 'modify' +let modifyRegionBounds = {}; // { name: [x, y, w, h, rotate] } +let hasModImage = false; +let viewState = { scale: 1, x: 0, y: 0, isDragging: false, startX: 0, startY: 0 }; + +// ─── Startup ────────────────────────────────────────────────────────────────── +window.addEventListener('DOMContentLoaded', async () => { + const repackPref = AtlasAPI.get_pref('repack', false); + document.getElementById('chk-repack').checked = repackPref; + + const loaded = await AtlasAPI.startup_check(); + if (loaded) await loadRegions(); +}); + +// ─── Mode Switching ─────────────────────────────────────────────────────────── +function setMode(mode) { + currentMode = mode; + const normalHeader = document.getElementById('normal-header'); + const modifyHeader = document.getElementById('modify-header'); + const extractControls = document.getElementById('extract-controls'); + const modifyControls = document.getElementById('modify-controls'); + const repackOptions = document.getElementById('repack-options'); + const dropMsg = document.getElementById('drop-message-text'); + + if (mode === 'modify') { + normalHeader.classList.add('hidden'); + modifyHeader.classList.remove('hidden'); + extractControls.classList.add('hidden'); + modifyControls.classList.remove('hidden'); + repackOptions.classList.remove('hidden'); + dropMsg.textContent = 'Drop image to modify, or .atlas to load'; + } else { + normalHeader.classList.remove('hidden'); + modifyHeader.classList.add('hidden'); + extractControls.classList.remove('hidden'); + modifyControls.classList.add('hidden'); + repackOptions.classList.add('hidden'); + dropMsg.textContent = 'Drop .atlas file here to load'; + clearOverlay(); + } +} + +async function enterModifyMode() { + try { + const data = await AtlasAPI.enter_modify_mode(); + if (data) { + setMode('modify'); + modifyRegionBounds = data.regions || {}; + hasModImage = false; + document.getElementById('modify-status-text').innerText = 'Select regions and click Modify Selected'; + document.getElementById('btn-save-mod').disabled = true; + previewImg.src = data.image; + previewImg.style.display = 'block'; + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + if (imgW > containerW || imgH > containerH) { + viewState.scale = Math.min(containerW / imgW, containerH / imgH); + applyTransform(); + } + previewImg.onload = null; + }; + } else { + showToast('Load an atlas first.', 'error'); + } + } catch (e) { + console.error(e); + showToast('Failed to enter modify mode.', 'error'); + } +} + +async function exitModifyMode() { + try { AtlasAPI.exit_modify_mode(); } catch (e) { console.error(e); } + setMode('extract'); + modifyRegionBounds = {}; + hasModImage = false; + clearOverlay(); + previewImg.style.display = 'none'; + resetPreview(); + document.getElementById('status-text').innerText = 'Ready'; + updatePreview(getSelectedNames()); +} + +async function modifySelected() { + const names = getSelectedNames(); + if (names.length === 0) { showToast('Select at least one region to modify.', 'error'); return; } + try { + document.getElementById('modify-status-text').innerText = 'Selecting mod image...'; + const repack = document.getElementById('chk-repack').checked; + const result = await AtlasAPI.select_mod_image(names, repack); + if (result) { + onModPreviewReceived(result); + } else { + document.getElementById('modify-status-text').innerText = 'Cancelled or no image selected.'; + } + } catch (e) { + console.error(e); + showToast('Error selecting mod image.', 'error'); + } +} + +function onModPreviewReceived(data) { + hasModImage = true; + if (data.regions) modifyRegionBounds = data.regions; + previewImg.src = data.image; + previewImg.style.display = 'block'; + document.getElementById('modify-status-text').innerText = 'Mod image merged. Ready to save.'; + document.getElementById('btn-save-mod').disabled = false; + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth; + const imgH = previewImg.naturalHeight; + document.getElementById('modify-status-text').innerText = `Merged preview (${imgW}x${imgH}). Ready to save.`; + if (imgW > containerW || imgH > containerH) { + viewState.scale = Math.min(containerW / imgW, containerH / imgH); + } + applyTransform(); + previewImg.onload = null; + }; +} + +async function saveModified() { + try { + document.getElementById('modify-status-text').innerText = 'Saving...'; + const result = await AtlasAPI.save_modified(); + if (result.startsWith('Error') || result === 'Cancelled') { + showToast(result, result === 'Cancelled' ? 'info' : 'error'); + } else { + showToast(result, 'success'); + } + document.getElementById('modify-status-text').innerText = result; + } catch (e) { + console.error(e); + showToast('Save failed.', 'error'); + } +} + +document.getElementById('chk-repack').addEventListener('change', async (e) => { + AtlasAPI.set_pref('repack', e.target.checked); + if (!hasModImage) return; + const statusEl = document.getElementById('modify-status-text'); + statusEl.innerText = e.target.checked ? 'Applying repack...' : 'Reverting repack...'; + try { + const result = await AtlasAPI.toggle_repack(e.target.checked); + if (result) { + onModPreviewReceived(result); + } else { + showToast('No merged data to repack.', 'error'); + } + } catch (err) { + console.error(err); + showToast('Repack toggle failed.', 'error'); + } +}); + +// ─── Core Logic ─────────────────────────────────────────────────────────────── +async function openFile() { + try { + const success = await AtlasAPI.choose_file(); + if (success) { + selectedIndices.clear(); + lastClickIndex = -1; + document.getElementById('preview-img').style.display = 'none'; + resetPreview(); + updateButtons(); + await loadRegions(); + } + } catch (e) { + console.error(e); + } +} + +async function loadRegions() { + regionsData = AtlasAPI.get_region_names(); + if (!regionsData) return; + const listEl = document.getElementById('region-list'); + listEl.innerHTML = ''; + document.getElementById('count').innerText = regionsData.length; + regionsData.forEach((name, index) => { + const li = document.createElement('li'); + li.className = 'region-item'; + li.innerText = name; + li.dataset.index = index; + li.addEventListener('mousedown', (e) => onRegionMouseDown(e, index, name)); + li.addEventListener('mouseenter', (e) => onRegionMouseEnter(e, index)); + listEl.appendChild(li); + }); + if (regionsData.length > 0) { + document.getElementById('status-text').innerText = 'Atlas loaded.'; + document.getElementById('btn-enter-modify').disabled = false; + document.getElementById('btn-extract-all').disabled = false; + } else { + document.getElementById('btn-enter-modify').disabled = true; + document.getElementById('btn-extract-all').disabled = true; + } +} + +function getSelectedNames() { + return Array.from(selectedIndices).sort((a, b) => a - b).map(i => regionsData[i]); +} + +// ─── Auto-Scroll State ──────────────────────────────────────────────────────── +let autoScrollSpeed = 0; +let autoScrollInterval = null; +const SCROLL_ZONE_SIZE = 50; +const MAX_SCROLL_SPEED = 15; +let lastMouseX = 0; +let lastMouseY = 0; + +function onRegionMouseDown(e, index, name) { + if (e.button !== 0) return; + if (e.shiftKey) e.preventDefault(); + isDragSelecting = true; + dragStartIndex = index; + if (e.ctrlKey || e.metaKey) { + toggleIndex(index); lastClickIndex = index; + } else if (e.shiftKey && lastClickIndex !== -1) { + selectRange(Math.min(lastClickIndex, index), Math.max(lastClickIndex, index), false); + } else { + selectedIndices.clear(); selectedIndices.add(index); lastClickIndex = index; + } + renderSelection(); + triggerPreviewUpdate(); + window.addEventListener('mousemove', onWindowMouseMove); + startAutoScroll(); +} + +function onWindowMouseMove(e) { + if (!isDragSelecting) return; + e.preventDefault(); + lastMouseX = e.clientX; lastMouseY = e.clientY; + const container = document.getElementById('region-list-container'); + const rect = container.getBoundingClientRect(); + if (e.clientY < rect.top + SCROLL_ZONE_SIZE) { + autoScrollSpeed = -((rect.top + SCROLL_ZONE_SIZE - e.clientY) / SCROLL_ZONE_SIZE) * MAX_SCROLL_SPEED; + } else if (e.clientY > rect.bottom - SCROLL_ZONE_SIZE) { + autoScrollSpeed = ((e.clientY - (rect.bottom - SCROLL_ZONE_SIZE)) / SCROLL_ZONE_SIZE) * MAX_SCROLL_SPEED; + } else { + autoScrollSpeed = 0; + } + updateSelectionFromMouse(e.clientX, e.clientY); +} + +function updateSelectionFromMouse(clientX, clientY) { + const container = document.getElementById('region-list-container'); + const rect = container.getBoundingClientRect(); + const checkY = Math.max(rect.top + 1, Math.min(clientY, rect.bottom - 1)); + const el = document.elementFromPoint(rect.left + rect.width / 2, checkY); + const item = el?.closest('.region-item'); + if (item) { + const index = parseInt(item.dataset.index); + if (!isNaN(index)) { + const start = Math.min(dragStartIndex, index); + const end = Math.max(dragStartIndex, index); + selectedIndices.clear(); + for (let i = start; i <= end; i++) selectedIndices.add(i); + lastClickIndex = index; + renderSelection(); + triggerPreviewUpdate(); + } + } +} + +// ─── Preview Debounce ───────────────────────────────────────────────────────── +let previewTimeout = null; +let lastSelectedJSON = '[]'; + +function triggerPreviewUpdate() { + const currentJSON = JSON.stringify(getSelectedNames()); + if (currentJSON !== lastSelectedJSON) { + lastSelectedJSON = currentJSON; + if (previewTimeout) clearTimeout(previewTimeout); + previewTimeout = setTimeout(() => { + const names = getSelectedNames(); + if (currentMode === 'modify') updateModifyPreview(names); + else updatePreview(names); + }, 50); + updateButtons(); + } +} + +function updateModifyPreview(names) { + drawRegionOverlay(); + if (!names || names.length === 0) { + document.getElementById('modify-status-text').innerText = hasModImage + ? 'Mod image merged. Ready to save.' + : 'Select regions and click Modify Selected'; + } else { + document.getElementById('modify-status-text').innerText = hasModImage + ? `Merged preview. ${names.length} region(s) selected.` + : `${names.length} region(s) selected`; + } +} + +function startAutoScroll() { + if (autoScrollInterval) return; + function scrollLoop() { + if (!isDragSelecting) { stopAutoScroll(); return; } + if (autoScrollSpeed !== 0) { + document.getElementById('region-list-container').scrollTop += autoScrollSpeed; + updateSelectionFromMouse(lastMouseX, lastMouseY); + } + triggerPreviewUpdate(); + autoScrollInterval = requestAnimationFrame(scrollLoop); + } + autoScrollInterval = requestAnimationFrame(scrollLoop); +} + +function stopAutoScroll() { + if (autoScrollInterval) { cancelAnimationFrame(autoScrollInterval); autoScrollInterval = null; } + autoScrollSpeed = 0; +} + +function onRegionMouseEnter(e, index) { /* handled by global mousemove */ } + +window.addEventListener('mouseup', () => { + if (isDragSelecting) { + isDragSelecting = false; + stopAutoScroll(); + window.removeEventListener('mousemove', onWindowMouseMove); + if (currentMode === 'extract') updatePreview(getSelectedNames()); + else updateModifyPreview(getSelectedNames()); + updateButtons(); + } + viewState.isDragging = false; +}); + +function toggleIndex(index) { + if (selectedIndices.has(index)) selectedIndices.delete(index); + else selectedIndices.add(index); +} + +function selectRange(start, end, keepExisting) { + if (!keepExisting) selectedIndices.clear(); + for (let i = start; i <= end; i++) selectedIndices.add(i); +} + +function renderSelection() { + document.querySelectorAll('.region-item').forEach((el, idx) => { + el.classList.toggle('selected', selectedIndices.has(idx)); + }); +} + +const previewContainer = document.getElementById('preview-container'); +const previewImg = document.getElementById('preview-img'); + +function resetPreview() { + viewState = { scale: 1, x: 0, y: 0, isDragging: false, startX: 0, startY: 0 }; + applyTransform(); +} + +function applyTransform() { + previewImg.style.transform = `translate(calc(-50% + ${viewState.x}px), calc(-50% + ${viewState.y}px)) scale(${viewState.scale})`; + if (currentMode === 'modify') drawRegionOverlay(); +} + +// ─── Region Overlay ─────────────────────────────────────────────────────────── +function drawRegionOverlay() { + const canvas = document.getElementById('region-overlay'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const containerW = previewContainer.clientWidth; + const containerH = previewContainer.clientHeight; + canvas.width = containerW * dpr; canvas.height = containerH * dpr; + canvas.style.width = containerW + 'px'; canvas.style.height = containerH + 'px'; + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, containerW, containerH); + if (currentMode !== 'modify' || selectedIndices.size === 0) return; + + const imgW = previewImg.naturalWidth, imgH = previewImg.naturalHeight; + if (!imgW || !imgH) return; + + const scale = viewState.scale; + const centerX = containerW / 2 + viewState.x; + const centerY = containerH / 2 + viewState.y; + const topLeftX = centerX - imgW * scale / 2; + const topLeftY = centerY - imgH * scale / 2; + const lineWidth = 3; + + for (const name of getSelectedNames()) { + const bounds = modifyRegionBounds[name]; + if (!bounds) continue; + const [bx, by, bw, bh, rotate] = bounds; + const isRotated = rotate === 90 || rotate === 270; + const drawW = isRotated ? bh : bw; + const drawH = isRotated ? bw : bh; + const rx = topLeftX + bx * scale; + const ry = topLeftY + by * scale; + const rw = drawW * scale; + const rh = drawH * scale; + + ctx.strokeStyle = 'rgba(255, 60, 60, 0.85)'; + ctx.lineWidth = lineWidth; + ctx.strokeRect(rx - lineWidth / 2, ry - lineWidth / 2, rw + lineWidth, rh + lineWidth); + + const fontSize = 13; + ctx.font = `bold ${fontSize}px "Segoe UI", sans-serif`; + const textW = ctx.measureText(name).width; + const labelX = rx; + const labelY = ry - lineWidth - 2; + ctx.fillStyle = 'rgba(255, 60, 60, 0.85)'; + ctx.fillRect(labelX - 1, labelY - fontSize, textW + 8, fontSize + 4); + ctx.fillStyle = 'white'; + ctx.fillText(name, labelX + 3, labelY - 1); + } +} + +function clearOverlay() { + const canvas = document.getElementById('region-overlay'); + if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); +} + +async function updatePreview(names) { + const status = document.getElementById('status-text'); + if (!names || names.length === 0) { + previewImg.style.display = 'none'; status.innerText = 'No selection'; return; + } + const base64Img = AtlasAPI.get_preview ? await AtlasAPI.get_preview(names) : null; + if (base64Img) { + previewImg.src = base64Img; + previewImg.style.display = 'block'; + status.innerText = names.length === 1 ? `Previewing: ${names[0]}` : `Previewing: ${names.length} regions`; + previewImg.onload = function () { + resetPreview(); + const containerW = previewContainer.clientWidth - 40; + const containerH = previewContainer.clientHeight - 40; + const imgW = previewImg.naturalWidth, imgH = previewImg.naturalHeight; + status.innerText = names.length === 1 + ? `Previewing: ${names[0]} (${imgW}x${imgH})` + : `Previewing: ${names.length} regions (${imgW}x${imgH})`; + if (imgW > containerW || imgH > containerH) { + viewState.scale = Math.min(containerW / imgW, containerH / imgH); + applyTransform(); + } + previewImg.onload = null; + }; + } else { + previewImg.style.display = 'none'; status.innerText = 'Preview failed'; + } +} + +// ─── Pan & Zoom ─────────────────────────────────────────────────────────────── +previewContainer.addEventListener('wheel', (e) => { + e.preventDefault(); + const direction = -Math.sign(e.deltaY); + const newScale = viewState.scale + direction * 0.1 * viewState.scale; + if (newScale > 0.1 && newScale < 50) { + const rect = previewContainer.getBoundingClientRect(); + const cx = rect.width / 2, cy = rect.height / 2; + const mx = e.clientX - rect.left - cx, my = e.clientY - rect.top - cy; + viewState.x = mx - (mx - viewState.x) * (newScale / viewState.scale); + viewState.y = my - (my - viewState.y) * (newScale / viewState.scale); + viewState.scale = newScale; + applyTransform(); + } +}); + +previewContainer.addEventListener('mousedown', (e) => { + if (e.button !== 0 && e.button !== 1) return; + e.preventDefault(); + viewState.isDragging = true; + viewState.startX = e.clientX - viewState.x; + viewState.startY = e.clientY - viewState.y; +}); + +window.addEventListener('mousemove', (e) => { + if (viewState.isDragging) { + viewState.x = e.clientX - viewState.startX; + viewState.y = e.clientY - viewState.startY; + applyTransform(); + } +}); + +// ─── Touch pan & pinch-to-zoom (Android) ───────────────────────────────────── +let _lastTouchDist = null; +let _lastTouchMid = null; + +previewContainer.addEventListener('touchstart', (e) => { + if (e.touches.length === 2) { + _lastTouchDist = Math.hypot( + e.touches[1].clientX - e.touches[0].clientX, + e.touches[1].clientY - e.touches[0].clientY + ); + _lastTouchMid = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2, + }; + } else if (e.touches.length === 1) { + viewState.isDragging = true; + viewState.startX = e.touches[0].clientX - viewState.x; + viewState.startY = e.touches[0].clientY - viewState.y; + } +}, { passive: true }); + +previewContainer.addEventListener('touchmove', (e) => { + if (e.touches.length === 2) { + const dist = Math.hypot( + e.touches[1].clientX - e.touches[0].clientX, + e.touches[1].clientY - e.touches[0].clientY + ); + if (_lastTouchDist) { + const factor = dist / _lastTouchDist; + const newScale = Math.min(50, Math.max(0.1, viewState.scale * factor)); + const rect = previewContainer.getBoundingClientRect(); + const mid = { x: (e.touches[0].clientX + e.touches[1].clientX) / 2, y: (e.touches[0].clientY + e.touches[1].clientY) / 2 }; + const cx = rect.width / 2, cy = rect.height / 2; + const mx = mid.x - rect.left - cx, my = mid.y - rect.top - cy; + viewState.x = mx - (mx - viewState.x) * (newScale / viewState.scale); + viewState.y = my - (my - viewState.y) * (newScale / viewState.scale); + viewState.scale = newScale; + applyTransform(); + } + _lastTouchDist = dist; + } else if (e.touches.length === 1 && viewState.isDragging) { + viewState.x = e.touches[0].clientX - viewState.startX; + viewState.y = e.touches[0].clientY - viewState.startY; + applyTransform(); + } +}, { passive: true }); + +previewContainer.addEventListener('touchend', () => { + _lastTouchDist = null; _lastTouchMid = null; viewState.isDragging = false; +}, { passive: true }); + +// ─── Buttons ────────────────────────────────────────────────────────────────── +function updateButtons() { + const btnSel = document.getElementById('btn-extract-sel'); + btnSel.disabled = selectedIndices.size === 0; + btnSel.innerText = `Extract Selected (${selectedIndices.size})`; + const btnModSel = document.getElementById('btn-modify-sel'); + if (btnModSel) { + btnModSel.disabled = selectedIndices.size === 0; + btnModSel.innerText = `Modify Selected (${selectedIndices.size})`; + } +} + +async function extractSelected() { + if (selectedIndices.size === 0) return; + const names = Array.from(selectedIndices).map(i => regionsData[i]); + document.getElementById('status-text').innerText = 'Extracting...'; + const result = await AtlasAPI.extract_files(names); + showToast(result, result.includes('Error') ? 'error' : 'success'); + document.getElementById('status-text').innerText = 'Ready'; +} + +async function extractAll() { + if (document.getElementById('count').innerText === '0') return; + const confirmed = await showConfirm('Are you sure you want to extract all regions?', 'Confirm Extraction'); + if (!confirmed) return; + document.getElementById('status-text').innerText = 'Extracting ALL...'; + const result = await AtlasAPI.extract_files(null); + showToast(result, result.includes('Error') ? 'error' : 'success'); + document.getElementById('status-text').innerText = 'Ready'; +} + +// ─── Modal ──────────────────────────────────────────────────────────────────── +function showConfirm(message, title = 'Confirm') { + return new Promise((resolve) => { + const overlay = document.getElementById('modal-overlay'); + document.getElementById('modal-title').innerText = title; + document.getElementById('modal-message').innerText = message; + overlay.classList.remove('hidden'); + const btnConfirm = document.getElementById('btn-modal-confirm'); + const btnCancel = document.getElementById('btn-modal-cancel'); + if (document.activeElement) document.activeElement.blur(); + btnConfirm.focus(); + function cleanup() { + overlay.classList.add('hidden'); + btnConfirm.removeEventListener('click', onConfirm); + btnCancel.removeEventListener('click', onCancel); + window.removeEventListener('keydown', onKey); + } + function onConfirm() { cleanup(); resolve(true); } + function onCancel() { cleanup(); resolve(false); } + function onKey(e) { if (e.key === 'Escape') onCancel(); } + btnConfirm.addEventListener('click', onConfirm); + btnCancel.addEventListener('click', onCancel); + window.addEventListener('keydown', onKey); + }); +} + +// ─── Toast ──────────────────────────────────────────────────────────────────── +window.showToast = function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerText = message; + container.appendChild(toast); + setTimeout(() => { + toast.style.animation = 'none'; + toast.offsetHeight; + toast.style.animation = 'fadeOut 0.5s ease-out forwards'; + toast.addEventListener('animationend', () => toast.remove()); + }, 3000); +}; + +// ─── Keyboard Navigation ────────────────────────────────────────────────────── +window.addEventListener('keydown', (e) => { + if (regionsData.length === 0) return; + if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return; + e.preventDefault(); + let newIndex = lastClickIndex; + if (e.key === 'ArrowDown') newIndex = Math.min(newIndex + 1, regionsData.length - 1); + else newIndex = Math.max(newIndex - 1, 0); + if (newIndex === lastClickIndex && selectedIndices.size > 0) return; + if (e.shiftKey) { + if (dragStartIndex === -1) dragStartIndex = lastClickIndex; + selectedIndices.clear(); + const start = Math.min(dragStartIndex, newIndex), end = Math.max(dragStartIndex, newIndex); + for (let i = start; i <= end; i++) selectedIndices.add(i); + } else { + selectedIndices.clear(); selectedIndices.add(newIndex); dragStartIndex = newIndex; + } + lastClickIndex = newIndex; + renderSelection(); + if (currentMode === 'extract') updatePreview(getSelectedNames()); + else updateModifyPreview(getSelectedNames()); + updateButtons(); + const item = document.querySelector(`.region-item[data-index="${newIndex}"]`); + if (item) item.scrollIntoView({ block: 'nearest' }); +}); + +// ─── Drag and Drop ──────────────────────────────────────────────────────────── +const dropOverlay = document.getElementById('drop-overlay'); + +['dragover', 'drop'].forEach(ev => window.addEventListener(ev, e => e.preventDefault(), false)); + +window.addEventListener('dragenter', (e) => { + e.preventDefault(); + if (e.dataTransfer.types.includes('Files')) { + dropOverlay.classList.remove('hidden'); + dropOverlay.style.pointerEvents = 'auto'; + } +}); + +dropOverlay.addEventListener('dragover', e => e.preventDefault()); + +dropOverlay.addEventListener('dragleave', (e) => { + e.preventDefault(); + if (e.relatedTarget === null || !dropOverlay.contains(e.relatedTarget)) { + dropOverlay.classList.add('hidden'); + dropOverlay.style.pointerEvents = 'none'; + } +}); + +dropOverlay.addEventListener('drop', async (e) => { + e.preventDefault(); + dropOverlay.classList.add('hidden'); + dropOverlay.style.pointerEvents = 'none'; + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const atlasFile = files.find(f => f.name.endsWith('.atlas')); + const imageExtensions = /\.(png|jpg|jpeg|webp|bmp|gif)$/i; + const imageFiles = {}; + for (const f of files) if (imageExtensions.test(f.name)) imageFiles[f.name] = f; + + if (atlasFile) { + const loaded = await _loadAtlasFiles(atlasFile, imageFiles); + if (loaded) { + if (currentMode === 'modify') { setMode('extract'); modifyRegionBounds = {}; hasModImage = false; } + selectedIndices.clear(); lastClickIndex = -1; + document.getElementById('preview-img').style.display = 'none'; + resetPreview(); clearOverlay(); updateButtons(); + await loadRegions(); + showToast('Atlas loaded via drag & drop.', 'success'); + } + } else { + // Image dropped in modify mode + const imgFile = files.find(f => imageExtensions.test(f.name)); + if (imgFile && currentMode === 'modify') { + const names = getSelectedNames(); + if (names.length === 0) { showToast('Select at least one region first.', 'error'); return; } + const repack = document.getElementById('chk-repack').checked; + const result = await AtlasAPI.process_mod_image(imgFile, names, repack); + if (result) { + onModPreviewReceived(result); + showToast('Mod image loaded via drag & drop.', 'success'); + } + } else { + showToast('Enter Modify Mode first to drop images.', 'error'); + } + } +}); + +// ─── Context Menu ───────────────────────────────────────────────────────────── +const contextMenu = document.getElementById('context-menu'); + +previewContainer.addEventListener('contextmenu', (e) => { + e.preventDefault(); + if (currentMode !== 'extract') return; + if (previewImg.style.display === 'none' || !previewImg.src) return; + contextMenu.style.left = e.clientX + 'px'; + contextMenu.style.top = e.clientY + 'px'; + contextMenu.classList.remove('hidden'); +}); + +window.addEventListener('click', () => contextMenu.classList.add('hidden')); +window.addEventListener('keydown', (e) => { if (e.key === 'Escape') contextMenu.classList.add('hidden'); }); + +async function copyPreviewImage() { + contextMenu.classList.add('hidden'); + try { + const img = previewImg; + if (!img.naturalWidth) { showToast('No image to copy.', 'error'); return; } + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; + canvas.getContext('2d').drawImage(img, 0, 0); + const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); + if (!blob) { showToast('Failed to copy image.', 'error'); return; } + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); + showToast('Image copied to clipboard.', 'success'); + } catch (e) { + console.error(e); + showToast('Failed to copy image.', 'error'); + } +} + +// ─── URL helper (used internally) ──────────────────────────────────────────── +async function openExternalUrl(url) { + try { + const parsed = new URL(String(url)); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + showToast('Invalid URL.', 'error'); return; + } + const result = await AtlasAPI.open_url(parsed.toString()); + if (!result || !result.ok) showToast((result && result.error) || 'Failed to open URL.', 'error'); + } catch (err) { + console.error(err); + showToast('Invalid URL.', 'error'); + } +} + +// ─── Expose globals for inline onclick handlers in index.html ───────────────── +window.openFile = openFile; +window.enterModifyMode = enterModifyMode; +window.exitModifyMode = exitModifyMode; +window.modifySelected = modifySelected; +window.saveModified = saveModified; +window.extractSelected = extractSelected; +window.extractAll = extractAll; +window.copyPreviewImage = copyPreviewImage; +window.showToast = window.showToast; // already set above diff --git a/android-app/www/style.css b/android-app/www/style.css new file mode 100644 index 0000000..96a650d --- /dev/null +++ b/android-app/www/style.css @@ -0,0 +1,663 @@ +body { + margin: 0; + padding: 0; + font-family: "Segoe UI", sans-serif; + height: 100vh; + display: flex; + background-color: #2b2b2b; + color: #eee; + overflow: hidden; + user-select: none; +} + +/* Layout */ +#left-panel { + width: 300px; + min-width: 300px; + background-color: #1e1e1e; + border-right: 1px solid #444; + display: flex; + flex-direction: column; + position: relative; +} +#right-panel { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + background-color: #333; + position: relative; +} + +/* Header */ +.panel-header { + padding: 10px; + background-color: #252526; + font-weight: bold; + border-bottom: 1px solid #444; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 10; + min-height: 40px; + box-sizing: border-box; +} + +#normal-header, +#modify-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 8px; +} + +#normal-header > span { + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.panel-header-buttons { + display: flex; + gap: 6px; + flex-shrink: 0; +} + +/* --- BUTTON STYLES (Shared) --- */ +button { + height: 28px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: "Segoe UI", sans-serif; + font-size: 12px; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; + outline: none; + box-sizing: border-box; + transition: all 0.2s; +} + +/* Open / Back buttons (grey) */ +.btn-open { + background-color: #3c3c3c; + color: #ccc; + border-color: #555; +} +.btn-open:hover { + background-color: #4c4c4c; + color: white; + border-color: #666; +} +.btn-open:disabled { + background-color: #333; + color: #666; + cursor: not-allowed; + border-color: #444; +} + +/* Extract buttons (blue) */ +.action-btn { + background-color: #0e639c; + color: white; + white-space: nowrap; + flex-shrink: 0; +} +.action-btn:hover { + background-color: #1177bb; +} +.action-btn:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; + border-color: #444; +} + +/* Save button (green) */ +.btn-save { + background-color: #388e3c; + color: white; +} +.btn-save:hover { + background-color: #4caf50; +} +.btn-save:disabled { + background-color: #444; + color: #888; + cursor: not-allowed; + border-color: #444; +} + +/* Icon Style */ +.btn-icon { + width: 16px; + height: 16px; + fill: currentColor; +} + +/* List Area */ +#region-list-container { + flex: 1; + overflow-y: auto; +} +#region-list { + list-style: none; + padding: 0; + margin: 0; + padding-bottom: 20px; +} +.region-item { + padding: 6px 15px; + font-size: 13px; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; + user-select: none; +} +.region-item:hover { + background-color: #2a2d2e; +} + +/* Highlight Styles */ +.region-item.selected { + background-color: #094771; + color: white; +} + +/* Right Panel Controls */ +#controls { + padding: 10px; + background-color: #252526; + border-bottom: 1px solid #444; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; + align-items: center; + z-index: 10; + min-height: 40px; + box-sizing: border-box; +} + +#extract-controls, +#modify-controls { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + overflow: hidden; +} + +/* Repack Options Row */ +#repack-options { + width: 100%; + padding-top: 6px; + border-top: 1px solid #3a3a3a; + margin-top: 2px; +} + +.toggle-label { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: #bbb; + user-select: none; +} + +.toggle-label input[type="checkbox"] { + display: none; +} + +.toggle-switch { + position: relative; + width: 32px; + height: 18px; + background-color: #555; + border-radius: 9px; + transition: background-color 0.2s; + flex-shrink: 0; +} + +.toggle-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background-color: #ccc; + border-radius: 50%; + transition: + transform 0.2s, + background-color 0.2s; +} + +.toggle-label input:checked + .toggle-switch { + background-color: #0e639c; +} + +.toggle-label input:checked + .toggle-switch::after { + transform: translateX(14px); + background-color: white; +} + +.info-icon { + width: 14px; + height: 14px; + fill: #777; + flex-shrink: 0; + cursor: help; +} + +.toggle-label:hover .info-icon { + fill: #aaa; +} + +/* Preview Area */ +#preview-container { + flex: 1; + overflow: hidden; + background-image: conic-gradient( + #222 90deg, + #333 90deg 180deg, + #222 180deg 270deg, + #333 270deg + ); + background-size: 20px 20px; + position: relative; + cursor: grab; + display: flex; + align-items: center; + justify-content: center; +} +#preview-container:active { + cursor: grabbing; +} + +img#preview-img { + position: absolute; + top: 50%; + left: 50%; + max-width: none; + max-height: none; + border: 1px solid #555; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + display: none; + transform-origin: center center; + transition: transform 0.05s ease-out; + image-rendering: optimizeQuality; +} + +#region-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 5; +} + +#status-text, +#modify-status-text { + flex: 1; + min-width: 0; + color: #aaa; + font-size: 0.9em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Toast Notification */ +#toast-container { + position: fixed; + bottom: 20px; + left: 20px; + display: flex; + flex-direction: column; + gap: 10px; + z-index: 1000; +} +.toast { + background-color: #333; + color: #fff; + padding: 12px 20px; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-left: 4px solid #0e639c; /* Blue accent */ + font-size: 14px; + animation: slideIn 0.3s ease-out forwards; + opacity: 0; + transform: translateY(20px); + max-width: 300px; +} +.toast.success { + border-left-color: #4caf50; +} +.toast.error { + border-left-color: #f44336; +} + +@keyframes slideIn { + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes fadeOut { + to { + opacity: 0; + transform: translateY(-10px); + } +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 10px; + height: 10px; + background-color: #1e1e1e; +} +::-webkit-scrollbar-thumb { + background-color: #555; + border-radius: 5px; +} +::-webkit-scrollbar-thumb:hover { + background-color: #666; +} +::-webkit-scrollbar-corner { + background-color: #1e1e1e; +} + +/* Modal Styling */ +.hidden { + display: none !important; +} + +#modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 2000; + display: flex; + justify-content: center; + align-items: center; + backdrop-filter: blur(2px); +} + +#modal-box { + background-color: #252526; + padding: 24px; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + min-width: 320px; + max-width: 400px; + border: 1px solid #454545; + animation: modalPop 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); +} + +@keyframes modalPop { + from { + transform: scale(0.9); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + +#modal-title { + margin-top: 0; + margin-bottom: 10px; + color: #ddd; + font-size: 18px; +} + +#modal-message { + color: #bbb; + margin-bottom: 24px; + line-height: 1.5; +} + +.modal-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.btn-primary, +.btn-secondary { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-size: 14px; + font-weight: 500; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #0e639c; + color: white; +} +.btn-primary:hover { + background-color: #1177bb; +} +.btn-primary:active { + background-color: #094771; +} + +.btn-secondary { + background-color: #3c3c3c; + color: #ccc; + border: 1px solid #555; +} +.btn-secondary:hover { + background-color: #4c4c4c; + color: white; +} +.btn-secondary:active { + background-color: #2d2d2d; +} + +/* Context Menu */ +#context-menu { + position: fixed; + background-color: #2d2d2d; + border: 1px solid #454545; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + padding: 4px 0; + z-index: 4000; + min-width: 160px; +} + +.context-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + font-size: 13px; + color: #ccc; + cursor: pointer; + transition: background-color 0.1s; +} + +.context-menu-item:hover { + background-color: #094771; + color: white; +} + +.context-menu-item .btn-icon { + width: 14px; + height: 14px; +} + +/* Drop Overlay Styling */ +#drop-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(14, 99, 156, 0.85); + z-index: 3000; + display: flex; + justify-content: center; + align-items: center; + backdrop-filter: blur(8px); + pointer-events: none; +} + +#drop-overlay:not(.hidden) { + display: flex !important; + pointer-events: auto; +} + +.drop-message { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + color: white; + pointer-events: none; +} + +.drop-message svg { + width: 80px; + height: 80px; + fill: white; + animation: bounce 1s infinite alternate; +} + +@keyframes bounce { + from { + transform: translateY(0); + } + to { + transform: translateY(-15px); + } +} + +.drop-message span { + font-size: 24px; + font-weight: bold; +} + + +/* ========================================== + UPDATE NOTIFICATION BAR (Bottom of right panel) + ========================================== */ + +.toast-update { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background-color: #1e2a38; + border-top: 1px solid #2d6a9f; + font-size: 12px; + animation: slideUpBar 0.25s ease-out forwards; + flex-shrink: 0; +} + +@keyframes slideUpBar { + from { + opacity: 0; + transform: translateY(100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.toast-update-icon { + width: 16px; + height: 16px; + fill: #4fc3f7; + flex-shrink: 0; +} + +.toast-update-title { + font-weight: 600; + color: #e0f0ff; + white-space: nowrap; +} + +.toast-update-sub { + color: #7bafd4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} + +.toast-update-btn-go { + background-color: #0e639c; + color: white; + border: none; + border-radius: 3px; + padding: 0 10px; + height: 22px; + font-size: 11px; + font-family: "Segoe UI", sans-serif; + cursor: pointer; + transition: background-color 0.15s; + white-space: nowrap; + flex-shrink: 0; + margin-left: auto; +} +.toast-update-btn-go:hover { + background-color: #1177bb; +} + +.toast-update-btn-go:disabled { + background-color: #4a4a4a; + color: #999; + cursor: not-allowed; +} + +.toast-update-btn-close { + background: transparent; + color: #ff6464; + border: 1px solid #ff6464; + padding: 0 6px; + height: 22px; + font-size: 11px; + cursor: pointer; + border-radius: 3px; + transition: color 0.15s, background-color 0.15s; + white-space: nowrap; + flex-shrink: 0; +} +.toast-update-btn-close:hover { + color: #e0f0ff; + background-color: rgba(255, 255, 255, 0.08); +} + +.toast-update-error { + background-color: #3a1f1f; + border-top: 1px solid #a94442; +} + +.toast-update-error .toast-update-title { + color: #ffd6d6; +} + +.toast-update-error .toast-update-sub { + color: #ffb3b3; +} \ No newline at end of file From 3d603ae4c1fb7c872559c5a4bc3215482c17c390 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 05:51:18 +0000 Subject: [PATCH 2/5] fix: remove duplicate img.onload in _loadImage Agent-Logs-Url: https://github.com/com55/AtlasToolkit/sessions/63d32cf1-ee63-4c69-9e95-c2020b8ea60c Co-authored-by: com55 <33087300+com55@users.noreply.github.com> --- android-app/www/js/atlas-extracter.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/android-app/www/js/atlas-extracter.js b/android-app/www/js/atlas-extracter.js index 15f13dd..538b045 100644 --- a/android-app/www/js/atlas-extracter.js +++ b/android-app/www/js/atlas-extracter.js @@ -270,9 +270,7 @@ export function _loadImage(source) { img.onload = () => resolve(img); img.onerror = () => reject(new Error(`Failed to load image: ${source}`)); if (source instanceof File) { - const url = URL.createObjectURL(source); - img.onload = () => { resolve(img); }; - img.src = url; + img.src = URL.createObjectURL(source); } else { img.src = source; } From 31319f187b56516204d579ca371c91527863f66c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 06:05:29 +0000 Subject: [PATCH 3/5] feat: add Android APK build CI workflow and local build script Agent-Logs-Url: https://github.com/com55/AtlasToolkit/sessions/c814f5cd-bf6f-4d15-8dad-0382a5ca3583 Co-authored-by: com55 <33087300+com55@users.noreply.github.com> --- .github/workflows/auto_tag.yml | 10 +- .github/workflows/build_android.yml | 199 ++++++++++++++++++++++++++++ android-app/README.md | 61 ++++++--- android-app/build-apk.sh | 169 +++++++++++++++++++++++ 4 files changed, 420 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build_android.yml create mode 100755 android-app/build-apk.sh diff --git a/.github/workflows/auto_tag.yml b/.github/workflows/auto_tag.yml index 67a39b0..68c53ca 100644 --- a/.github/workflows/auto_tag.yml +++ b/.github/workflows/auto_tag.yml @@ -57,10 +57,18 @@ jobs: should_build: ${{ steps.check.outputs.should_create }} tag_name: ${{ steps.create_tag.outputs.tag_name }} - # Trigger build and release after tag is created + # Trigger Windows build and release after tag is created build-release: needs: create-tag if: needs.create-tag.outputs.should_build == 'true' uses: ./.github/workflows/build_release.yml with: tag_name: ${{ needs.create-tag.outputs.tag_name }} + + # Trigger Android APK build and release after tag is created + build-android: + needs: create-tag + if: needs.create-tag.outputs.should_build == 'true' + uses: ./.github/workflows/build_android.yml + with: + tag_name: ${{ needs.create-tag.outputs.tag_name }} diff --git a/.github/workflows/build_android.yml b/.github/workflows/build_android.yml new file mode 100644 index 0000000..45e6b3b --- /dev/null +++ b/.github/workflows/build_android.yml @@ -0,0 +1,199 @@ +name: Build Android APK + +on: + # 1. AUTO RELEASE TRIGGER (from auto_tag.yml) + workflow_call: + inputs: + tag_name: + required: true + type: string + # 2. MANUAL TRIGGER (tag push) + push: + tags: + - "v*" + # 3. MANUAL TRIGGER (workflow_dispatch) + workflow_dispatch: + inputs: + test_version: + description: "Optional test version (digits only, e.g. 1.2.3)" + required: false + type: string + +permissions: + contents: write + +env: + MANUAL_TEST_VERSION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.test_version || '' }} + IS_RELEASE: ${{ inputs.tag_name != '' || startsWith(github.ref, 'refs/tags/v') }} + TAG_NAME: ${{ inputs.tag_name != '' && inputs.tag_name || (startsWith(github.ref, 'refs/tags/v') && github.ref_name) || '' }} + +jobs: + build-android: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag_name || github.ref }} + + # ── Java 17 (required by Gradle / Android build tools) ─────────────── + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + # ── Node.js 18 (Capacitor CLI) ──────────────────────────────────────── + - name: Set up Node.js 18 + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: npm + cache-dependency-path: android-app/package-lock.json + + # ── Android SDK ─────────────────────────────────────────────────────── + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + # ── Determine version ───────────────────────────────────────────────── + - name: Determine build version + id: build_version + shell: bash + run: | + if [ -n "${{ inputs.tag_name }}" ]; then + VERSION="${{ inputs.tag_name }}" + VERSION="${VERSION#v}" + elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + elif [ -n "${{ env.MANUAL_TEST_VERSION }}" ]; then + VERSION="${{ env.MANUAL_TEST_VERSION }}" + else + VERSION=$(grep -oP '(?<=^version = ")[^"]+' pyproject.toml || echo "0.0.0") + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building APK version: $VERSION" + + # ── Install Capacitor dependencies ──────────────────────────────────── + - name: Install npm dependencies + working-directory: android-app + run: npm ci --prefer-offline || npm install + + # ── Add Android platform (generates android/ Gradle project) ───────── + - name: Add Capacitor Android platform + working-directory: android-app + run: npx cap add android + + # ── Sync web assets into the native project ─────────────────────────── + - name: Sync Capacitor assets + working-directory: android-app + run: npx cap sync android --no-deps + + # ── Inject version into Android project ─────────────────────────────── + - name: Set versionName in build.gradle + working-directory: android-app/android + run: | + VERSION="${{ steps.build_version.outputs.version }}" + sed -i "s/versionName \"[^\"]*\"/versionName \"$VERSION\"/" app/build.gradle + echo "Set versionName to: $VERSION" + + # ── Build debug APK (always) ────────────────────────────────────────── + - name: Build debug APK + working-directory: android-app/android + run: ./gradlew assembleDebug --no-daemon + + # ── Build release APK (only on release/tag triggers) ───────────────── + - name: Build release APK (unsigned) + if: ${{ env.IS_RELEASE == 'true' }} + working-directory: android-app/android + run: ./gradlew assembleRelease --no-daemon + + # ── Sign release APK if keystore secrets are available ──────────────── + - name: Check keystore secrets + if: ${{ env.IS_RELEASE == 'true' }} + id: check_keystore + shell: bash + run: | + if [ -n "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" ] && \ + [ -n "${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" ] && \ + [ -n "${{ secrets.ANDROID_KEY_ALIAS }}" ] && \ + [ -n "${{ secrets.ANDROID_KEY_PASSWORD }}" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "⚠️ Keystore secrets not set — release APK will be unsigned." + fi + + - name: Sign release APK + if: ${{ env.IS_RELEASE == 'true' && steps.check_keystore.outputs.available == 'true' }} + shell: bash + env: + KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + KEYSTORE_PATH="$RUNNER_TEMP/release.keystore" + echo "$KEYSTORE_B64" | base64 --decode > "$KEYSTORE_PATH" + + UNSIGNED_APK="android-app/android/app/build/outputs/apk/release/app-release-unsigned.apk" + SIGNED_APK="android-app/android/app/build/outputs/apk/release/app-release-signed.apk" + + # Align + zipalign -v -p 4 "$UNSIGNED_APK" "$SIGNED_APK" + + # Sign with apksigner + apksigner sign \ + --ks "$KEYSTORE_PATH" \ + --ks-pass "pass:$KEYSTORE_PASS" \ + --ks-key-alias "$KEY_ALIAS" \ + --key-pass "pass:$KEY_PASS" \ + --out "$SIGNED_APK" \ + "$SIGNED_APK" + + apksigner verify "$SIGNED_APK" + rm -f "$KEYSTORE_PATH" + echo "APK signed successfully: $SIGNED_APK" + + # ── Rename and stage artifacts ──────────────────────────────────────── + - name: Stage APK artifacts + id: stage + shell: bash + run: | + VERSION="${{ steps.build_version.outputs.version }}" + DEST="apk-output" + mkdir -p "$DEST" + + DEBUG_SRC="android-app/android/app/build/outputs/apk/debug/app-debug.apk" + DEBUG_DEST="$DEST/AtlasToolkit-Android-debug-v${VERSION}.apk" + cp "$DEBUG_SRC" "$DEBUG_DEST" + echo "debug_apk=$DEBUG_DEST" >> "$GITHUB_OUTPUT" + + if [ "${{ env.IS_RELEASE }}" == "true" ]; then + # Prefer signed, fall back to unsigned + if [ -f "android-app/android/app/build/outputs/apk/release/app-release-signed.apk" ]; then + REL_SRC="android-app/android/app/build/outputs/apk/release/app-release-signed.apk" + else + REL_SRC="android-app/android/app/build/outputs/apk/release/app-release-unsigned.apk" + fi + REL_DEST="$DEST/AtlasToolkit-Android-v${VERSION}.apk" + cp "$REL_SRC" "$REL_DEST" + echo "release_apk=$REL_DEST" >> "$GITHUB_OUTPUT" + fi + + # ── Upload debug APK as artifact (always) ──────────────────────────── + - name: Upload debug APK artifact + uses: actions/upload-artifact@v4 + with: + name: AtlasToolkit-Android-debug-v${{ steps.build_version.outputs.version }} + path: ${{ steps.stage.outputs.debug_apk }} + + # ── Upload release APK to GitHub Release ───────────────────────────── + - name: Upload release APK to GitHub Release + if: ${{ env.IS_RELEASE == 'true' && steps.stage.outputs.release_apk != '' }} + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ env.TAG_NAME }} + target_commitish: ${{ github.sha }} + files: ${{ steps.stage.outputs.release_apk }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/android-app/README.md b/android-app/README.md index da07476..e4caf53 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -43,42 +43,67 @@ android-app/ ## Build Instructions -### 1. Install dependencies +### Option A — Local build script ```bash cd android-app -npm install -``` -### 2. Add the Android platform +# Debug APK (fastest, no signing needed) +./build-apk.sh -```bash -npx cap add android +# Unsigned release APK +./build-apk.sh --release + +# Signed release APK (set env vars first) +export ANDROID_KEYSTORE_PATH=/path/to/release.keystore +export ANDROID_KEYSTORE_PASSWORD=yourKeystorePassword +export ANDROID_KEY_ALIAS=yourKeyAlias +export ANDROID_KEY_PASSWORD=yourKeyPassword +./build-apk.sh --release --sign ``` -This generates the `android/` folder with a native Android project. +Output APKs are written to: +- Debug: `android/app/build/outputs/apk/debug/app-debug.apk` +- Release: `android/app/build/outputs/apk/release/app-release-{unsigned,signed}.apk` -### 3. Sync web assets into the native project +### Option B — GitHub Actions CI -```bash -npx cap sync android -``` +The workflow `.github/workflows/build_android.yml` runs automatically on: + +| Trigger | What happens | +|---|---| +| Push to a `v*` tag | Builds debug + release APK, uploads release APK to GitHub Release | +| `auto_tag.yml` creates a tag | Same as above (called as reusable workflow) | +| `workflow_dispatch` | Builds debug APK and uploads as an artifact | -Run this command every time you modify files inside `www/`. +#### Keystore secrets (for signed release APKs in CI) -### 4. Open in Android Studio +Add these secrets to your GitHub repository (**Settings → Secrets and variables → Actions**): + +| Secret | Description | +|---|---| +| `ANDROID_KEYSTORE_BASE64` | Base64-encoded `.jks` / `.keystore` file (`base64 -w0 release.keystore`) | +| `ANDROID_KEYSTORE_PASSWORD` | Keystore password | +| `ANDROID_KEY_ALIAS` | Key alias inside the keystore | +| `ANDROID_KEY_PASSWORD` | Key password | + +If these secrets are absent, the CI still builds and uploads an **unsigned** release APK. + +### Option C — Android Studio (manual) ```bash -npx cap open android +cd android-app +npm install +npx cap add android # only once +npx cap sync android # run after every www/ change +npx cap open android # opens in Android Studio ``` In Android Studio: - Select **Build → Build Bundle(s) / APK(s) → Build APK(s)** -- The signed/debug APK will be in `android/app/build/outputs/apk/debug/` - -### 5. Run on device / emulator +- The debug APK will be at `android/app/build/outputs/apk/debug/app-debug.apk` -In Android Studio press the **▶ Run** button, or from the CLI: +### Option D — Run on device / emulator ```bash npx cap run android diff --git a/android-app/build-apk.sh b/android-app/build-apk.sh new file mode 100755 index 0000000..5bd10cc --- /dev/null +++ b/android-app/build-apk.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# build-apk.sh — Local build script for AtlasToolkit Android APK +# +# Usage: +# ./build-apk.sh # debug build (default) +# ./build-apk.sh --release # unsigned release build +# ./build-apk.sh --release --sign # signed release build (requires keystore env vars) +# +# Environment variables for signing (--sign flag): +# ANDROID_KEYSTORE_PATH Path to your .keystore / .jks file +# ANDROID_KEYSTORE_PASSWORD Keystore password +# ANDROID_KEY_ALIAS Key alias +# ANDROID_KEY_PASSWORD Key password +# +# Prerequisites: +# - Node.js >= 18 and npm >= 9 +# - Java 17 (JAVA_HOME must be set, or java/javac must be on PATH) +# - Android SDK (ANDROID_HOME or ANDROID_SDK_ROOT must be set) +# Alternatively, Android Studio installs it at ~/Android/Sdk (Linux/macOS) +# or %LOCALAPPDATA%\Android\Sdk (Windows/WSL) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Parse arguments ────────────────────────────────────────────────────────── +BUILD_TYPE="debug" +SIGN=false + +for arg in "$@"; do + case "$arg" in + --release) BUILD_TYPE="release" ;; + --sign) SIGN=true ;; + --help|-h) + sed -n '2,30p' "$0" | sed 's/^# //' | sed 's/^#//' + exit 0 + ;; + *) echo "Unknown argument: $arg" >&2; exit 1 ;; + esac +done + +echo "=== AtlasToolkit Android Build ===" +echo "Build type : $BUILD_TYPE" +echo "Signing : $SIGN" +echo + +# ── Verify prerequisites ───────────────────────────────────────────────────── +command -v node >/dev/null 2>&1 || { echo "❌ Node.js not found. Install Node.js >= 18."; exit 1; } +command -v npm >/dev/null 2>&1 || { echo "❌ npm not found."; exit 1; } +command -v java >/dev/null 2>&1 || { echo "❌ Java not found. Install Java 17."; exit 1; } + +NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/') +if [ "$NODE_MAJOR" -lt 18 ]; then + echo "❌ Node.js >= 18 required (found $(node --version))"; exit 1 +fi + +# Detect Android SDK +if [ -z "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + GUESSES=( + "$HOME/Android/Sdk" + "$HOME/Library/Android/sdk" + "/usr/local/lib/android/sdk" + ) + for g in "${GUESSES[@]}"; do + if [ -d "$g" ]; then + export ANDROID_HOME="$g" + echo "ℹ️ Auto-detected ANDROID_HOME=$ANDROID_HOME" + break + fi + done +fi + +if [ -z "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then + echo "❌ Android SDK not found." + echo " Set ANDROID_HOME or ANDROID_SDK_ROOT, or install Android Studio." + exit 1 +fi + +echo "✔ Node.js $(node --version)" +echo "✔ Java $(java -version 2>&1 | head -1)" +echo "✔ Android SDK: ${ANDROID_HOME:-${ANDROID_SDK_ROOT}}" +echo + +# ── Install npm dependencies ───────────────────────────────────────────────── +echo "📦 Installing npm dependencies..." +cd "$SCRIPT_DIR" +if [ -f package-lock.json ]; then + npm ci +else + npm install +fi +echo + +# ── Add Android platform if not already present ─────────────────────────────── +if [ ! -d "$SCRIPT_DIR/android" ]; then + echo "➕ Adding Capacitor Android platform..." + npx cap add android + echo +fi + +# ── Sync web assets ─────────────────────────────────────────────────────────── +echo "🔄 Syncing Capacitor assets..." +npx cap sync android --no-deps +echo + +# ── Build with Gradle ──────────────────────────────────────────────────────── +GRADLE="$SCRIPT_DIR/android/gradlew" +chmod +x "$GRADLE" + +if [ "$BUILD_TYPE" = "release" ]; then + GRADLE_TASK="assembleRelease" +else + GRADLE_TASK="assembleDebug" +fi + +echo "🏗️ Running Gradle task: $GRADLE_TASK ..." +cd "$SCRIPT_DIR/android" +"$GRADLE" "$GRADLE_TASK" --no-daemon +echo + +# ── Signing (release only) ─────────────────────────────────────────────────── +if [ "$BUILD_TYPE" = "release" ] && [ "$SIGN" = "true" ]; then + echo "🔏 Signing release APK..." + + : "${ANDROID_KEYSTORE_PATH:? ❌ Set ANDROID_KEYSTORE_PATH}" + : "${ANDROID_KEYSTORE_PASSWORD:? ❌ Set ANDROID_KEYSTORE_PASSWORD}" + : "${ANDROID_KEY_ALIAS:? ❌ Set ANDROID_KEY_ALIAS}" + : "${ANDROID_KEY_PASSWORD:? ❌ Set ANDROID_KEY_PASSWORD}" + + UNSIGNED="$SCRIPT_DIR/android/app/build/outputs/apk/release/app-release-unsigned.apk" + ALIGNED="$SCRIPT_DIR/android/app/build/outputs/apk/release/app-release-aligned.apk" + SIGNED="$SCRIPT_DIR/android/app/build/outputs/apk/release/app-release-signed.apk" + + # Find zipalign and apksigner from the Android SDK + SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT}}" + ZIPALIGN=$(find "$SDK_ROOT/build-tools" -name zipalign | sort -V | tail -1) + APKSIGNER=$(find "$SDK_ROOT/build-tools" -name apksigner | sort -V | tail -1) + + if [ -z "$ZIPALIGN" ] || [ -z "$APKSIGNER" ]; then + echo "❌ zipalign or apksigner not found in Android SDK build-tools." + echo " Install build-tools via Android Studio SDK Manager." + exit 1 + fi + + "$ZIPALIGN" -v -p 4 "$UNSIGNED" "$ALIGNED" + "$APKSIGNER" sign \ + --ks "$ANDROID_KEYSTORE_PATH" \ + --ks-pass "pass:$ANDROID_KEYSTORE_PASSWORD" \ + --ks-key-alias "$ANDROID_KEY_ALIAS" \ + --key-pass "pass:$ANDROID_KEY_PASSWORD" \ + --out "$SIGNED" \ + "$ALIGNED" + "$APKSIGNER" verify "$SIGNED" + rm -f "$ALIGNED" + echo "✔ Signed APK: $SIGNED" +fi + +# ── Report output ──────────────────────────────────────────────────────────── +echo +echo "=== Build complete ===" +if [ "$BUILD_TYPE" = "release" ]; then + OUT_DIR="$SCRIPT_DIR/android/app/build/outputs/apk/release" + echo "📱 Release APK(s):" + ls -lh "$OUT_DIR"/*.apk 2>/dev/null || true +else + OUT_DIR="$SCRIPT_DIR/android/app/build/outputs/apk/debug" + echo "📱 Debug APK:" + ls -lh "$OUT_DIR"/*.apk 2>/dev/null || true +fi From a8b1eaf71ffbf86391a15ac05248d2cf457c870e Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:44:14 +0000 Subject: [PATCH 4/5] move web assets to root www and symlink android-app/www --- android-app/README.md | 33 +- android-app/build-apk.sh | 42 +- android-app/package-lock.json | 1185 +++++++++++++++++ android-app/setup_envroment.sh | 152 +++ android-app/www | 1 + {android-app/www => www}/index.html | 0 {android-app/www => www}/js/atlas-api.js | 0 .../www => www}/js/atlas-converter.js | 0 .../www => www}/js/atlas-extracter.js | 0 {android-app/www => www}/js/atlas-modifier.js | 0 {android-app/www => www}/script.js | 0 {android-app/www => www}/style.css | 0 12 files changed, 1401 insertions(+), 12 deletions(-) create mode 100644 android-app/package-lock.json create mode 100755 android-app/setup_envroment.sh create mode 120000 android-app/www rename {android-app/www => www}/index.html (100%) rename {android-app/www => www}/js/atlas-api.js (100%) rename {android-app/www => www}/js/atlas-converter.js (100%) rename {android-app/www => www}/js/atlas-extracter.js (100%) rename {android-app/www => www}/js/atlas-modifier.js (100%) rename {android-app/www => www}/script.js (100%) rename {android-app/www => www}/style.css (100%) diff --git a/android-app/README.md b/android-app/README.md index e4caf53..ac81126 100644 --- a/android-app/README.md +++ b/android-app/README.md @@ -6,8 +6,8 @@ The Python/pywebview desktop backend has been fully rewritten in JavaScript usin ## Architecture ``` -android-app/ -├── www/ ← Web assets (served by Capacitor) +AtlasToolkit/ +├── www/ ← Web assets (source of truth) │ ├── index.html ← App shell (same UI, ES-module script) │ ├── style.css ← Unchanged from desktop version │ ├── script.js ← UI logic (pywebview.api → AtlasAPI) @@ -16,8 +16,10 @@ android-app/ │ ├── atlas-extracter.js ← Port of atlas_extracter.py (Canvas API) │ ├── atlas-modifier.js ← Port of atlas_modifier.py (Canvas API) │ └── atlas-api.js ← Drop-in replacement for pywebview.api -├── package.json -└── capacitor.config.json +└── android-app/ + ├── www -> ../www ← symlink used by Capacitor + ├── package.json + └── capacitor.config.json ``` ### What changed vs the desktop app @@ -36,7 +38,8 @@ android-app/ - **Node.js** ≥ 18 and **npm** ≥ 9 - **Android Studio** (latest stable) with Android SDK ≥ 24 -- **Java 17** (required by Gradle) +- **Python virtual environment (venv)** for local scripts +- **Java 21** (recommended; Java 17 may work in some setups) - Android device or emulator (API 24+) --- @@ -48,12 +51,24 @@ android-app/ ```bash cd android-app +# 1) Activate venv first (required) +python -m venv .venv +source .venv/bin/activate + +# 2) Prepare environment (venv-only script) +source ./setup_envroment.sh +# or bootstrap dependencies on a clean machine: +# source ./setup_envroment.sh --install-all + # Debug APK (fastest, no signing needed) ./build-apk.sh # Unsigned release APK ./build-apk.sh --release +# One-shot bootstrap + build (still requires active venv) +# ./build-apk.sh --install-all --release + # Signed release APK (set env vars first) export ANDROID_KEYSTORE_PATH=/path/to/release.keystore export ANDROID_KEYSTORE_PASSWORD=yourKeystorePassword @@ -62,6 +77,11 @@ export ANDROID_KEY_PASSWORD=yourKeyPassword ./build-apk.sh --release --sign ``` +Notes: +- `setup_envroment.sh` must be sourced, not executed. +- `build-apk.sh` auto-sources `setup_envroment.sh` and will fail if no active venv is detected. +- If your Java version is outside 17-21, the build script will stop with a clear message. + Output APKs are written to: - Debug: `android/app/build/outputs/apk/debug/app-debug.apk` - Release: `android/app/build/outputs/apk/release/app-release-{unsigned,signed}.apk` @@ -139,7 +159,8 @@ For Android 13+ (API 33), `READ_MEDIA_IMAGES` is needed instead of `READ_EXTERNA The app works in a regular browser too (no native features required for core functionality): ```bash -# Serve www/ with any static file server, e.g.: +# Serve root www/ with any static file server, e.g.: +cd .. npx serve www # then open http://localhost:3000 ``` diff --git a/android-app/build-apk.sh b/android-app/build-apk.sh index 5bd10cc..a0885d2 100755 --- a/android-app/build-apk.sh +++ b/android-app/build-apk.sh @@ -5,6 +5,7 @@ # ./build-apk.sh # debug build (default) # ./build-apk.sh --release # unsigned release build # ./build-apk.sh --release --sign # signed release build (requires keystore env vars) +# ./build-apk.sh --install-all # install Java/SDK deps via setup script before build # # Environment variables for signing (--sign flag): # ANDROID_KEYSTORE_PATH Path to your .keystore / .jks file @@ -14,10 +15,10 @@ # # Prerequisites: # - Node.js >= 18 and npm >= 9 -# - Java 17 (JAVA_HOME must be set, or java/javac must be on PATH) -# - Android SDK (ANDROID_HOME or ANDROID_SDK_ROOT must be set) -# Alternatively, Android Studio installs it at ~/Android/Sdk (Linux/macOS) -# or %LOCALAPPDATA%\Android\Sdk (Windows/WSL) +# - Active Python virtual environment (VIRTUAL_ENV set) +# - Java 21 (preferred) or Java 17 +# - Android SDK +# - setup_envroment.sh (auto-sourced by this script) set -euo pipefail @@ -26,11 +27,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ── Parse arguments ────────────────────────────────────────────────────────── BUILD_TYPE="debug" SIGN=false +INSTALL_ALL=false for arg in "$@"; do case "$arg" in --release) BUILD_TYPE="release" ;; --sign) SIGN=true ;; + --install-all) INSTALL_ALL=true ;; --help|-h) sed -n '2,30p' "$0" | sed 's/^# //' | sed 's/^#//' exit 0 @@ -44,16 +47,39 @@ echo "Build type : $BUILD_TYPE" echo "Signing : $SIGN" echo +# ── Bootstrap shell environment (venv-only) ──────────────────────────────── +SETUP_SCRIPT="$SCRIPT_DIR/setup_envroment.sh" +if [ ! -f "$SETUP_SCRIPT" ]; then + echo "❌ setup_envroment.sh not found at $SETUP_SCRIPT" + exit 1 +fi + +if [ "$INSTALL_ALL" = "true" ]; then + # shellcheck source=/dev/null + source "$SETUP_SCRIPT" --install-all || exit 1 +else + # shellcheck source=/dev/null + source "$SETUP_SCRIPT" -- || exit 1 +fi +echo + # ── Verify prerequisites ───────────────────────────────────────────────────── command -v node >/dev/null 2>&1 || { echo "❌ Node.js not found. Install Node.js >= 18."; exit 1; } command -v npm >/dev/null 2>&1 || { echo "❌ npm not found."; exit 1; } -command -v java >/dev/null 2>&1 || { echo "❌ Java not found. Install Java 17."; exit 1; } +command -v java >/dev/null 2>&1 || { echo "❌ Java not found. Install Java 21 or 17."; exit 1; } NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/') if [ "$NODE_MAJOR" -lt 18 ]; then echo "❌ Node.js >= 18 required (found $(node --version))"; exit 1 fi +JAVA_MAJOR=$(java -version 2>&1 | sed -n '1s/.*version "\([0-9][0-9]*\).*/\1/p') +if [ -z "$JAVA_MAJOR" ] || [ "$JAVA_MAJOR" -lt 17 ] || [ "$JAVA_MAJOR" -gt 21 ]; then + echo "❌ Java 17-21 required (found $(java -version 2>&1 | head -1))" + echo " Tip: source ./setup_envroment.sh --install-java" + exit 1 +fi + # Detect Android SDK if [ -z "${ANDROID_HOME:-}" ] && [ -z "${ANDROID_SDK_ROOT:-}" ]; then GUESSES=( @@ -100,7 +126,11 @@ fi # ── Sync web assets ─────────────────────────────────────────────────────────── echo "🔄 Syncing Capacitor assets..." -npx cap sync android --no-deps +if npx cap sync android --help 2>&1 | grep -q -- '--no-deps'; then + npx cap sync android --no-deps +else + npx cap sync android +fi echo # ── Build with Gradle ──────────────────────────────────────────────────────── diff --git a/android-app/package-lock.json b/android-app/package-lock.json new file mode 100644 index 0000000..c134d93 --- /dev/null +++ b/android-app/package-lock.json @@ -0,0 +1,1185 @@ +{ + "name": "atlastoolkit-android", + "version": "0.2.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atlastoolkit-android", + "version": "0.2.2", + "dependencies": { + "@capacitor/android": "^7.0.0", + "@capacitor/core": "^7.0.0", + "@capacitor/filesystem": "^7.0.0" + }, + "devDependencies": { + "@capacitor/cli": "^7.0.0" + } + }, + "node_modules/@capacitor/android": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-7.6.1.tgz", + "integrity": "sha512-wjK2FloJSp5eVqy/DecRA4zBuGhe/pY8pkkU5+G1mfBFqrmmuXJJIBdKgd2/iqyWhsp89LRZMmHV8EEDXYPqPg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^7.6.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.6.1.tgz", + "integrity": "sha512-MdmelaYbwWldKlNxiLOhfHV8F8hoM6bIHPRB/+k81FTQoOqq3HYIdHVMZVl7s2800ubNnr4lSP3FevxU20lDew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.6.1.tgz", + "integrity": "sha512-nsNouCMxgYenyemy20sZwZYMtFi93LSZVWm2KqHTYIPIDgwx24+PzwHIdRQBZdK7hpvD5jQEhWuo/QyLLnAyBQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/filesystem": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-7.1.8.tgz", + "integrity": "sha512-Qpw/2SE4/CzqAUvGgSM9hw/uXQ5qoOaF4wxbToXwpAaKPS+tzletS1h5ti3jjLmGcqizTs2sEXMtcsARW/Ceew==", + "license": "MIT", + "dependencies": { + "@capacitor/synapse": "^1.0.3" + }, + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/synapse": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz", + "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==", + "license": "ISC" + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz", + "integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/android-app/setup_envroment.sh b/android-app/setup_envroment.sh new file mode 100755 index 0000000..c3e6dbe --- /dev/null +++ b/android-app/setup_envroment.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# setup_envroment.sh +# +# Prepare shell environment for Android builds in this repo. +# This script is intended for GitHub Codespaces-style environments. +# +# IMPORTANT: +# - Must be run from an active Python virtual environment. +# - Must be sourced (not executed) so exported vars affect your current shell. +# +# Usage: +# source ./setup_envroment.sh +# source ./setup_envroment.sh --install-sdk +# source ./setup_envroment.sh --install-java +# source ./setup_envroment.sh --install-all +# +# Optional: +# --install-sdk Install Android SDK cmdline-tools + required packages if missing. +# --install-java Install OpenJDK 21 if missing (apt-based environments). +# --install-all Install both Java and Android SDK dependencies. + +set -euo pipefail + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "❌ Please source this script, do not execute it directly." + echo " Use: source ./setup_envroment.sh" + exit 1 +fi + +if [[ -z "${VIRTUAL_ENV:-}" ]]; then + echo "❌ This script is allowed only inside an active Python virtual environment." + echo " Activate venv first, then run: source ./setup_envroment.sh" + return 1 +fi + +INSTALL_SDK=false +INSTALL_JAVA=false +for arg in "$@"; do + case "$arg" in + --) break ;; + --install-sdk) INSTALL_SDK=true ;; + --install-java) INSTALL_JAVA=true ;; + --install-all) + INSTALL_JAVA=true + INSTALL_SDK=true + ;; + --help|-h) + sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# //' | sed 's/^#//' + return 0 + ;; + *) + echo "❌ Unknown argument: $arg" + return 1 + ;; + esac +done + +export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}" +export ANDROID_HOME="$ANDROID_SDK_ROOT" + +if [[ -d "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" ]]; then + export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$PATH" +fi +if [[ -d "$ANDROID_SDK_ROOT/platform-tools" ]]; then + export PATH="$ANDROID_SDK_ROOT/platform-tools:$PATH" +fi + +if [[ -x "/usr/lib/jvm/java-21-openjdk-amd64/bin/java" ]]; then + export JAVA_HOME="/usr/lib/jvm/java-21-openjdk-amd64" +elif [[ -x "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" ]]; then + export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64" +fi +if [[ -n "${JAVA_HOME:-}" ]]; then + export PATH="$JAVA_HOME/bin:$PATH" +fi + +install_sdk() { + local sdk_root="$ANDROID_SDK_ROOT" + mkdir -p "$sdk_root/cmdline-tools" + + if [[ ! -x "$sdk_root/cmdline-tools/latest/bin/sdkmanager" ]]; then + echo "⬇️ Installing Android cmdline-tools..." + local zip_file="$sdk_root/commandlinetools-linux-latest.zip" + curl -fL -o "$zip_file" \ + "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip" + + rm -rf "$sdk_root/cmdline-tools/latest" "$sdk_root/cmdline-tools/cmdline-tools" + unzip -q -o "$zip_file" -d "$sdk_root/cmdline-tools" + mkdir -p "$sdk_root/cmdline-tools/latest" + cp -a "$sdk_root/cmdline-tools/cmdline-tools/." "$sdk_root/cmdline-tools/latest/" + fi + + export PATH="$sdk_root/cmdline-tools/latest/bin:$sdk_root/platform-tools:$PATH" + + if command -v sdkmanager >/dev/null 2>&1; then + echo "📦 Installing Android SDK packages..." + yes | sdkmanager --licenses >/dev/null + sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" + else + echo "❌ sdkmanager is still unavailable after cmdline-tools setup." + return 1 + fi +} + +install_java() { + if [[ -x "/usr/lib/jvm/java-21-openjdk-amd64/bin/java" ]]; then + return 0 + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "❌ Cannot auto-install Java 21: apt-get not found." + return 1 + fi + + echo "⬇️ Installing OpenJDK 21..." + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y openjdk-21-jdk + else + apt-get update + apt-get install -y openjdk-21-jdk + fi + + if [[ ! -x "/usr/lib/jvm/java-21-openjdk-amd64/bin/java" ]]; then + echo "❌ Java 21 installation did not complete as expected." + return 1 + fi +} + +if [[ "$INSTALL_JAVA" == "true" ]]; then + install_java +fi + +if [[ "$INSTALL_SDK" == "true" ]]; then + install_sdk +fi + +echo "=== Environment Ready ===" +echo "VIRTUAL_ENV : $VIRTUAL_ENV" +echo "ANDROID_SDK_ROOT: $ANDROID_SDK_ROOT" +echo "ANDROID_HOME : $ANDROID_HOME" +echo "JAVA_HOME : ${JAVA_HOME:-}" +echo "java : $(command -v java || echo '')" +echo "node : $(command -v node || echo '')" +echo "npm : $(command -v npm || echo '')" +echo "sdkmanager : $(command -v sdkmanager || echo '')" +echo +if [[ -x "./build-apk.sh" ]]; then + echo "Next: ./build-apk.sh --release" +else + echo "Next: run build script from android-app directory" +fi diff --git a/android-app/www b/android-app/www new file mode 120000 index 0000000..cec6c33 --- /dev/null +++ b/android-app/www @@ -0,0 +1 @@ +../www \ No newline at end of file diff --git a/android-app/www/index.html b/www/index.html similarity index 100% rename from android-app/www/index.html rename to www/index.html diff --git a/android-app/www/js/atlas-api.js b/www/js/atlas-api.js similarity index 100% rename from android-app/www/js/atlas-api.js rename to www/js/atlas-api.js diff --git a/android-app/www/js/atlas-converter.js b/www/js/atlas-converter.js similarity index 100% rename from android-app/www/js/atlas-converter.js rename to www/js/atlas-converter.js diff --git a/android-app/www/js/atlas-extracter.js b/www/js/atlas-extracter.js similarity index 100% rename from android-app/www/js/atlas-extracter.js rename to www/js/atlas-extracter.js diff --git a/android-app/www/js/atlas-modifier.js b/www/js/atlas-modifier.js similarity index 100% rename from android-app/www/js/atlas-modifier.js rename to www/js/atlas-modifier.js diff --git a/android-app/www/script.js b/www/script.js similarity index 100% rename from android-app/www/script.js rename to www/script.js diff --git a/android-app/www/style.css b/www/style.css similarity index 100% rename from android-app/www/style.css rename to www/style.css From 29d7007650e439e3eef0d78d67a734b6a680ee57 Mon Sep 17 00:00:00 2001 From: com55 <33087300+com55@users.noreply.github.com> Date: Mon, 6 Apr 2026 07:55:04 +0000 Subject: [PATCH 5/5] feat: add GitHub Actions workflow for deploying to GitHub Pages --- .github/workflows/deploy_pages.yml | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/deploy_pages.yml diff --git a/.github/workflows/deploy_pages.yml b/.github/workflows/deploy_pages.yml new file mode 100644 index 0000000..daf99c9 --- /dev/null +++ b/.github/workflows/deploy_pages.yml @@ -0,0 +1,41 @@ +name: Deploy GitHub Pages + +on: + push: + branches: + - web-app-and-android-app + paths: + - "www/**" + - ".github/workflows/deploy_pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: www + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4
Are you sure?