// SypnexAPI - Dynamically Bundled JavaScript API
// Generated: 2025-08-18 07:56:38.082651
// Minified: False
// === sypnex-api-core.js ===
// SypnexAPI Core - Main class and initialization
// This file contains the core SypnexAPI class that gets injected into user app sandboxes
/**
* SypnexAPI - Main API class for user applications
* Provides access to OS features and services in a sandboxed environment
* @class
*/
class SypnexAPI {
/**
* Create a new SypnexAPI instance
* @param {string} appId - Unique identifier for the application
* @param {object} helpers - Helper functions provided by the OS environment
* @param {function} [helpers.getAppSetting] - Function to get app settings
* @param {function} [helpers.getAllAppSettings] - Function to get all app settings
* @param {function} [helpers.showNotification] - Function to show notifications
*/
constructor(appId, helpers = {}) {
this.appId = appId;
this.baseUrl = '/api';
this.initialized = false;
this.cleanupHooks = []; // User-defined cleanup functions
// Store helper functions passed from the OS
this.getAppSetting = helpers.getAppSetting || this._defaultGetAppSetting;
this.getAllAppSettings = helpers.getAllAppSettings || this._defaultGetAllAppSettings;
this.showNotification = helpers.showNotification || this._defaultShowNotification;
this.init();
}
/**
* Initialize the SypnexAPI instance
* Checks for required helper functions and sets up the API
* @async
* @returns {Promise<void>}
*/
async init() {
try {
// Check if we have the required helper functions
if (typeof this.getAppSetting === 'function' && typeof this.getAllAppSettings === 'function') {
this.initialized = true;
} else {
console.warn('SypnexAPI: Running outside OS environment, some features may not work');
}
} catch (error) {
console.error('SypnexAPI initialization error:', error);
}
}
/**
* Default implementation for getting app settings via direct API calls
* @private
* @async
* @param {string} key - Setting key to retrieve
* @param {*} [defaultValue=null] - Default value if setting not found
* @returns {Promise<*>} The setting value or default value
*/
// Default implementations that fall back to direct API calls
async _defaultGetAppSetting(key, defaultValue = null) {
try {
const response = await fetch(`${this.baseUrl}/app-settings/${this.appId}/${key}`);
if (response.ok) {
const data = await response.json();
return data.value !== undefined ? data.value : defaultValue;
}
return defaultValue;
} catch (error) {
console.error(`SypnexAPI: Error getting setting ${key}:`, error);
return defaultValue;
}
}
/**
* Default implementation for getting all app settings via direct API calls
* @private
* @async
* @returns {Promise<object>} Object containing all app settings
*/
async _defaultGetAllAppSettings() {
try {
const response = await fetch(`${this.baseUrl}/app-settings/${this.appId}`);
if (response.ok) {
const data = await response.json();
return data.settings || {};
}
return {};
} catch (error) {
console.error('SypnexAPI: Error getting all settings:', error);
return {};
}
}
/**
* Default implementation for showing notifications via console
* @private
* @param {string} message - Notification message
* @param {string} [type='info'] - Notification type (info, error, warn, etc.)
*/
_defaultShowNotification(message, type = 'info') {
if (type === 'error') {
console.error(message);
}
}
/**
* Get access to the global SypnexOS apps registry
* @returns {Object} - The window.sypnexOS object containing apps Map and other OS functions
*/
getSypnexOS() {
if (typeof window !== 'undefined' && window.sypnexOS) {
return window.sypnexOS;
}
return null;
}
/**
* Get access to the global SypnexApps tracker
* @returns {Object} - The window.sypnexApps object containing app tracking info
*/
getSypnexApps() {
if (typeof window !== 'undefined' && window.sypnexApps) {
return window.sypnexApps;
}
return null;
}
/**
* Get metadata for this application
* @async
* @returns {Promise<object|null>} Application metadata or null if error
*/
async getAppMetadata() {
try {
const response = await fetch(`${this.baseUrl}/app-metadata/${this.appId}`);
if (response.ok) {
const data = await response.json();
return data.metadata;
}
return null;
} catch (error) {
console.error('SypnexAPI: Error getting app metadata:', error);
return null;
}
}
/**
* Check if the SypnexAPI has been initialized
* @returns {boolean} True if initialized, false otherwise
*/
isInitialized() {
return this.initialized;
}
/**
* Get the application ID
* @returns {string} The application identifier
*/
getAppId() {
return this.appId;
}
/**
* Get the saved window state for this application
* @async
* @returns {Promise<object|null>} Window state object or null if not found
*/
async getWindowState() {
try {
const response = await fetch(`${this.baseUrl}/window-state/${this.appId}`);
if (response.ok) {
const data = await response.json();
return data.state;
}
return null;
} catch (error) {
console.error('SypnexAPI: Error getting window state:', error);
return null;
}
}
/**
* Save the window state for this application
* @async
* @param {object} state - Window state object to save
* @returns {Promise<boolean>} True if saved successfully, false otherwise
*/
async saveWindowState(state) {
try {
const response = await fetch(`${this.baseUrl}/window-state/${this.appId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(state)
});
if (response.ok) {
return true;
} else {
console.error('SypnexAPI: Failed to save window state');
return false;
}
} catch (error) {
console.error('SypnexAPI: Error saving window state:', error);
return false;
}
}
/**
* Request the OS to refresh the latest app versions cache
* Useful when an app knows it has been updated or wants to force a cache refresh
* @async
* @returns {Promise<boolean>} True if refresh was successful, false otherwise
*/
async refreshAppVersionsCache() {
try {
// Call the global OS method if available
if (typeof window !== 'undefined' && window.sypnexOS && window.sypnexOS.refreshLatestVersionsCache) {
const result = await window.sypnexOS.refreshLatestVersionsCache();
if (result) {
return true;
} else {
console.warn(`SypnexAPI [${this.appId}]: App versions cache refresh failed`);
return false;
}
} else {
console.warn(`SypnexAPI [${this.appId}]: OS cache refresh not available - running outside OS environment`);
return false;
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error refreshing app versions cache:`, error);
return false;
}
}
// ========================================
// SERVICE MANAGEMENT API METHODS
// ========================================
/**
* Get a list of all available services
* @async
* @returns {Promise<Array>} Array of service objects
* @throws {Error} If the request fails
* @example
* const services = await sypnexAPI.getServices();
* console.log(services); // [{ id: "service1", name: "Service 1", running: true }, ...]
*/
async getServices() {
const response = await this.proxyHTTP({
url: '/api/services',
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response || response.status < 200 || response.status >= 300) {
throw new Error(`Request failed with status: ${response?.status || 'Unknown'}`);
}
if (response.error) {
throw new Error(response.error);
}
const data = response.content;
return data.services || [];
}
/**
* Start a specific service by ID
* @async
* @param {string} serviceId - The ID of the service to start
* @returns {Promise<void>}
* @throws {Error} If the request fails
* @example
* await sypnexAPI.startService("my-service-id");
*/
async startService(serviceId) {
const response = await this.proxyHTTP({
url: `/api/services/${serviceId}/start`,
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response || response.status < 200 || response.status >= 300) {
throw new Error(`Request failed with status: ${response?.status || 'Unknown'}`);
}
if (response.error) {
throw new Error(response.error);
}
}
/**
* Stop a specific service by ID
* @async
* @param {string} serviceId - The ID of the service to stop
* @returns {Promise<void>}
* @throws {Error} If the request fails
* @example
* await sypnexAPI.stopService("my-service-id");
*/
async stopService(serviceId) {
const response = await this.proxyHTTP({
url: `/api/services/${serviceId}/stop`,
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response || response.status < 200 || response.status >= 300) {
throw new Error(`Request failed with status: ${response?.status || 'Unknown'}`);
}
if (response.error) {
throw new Error(response.error);
}
}
/**
* Register a cleanup function to be called when the app is closed
* Use this for custom cleanup like stopping game loops, disposing WebGL contexts, etc.
* @param {function} cleanupFunction - Function to call during app cleanup
* @param {string} [description] - Optional description for debugging
*
* @example
* // For Three.js apps
* sypnexAPI.onBeforeClose(() => {
* if (renderer) {
* renderer.dispose();
* renderer.domElement = null;
* }
* if (animationId) {
* cancelAnimationFrame(animationId);
* }
* }, 'Three.js cleanup');
*
* // For game loops
* sypnexAPI.onBeforeClose(() => {
* gameRunning = false;
* if (gameLoopInterval) {
* clearInterval(gameLoopInterval);
* }
* }, 'Game loop cleanup');
*/
onBeforeClose(cleanupFunction, description = 'User cleanup') {
if (typeof cleanupFunction !== 'function') {
console.warn(`SypnexAPI [${this.appId}]: onBeforeClose expects a function, got ${typeof cleanupFunction}`);
return;
}
this.cleanupHooks.push({
fn: cleanupFunction,
description: description
});
}
/**
* Remove a previously registered cleanup function
* @param {function} cleanupFunction - The function to remove
*/
removeCleanupHook(cleanupFunction) {
const index = this.cleanupHooks.findIndex(hook => hook.fn === cleanupFunction);
if (index > -1) {
this.cleanupHooks.splice(index, 1);
}
}
/**
* Internal method called by the OS during app cleanup
* Executes all registered cleanup hooks
*/
cleanup() {
if (this.cleanupHooks.length === 0) {
return;
}
for (const hook of this.cleanupHooks) {
try {
hook.fn();
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error in cleanup hook "${hook.description}":`, error);
}
}
// Clear the hooks after execution
this.cleanupHooks = [];
}
}
// Export for use in modules (if supported)
if (typeof module !== 'undefined' && module.exports) {
module.exports = SypnexAPI;
}
// Make SypnexAPI globally available for OS use
if (typeof window !== 'undefined') {
window.SypnexAPI = SypnexAPI;
}
// Global fetch override for automatic token injection
// This will be included in both main system and sandboxed apps
if (typeof window !== 'undefined' && window.fetch && !window._sypnexFetchOverridden) {
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
// Initialize headers if not present
if (!options.headers) {
options.headers = {};
}
// Only add session token to internal requests (relative URLs starting with /)
if (typeof url === 'string' && url.startsWith('/')) {
// Add access token header only to internal requests
options.headers['X-Session-Token'] = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImRlbW8iLCJjcmVhdGVkX2F0IjoxNzU1NDgxMzg0Ljk0MTIwODYsImV4cCI6MTc1NTU2Nzc4NC45NDEyMDg2LCJpc3MiOiJ5b3VyLWluc3RhbmNlLW5hbWUiLCJpYXQiOjE3NTU0ODEzODQuOTQxMjA4Nn0.owOeyZZRueL_sLw0wfpC2wXSko0uyC-n50KL2H4mDkw';
}
// Call original fetch with modified options
return originalFetch(url, options);
};
// Mark as overridden to prevent double-override
window._sypnexFetchOverridden = true;
}
// === sypnex-api-ui.js ===
// SypnexAPI UI Components - Modal dialogs, confirmations, file pickers, etc.
// This file extends the SypnexAPI class with UI component functionality
// Extend SypnexAPI with UI methods
Object.assign(SypnexAPI.prototype, {
/**
* Show a confirmation dialog with standard OS styling
* @async
* @param {string} title - Dialog title
* @param {string} message - Dialog message
* @param {object} [options={}] - Configuration options
* @param {string} [options.confirmText='Yes'] - Text for confirm button
* @param {string} [options.cancelText='No'] - Text for cancel button
* @param {string} [options.type='warning'] - Dialog type: 'warning', 'danger', 'info'
* @param {string} [options.icon='fas fa-exclamation-triangle'] - FontAwesome icon class
* @memberof SypnexAPI.prototype
* @returns {Promise<boolean>} True if confirmed, false if cancelled
*/
async showConfirmation(title, message, options = {}) {
const {
confirmText = 'Yes',
cancelText = 'No',
type = 'warning',
icon = 'fas fa-exclamation-triangle'
} = options;
return new Promise((resolve) => {
// Remove any existing confirmation modal
const existingModal = document.getElementById('sypnex-confirmation-modal');
if (existingModal) {
existingModal.remove();
}
// Create the modal with proper OS styling
const modal = document.createElement('div');
modal.id = 'sypnex-confirmation-modal';
modal.style.cssText = `
display: block;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
`;
// Create modal content with proper structure
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background-color: var(--glass-bg);
margin: 5% auto;
padding: 0;
border: 1px solid var(--glass-border);
border-radius: 12px;
width: 90%;
max-width: 500px;
backdrop-filter: blur(10px);
`;
// Modal header
const modalHeader = document.createElement('div');
modalHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--glass-border);
`;
const headerTitle = document.createElement('h3');
headerTitle.style.cssText = `
margin: 0;
color: var(--text-primary);
`;
headerTitle.innerHTML = `<i class="${icon}"></i> ${title}`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--text-secondary);
`;
closeBtn.onmouseover = () => closeBtn.style.color = 'var(--text-primary)';
closeBtn.onmouseout = () => closeBtn.style.color = 'var(--text-secondary)';
modalHeader.appendChild(headerTitle);
modalHeader.appendChild(closeBtn);
// Modal body
const modalBody = document.createElement('div');
modalBody.style.cssText = `padding: 20px;`;
const messageP = document.createElement('p');
messageP.style.cssText = `
color: var(--text-primary);
margin: 0 0 15px 0;
line-height: 1.5;
`;
messageP.textContent = message;
modalBody.appendChild(messageP);
// Add warning text for danger type
if (type === 'danger') {
const warningP = document.createElement('p');
warningP.style.cssText = `
color: var(--error-color);
margin: 10px 0 0 0;
font-size: 14px;
font-style: italic;
`;
warningP.textContent = 'This action cannot be undone.';
modalBody.appendChild(warningP);
}
// Modal footer
const modalFooter = document.createElement('div');
modalFooter.style.cssText = `
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 20px;
border-top: 1px solid var(--glass-border);
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = cancelText;
cancelBtn.className = 'app-btn secondary';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = confirmText;
confirmBtn.className = `app-btn ${type === 'danger' ? 'danger' : 'primary'}`;
modalFooter.appendChild(cancelBtn);
modalFooter.appendChild(confirmBtn);
// Assemble modal
modalContent.appendChild(modalHeader);
modalContent.appendChild(modalBody);
modalContent.appendChild(modalFooter);
modal.appendChild(modalContent);
// Add to document
document.body.appendChild(modal);
// Setup event handlers
const closeModal = (confirmed) => {
modal.remove();
resolve(confirmed);
document.removeEventListener('keydown', escapeHandler);
};
// Event listeners
closeBtn.addEventListener('click', () => closeModal(false));
cancelBtn.addEventListener('click', () => closeModal(false));
confirmBtn.addEventListener('click', () => closeModal(true));
// Escape key to close
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(false);
}
};
document.addEventListener('keydown', escapeHandler);
});
},
/**
* Show an input modal for getting text input from user
* @param {string} title - Modal title
* @param {string} message - Modal message/label
* @param {object} [options={}] - Configuration options
* @param {string} [options.placeholder=''] - Input placeholder text
* @param {string} [options.defaultValue=''] - Default input value
* @param {string} [options.confirmText='Create'] - Text for confirm button
* @param {string} [options.cancelText='Cancel'] - Text for cancel button
* @param {string} [options.icon='fas fa-edit'] - FontAwesome icon class
* @param {string} [options.inputType='text'] - Input type: 'text', 'textarea'
* @memberof SypnexAPI.prototype
* @returns {Promise<string|null>} Input value if confirmed, null if cancelled
*/
async showInputModal(title, message, options = {}) {
const {
placeholder = '',
defaultValue = '',
confirmText = 'Create',
cancelText = 'Cancel',
icon = 'fas fa-edit',
inputType = 'text'
} = options;
return new Promise((resolve) => {
// Remove any existing modal
const existingModal = document.getElementById('sypnex-input-modal');
if (existingModal) {
existingModal.remove();
}
// Create the modal
const modal = document.createElement('div');
modal.id = 'sypnex-input-modal';
modal.style.cssText = `
display: block;
position: fixed;
z-index: 11000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
`;
// Create modal content
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: var(--glass-bg);
margin: 5% auto;
padding: 0;
border: 1px solid var(--glass-border);
border-radius: 12px;
width: 90%;
max-width: 500px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
`;
// Modal header
const modalHeader = document.createElement('div');
modalHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--glass-border);
background: var(--glass-bg);
border-radius: 12px 12px 0 0;
`;
const headerTitle = document.createElement('h3');
headerTitle.style.cssText = `
margin: 0;
color: var(--text-primary);
font-size: 1.2em;
display: flex;
align-items: center;
gap: 10px;
`;
headerTitle.innerHTML = `<i class="${icon}" style="color: var(--accent-color);"></i> ${title}`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 1.5em;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
`;
closeBtn.onmouseover = () => {
closeBtn.style.background = 'rgba(255, 71, 87, 0.1)';
closeBtn.style.color = '#ff4757';
closeBtn.style.transform = 'scale(1.1)';
};
closeBtn.onmouseout = () => {
closeBtn.style.background = 'none';
closeBtn.style.color = 'var(--text-secondary)';
closeBtn.style.transform = 'scale(1)';
};
modalHeader.appendChild(headerTitle);
modalHeader.appendChild(closeBtn);
// Modal body
const modalBody = document.createElement('div');
modalBody.style.cssText = `
padding: 20px;
background: var(--glass-bg);
`;
const label = document.createElement('label');
label.style.cssText = `
display: block;
margin-bottom: 5px;
color: var(--text-primary);
font-weight: bold;
font-size: 14px;
`;
label.textContent = message;
let input;
if (inputType === 'textarea') {
input = document.createElement('textarea');
input.style.cssText = `
width: 100%;
padding: 10px;
border: 1px solid var(--glass-border);
border-radius: 6px;
background: rgba(20, 20, 20, 0.8);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
`;
} else {
input = document.createElement('input');
input.type = 'text';
input.style.cssText = `
width: 100%;
padding: 10px;
border: 1px solid var(--glass-border);
border-radius: 6px;
background: rgba(20, 20, 20, 0.8);
color: var(--text-primary);
font-family: inherit;
font-size: 14px;
box-sizing: border-box;
`;
}
input.placeholder = placeholder;
input.value = defaultValue;
input.onfocus = () => {
input.style.borderColor = 'var(--accent-color)';
input.style.boxShadow = '0 0 0 2px rgba(0, 212, 255, 0.2)';
};
input.onblur = () => {
input.style.borderColor = 'var(--glass-border)';
input.style.boxShadow = 'none';
};
modalBody.appendChild(label);
modalBody.appendChild(input);
// Modal footer
const modalFooter = document.createElement('div');
modalFooter.style.cssText = `
padding: 20px;
border-top: 1px solid var(--glass-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: var(--glass-bg);
border-radius: 0 0 12px 12px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = cancelText;
cancelBtn.className = 'app-btn secondary';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = confirmText;
confirmBtn.className = 'app-btn primary';
modalFooter.appendChild(cancelBtn);
modalFooter.appendChild(confirmBtn);
// Assemble modal
modalContent.appendChild(modalHeader);
modalContent.appendChild(modalBody);
modalContent.appendChild(modalFooter);
modal.appendChild(modalContent);
// Add to document
document.body.appendChild(modal);
// Focus the input
setTimeout(() => input.focus(), 100);
// Setup event handlers
const closeModal = (inputValue) => {
modal.remove();
resolve(inputValue);
document.removeEventListener('keydown', escapeHandler);
};
// Event listeners
closeBtn.addEventListener('click', () => closeModal(null));
cancelBtn.addEventListener('click', () => closeModal(null));
confirmBtn.addEventListener('click', () => {
const value = input.value.trim();
if (value) {
closeModal(value);
}
});
// Enter key to confirm
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (inputType !== 'textarea' || e.ctrlKey)) {
e.preventDefault();
const value = input.value.trim();
if (value) {
closeModal(value);
}
}
});
// Escape key to close
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(null);
}
};
document.addEventListener('keydown', escapeHandler);
});
},
/**
* Show a file upload modal
* @param {string} title - Modal title
* @param {string} message - Modal message/label
* @param {object} [options={}] - Configuration options
* @param {string} [options.confirmText='Upload'] - Text for confirm button
* @param {string} [options.cancelText='Cancel'] - Text for cancel button
* @param {string} [options.icon='fas fa-upload'] - FontAwesome icon class
* @param {string} [options.accept='*'] - File accept types
* @memberof SypnexAPI.prototype
* @returns {Promise<File|null>} Selected file if confirmed, null if cancelled
*/
async showFileUploadModal(title, message, options = {}) {
const {
confirmText = 'Upload',
cancelText = 'Cancel',
icon = 'fas fa-upload',
accept = '*',
uploadCallback = null
} = options;
return new Promise((resolve) => {
// Remove any existing modal
const existingModal = document.getElementById('sypnex-upload-modal');
if (existingModal) {
existingModal.remove();
}
// Create the modal
const modal = document.createElement('div');
modal.id = 'sypnex-upload-modal';
modal.style.cssText = `
display: block;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
`;
// Create modal content
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: var(--glass-bg);
margin: 5% auto;
padding: 0;
border: 1px solid var(--glass-border);
border-radius: 12px;
width: 90%;
max-width: 500px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
`;
// Modal header
const modalHeader = document.createElement('div');
modalHeader.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--glass-border);
background: var(--glass-bg);
border-radius: 12px 12px 0 0;
`;
const headerTitle = document.createElement('h3');
headerTitle.style.cssText = `
margin: 0;
color: var(--text-primary);
font-size: 1.2em;
display: flex;
align-items: center;
gap: 10px;
`;
headerTitle.innerHTML = `<i class="${icon}" style="color: var(--accent-color);"></i> ${title}`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '×';
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 1.5em;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
`;
closeBtn.onmouseover = () => {
closeBtn.style.background = 'rgba(255, 71, 87, 0.1)';
closeBtn.style.color = '#ff4757';
closeBtn.style.transform = 'scale(1.1)';
};
closeBtn.onmouseout = () => {
closeBtn.style.background = 'none';
closeBtn.style.color = 'var(--text-secondary)';
closeBtn.style.transform = 'scale(1)';
};
modalHeader.appendChild(headerTitle);
modalHeader.appendChild(closeBtn);
// Modal body
const modalBody = document.createElement('div');
modalBody.style.cssText = `
padding: 20px;
background: var(--glass-bg);
`;
const label = document.createElement('label');
label.style.cssText = `
display: block;
margin-bottom: 5px;
color: var(--text-primary);
font-weight: bold;
font-size: 14px;
`;
label.textContent = message;
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = accept;
fileInput.style.cssText = `
display: none;
`;
// Custom file input button
const customFileBtn = document.createElement('button');
customFileBtn.type = 'button';
customFileBtn.className = 'app-btn secondary';
customFileBtn.style.cssText = `
width: 100%;
padding: 12px;
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 2px dashed var(--glass-border);
background: rgba(0, 212, 255, 0.05);
transition: all 0.3s ease;
`;
customFileBtn.innerHTML = `
<i class="fas fa-cloud-upload-alt"></i>
<span>Choose File to Upload</span>
`;
customFileBtn.onmouseover = () => {
customFileBtn.style.borderColor = 'var(--accent-color)';
customFileBtn.style.background = 'rgba(0, 212, 255, 0.1)';
customFileBtn.style.transform = 'translateY(-1px)';
};
customFileBtn.onmouseout = () => {
customFileBtn.style.borderColor = 'var(--glass-border)';
customFileBtn.style.background = 'rgba(0, 212, 255, 0.05)';
customFileBtn.style.transform = 'translateY(0)';
};
// Click handler for custom button
customFileBtn.addEventListener('click', () => {
fileInput.click();
});
// File info display
const fileInfo = document.createElement('div');
fileInfo.style.cssText = `
display: none;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 6px;
padding: 10px;
margin-top: 10px;
`;
// Progress bar container (hidden initially)
const progressContainer = document.createElement('div');
progressContainer.style.cssText = `
display: none;
margin-top: 15px;
`;
const progressLabel = document.createElement('div');
progressLabel.style.cssText = `
color: var(--text-primary);
font-size: 14px;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
`;
progressLabel.innerHTML = `
<span>Uploading...</span>
<span class="progress-percent">0%</span>
`;
const progressBarBg = document.createElement('div');
progressBarBg.style.cssText = `
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--glass-border);
`;
const progressBarFill = document.createElement('div');
progressBarFill.style.cssText = `
width: 0%;
height: 100%;
background: linear-gradient(90deg, var(--accent-color), #00ff88);
border-radius: 4px;
transition: width 0.3s ease;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
`;
progressBarBg.appendChild(progressBarFill);
progressContainer.appendChild(progressLabel);
progressContainer.appendChild(progressBarBg);
modalBody.appendChild(label);
modalBody.appendChild(customFileBtn);
modalBody.appendChild(fileInput);
modalBody.appendChild(fileInfo);
modalBody.appendChild(progressContainer);
// Modal footer
const modalFooter = document.createElement('div');
modalFooter.style.cssText = `
padding: 20px;
border-top: 1px solid var(--glass-border);
display: flex;
justify-content: flex-end;
gap: 10px;
background: var(--glass-bg);
border-radius: 0 0 12px 12px;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = cancelText;
cancelBtn.className = 'app-btn secondary';
const confirmBtn = document.createElement('button');
confirmBtn.textContent = confirmText;
confirmBtn.className = 'app-btn primary';
confirmBtn.disabled = true;
modalFooter.appendChild(cancelBtn);
modalFooter.appendChild(confirmBtn);
// Assemble modal
modalContent.appendChild(modalHeader);
modalContent.appendChild(modalBody);
modalContent.appendChild(modalFooter);
modal.appendChild(modalContent);
// Add to document
document.body.appendChild(modal);
// File selection handler
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
confirmBtn.disabled = false;
// Update custom button appearance
customFileBtn.innerHTML = `
<i class="fas fa-check-circle" style="color: var(--accent-color);"></i>
<span>${file.name}</span>
`;
customFileBtn.style.borderColor = 'var(--accent-color)';
customFileBtn.style.background = 'rgba(0, 212, 255, 0.15)';
fileInfo.style.display = 'block';
fileInfo.innerHTML = `
<p style="margin: 5px 0; color: var(--text-primary); font-size: 14px;">
<strong style="color: var(--accent-color);">Selected File:</strong> ${file.name}
</p>
<p style="margin: 5px 0; color: var(--text-primary); font-size: 14px;">
<strong style="color: var(--accent-color);">Size:</strong> ${(file.size / 1024).toFixed(1)} KB
</p>
`;
} else {
confirmBtn.disabled = true;
// Reset custom button appearance
customFileBtn.innerHTML = `
<i class="fas fa-cloud-upload-alt"></i>
<span>Choose File to Upload</span>
`;
customFileBtn.style.borderColor = 'var(--glass-border)';
customFileBtn.style.background = 'rgba(0, 212, 255, 0.05)';
fileInfo.style.display = 'none';
}
});
// Setup event handlers
const closeModal = (selectedFile) => {
modal.remove();
resolve(selectedFile);
document.removeEventListener('keydown', escapeHandler);
};
// Event listeners
closeBtn.addEventListener('click', () => closeModal(null));
cancelBtn.addEventListener('click', () => closeModal(null));
confirmBtn.addEventListener('click', async () => {
const file = fileInput.files[0];
if (file && uploadCallback) {
// Show progress UI
progressContainer.style.display = 'block';
confirmBtn.disabled = true;
customFileBtn.style.display = 'none';
// Change cancel button to indicate it can cancel upload
cancelBtn.textContent = 'Cancel Upload';
let currentUpload = null;
// Progress callback function
const updateProgress = (percent) => {
progressBarFill.style.width = percent + '%';
progressContainer.querySelector('.progress-percent').textContent = Math.round(percent) + '%';
};
// Update cancel button handler for upload cancellation
const uploadCancelHandler = () => {
if (currentUpload && currentUpload.abort) {
currentUpload.abort();
}
closeModal(null);
};
// Replace cancel button event listener
cancelBtn.removeEventListener('click', closeModal);
cancelBtn.addEventListener('click', uploadCancelHandler);
try {
// Call the upload function with progress callback
const uploadResult = uploadCallback(file, updateProgress);
// Check if uploadCallback returns an object with promise and abort
if (uploadResult && uploadResult.promise && uploadResult.abort) {
currentUpload = uploadResult;
const result = await uploadResult.promise;
closeModal(result);
} else {
// Fallback for legacy uploadCallback that returns just a promise
const result = await uploadResult;
closeModal(result);
}
} catch (error) {
// Hide progress and show error
progressContainer.style.display = 'none';
confirmBtn.disabled = false;
cancelBtn.textContent = cancelText; // Reset cancel button text
customFileBtn.style.display = 'block';
// Restore original cancel handler
cancelBtn.removeEventListener('click', uploadCancelHandler);
cancelBtn.addEventListener('click', () => closeModal(null));
// Show error in the modal unless it was cancelled
if (error.message !== 'Upload cancelled by user') {
const errorDiv = document.createElement('div');
errorDiv.style.cssText = `
background: rgba(255, 71, 87, 0.1);
border: 1px solid rgba(255, 71, 87, 0.3);
border-radius: 6px;
padding: 10px;
margin-top: 10px;
color: #ff4757;
`;
errorDiv.innerHTML = `<i class="fas fa-exclamation-triangle"></i> Upload failed: ${error.message}`;
modalBody.appendChild(errorDiv);
// Remove error after 5 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.remove();
}
}, 5000);
}
}
} else if (file) {
// Fallback to regular modal behavior if no upload callback
closeModal(file);
}
});
// Escape key to close
const escapeHandler = (e) => {
if (e.key === 'Escape') {
closeModal(null);
}
};
document.addEventListener('keydown', escapeHandler);
});
},
/**
* Create a hamburger menu with customizable items
* @param {HTMLElement} container - The container element to append the menu to
* @param {Array} menuItems - Array of menu item objects
* @param {object} [options={}] - Configuration options
* @param {string} [options.position='right'] - Position of menu ('left' or 'right')
* @param {string} [options.buttonClass=''] - Additional CSS classes for the button
* @param {string} [options.menuId=''] - Custom ID for the menu (auto-generated if not provided)
* @memberof SypnexAPI.prototype
* @returns {object} Object with methods to control the menu
*
* @example
* const menuItems = [
* { icon: 'fas fa-sync-alt', text: 'Refresh', action: () => console.log('Refresh') },
* { icon: 'fas fa-folder-plus', text: 'New Folder', action: () => console.log('New Folder') },
* { type: 'separator' },
* { icon: 'fas fa-upload', text: 'Upload File', action: () => console.log('Upload') }
* ];
*
* const menu = sypnexAPI.createHamburgerMenu(container, menuItems, { position: 'right' });
*/
createHamburgerMenu(container, menuItems, options = {}) {
const {
position = 'right',
buttonClass = '',
menuId = `hamburger-menu-${Date.now()}`
} = options;
// Create hamburger button
const hamburgerBtn = document.createElement('button');
hamburgerBtn.className = `hamburger-btn ${buttonClass}`;
hamburgerBtn.innerHTML = '<i class="fas fa-bars"></i>';
hamburgerBtn.style.cssText = `
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
color: var(--text-primary);
transition: all 0.2s ease;
backdrop-filter: blur(10px);
`;
// Create dropdown menu
const dropdownMenu = document.createElement('div');
dropdownMenu.id = menuId;
dropdownMenu.className = 'sypnex-dropdown-menu';
dropdownMenu.style.cssText = `
display: none;
position: absolute;
${position}: 0;
top: 100%;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 9999;
min-width: 180px;
width: 180px;
max-height: 70vh;
overflow-y: auto;
padding: 0;
margin-top: 4px;
backdrop-filter: blur(10px);
`;
// Populate menu items
menuItems.forEach(item => {
if (item.type === 'separator') {
const separator = document.createElement('div');
separator.style.cssText = `
height: 1px;
background: var(--glass-border);
margin: 4px 0;
`;
dropdownMenu.appendChild(separator);
} else {
const menuItem = document.createElement('button');
menuItem.className = 'sypnex-menu-item';
menuItem.innerHTML = `
<i class="${item.icon}" style="width: 16px; margin-right: 10px;"></i>
${item.text}
`;
menuItem.style.cssText = `
display: flex;
align-items: center;
width: 100%;
padding: 10px 16px;
background: none;
border: none;
text-align: left;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.2s ease;
font-size: 14px;
`;
menuItem.addEventListener('mouseenter', () => {
menuItem.style.background = 'var(--glass-hover)';
});
menuItem.addEventListener('mouseleave', () => {
menuItem.style.background = 'none';
});
menuItem.addEventListener('click', () => {
if (typeof item.action === 'function') {
item.action();
}
hideMenu();
});
dropdownMenu.appendChild(menuItem);
}
});
// Setup container with relative positioning
if (container.style.position !== 'relative' && container.style.position !== 'absolute') {
container.style.position = 'relative';
}
// Append elements to container
container.appendChild(hamburgerBtn);
container.appendChild(dropdownMenu);
// Show/hide functionality
const showMenu = () => {
dropdownMenu.style.display = 'block';
hamburgerBtn.style.background = 'var(--glass-hover)';
};
const hideMenu = () => {
dropdownMenu.style.display = 'none';
hamburgerBtn.style.background = 'var(--glass-bg)';
};
const toggleMenu = () => {
if (dropdownMenu.style.display === 'none' || dropdownMenu.style.display === '') {
showMenu();
} else {
hideMenu();
}
};
// Event listeners
hamburgerBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleMenu();
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!container.contains(e.target)) {
hideMenu();
}
});
// Hover effects for button
hamburgerBtn.addEventListener('mouseenter', () => {
if (dropdownMenu.style.display === 'none' || dropdownMenu.style.display === '') {
hamburgerBtn.style.background = 'var(--glass-hover)';
}
});
hamburgerBtn.addEventListener('mouseleave', () => {
if (dropdownMenu.style.display === 'none' || dropdownMenu.style.display === '') {
hamburgerBtn.style.background = 'var(--glass-bg)';
}
});
// Return control object
return {
show: showMenu,
hide: hideMenu,
toggle: toggleMenu,
button: hamburgerBtn,
menu: dropdownMenu,
destroy: () => {
hamburgerBtn.remove();
dropdownMenu.remove();
}
};
}
});
// === sypnex-api-keyboard.js ===
// SypnexAPI Keyboard Management - Global keyboard shortcut system
// This file extends the SypnexAPI class with keyboard shortcut functionality
// Global keyboard manager (initialized once)
(function() {
// Skip if already initialized
if (window.sypnexKeyboardManager) return;
// Private global state for keyboard management
const keyboardState = {
appShortcuts: new Map(), // appId -> {shortcuts, config}
isInitialized: false
};
/**
* Handle global keydown events and route to active application
* @param {KeyboardEvent} event - The keyboard event
*/
function handleGlobalKeydown(event) {
// Use the OS's existing activeWindow tracking
const activeAppId = window.sypnexOS && window.sypnexOS.activeWindow;
// Only process if we have an active app that has shortcuts registered
if (!activeAppId || !keyboardState.appShortcuts.has(activeAppId)) return;
// Check if the active app is minimized - if so, don't process shortcuts
const activeWindowElement = window.sypnexOS.apps && window.sypnexOS.apps.get(activeAppId);
if (activeWindowElement && activeWindowElement.dataset.minimized === 'true') {
return; // App is minimized, ignore keyboard shortcuts
}
// Get the active app's shortcuts
const appConfig = keyboardState.appShortcuts.get(activeAppId);
// Convert event to key string (e.g., "f", "ctrl+s", "ArrowLeft")
const keyString = eventToKeyString(event);
// Check if this key is registered for the active app
const handler = appConfig.shortcuts[keyString];
if (handler && typeof handler === 'function') {
// Prevent default if configured to do so
if (appConfig.config.preventDefault !== false) {
event.preventDefault();
}
// Stop propagation if configured
if (appConfig.config.stopPropagation) {
event.stopPropagation();
}
try {
// Call the handler
handler();
} catch (error) {
console.error(`SypnexKeyboardManager: Error executing shortcut "${keyString}" for app ${activeAppId}:`, error);
}
}
// If no handler found, do nothing - let the event continue normally
}
/**
* Convert keyboard event to standardized key string
* @param {KeyboardEvent} event - The keyboard event
* @returns {string} Standardized key string
* @memberof SypnexAPI.prototype
*/
function eventToKeyString(event) {
const parts = [];
// Add modifiers in consistent order
if (event.ctrlKey) parts.push('ctrl');
if (event.altKey) parts.push('alt');
if (event.shiftKey) parts.push('shift');
if (event.metaKey) parts.push('meta');
// Add the main key
let key = event.key.toLowerCase();
// Normalize some special keys
if (key === ' ') key = 'space';
if (key === 'escape') key = 'escape';
if (key.startsWith('arrow')) key = key; // Keep ArrowLeft, ArrowRight, etc.
parts.push(key);
return parts.join('+');
}
// Initialize keyboard manager
function initKeyboardManager() {
if (keyboardState.isInitialized) return;
// Single global listener for all keyboard events
document.addEventListener('keydown', handleGlobalKeydown);
keyboardState.isInitialized = true;
}
// Expose keyboard manager functions globally
window.sypnexKeyboardManager = {
/**
* Register keyboard shortcuts for an application
* @param {string} appId - Application identifier
* @param {object} shortcuts - Key to function mappings
* @param {object} config - Configuration options
*/
registerApp(appId, shortcuts, config) {
keyboardState.appShortcuts.set(appId, {shortcuts, config});
},
/**
* Unregister all shortcuts for an application
* @memberof SypnexAPI.prototype
* @param {string} appId - Application identifier
*/
unregisterApp(appId) {
const appConfig = keyboardState.appShortcuts.get(appId);
if (appConfig) {
const shortcutCount = Object.keys(appConfig.shortcuts).length;
keyboardState.appShortcuts.delete(appId);
return shortcutCount;
}
return 0;
},
/**
* Get statistics about registered shortcuts
* @memberof SypnexAPI.prototype
* @returns {object} Statistics object
*/
getStats() {
const totalShortcuts = Array.from(keyboardState.appShortcuts.values())
.reduce((total, config) => total + Object.keys(config.shortcuts).length, 0);
const activeAppId = window.sypnexOS && window.sypnexOS.activeWindow;
return {
registeredApps: keyboardState.appShortcuts.size,
totalShortcuts: totalShortcuts,
activeApp: activeAppId
};
}
};
// Initialize on load
initKeyboardManager();
})();
// Extend SypnexAPI with keyboard shortcut methods
Object.assign(SypnexAPI.prototype, {
/**
* Register keyboard shortcuts for this application
* @param {object} shortcuts - Object mapping key strings to handler functions
* @param {object} config - Configuration options
* @param {boolean} config.preventDefault - Whether to prevent default behavior (default: true)
* @param {boolean} config.stopPropagation - Whether to stop event propagation (default: false)
* @memberof SypnexAPI.prototype
* @example
* this.registerKeyboardShortcuts({
* 'f': () => this.toggleFullscreen(),
* 'escape': () => this.exitFullscreen(),
* 'space': () => this.pausePlay(),
* 'ctrl+s': () => this.save()
* });
*/
registerKeyboardShortcuts(shortcuts, config = {}) {
const appId = this.appId;
if (!appId) {
console.warn('SypnexAPI: Cannot register keyboard shortcuts - no appId available');
return;
}
// Default configuration
const defaultConfig = {
preventDefault: true,
stopPropagation: false
};
const finalConfig = Object.assign({}, defaultConfig, config);
// Register with the global keyboard manager
window.sypnexKeyboardManager.registerApp(appId, shortcuts, finalConfig);
// Track in sandbox for cleanup
if (window.appKeyboardShortcuts) {
window.appKeyboardShortcuts.set(appId, Object.keys(shortcuts));
}
},
/**
* Get keyboard shortcut statistics
* @memberof SypnexAPI.prototype
* @returns {object} Statistics about registered shortcuts
*/
getKeyboardStats() {
return window.sypnexKeyboardManager.getStats();
}
});
// === sypnex-api-window.js ===
// SypnexAPI Window Management - Explicit window object management for sandboxed apps
// This file extends the SypnexAPI class with createAppWindow functionality
// Global window manager (initialized once)
(function() {
// Skip if already initialized
if (window.sypnexWindowManager) return;
// Private global state for window management
const windowState = {
appWindows: new Map(), // appId -> {windowProxy, properties: Set}
isInitialized: false
};
/**
* Create an isolated window proxy for an app with automatic property tracking
* @param {string} appId - The application ID
* @returns {Object} Proxy object that tracks property assignments
*/
function createAppWindowProxy(appId) {
// Check if we already have a proxy for this app
if (windowState.appWindows.has(appId)) {
const existingData = windowState.appWindows.get(appId);
if (existingData.windowProxy) {
// Return the existing proxy
return existingData.windowProxy;
}
}
// Create a Set to track properties created by this app
const appProperties = new Set();
// Store the tracking set for cleanup later
if (!windowState.appWindows.has(appId)) {
windowState.appWindows.set(appId, {
windowProxy: null,
properties: appProperties
});
} else {
windowState.appWindows.get(appId).properties = appProperties;
}
// Methods that need to be bound to the real window object
const boundMethods = new Set([
// DOM/CSS methods
'getComputedStyle',
'getSelection',
'matchMedia',
// Animation/timing methods
'requestAnimationFrame',
'cancelAnimationFrame',
'requestIdleCallback',
'cancelIdleCallback',
// Scrolling methods
'scrollTo',
'scroll',
'scrollBy',
// Window manipulation methods
'resizeTo',
'resizeBy',
'moveTo',
'moveBy',
// Additional common methods that need proper binding
'alert', // Alert dialogs
'confirm', // Confirmation dialogs
'prompt', // Input prompts
'print', // Print page
'focus', // Window focus
'blur', // Window blur
'find', // Text search (legacy)
'stop', // Stop page loading
'atob', // Base64 decode
'btoa' // Base64 encode
]);
// Create a proxy that intercepts property assignments
const windowProxy = new Proxy(window, {
set(target, property, value) {
// Track this property assignment
appProperties.add(property);
// Log the assignment for debugging
// Set the property on the real window object
target[property] = value;
return true;
},
get(target, property) {
const value = target[property];
// If it's a method that needs to be bound to the real window, bind it
if (typeof value === 'function' && boundMethods.has(property)) {
return value.bind(target);
}
// Return the actual property from window
return value;
},
has(target, property) {
return property in target;
},
deleteProperty(target, property) {
// If this app created this property, remove it from tracking
appProperties.delete(property);
delete target[property];
return true;
}
});
// Store the proxy for later reference
windowState.appWindows.get(appId).windowProxy = windowProxy;
return windowProxy;
}
/**
* Clean up all window properties created by a specific app
* @param {string} appId - The application ID to clean up
*/
function cleanupAppWindow(appId) {
const appData = windowState.appWindows.get(appId);
if (!appData) return;
const { properties } = appData;
let cleanedCount = 0;
// Delete all properties that this app created
for (const property of properties) {
if (property in window) {
try {
delete window[property];
cleanedCount++;
} catch (error) {
console.warn(`App ${appId}: Failed to clean up window.${property}:`, error);
}
}
}
// Clear the tracking data
properties.clear();
windowState.appWindows.delete(appId);
if (cleanedCount > 0) {
}
}
/**
* Initialize the window management system
* @memberof SypnexAPI.prototype
*/
function initializeWindowManager() {
if (windowState.isInitialized) return;
// Hook into the existing app cleanup system
if (window.sypnexAppSandbox && window.sypnexAppSandbox.addCleanupHook) {
window.sypnexAppSandbox.addCleanupHook('window-management', (appId) => {
cleanupAppWindow(appId);
});
}
windowState.isInitialized = true;
}
// Initialize immediately
initializeWindowManager();
// Make the window manager globally available
window.sypnexWindowManager = {
createAppWindowProxy,
cleanupAppWindow,
state: windowState // For debugging
};
})();
// Extend SypnexAPI with window management methods
Object.assign(SypnexAPI.prototype, {
/**
* Get the isolated window object for this app with automatic property tracking
* Returns the same window proxy instance for subsequent calls (singleton pattern).
* All properties assigned to this window proxy will be automatically cleaned up
* when the app is closed, preventing memory leaks and conflicts.
*
* @memberof SypnexAPI.prototype
* @returns {Object} Window proxy object that tracks property assignments
*
* @example
* // Instead of: window.myData = { ... }
* // Use:
* const appWindow = sypnexAPI.getAppWindow();
* appWindow.myData = { ... }; // This will be automatically cleaned up
*
* // Multiple calls return the same proxy:
* const w1 = sypnexAPI.getAppWindow();
* const w2 = sypnexAPI.getAppWindow();
* console.log(w1 === w2); // true
*
* // The proxy behaves exactly like window for reading:
* appWindow.document.getElementById('myId'); // Works normally
* appWindow.localStorage.getItem('key'); // Works normally
*/
getAppWindow() {
if (!window.sypnexWindowManager) {
console.error('SypnexAPI: Window manager not initialized');
return window; // Fallback to regular window
}
return window.sypnexWindowManager.createAppWindowProxy(this.appId);
},
/**
* Manually clean up window properties for this app
* @memberof SypnexAPI.prototype
* This is automatically called when the app closes, but can be called manually if needed
*/
cleanupAppWindow() {
if (!window.sypnexWindowManager) {
console.warn('SypnexAPI: Window manager not initialized');
return;
}
window.sypnexWindowManager.cleanupAppWindow(this.appId);
}
});
// === sypnex-api-scaling.js ===
// SypnexAPI Scaling - Centralized scaling utilities for all apps
// This file extends the SypnexAPI class with robust scaling compensation utilities
//
// This module provides a centralized solution for handling app scaling across all user applications.
// Previously, scaling utilities were duplicated in multiple places (utils, file explorer utils).
// Now all apps can access these utilities via sypnexAPI.scaling or the convenience methods.
//
// Key features:
// - Detects app scale from CSS classes or transform matrix
// - Provides coordinate transformation utilities
// - Handles mouse coordinate scaling
// - Includes element bounding rect scaling
// - Supports optional zoom scaling for canvas-based apps
// - Auto-detects scale changes with callback support
//
// Usage examples:
// sypnexAPI.scaling.detectAppScale()
// sypnexAPI.getScaledMouseCoords(event)
// sypnexAPI.getScaledBoundingClientRect(element)
// sypnexAPI.screenToAppCoords(x, y, zoomScale)
/**
* Scaling utilities for handling app scaling across all user applications
* Provides methods to handle coordinate transformations, element positioning,
* and mouse interactions when apps are scaled by the OS
* @namespace
*/
const scalingUtils = {
// Internal scale cache
_appScale: 1.0,
/**
* Detect the current app scale from CSS transform
* @returns {number} Scale factor (1.0 = 100%, 0.8 = 80%, etc.)
*/
detectAppScale() {
try {
// Find the app window container
const appWindow = document.querySelector('.app-window');
if (!appWindow) {
return 1.0;
}
// Check for scale classes
const scaleClasses = ['scale-75', 'scale-80', 'scale-85', 'scale-90', 'scale-95',
'scale-100', 'scale-105', 'scale-110', 'scale-115', 'scale-120',
'scale-125', 'scale-130', 'scale-135', 'scale-140', 'scale-145', 'scale-150'];
for (const scaleClass of scaleClasses) {
if (appWindow.classList.contains(scaleClass)) {
const scaleValue = parseInt(scaleClass.replace('scale-', ''));
this._appScale = scaleValue / 100;
return this._appScale;
}
}
// Fallback: check computed transform
const computedStyle = window.getComputedStyle(appWindow);
const transform = computedStyle.transform;
if (transform && transform !== 'none') {
// Parse transform matrix to extract scale
const matrix = transform.match(/matrix\(([^)]+)\)/);
if (matrix) {
const values = matrix[1].split(',').map(v => parseFloat(v.trim()));
if (values.length >= 4) {
// Matrix format: matrix(a, b, c, d, tx, ty) where a and d are scale factors
const scaleX = values[0];
const scaleY = values[3];
this._appScale = (scaleX + scaleY) / 2; // Average of X and Y scale
return this._appScale;
}
}
}
this._appScale = 1.0;
return 1.0;
} catch (error) {
console.error('Error detecting app scale:', error);
this._appScale = 1.0;
return 1.0;
}
},
/**
* Get the total effective scale (app scale × optional zoom scale)
* @param {number} [zoomScale=1.0] - Optional zoom scale to combine with app scale
* @returns {number} Combined scale factor
*/
getEffectiveScale(zoomScale = 1.0) {
const appScale = this.detectAppScale();
return appScale * zoomScale;
},
/**
* Convert screen coordinates to app coordinates (accounting for app scale and optional zoom)
* @param {number} screenX - Screen X coordinate
* @param {number} screenY - Screen Y coordinate
* @param {number} [zoomScale=1.0] - Optional zoom scale
* @returns {object} Object with x and y properties in app coordinates
*/
screenToAppCoords(screenX, screenY, zoomScale = 1.0) {
const scale = this.getEffectiveScale(zoomScale);
return {
x: screenX / scale,
y: screenY / scale
};
},
/**
* Convert app coordinates to screen coordinates (accounting for app scale and optional zoom)
* @param {number} appX - App X coordinate
* @param {number} appY - App Y coordinate
* @param {number} [zoomScale=1.0] - Optional zoom scale
* @returns {object} Object with x and y properties in screen coordinates
*/
appToScreenCoords(appX, appY, zoomScale = 1.0) {
const scale = this.getEffectiveScale(zoomScale);
return {
x: appX * scale,
y: appY * scale
};
},
/**
* Get scaled element bounding rectangle (compensates for app scaling)
* @param {Element} element - DOM element to get bounds for
* @returns {object} DOMRect-like object with scaled coordinates
*/
getScaledBoundingClientRect(element) {
const rect = element.getBoundingClientRect();
const appScale = this.detectAppScale();
// Note: Don't include zoom scale here as getBoundingClientRect already accounts for CSS transforms
return {
left: rect.left / appScale,
top: rect.top / appScale,
right: rect.right / appScale,
bottom: rect.bottom / appScale,
width: rect.width / appScale,
height: rect.height / appScale,
x: rect.x / appScale,
y: rect.y / appScale
};
},
/**
* Get scaled mouse coordinates from event (compensates for app scaling only)
* @param {Event} e - Mouse event
* @returns {object} Object with x and y properties in scaled coordinates
*/
getScaledMouseCoords(e) {
const appScale = this.detectAppScale();
return {
x: e.clientX / appScale,
y: e.clientY / appScale
};
},
/**
* Initialize scale detection with optional change callback
* @param {function} [onScaleChange] - Callback function called when scale changes
* @returns {MutationObserver} Observer instance for cleanup
*/
initScaleDetection(onScaleChange = null) {
// Detect scale on initialization
this.detectAppScale();
// Listen for scale changes (if the app scale changes dynamically)
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const oldScale = this._appScale;
const newScale = this.detectAppScale();
if (oldScale !== newScale) {
// Trigger callback if provided
if (onScaleChange && typeof onScaleChange === 'function') {
onScaleChange(newScale, oldScale);
}
}
}
});
});
// Observe the app window for class changes
const appWindow = document.querySelector('.app-window');
if (appWindow) {
observer.observe(appWindow, {
attributes: true,
attributeFilter: ['class']
});
}
return observer;
},
/**
* Get current cached app scale (without re-detection)
* @returns {number} Cached app scale factor
*/
getCurrentScale() {
return this._appScale;
},
/**
* Force refresh of scale detection
* @returns {number} New scale factor
*/
refreshScale() {
return this.detectAppScale();
}
};
// Extend SypnexAPI with scaling methods
Object.assign(SypnexAPI.prototype, {
/**
* Access to scaling utilities
* @type {object}
* @memberof SypnexAPI.prototype
*/
get scaling() {
return scalingUtils;
},
/**
* Convenience method: Detect current app scale
* @memberof SypnexAPI.prototype
* @returns {number} Scale factor
*/
detectAppScale() {
return scalingUtils.detectAppScale();
},
/**
* Convenience method: Get scaled mouse coordinates
* @param {Event} e - Mouse event
* @memberof SypnexAPI.prototype
* @returns {object} Scaled coordinates
*/
getScaledMouseCoords(e) {
return scalingUtils.getScaledMouseCoords(e);
},
/**
* Convenience method: Get scaled element bounds
* @param {Element} element - DOM element
* @memberof SypnexAPI.prototype
* @returns {object} Scaled bounding rectangle
*/
getScaledBoundingClientRect(element) {
return scalingUtils.getScaledBoundingClientRect(element);
},
/**
* Convenience method: Convert screen to app coordinates
* @param {number} screenX - Screen X coordinate
* @param {number} screenY - Screen Y coordinate
* @param {number} [zoomScale=1.0] - Optional zoom scale
* @memberof SypnexAPI.prototype
* @returns {object} App coordinates
*/
screenToAppCoords(screenX, screenY, zoomScale = 1.0) {
return scalingUtils.screenToAppCoords(screenX, screenY, zoomScale);
},
/**
* Convenience method: Convert app to screen coordinates
* @param {number} appX - App X coordinate
* @param {number} appY - App Y coordinate
* @param {number} [zoomScale=1.0] - Optional zoom scale
* @memberof SypnexAPI.prototype
* @returns {object} Screen coordinates
*/
appToScreenCoords(appX, appY, zoomScale = 1.0) {
return scalingUtils.appToScreenCoords(appX, appY, zoomScale);
},
/**
* Convenience method: Initialize scale detection
* @param {function} [onScaleChange] - Callback for scale changes
* @memberof SypnexAPI.prototype
* @returns {MutationObserver} Observer instance
*/
initScaleDetection(onScaleChange = null) {
return scalingUtils.initScaleDetection(onScaleChange);
}
});
// === sypnex-api-settings.js ===
// SypnexAPI Settings - App settings and user preferences
// This file extends the SypnexAPI class with settings management functionality
// Extend SypnexAPI with settings methods
Object.assign(SypnexAPI.prototype, {
/**
* Get an application setting
* @async
* @param {string} key - Setting key to retrieve
* @param {*} [defaultValue=null] - Default value if setting not found
* @memberof SypnexAPI.prototype
* @returns {Promise<*>} The setting value or default value
*/
async getSetting(key, defaultValue = null) {
try {
return await this.getAppSetting(key, defaultValue);
} catch (error) {
console.error(`SypnexAPI: Error getting setting ${key}:`, error);
return defaultValue;
}
},
/**
* Set an application setting
* @async
* @param {string} key - Setting key to set
* @param {*} value - Value to store
* @memberof SypnexAPI.prototype
* @returns {Promise<boolean>} True if saved successfully, false otherwise
*/
async setSetting(key, value) {
try {
const response = await fetch(`${this.baseUrl}/app-settings/${this.appId}/${key}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ value })
});
if (response.ok) {
return true;
} else {
console.error(`SypnexAPI: Failed to save setting ${key}`);
return false;
}
} catch (error) {
console.error(`SypnexAPI: Error setting ${key}:`, error);
return false;
}
},
/**
* Get all application settings
* @async
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} Object containing all app settings
*/
async getAllSettings() {
try {
return await this.getAllAppSettings();
} catch (error) {
console.error('SypnexAPI: Error getting all settings:', error);
return {};
}
},
/**
* Delete an application setting
* @async
* @param {string} key - Setting key to delete
* @memberof SypnexAPI.prototype
* @returns {Promise<boolean>} True if deleted successfully, false otherwise
*/
async deleteSetting(key) {
try {
const response = await fetch(`${this.baseUrl}/app-settings/${this.appId}/${key}`, {
method: 'DELETE'
});
if (response.ok) {
return true;
} else {
console.error(`SypnexAPI: Failed to delete setting ${key}`);
return false;
}
} catch (error) {
console.error(`SypnexAPI: Error deleting setting ${key}:`, error);
return false;
}
},
/**
* Get a user preference value
* @async
* @param {string} category - Preference category
* @param {string} key - Preference key
* @param {*} [defaultValue=null] - Default value if preference not found
* @memberof SypnexAPI.prototype
* @returns {Promise<*>} The preference value or default value
*/
async getPreference(category, key, defaultValue = null) {
try {
const response = await fetch(`${this.baseUrl}/preferences/${category}/${key}`);
if (response.ok) {
const data = await response.json();
return data.value !== undefined ? data.value : defaultValue;
}
return defaultValue;
} catch (error) {
console.error(`SypnexAPI: Error getting preference ${category}.${key}:`, error);
return defaultValue;
}
},
/**
* Set a user preference value
* @async
* @param {string} category - Preference category
* @param {string} key - Preference key
* @param {*} value - Value to store
* @memberof SypnexAPI.prototype
* @returns {Promise<boolean>} True if saved successfully, false otherwise
*/
async setPreference(category, key, value) {
try {
const response = await fetch(`${this.baseUrl}/preferences/${category}/${key}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ value })
});
if (response.ok) {
return true;
} else {
console.error(`SypnexAPI: Failed to save preference ${category}.${key}`);
return false;
}
} catch (error) {
console.error(`SypnexAPI: Error setting preference ${category}.${key}:`, error);
return false;
}
}
});
// === sypnex-api-crypto.js ===
// SypnexAPI Crypto - Simple encryption/decryption for user applications
// Extends SypnexAPI with basic crypto methods
Object.assign(SypnexAPI.prototype, {
/**
* Encrypt a value using the system's encryption service
* @param {string|object} value - The value to encrypt (will be JSON.stringify'd if object)
* @memberof SypnexAPI.prototype
* @returns {Promise<string|null>} The encrypted value as a string, or null if encryption failed
* @example
* // Encrypt a simple string
* const encrypted = await sypnexAPI.encrypt("my secret data");
*
* // Encrypt an object
* const encryptedObj = await sypnexAPI.encrypt({username: "john", password: "secret"});
*/
async encrypt(value) {
try {
const response = await fetch(`${this.baseUrl}/crypto/encrypt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ value })
});
if (response.ok) {
const data = await response.json();
if (data.success) {
return data.encrypted;
} else {
console.error('SypnexAPI: Encryption failed:', data.error);
return null;
}
} else {
console.error('SypnexAPI: Encryption request failed:', response.status);
return null;
}
} catch (error) {
console.error('SypnexAPI: Error encrypting value:', error);
return null;
}
},
/**
* Decrypt a value that was previously encrypted with the encrypt() method
* @param {string} encryptedValue - The encrypted value to decrypt
* @memberof SypnexAPI.prototype
* @returns {Promise<string|null>} The decrypted value, or null if decryption failed
* @example
* // Decrypt a previously encrypted value
* const decrypted = await sypnexAPI.decrypt(encryptedValue);
*
* // Handle decryption failure
* if (decrypted === null) {
* console.error("Failed to decrypt value");
* }
*/
async decrypt(encryptedValue) {
try {
const response = await fetch(`${this.baseUrl}/crypto/decrypt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ encrypted: encryptedValue })
});
if (response.ok) {
const data = await response.json();
if (data.success) {
return data.value;
} else {
console.error('SypnexAPI: Decryption failed:', data.error);
return null;
}
} else {
console.error('SypnexAPI: Decryption request failed:', response.status);
return null;
}
} catch (error) {
console.error('SypnexAPI: Error decrypting value:', error);
return null;
}
}
});
// === sypnex-api-socket.js ===
// SypnexAPI Socket - WebSocket communication
// This file extends the SypnexAPI class with Socket.IO functionality
// Extend SypnexAPI with socket methods
Object.assign(SypnexAPI.prototype, {
// Socket.IO instance for this app (sandboxed)
socket: null,
socketConnected: false,
socketEventListeners: new Map(), // Store event listeners
socketUrl: window.location.origin, // Default to same origin
// Auto-reconnect settings
autoReconnect: true,
reconnectAttempts: 0,
maxReconnectAttempts: 10,
reconnectDelay: 1000, // Start with 1 second
maxReconnectDelay: 30000, // Max 30 seconds
reconnectTimer: null,
roomsToRejoin: new Set(), // Track rooms to rejoin after reconnect
manualDisconnect: false, // Track if disconnect was manual
// Connection health monitoring
healthCheckInterval: 30000, // 30 seconds
healthCheckTimer: null,
enableHealthChecks: true,
/**
* Connect to Socket.IO server for this app instance
* @param {string} url - Socket.IO server URL (defaults to current origin)
* @param {object} options - Socket.IO connection options
* @memberof SypnexAPI.prototype
* @returns {Promise<boolean>} - Connection success status
*/
async connectSocket(url = null, options = {}) {
try {
// Use provided URL or default to current origin
const socketUrl = url || this.socketUrl;
// Connect to default namespace (same as websocket-server.html)
// App sandboxing is handled through app-specific data in messages
const fullUrl = socketUrl;
// Default options for app sandboxing
const defaultOptions = {
transports: ['websocket', 'polling'],
autoConnect: true,
forceNew: true, // Ensure new connection for each app
reconnection: this.autoReconnect,
reconnectionAttempts: this.maxReconnectAttempts,
reconnectionDelay: this.reconnectDelay,
reconnectionDelayMax: this.maxReconnectDelay,
timeout: 20000,
...options
};
// Create Socket.IO instance
this.socket = io(fullUrl, defaultOptions);
// Suppress WebSocket errors during disconnect
if (this.socket.io && this.socket.io.engine) {
const originalOnError = this.socket.io.engine.onerror;
this.socket.io.engine.onerror = (error) => {
// Only log errors if not during manual disconnect
if (!this.manualDisconnect) {
if (originalOnError) {
originalOnError.call(this.socket.io.engine, error);
}
}
};
}
// Set up connection event handlers
this.socket.on('connect', () => {
this.socketConnected = true;
this._triggerEvent('socket_connected', { appId: this.appId });
// Send app identification message
this.socket.emit('app_connect', {
appId: this.appId,
timestamp: Date.now()
});
// Start health checks
this.startHealthChecks();
});
this.socket.on('disconnect', (reason) => {
this.socketConnected = false;
this._triggerEvent('socket_disconnected', { appId: this.appId, reason });
// Don't auto-reconnect if it was a manual disconnect
if (this.manualDisconnect) {
this.manualDisconnect = false;
return;
}
// Start auto-reconnect if enabled
if (this.autoReconnect && reason !== 'io client disconnect') {
this._scheduleReconnect();
}
});
this.socket.on('connect_error', (error) => {
// Don't log connection errors during manual disconnect
if (!this.manualDisconnect) {
console.error(`SypnexAPI [${this.appId}]: Socket.IO connection error:`, error);
this._triggerEvent('socket_error', { appId: this.appId, error: error.message });
}
});
// Socket.IO reconnection events
this.socket.on('reconnect_attempt', (attemptNumber) => {
this._triggerEvent('reconnect_attempt', { appId: this.appId, attempt: attemptNumber });
});
this.socket.on('reconnect', (attemptNumber) => {
this.socketConnected = true;
this.reconnectAttempts = 0;
this._triggerEvent('reconnected', { appId: this.appId, attempts: attemptNumber });
// Rejoin rooms after reconnection
this._rejoinRooms();
});
this.socket.on('reconnect_error', (error) => {
console.error(`SypnexAPI [${this.appId}]: Reconnection error:`, error);
this._triggerEvent('reconnect_error', { appId: this.appId, error: error.message });
});
this.socket.on('reconnect_failed', () => {
console.error(`SypnexAPI [${this.appId}]: Reconnection failed after ${this.maxReconnectAttempts} attempts`);
this._triggerEvent('reconnect_failed', { appId: this.appId, attempts: this.maxReconnectAttempts });
});
// Wait for connection
return new Promise((resolve) => {
if (this.socket.connected) {
resolve(true);
} else {
this.socket.once('connect', () => resolve(true));
this.socket.once('connect_error', () => resolve(false));
}
});
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error connecting to Socket.IO:`, error);
return false;
}
},
/**
@memberof SypnexAPI.prototype
* Disconnect from Socket.IO server
*/
disconnectSocket() {
if (this.socket) {
this.manualDisconnect = true; // Mark as manual disconnect
this.stopHealthChecks(); // Stop health checks
this.socket.disconnect();
this.socket = null;
this.socketConnected = false;
this.roomsToRejoin.clear(); // Clear rooms to rejoin
}
},
/**
* Check if Socket.IO is connected
* @memberof SypnexAPI.prototype
* @returns {boolean} - Connection status
*/
isSocketConnected() {
return this.socketConnected && this.socket && this.socket.connected;
},
/**
* Send a message via Socket.IO
* @param {string} event - Event name
* @param {any} data - Data to send
* @param {string} room - Room to send to (optional)
* @memberof SypnexAPI.prototype
* @returns {boolean} - Success status
*/
sendMessage(event, data, room = null) {
if (!this.isSocketConnected()) {
console.error(`SypnexAPI [${this.appId}]: Cannot send message - not connected`);
return false;
}
try {
const messageData = {
appId: this.appId,
data: data,
timestamp: Date.now()
};
if (room) {
// Send to specific room using the same format as websocket-server.html
this.socket.emit('message', {
message: data,
room: room,
event_type: event,
appId: this.appId
});
} else {
// Send to all using the same format as websocket-server.html
this.socket.emit('message', {
message: data,
room: 'global',
event_type: event,
appId: this.appId
});
}
return true;
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error sending message:`, error);
return false;
}
},
/**
* Join a Socket.IO room
* @param {string} roomName - Room to join
* @memberof SypnexAPI.prototype
* @returns {boolean} - Success status
*/
joinRoom(roomName) {
if (!this.isSocketConnected()) {
console.error(`SypnexAPI [${this.appId}]: Cannot join room - not connected`);
return false;
}
try {
this.socket.emit('join_room', { room: roomName, appId: this.appId });
this.roomsToRejoin.add(roomName); // Track room for reconnection
return true;
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error joining room:`, error);
return false;
}
},
/**
* Leave a Socket.IO room
* @param {string} roomName - Room to leave
* @memberof SypnexAPI.prototype
* @returns {boolean} - Success status
*/
leaveRoom(roomName) {
if (!this.isSocketConnected()) {
console.error(`SypnexAPI [${this.appId}]: Cannot leave room - not connected`);
return false;
}
try {
this.socket.emit('leave_room', { room: roomName, appId: this.appId });
this.roomsToRejoin.delete(roomName); // Remove from reconnection tracking
return true;
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error leaving room:`, error);
return false;
}
},
/**
* Send a ping to test connection
* @memberof SypnexAPI.prototype
* @returns {Promise<number>} - Ping time in milliseconds
*/
async ping() {
if (!this.isSocketConnected()) {
throw new Error('Socket not connected');
}
return new Promise((resolve) => {
const startTime = Date.now();
this.socket.emit('ping', () => {
const pingTime = Date.now() - startTime;
resolve(pingTime);
});
});
},
/**
* Listen for Socket.IO events
* @param {string} eventName - Event name to listen for
* @memberof SypnexAPI.prototype
* @param {function} callback - Callback function
*/
on(eventName, callback) {
if (!this.socket) {
console.error(`SypnexAPI [${this.appId}]: Cannot listen for events - not connected`);
return;
}
// Store callback for cleanup
if (!this.socketEventListeners.has(eventName)) {
this.socketEventListeners.set(eventName, []);
}
this.socketEventListeners.get(eventName).push(callback);
// Add listener to socket
this.socket.on(eventName, (data) => {
callback(data);
});
},
/**
* Remove Socket.IO event listener
* @param {string} eventName - Event name
* @memberof SypnexAPI.prototype
* @param {function} callback - Callback function to remove
*/
off(eventName, callback) {
if (!this.socket) {
return;
}
// Remove from stored listeners
if (this.socketEventListeners.has(eventName)) {
const listeners = this.socketEventListeners.get(eventName);
const index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
// Remove from socket
this.socket.off(eventName, callback);
},
/**
* Trigger internal events (for app communication)
* @param {string} eventName - Event name
* @memberof SypnexAPI.prototype
* @param {any} data - Event data
*/
_triggerEvent(eventName, data) {
if (this.socketEventListeners.has(eventName)) {
const listeners = this.socketEventListeners.get(eventName);
listeners.forEach(callback => {
try {
callback(data);
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error in event callback:`, error);
}
});
}
},
/**
* Get the Socket.IO instance
* @memberof SypnexAPI.prototype
* @returns {object|null} - Socket.IO instance or null
*/
getSocket() {
return this.socket;
},
/**
* Get Socket.IO connection state
* @memberof SypnexAPI.prototype
* @returns {object} - Connection state object
*/
getSocketState() {
return {
connected: this.isSocketConnected(),
appId: this.appId,
url: this.socketUrl,
autoReconnect: this.autoReconnect,
reconnectAttempts: this.reconnectAttempts,
roomsToRejoin: Array.from(this.roomsToRejoin),
healthChecks: this.enableHealthChecks,
socket: this.socket ? {
id: this.socket.id,
connected: this.socket.connected,
disconnected: this.socket.disconnected
} : null
};
},
// ===== CONNECTION HEALTH MONITORING =====
/**
@memberof SypnexAPI.prototype
* Start periodic health checks
*/
startHealthChecks() {
if (!this.enableHealthChecks || this.healthCheckTimer) {
return;
}
this.healthCheckTimer = setInterval(() => {
this.performHealthCheck();
}, this.healthCheckInterval);
},
/**
@memberof SypnexAPI.prototype
* Stop periodic health checks
*/
stopHealthChecks() {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
},
/**
@memberof SypnexAPI.prototype
* Perform a health check ping
*/
async performHealthCheck() {
if (!this.isSocketConnected()) {
return;
}
try {
const pingTime = await this.ping();
} catch (error) {
console.warn(`SypnexAPI [${this.appId}]: Health check failed:`, error.message);
// If health check fails, it might indicate connection issues
// The auto-reconnect will handle reconnection if needed
}
},
/**
* Enable or disable health checks
* @memberof SypnexAPI.prototype
* @param {boolean} enabled - Whether to enable health checks
*/
setHealthChecks(enabled) {
this.enableHealthChecks = enabled;
if (enabled && this.isSocketConnected()) {
this.startHealthChecks();
} else {
this.stopHealthChecks();
}
},
/**
* Set health check interval
* @memberof SypnexAPI.prototype
* @param {number} intervalMs - Interval in milliseconds
*/
setHealthCheckInterval(intervalMs) {
this.healthCheckInterval = intervalMs;
if (this.healthCheckTimer) {
this.stopHealthChecks();
this.startHealthChecks();
}
},
// ===== AUTO-RECONNECT HELPER METHODS =====
/**
* Enable or disable auto-reconnect
* @memberof SypnexAPI.prototype
* @param {boolean} enabled - Whether to enable auto-reconnect
*/
setAutoReconnect(enabled) {
this.autoReconnect = enabled;
if (this.socket) {
this.socket.io.reconnection(enabled);
}
},
/**
* Set auto-reconnect configuration
* @memberof SypnexAPI.prototype
* @param {object} config - Reconnect configuration
*/
setReconnectConfig(config) {
if (config.maxAttempts !== undefined) {
this.maxReconnectAttempts = config.maxAttempts;
}
if (config.delay !== undefined) {
this.reconnectDelay = config.delay;
}
if (config.maxDelay !== undefined) {
this.maxReconnectDelay = config.maxDelay;
}
if (this.socket) {
this.socket.io.reconnectionAttempts(this.maxReconnectAttempts);
this.socket.io.reconnectionDelay(this.reconnectDelay);
this.socket.io.reconnectionDelayMax(this.maxReconnectDelay);
}
},
/**
@memberof SypnexAPI.prototype
* Manually trigger reconnection
*/
reconnect() {
if (this.socket) {
this.manualDisconnect = false; // Reset manual disconnect flag
this.socket.connect();
}
},
/**
* Schedule a reconnection attempt
* @memberof SypnexAPI.prototype
* @private
*/
_scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`SypnexAPI [${this.appId}]: Max reconnection attempts reached`);
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
this.reconnectTimer = setTimeout(() => {
if (this.socket && !this.socket.connected && !this.manualDisconnect) {
this.socket.connect();
}
}, delay);
},
/**
* Rejoin rooms after reconnection
* @memberof SypnexAPI.prototype
* @private
*/
_rejoinRooms() {
if (this.roomsToRejoin.size === 0) {
return;
}
this.roomsToRejoin.forEach(roomName => {
try {
this.socket.emit('join_room', { room: roomName, appId: this.appId });
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error rejoining room '${roomName}':`, error);
}
});
}
});
// === sypnex-api-vfs.js ===
// SypnexAPI VFS - Virtual File System operations
// This file extends the SypnexAPI class with virtual file system functionality
// Extend SypnexAPI with VFS methods
Object.assign(SypnexAPI.prototype, {
/**
* Get virtual file system statistics
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - System statistics
*/
async getVirtualFileStats() {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/stats`);
if (response.ok) {
return await response.json();
} else {
throw new Error(`Failed to get stats: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error getting virtual file stats:`, error);
throw error;
}
},
/**
* List files and directories in a path
* @param {string} path - Directory path (defaults to '/')
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Directory contents
*/
async listVirtualFiles(path = '/') {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/list?path=${encodeURIComponent(path)}`);
if (response.ok) {
return await response.json();
} else {
throw new Error(`Failed to list files: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error listing virtual files:`, error);
throw error;
}
},
/**
* Create a new folder
* @param {string} name - Folder name
* @param {string} parentPath - Parent directory path (defaults to '/')
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Creation result
*/
async createVirtualFolder(name, parentPath = '/') {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/create-folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, parent_path: parentPath })
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to create folder: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error creating virtual folder:`, error);
throw error;
}
},
/**
* Create a new file
* @param {string} name - File name
* @param {string} content - File content
* @param {string} parentPath - Parent directory path (defaults to '/')
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Creation result
*/
async createVirtualFile(name, content = '', parentPath = '/') {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/create-file`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, content, parent_path: parentPath })
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to create file: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error creating virtual file:`, error);
throw error;
}
},
/**
* Upload a file from the host system
* @param {File} file - File object from input element
* @param {string} parentPath - Parent directory path (defaults to '/')
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Upload result
*/
async uploadVirtualFile(file, parentPath = '/') {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('parent_path', parentPath);
const response = await fetch(`${this.baseUrl}/virtual-files/upload-file-streaming`, {
method: 'POST',
body: formData
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to upload file: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error uploading virtual file:`, error);
throw error;
}
},
/**
* Upload a file with real progress tracking based on actual upload progress
* @param {File} file - File object from input element
* @param {string} parentPath - Parent directory path (defaults to '/')
* @param {Function} progressCallback - Callback for progress updates (percent)
* @memberof SypnexAPI.prototype
* @returns {Object} - Object with promise and abort method { promise: Promise<object>, abort: Function }
*/
uploadVirtualFileChunked(file, parentPath = '/', progressCallback = null) {
try {
if (progressCallback) progressCallback(0);
// Create FormData for the upload
const formData = new FormData();
formData.append('file', file);
formData.append('parent_path', parentPath);
// Create XMLHttpRequest for progress tracking
let xhr = null;
const promise = new Promise((resolve, reject) => {
xhr = new XMLHttpRequest();
// Track upload progress
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && progressCallback) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
progressCallback(percentComplete);
}
});
// Handle successful completion
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText);
if (progressCallback) progressCallback(100);
resolve(result);
} catch (parseError) {
reject(new Error('Invalid JSON response from server'));
}
} else {
try {
const errorData = JSON.parse(xhr.responseText);
reject(new Error(errorData.error || `Upload failed with status: ${xhr.status}`));
} catch (parseError) {
reject(new Error(`Upload failed with status: ${xhr.status}`));
}
}
});
// Handle errors
xhr.addEventListener('error', () => {
reject(new Error('Network error during upload'));
});
// Handle abort
xhr.addEventListener('abort', () => {
reject(new Error('Upload cancelled by user'));
});
// Start the upload
xhr.open('POST', `${this.baseUrl}/virtual-files/upload-file-streaming`);
xhr.send(formData);
});
// Return both promise and abort function
return {
promise: promise,
abort: () => {
if (xhr) {
xhr.abort();
}
}
};
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error uploading chunked file:`, error);
throw error;
}
},
/**
* Read a file's content
* @param {string} filePath - Path to the file
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - File data
*/
async readVirtualFile(filePath) {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/read/${encodeURIComponent(filePath.substring(1))}`);
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to read file: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error reading virtual file:`, error);
throw error;
}
},
/**
* Get a file's content as text
* @param {string} filePath - Path to the file
* @memberof SypnexAPI.prototype
* @returns {Promise<string>} - File content as text
*/
async readVirtualFileText(filePath) {
try {
const fileData = await this.readVirtualFile(filePath);
return fileData.content || '';
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error reading virtual file text:`, error);
throw error;
}
},
/**
* Get a file's content as JSON
* @param {string} filePath - Path to the file
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Parsed JSON content
*/
async readVirtualFileJSON(filePath) {
try {
const content = await this.readVirtualFileText(filePath);
return JSON.parse(content);
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error reading virtual file JSON:`, error);
throw error;
}
},
/**
* Get a file's content as Blob (for binary files, images, etc.)
* @param {string} filePath - Path to the file
* @memberof SypnexAPI.prototype
* @returns {Promise<Blob>} - File content as Blob
*/
async readVirtualFileBlob(filePath) {
try {
const fileUrl = this.getVirtualFileUrl(filePath);
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch binary file: ${response.status} ${response.statusText}`);
}
return await response.blob();
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error reading virtual file blob:`, error);
throw error;
}
},
/**
* Serve a file directly (for binary files, images, etc.)
* @param {string} filePath - Path to the file
* @memberof SypnexAPI.prototype
* @returns {string} - Direct URL to serve the file
*/
getVirtualFileUrl(filePath) {
return `${this.baseUrl}/virtual-files/serve/${encodeURIComponent(filePath.substring(1))}`;
}, /**
* Delete a file or directory
* @param {string} itemPath - Path to the item to delete
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Deletion result
*/
async deleteVirtualItem(itemPath) {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/delete/${encodeURIComponent(itemPath.substring(1))}`, {
method: 'DELETE'
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to delete item: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error deleting virtual item:`, error);
throw error;
}
},
/**
* Get information about a file or directory
* @param {string} itemPath - Path to the item
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Item information
*/
async getVirtualItemInfo(itemPath) {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/info/${encodeURIComponent(itemPath.substring(1))}`);
if (response.ok) {
return await response.json();
} else {
// For 404 errors, create a specific error type that won't be logged
if (response.status === 404) {
const notFoundError = new Error('Item not found');
notFoundError.isNotFound = true;
notFoundError.status = 404;
throw notFoundError;
}
const errorData = await response.json();
const error = new Error(errorData.error || `Failed to get item info: ${response.status}`);
error.status = response.status;
throw error;
}
} catch (error) {
// Only log errors that aren't expected 404s
if (!error.isNotFound && error.status !== 404) {
console.error(`SypnexAPI [${this.appId}]: Error getting virtual item info:`, error);
}
throw error;
}
},
/**
* Check if a file or directory exists
* @param {string} itemPath - Path to the item
* @memberof SypnexAPI.prototype
* @returns {Promise<boolean>} - Whether the item exists
*/
async virtualItemExists(itemPath) {
try {
await this.getVirtualItemInfo(itemPath);
return true;
} catch (error) {
// Return false for any 404 or not found errors
if (error.isNotFound || error.status === 404) {
return false;
}
throw error;
}
},
/**
* Write content to a file (creates or overwrites)
* @param {string} filePath - Path to the file
* @param {string} content - File content
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async writeVirtualFile(filePath, content) {
try {
const response = await fetch(`${this.baseUrl}/virtual-files/write${filePath}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content })
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to write file: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error writing virtual file:`, error);
throw error;
}
},
/**
* Write JSON content to a file
* @param {string} filePath - Path to the file
* @param {object} data - JSON data to write
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async writeVirtualFileJSON(filePath, data) {
try {
const content = JSON.stringify(data, null, 2);
return await this.writeVirtualFile(filePath, content);
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error writing virtual file JSON:`, error);
throw error;
}
},
/**
* Write binary content to a file using the upload endpoint
* @param {string} filePath - Path to the file
* @param {Uint8Array|Blob} binaryData - Binary data to write
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async writeVirtualFileBinary(filePath, binaryData) {
try {
// First check if file exists and delete it
const exists = await this.virtualItemExists(filePath);
if (exists) {
await this.deleteVirtualItem(filePath);
}
// Extract name and parent path
const pathParts = filePath.split('/');
const fileName = pathParts.pop();
const parentPath = pathParts.length > 0 ? pathParts.join('/') || '/' : '/';
// Create FormData for the upload
const formData = new FormData();
// Convert Uint8Array to Blob if needed
let blob;
if (binaryData instanceof Uint8Array) {
blob = new Blob([binaryData], { type: 'application/octet-stream' });
} else if (binaryData instanceof Blob) {
blob = binaryData;
} else {
throw new Error('Binary data must be Uint8Array or Blob');
}
formData.append('file', blob, fileName);
formData.append('parent_path', parentPath);
const response = await fetch(`${this.baseUrl}/virtual-files/upload-file`, {
method: 'POST',
body: formData
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to upload binary file: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error writing virtual file binary:`, error);
throw error;
}
},
/**
* Create a directory structure (creates parent directories if needed)
* @param {string} dirPath - Directory path to create
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Creation result
*/
async createVirtualDirectoryStructure(dirPath) {
try {
const pathParts = dirPath.split('/').filter(part => part.length > 0);
let currentPath = '/';
for (const part of pathParts) {
const fullPath = currentPath === '/' ? `/${part}` : `${currentPath}/${part}`;
// Check if directory exists
const exists = await this.virtualItemExists(fullPath);
if (!exists) {
// Create the directory
const parentPath = currentPath;
await this.createVirtualFolder(part, parentPath);
}
currentPath = fullPath;
}
return { success: true, path: dirPath };
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error creating directory structure:`, error);
throw error;
}
}
});
// === sypnex-api-libraries.js ===
// SypnexAPI Libraries - CDN library loading
// This file extends the SypnexAPI class with library loading functionality
// Extend SypnexAPI with library methods
Object.assign(SypnexAPI.prototype, {
/**
* Load a library from CDN
* @param {string} url - CDN URL of the library
* @param {object} options - Loading options
* @memberof SypnexAPI.prototype
* @returns {Promise<any>} - Loaded library or true if successful
*/
async loadLibrary(url, options = {}) {
const {
localName = null,
timeout = 10000
} = options;
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error(`Library load timeout: ${url}`));
}, timeout);
const script = document.createElement('script');
script.src = url;
script.onload = () => {
clearTimeout(timeoutId);
if (localName && window[localName]) {
resolve(window[localName]);
} else {
resolve(true);
}
};
script.onerror = () => {
clearTimeout(timeoutId);
reject(new Error(`Failed to load library: ${url}`));
};
document.head.appendChild(script);
});
},
});
// === sypnex-api-file-explorer.js ===
// SypnexAPI File Explorer - File explorer UI component
// This file extends the SypnexAPI class with file explorer functionality
// Extend SypnexAPI with file explorer methods
Object.assign(SypnexAPI.prototype, {
/**
* Show a file explorer modal for selecting files or directories
* @param {object} options - Configuration options
* @param {string} options.mode - 'open' for loading files, 'save' for saving files
* @param {string} options.title - Modal title
* @param {string} options.initialPath - Starting directory path
* @param {string} options.fileName - Default filename for save mode
* @param {string} options.fileExtension - Required file extension (e.g., '.txt')
* @param {function} options.onSelect - Callback when file is selected
* @param {function} options.onCancel - Callback when modal is cancelled
* @memberof SypnexAPI.prototype
* @returns {Promise<string>} - Selected file path or null if cancelled
*/
async showFileExplorer(options = {}) {
const {
mode = 'open',
title = mode === 'open' ? 'Open File' : 'Save File',
initialPath = '/',
fileName = '',
fileExtension = '',
onSelect = null,
onCancel = null
} = options;
return new Promise((resolve) => {
// Create modal container
const modal = document.createElement('div');
modal.className = 'sypnex-file-explorer-modal';
modal.innerHTML = `
<div class="sypnex-file-explorer-container">
<div class="sypnex-file-explorer-header">
<h3><i class="fas fa-folder-open" style="color: var(--accent-color);"></i> ${title}</h3>
<button class="sypnex-file-explorer-close">
<i class="fas fa-times"></i>
</button>
</div>
<div class="sypnex-file-explorer-toolbar">
<div class="sypnex-file-explorer-path">
<i class="fas fa-folder"></i>
<span class="sypnex-file-explorer-path-text">${initialPath}</span>
</div>
<div class="sypnex-file-explorer-hint">
<i class="fas fa-info-circle"></i> Click folders to navigate, click files to select
</div>
<div class="sypnex-file-explorer-actions">
<button class="sypnex-file-explorer-btn sypnex-file-explorer-new-folder">
<i class="fas fa-folder-plus"></i> New Folder
</button>
<button class="sypnex-file-explorer-btn sypnex-file-explorer-refresh">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
<div class="sypnex-file-explorer-content">
<div class="sypnex-file-explorer-main">
<div class="sypnex-file-explorer-breadcrumb">
<span class="sypnex-file-explorer-breadcrumb-item" data-path="/">Root</span>
</div>
<div class="sypnex-file-explorer-list">
<div class="sypnex-file-explorer-loading">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
</div>
</div>
${mode === 'save' ? `
<div class="sypnex-file-explorer-save-section">
<label for="sypnex-file-explorer-filename">File Name:</label>
<input type="text" id="sypnex-file-explorer-filename" class="sypnex-file-explorer-input"
value="${fileName}" placeholder="Enter filename${fileExtension ? ' (required: ' + fileExtension + ')' : ''}">
</div>
` : ''}
<div class="sypnex-file-explorer-footer">
<button class="sypnex-file-explorer-btn sypnex-file-explorer-btn-secondary sypnex-file-explorer-cancel">
Cancel
</button>
<button class="sypnex-file-explorer-btn sypnex-file-explorer-btn-primary sypnex-file-explorer-select" disabled>
${mode === 'open' ? 'Open' : 'Save'}
</button>
</div>
</div>
</div>
`;
// Add modal to DOM
document.body.appendChild(modal);
// Add styles if not already added
if (!document.getElementById('sypnex-file-explorer-styles')) {
const style = document.createElement('style');
style.id = 'sypnex-file-explorer-styles';
style.textContent = `
.sypnex-file-explorer-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.sypnex-file-explorer-overlay {
display: none;
}
.sypnex-file-explorer-container {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 12px;
width: 100%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
margin: 5% auto;
position: relative;
}
.sypnex-file-explorer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
border-bottom: 1px solid var(--glass-border);
background: var(--glass-bg);
border-radius: 12px 12px 0 0;
}
.sypnex-file-explorer-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 1.1em;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.sypnex-file-explorer-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
}
.sypnex-file-explorer-close:hover {
background: rgba(255, 71, 87, 0.1);
color: #ff4757;
}
.sypnex-file-explorer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 20px;
border-bottom: 1px solid var(--glass-border);
background: var(--glass-bg);
min-height: 60px;
}
.sypnex-file-explorer-hint {
color: var(--text-secondary);
font-size: 12px;
display: flex;
align-items: center;
gap: 5px;
flex: 1;
justify-content: center;
white-space: nowrap;
}
.sypnex-file-explorer-path {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 14px;
flex-shrink: 0;
}
.sypnex-file-explorer-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.sypnex-file-explorer-btn {
display: flex;
align-items: center;
gap: 8px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
font-weight: 500;
min-width: 120px;
justify-content: center;
}
.sypnex-file-explorer-btn:hover {
background: rgba(0, 212, 255, 0.1);
border-color: var(--accent-color);
box-shadow: 0 2px 8px rgba(0, 212, 255, 0.2);
}
.sypnex-file-explorer-btn:active {
background: rgba(0, 212, 255, 0.15);
box-shadow: 0 1px 4px rgba(0, 212, 255, 0.3);
}
.sypnex-file-explorer-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
}
.sypnex-file-explorer-btn-primary {
background: var(--accent-color);
color: white;
font-weight: 600;
}
.sypnex-file-explorer-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.sypnex-file-explorer-btn-secondary {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.sypnex-file-explorer-btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
.sypnex-file-explorer-content {
display: flex;
flex: 1;
min-height: 300px;
max-height: calc(90vh - 200px);
overflow: hidden;
}
.sypnex-file-explorer-main {
flex: 1;
display: flex;
flex-direction: column;
}
.sypnex-file-explorer-breadcrumb {
padding: 15px 20px;
border-bottom: 1px solid var(--glass-border);
background: var(--glass-bg);
}
.sypnex-file-explorer-breadcrumb-item {
color: var(--accent-color);
cursor: pointer;
transition: color 0.2s ease;
}
.sypnex-file-explorer-breadcrumb-item:hover {
color: var(--accent-hover);
}
.sypnex-file-explorer-list {
flex: 1;
overflow-y: auto;
padding: 20px;
max-height: 100%;
}
.sypnex-file-explorer-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
padding: 40px;
}
.sypnex-file-explorer-item {
display: flex;
align-items: center;
gap: 15px;
padding: 12px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
margin-bottom: 5px;
}
.sypnex-file-explorer-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.sypnex-file-explorer-item.selected {
background: rgba(0, 212, 255, 0.2);
border: 1px solid var(--accent-color);
}
.sypnex-file-explorer-item[data-type="directory"] .sypnex-file-explorer-item-icon {
color: #ffd700;
}
.sypnex-file-explorer-item-icon {
width: 20px;
text-align: center;
color: var(--accent-color);
}
.sypnex-file-explorer-item-arrow {
color: var(--text-secondary);
font-size: 12px;
opacity: 0.7;
}
.sypnex-file-explorer-item[data-type="directory"]:hover .sypnex-file-explorer-item-arrow {
color: var(--accent-color);
opacity: 1;
}
.sypnex-file-explorer-item-name {
flex: 1;
color: var(--text-primary);
font-size: 14px;
}
.sypnex-file-explorer-item-size {
color: var(--text-secondary);
font-size: 12px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
}
.sypnex-file-explorer-save-section {
padding: 20px;
border-top: 1px solid var(--glass-border);
background: var(--glass-bg);
flex-shrink: 0;
}
.sypnex-file-explorer-save-section label {
display: block;
margin-bottom: 10px;
color: var(--text-primary);
font-weight: 500;
}
.sypnex-file-explorer-input {
width: 100%;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 10px 15px;
border-radius: 6px;
font-size: 14px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
transition: all 0.2s ease;
outline: none;
}
.sypnex-file-explorer-input:focus {
border-color: var(--accent-color);
background: rgba(0, 212, 255, 0.05);
}
.sypnex-file-explorer-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 20px;
border-top: 1px solid var(--glass-border);
background: var(--glass-bg);
border-radius: 0 0 12px 12px;
flex-shrink: 0;
}
.sypnex-file-explorer-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
padding: 40px;
font-style: italic;
}
/* Responsive Design */
@media (max-width: 768px) {
.sypnex-file-explorer-modal {
padding: 10px;
align-items: flex-start;
padding-top: 20px;
}
.sypnex-file-explorer-container {
max-width: 100%;
max-height: calc(100vh - 40px);
margin: 0;
}
.sypnex-file-explorer-header {
padding: 12px 15px;
}
.sypnex-file-explorer-header h3 {
font-size: 1em;
}
.sypnex-file-explorer-toolbar {
flex-direction: column;
gap: 10px;
padding: 12px 15px;
}
.sypnex-file-explorer-hint {
order: -1;
font-size: 11px;
}
.sypnex-file-explorer-actions {
justify-content: center;
}
.sypnex-file-explorer-btn {
padding: 6px 12px;
font-size: 13px;
}
.sypnex-file-explorer-breadcrumb,
.sypnex-file-explorer-save-section {
padding: 12px 15px;
}
.sypnex-file-explorer-footer {
padding: 15px;
flex-direction: column;
gap: 8px;
}
.sypnex-file-explorer-footer button {
width: 100%;
}
}
`;
document.head.appendChild(style);
}
// Add modal to DOM
document.body.appendChild(modal);
// Get references to elements
const pathText = modal.querySelector('.sypnex-file-explorer-path-text');
const breadcrumb = modal.querySelector('.sypnex-file-explorer-breadcrumb');
const fileList = modal.querySelector('.sypnex-file-explorer-list');
const selectBtn = modal.querySelector('.sypnex-file-explorer-select');
const cancelBtn = modal.querySelector('.sypnex-file-explorer-cancel');
const filenameInput = modal.querySelector('#sypnex-file-explorer-filename');
const refreshBtn = modal.querySelector('.sypnex-file-explorer-refresh');
const newFolderBtn = modal.querySelector('.sypnex-file-explorer-new-folder');
let currentPath = initialPath;
let selectedItem = null;
// Load directory contents
async function loadDirectory(path, isRefresh = false) {
try {
// For refresh operations, add a subtle loading indicator instead of clearing content
if (isRefresh) {
// Add a subtle loading overlay to existing content
const existingContent = fileList.innerHTML;
if (!fileList.querySelector('.sypnex-file-explorer-refresh-overlay')) {
const overlay = document.createElement('div');
overlay.className = 'sypnex-file-explorer-refresh-overlay';
overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.1);
backdrop-filter: blur(1px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
opacity: 0;
transition: opacity 0.2s ease;
`;
overlay.innerHTML = '<div style="color: var(--text-secondary); font-size: 12px;"><i class="fas fa-sync-alt fa-spin"></i> Updating...</div>';
fileList.style.position = 'relative';
fileList.appendChild(overlay);
// Fade in the overlay
setTimeout(() => overlay.style.opacity = '1', 10);
}
} else {
// For initial loads, show the loading spinner
fileList.innerHTML = '<div class="sypnex-file-explorer-loading"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
}
const response = await this.listVirtualFiles(path);
// Handle different response formats
let items = [];
if (Array.isArray(response)) {
items = response;
} else if (response && Array.isArray(response.items)) {
items = response.items;
} else if (response && typeof response === 'object') {
// Convert object to array if needed
items = Object.values(response);
}
if (!items || items.length === 0) {
fileList.innerHTML = '<div class="sypnex-file-explorer-empty">This directory is empty</div>';
fileList.style.position = '';
return;
}
// Ensure items is an array before sorting
if (!Array.isArray(items)) {
console.error('Items is not an array:', items);
fileList.innerHTML = '<div class="sypnex-file-explorer-empty">Error: Invalid response format</div>';
fileList.style.position = '';
return;
}
// Sort items: folders first, then files
const sortedItems = items.sort((a, b) => {
// Handle both 'type' and 'is_directory' fields for compatibility
const aIsDir = a.type === 'directory' || a.is_directory;
const bIsDir = b.type === 'directory' || b.is_directory;
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.name.localeCompare(b.name);
});
fileList.innerHTML = sortedItems.map(item => {
// Handle both 'type' and 'is_directory' fields for compatibility
const isDirectory = item.type === 'directory' || item.is_directory;
const icon = isDirectory ? 'fa-folder' : 'fa-file';
const size = isDirectory ? '' : this._formatFileSize(item.size || 0);
const itemPath = path === '/' ? `/${item.name}` : `${path}/${item.name}`;
return `
<div class="sypnex-file-explorer-item" data-path="${itemPath}" data-type="${isDirectory ? 'directory' : 'file'}" data-name="${item.name}">
<div class="sypnex-file-explorer-item-icon">
<i class="fas ${icon}"></i>
</div>
<div class="sypnex-file-explorer-item-name">${item.name}</div>
<div class="sypnex-file-explorer-item-size">${size}</div>
${isDirectory ? '<div class="sypnex-file-explorer-item-arrow"><i class="fas fa-chevron-right"></i></div>' : ''}
</div>
`;
}).join('');
// Reset fileList position in case it was modified for overlay
fileList.style.position = '';
// Update breadcrumb
updateBreadcrumb(path);
} catch (error) {
console.error('Error loading directory:', error);
fileList.innerHTML = '<div class="sypnex-file-explorer-empty">Error loading directory</div>';
fileList.style.position = '';
}
}
// Update breadcrumb navigation
function updateBreadcrumb(path) {
const parts = path.split('/').filter(part => part.length > 0);
let breadcrumbHTML = '<span class="sypnex-file-explorer-breadcrumb-item" data-path="/">Root</span>';
let currentPath = '';
parts.forEach((part, index) => {
currentPath += `/${part}`;
const isLast = index === parts.length - 1;
breadcrumbHTML += ` / <span class="sypnex-file-explorer-breadcrumb-item" data-path="${currentPath}" ${isLast ? 'style="color: var(--text-primary, #ffffff);"' : ''}>${part}</span>`;
});
breadcrumb.innerHTML = breadcrumbHTML;
}
// Format file size
this._formatFileSize = function(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Event listeners
if (fileList) {
fileList.addEventListener('click', async (e) => {
const item = e.target.closest('.sypnex-file-explorer-item');
if (!item) return;
const itemPath = item.dataset.path;
const itemType = item.dataset.type;
const itemName = item.dataset.name;
if (itemType === 'directory') {
// Navigate to directory
currentPath = itemPath;
if (pathText) pathText.textContent = currentPath;
await loadDirectory.call(this, currentPath);
} else {
// Select file
document.querySelectorAll('.sypnex-file-explorer-item').forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
selectedItem = { path: itemPath, name: itemName, type: itemType };
if (mode === 'save' && filenameInput) {
filenameInput.value = itemName;
}
if (selectBtn) selectBtn.disabled = false;
}
});
}
if (breadcrumb) {
breadcrumb.addEventListener('click', async (e) => {
const breadcrumbItem = e.target.closest('.sypnex-file-explorer-breadcrumb-item');
if (!breadcrumbItem) return;
const path = breadcrumbItem.dataset.path;
currentPath = path;
if (pathText) pathText.textContent = currentPath;
await loadDirectory.call(this, currentPath);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', async () => {
// Add visual loading state without changing button content
const icon = refreshBtn.querySelector('i');
const originalClasses = icon.className;
// Just change the icon class, don't touch innerHTML
icon.className = 'fas fa-sync-alt fa-spin';
refreshBtn.disabled = true;
refreshBtn.style.opacity = '0.7';
try {
await loadDirectory.call(this, currentPath, true); // true = isRefresh
} finally {
// Restore button state
icon.className = originalClasses;
refreshBtn.disabled = false;
refreshBtn.style.opacity = '';
}
});
}
if (newFolderBtn) {
newFolderBtn.addEventListener('click', async () => {
const folderName = await this.showInputModal(
'Create New Folder',
'Enter folder name:',
{
placeholder: 'e.g., My Documents',
confirmText: 'Create',
icon: 'fas fa-folder-plus'
}
);
if (!folderName) return;
try {
await this.createVirtualFolder(folderName, currentPath);
await loadDirectory.call(this, currentPath);
this.showNotification(`Folder "${folderName}" created successfully`, 'success');
} catch (error) {
this.showNotification(`Failed to create folder: ${error.message}`, 'error');
}
});
}
if (selectBtn) {
selectBtn.addEventListener('click', () => {
let selectedPath = null;
if (mode === 'open') {
if (selectedItem) {
selectedPath = selectedItem.path;
}
} else {
// Save mode
const filename = filenameInput ? filenameInput.value.trim() : '';
if (!filename) {
this.showNotification('Please enter a filename', 'warning');
return;
}
if (fileExtension && !filename.endsWith(fileExtension)) {
this.showNotification(`Filename must end with ${fileExtension}`, 'warning');
return;
}
selectedPath = currentPath === '/' ? `/${filename}` : `${currentPath}/${filename}`;
}
if (selectedPath) {
if (onSelect) onSelect(selectedPath);
resolve(selectedPath);
} else {
this.showNotification('Please select a file', 'warning');
return;
}
modal.remove();
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
if (onCancel) onCancel();
resolve(null);
modal.remove();
});
}
const closeBtn = modal.querySelector('.sypnex-file-explorer-close');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
if (onCancel) onCancel();
resolve(null);
modal.remove();
});
}
// Handle filename input for save mode
if (filenameInput && selectBtn) {
filenameInput.addEventListener('input', () => {
const filename = filenameInput.value.trim();
selectBtn.disabled = !filename;
});
}
// Load initial directory
loadDirectory.call(this, currentPath);
});
}
});
// === sypnex-api-logs.js ===
// SypnexAPI Logs - Logging system operations
// This file extends the SypnexAPI class with logging functionality
// Extend SypnexAPI with Logs methods
Object.assign(SypnexAPI.prototype, {
/**
* Write a log entry
* @param {object} logData - Log entry data
* @param {string} logData.level - Log level (debug, info, warn, error, critical)
* @param {string} logData.message - Log message
* @param {string} logData.component - Component type (core-os, user-apps, plugins, services)
* @param {string} [logData.source] - Source identifier (app name, plugin name, etc.)
* @param {object} [logData.details] - Additional details object
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async writeLog(logData) {
try {
// Validate required fields
if (!logData.level || !logData.message || !logData.component) {
throw new Error('Missing required fields: level, message, component');
}
// Set default source if not provided
if (!logData.source) {
logData.source = this.appId || 'unknown';
}
const response = await fetch(`${this.baseUrl}/logs/write`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(logData)
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(`Failed to write log: ${errorData.error || response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error writing log:`, error);
throw error;
}
},
/**
* Convenience method to write debug log
* @param {string} message - Log message
* @param {object} [details] - Additional details
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async logDebug(message, details = {}) {
return this.writeLog({
level: 'debug',
message: message,
component: 'user-apps',
source: this.appId,
details: details
});
},
/**
* Convenience method to write info log
* @param {string} message - Log message
* @param {object} [details] - Additional details
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async logInfo(message, details = {}) {
return this.writeLog({
level: 'info',
message: message,
component: 'user-apps',
source: this.appId,
details: details
});
},
/**
* Convenience method to write warning log
* @param {string} message - Log message
* @param {object} [details] - Additional details
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async logWarn(message, details = {}) {
return this.writeLog({
level: 'warn',
message: message,
component: 'user-apps',
source: this.appId,
details: details
});
},
/**
* Convenience method to write error log
* @param {string} message - Log message
* @param {object} [details] - Additional details
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async logError(message, details = {}) {
return this.writeLog({
level: 'error',
message: message,
component: 'user-apps',
source: this.appId,
details: details
});
},
/**
* Convenience method to write critical log
* @param {string} message - Log message
* @param {object} [details] - Additional details
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Write result
*/
async logCritical(message, details = {}) {
return this.writeLog({
level: 'critical',
message: message,
component: 'user-apps',
source: this.appId,
details: details
});
},
/**
* Read logs with filtering options
* @param {object} [filters] - Filter options
* @param {string} [filters.component] - Component to filter by (core-os, user-apps, plugins, services, all)
* @param {string} [filters.level] - Log level to filter by (debug, info, warn, error, critical, all)
* @param {string} [filters.date] - Date to filter by (YYYY-MM-DD format, defaults to today)
* @param {number} [filters.limit] - Maximum number of logs to return (default: 100)
* @param {string} [filters.source] - Source to filter by (app name, plugin name, etc.)
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Log entries and metadata
*/
async readLogs(filters = {}) {
try {
const params = new URLSearchParams();
if (filters.component) params.append('component', filters.component);
if (filters.level) params.append('level', filters.level);
if (filters.date) params.append('date', filters.date);
if (filters.limit) params.append('limit', filters.limit.toString());
if (filters.source) params.append('source', filters.source);
const response = await fetch(`${this.baseUrl}/logs/read?${params.toString()}`);
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(`Failed to read logs: ${errorData.error || response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error reading logs:`, error);
throw error;
}
},
/**
* Get available log dates for each component
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Available dates by component
*/
async getLogDates() {
try {
const response = await fetch(`${this.baseUrl}/logs/dates`);
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(`Failed to get log dates: ${errorData.error || response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error getting log dates:`, error);
throw error;
}
},
/**
* Clear logs with optional filtering
* @param {object} [filters] - Filter options
* @param {string} [filters.component] - Component to clear (core-os, user-apps, plugins, services, all)
* @param {string} [filters.date] - Specific date to clear (YYYY-MM-DD format) or 'all' for all dates
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Clear operation result
*/
async clearLogs(filters = {}) {
try {
const params = new URLSearchParams();
if (filters.component) params.append('component', filters.component);
if (filters.date) params.append('date', filters.date);
const response = await fetch(`${this.baseUrl}/logs/clear?${params.toString()}`, {
method: 'DELETE'
});
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(`Failed to clear logs: ${errorData.error || response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error clearing logs:`, error);
throw error;
}
},
/**
* Get logging system statistics
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Logging statistics
*/
async getLogStats() {
try {
const response = await fetch(`${this.baseUrl}/logs/stats`);
if (response.ok) {
return await response.json();
} else {
const errorData = await response.json();
throw new Error(`Failed to get log stats: ${errorData.error || response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error getting log stats:`, error);
throw error;
}
},
/**
* Get logs for the current app (convenience method)
* @param {object} [filters] - Additional filter options
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Log entries for this app
*/
async getMyLogs(filters = {}) {
return this.readLogs({
...filters,
source: this.appId,
component: 'user-apps'
});
}
});
// Create a namespace for direct access to logs functionality
if (typeof window !== 'undefined') {
window.SypnexLogs = {
// Direct access methods that don't require an app instance
async readLogs(filters = {}) {
const tempApi = new SypnexAPI('system-logs');
return tempApi.readLogs(filters);
},
async getLogDates() {
const tempApi = new SypnexAPI('system-logs');
return tempApi.getLogDates();
},
async getLogStats() {
const tempApi = new SypnexAPI('system-logs');
return tempApi.getLogStats();
},
async clearLogs(filters = {}) {
const tempApi = new SypnexAPI('system-logs');
return tempApi.clearLogs(filters);
}
};
}
// === sypnex-api-app-management.js ===
// SypnexAPI App Management - Application management operations
// This file extends the SypnexAPI class with app management functionality
// Extend SypnexAPI with app management methods
Object.assign(SypnexAPI.prototype, {
/**
* Get available applications from the registry
* @memberof SypnexAPI.prototype
* @async
* @returns {Promise<object>} - Available applications data
*/
async getAvailableApps() {
try {
const response = await fetch(`${this.baseUrl}/updates/latest`);
if (response.ok) {
return await response.json();
} else {
throw new Error(`Failed to get available apps: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error getting available apps:`, error);
throw error;
}
},
/**
* Get list of installed applications
* @memberof SypnexAPI.prototype
* @async
* @returns {Promise<Array>} - Array of installed applications
*/
async getInstalledApps() {
try {
const response = await fetch(`${this.baseUrl}/apps`);
if (response.ok) {
return await response.json();
} else {
throw new Error(`Failed to get installed apps: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error getting installed apps:`, error);
throw error;
}
},
/**
* Update a specific application to the latest version
* @memberof SypnexAPI.prototype
* @async
* @param {string} appId - Application ID to update
* @param {string} downloadUrl - Download URL for the app update (required)
* @returns {Promise<object>} - Update result
*/
async updateApp(appId, downloadUrl) {
try {
if (!downloadUrl) {
throw new Error('Download URL is required for app update');
}
const requestBody = {
download_url: downloadUrl
};
const fullUrl = `${this.baseUrl}/user-apps/update/${appId}`;
const response = await fetch(fullUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody)
});
if (response.ok) {
const result = await response.json();
return result;
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Update failed: ${response.status} ${response.statusText}: ${errorData.error || 'Unknown error'}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error updating app ${appId}:`, error);
throw error;
}
},
/**
* Refresh the application registry cache
* @async
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Refresh result
*/
async refreshAppRegistry() {
try {
const response = await fetch(`${this.baseUrl}/user-apps/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const result = await response.json();
return result;
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to refresh app registry: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error refreshing app registry:`, error);
throw error;
}
},
/**
* Install an application from the registry
* @async
* @param {string} appId - Application ID to install
* @param {object} [options={}] - Installation options
* @param {string} [options.version] - Specific version to install (defaults to latest)
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Installation result
*/
async installApp(appId, options = {}) {
try {
const { version } = options;
const payload = { app_id: appId };
if (version) {
payload.version = version;
}
const response = await fetch(`${this.baseUrl}/user-apps/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
if (response.ok) {
const result = await response.json();
return result;
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to install app: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error installing app ${appId}:`, error);
throw error;
}
},
/**
* Uninstall an application
* @async
* @param {string} appId - Application ID to uninstall
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Uninstallation result
*/
async uninstallApp(appId) {
try {
const response = await fetch(`${this.baseUrl}/user-apps/uninstall/${appId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
});
if (response.ok) {
const result = await response.json();
return result;
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to uninstall app: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error uninstalling app ${appId}:`, error);
throw error;
}
}
});
// === sypnex-api-network.js ===
// SypnexAPI Network - Network operations and HTTP proxy
// This file extends the SypnexAPI class with network functionality
// Extend SypnexAPI with network methods
Object.assign(SypnexAPI.prototype, {
/**
* Proxy an HTTP request through the system (tries direct CORS, falls back to proxy)
* @async
* @param {object} options - HTTP request options
* @param {string} options.url - Target URL for the request
* @param {string} [options.method='GET'] - HTTP method (GET, POST, PUT, DELETE, etc.)
* @param {object} [options.headers={}] - HTTP headers to send
* @param {*} [options.body] - Request body (will be JSON stringified if object)
* @param {number} [options.timeout=30] - Request timeout in seconds
* @param {boolean} [options.followRedirects=true] - Whether to follow redirects
* @param {boolean} [options.forceProxy=false] - Force use of backend proxy instead of direct CORS
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Response data in proxy format for compatibility
*/
async proxyHTTP(options) {
const {
url,
method = 'GET',
headers = {},
body = null,
timeout = 30,
followRedirects = true,
forceProxy = false
} = options;
if (!url) {
throw new Error('URL is required for HTTP proxy request');
}
// If forceProxy is true, skip direct CORS attempt
if (forceProxy) {
return await this._proxyThroughBackend(options);
}
// Try direct CORS first
try {
const result = await this._directCORSRequest(options);
return result;
} catch (corsError) {
// If CORS fails, fall back to backend proxy
const result = await this._proxyThroughBackend(options);
return result;
}
},
/**
* Make a direct CORS request
* @memberof SypnexAPI.prototype
* @private
*/
async _directCORSRequest(options) {
const {
url,
method = 'GET',
headers = {},
body = null,
timeout = 30,
followRedirects = true
} = options;
// Create timeout controller
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout * 1000);
try {
// Prepare fetch options
const fetchOptions = {
method: method.toUpperCase(),
headers: {
...headers
},
signal: controller.signal,
mode: 'cors',
credentials: 'omit',
redirect: followRedirects ? 'follow' : 'manual'
};
// Handle body based on content type and data type
// Only add body for methods that support it
if (body !== null && body !== undefined && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) {
if (typeof body === 'object' && !(body instanceof FormData) && !(body instanceof ArrayBuffer) && !(body instanceof Blob)) {
fetchOptions.body = JSON.stringify(body);
// Ensure Content-Type is set for JSON
if (!fetchOptions.headers['Content-Type'] && !fetchOptions.headers['content-type']) {
fetchOptions.headers['Content-Type'] = 'application/json';
}
} else {
fetchOptions.body = body;
}
}
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
// Check for CORS failure - if response is not ok and type is 'opaque' or 'cors', it's likely a CORS issue
if (!response.ok && (response.type === 'opaque' || response.type === 'cors')) {
throw new Error(`CORS request failed with status ${response.status}`);
}
// Also check if response is completely empty (another CORS indicator)
if (response.status === 0) {
throw new Error('Network request failed - likely CORS issue');
}
// Check if response is binary based on content-type
const contentType = response.headers.get('content-type') || '';
const isBinary = contentType.includes('audio/') ||
contentType.includes('video/') ||
contentType.includes('image/') ||
contentType.includes('application/octet-stream') ||
contentType.includes('application/pdf');
let content;
if (isBinary) {
// Handle binary response as base64
const arrayBuffer = await response.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
content = btoa(binary);
} else {
// Handle text response
content = await response.text();
// Try to parse as JSON if content-type suggests it
if (contentType.includes('application/json')) {
try {
content = JSON.parse(content);
} catch (e) {
// Keep as text if JSON parsing fails
}
}
}
// Return response in the same format as the old proxy
return {
status: response.status,
content: content,
is_binary: isBinary,
headers: Object.fromEntries(response.headers.entries())
};
} catch (fetchError) {
clearTimeout(timeoutId);
if (fetchError.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout} seconds`);
}
// Let CORS errors bubble up so they can trigger fallback
throw fetchError;
}
},
/**
* Make a request through the backend proxy
* @memberof SypnexAPI.prototype
* @private
*/
async _proxyThroughBackend(options) {
try {
const {
url,
method = 'GET',
headers = {},
body = null,
timeout = 30,
followRedirects = true
} = options;
const proxyRequest = {
url,
method: method.toUpperCase(),
headers,
timeout,
followRedirects
};
// Handle body based on content type and data type
if (body !== null && body !== undefined) {
proxyRequest.body = body;
}
const response = await fetch(`${this.baseUrl}/proxy/http`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(proxyRequest)
});
if (response.ok) {
const result = await response.json();
return result;
} else {
const errorData = await response.json();
throw new Error(errorData.error || `Proxy request failed: ${response.status}`);
}
} catch (error) {
console.error(`SypnexAPI [${this.appId}]: Error in backend proxy request:`, error);
// Return error in proxy format for compatibility
return {
status: 0,
error: error.message,
content: null,
is_binary: false
};
}
},
/**
* Make a GET request through the proxy
* @async
* @param {string} url - Target URL
* @param {object} [options={}] - Additional options
* @param {object} [options.headers={}] - HTTP headers
* @param {number} [options.timeout=30] - Request timeout in seconds
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Response data
*/
async proxyGET(url, options = {}) {
return await this.proxyHTTP({
url,
method: 'GET',
...options
});
},
/**
* Make a POST request through the proxy
* @async
* @param {string} url - Target URL
* @param {*} body - Request body
* @param {object} [options={}] - Additional options
* @param {object} [options.headers={}] - HTTP headers
* @param {number} [options.timeout=30] - Request timeout in seconds
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Response data
*/
async proxyPOST(url, body, options = {}) {
return await this.proxyHTTP({
url,
method: 'POST',
body,
...options
});
},
/**
* Make a PUT request through the proxy
* @async
* @param {string} url - Target URL
* @param {*} body - Request body
* @param {object} [options={}] - Additional options
* @param {object} [options.headers={}] - HTTP headers
* @param {number} [options.timeout=30] - Request timeout in seconds
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Response data
*/
async proxyPUT(url, body, options = {}) {
return await this.proxyHTTP({
url,
method: 'PUT',
body,
...options
});
},
/**
* Make a DELETE request through the proxy
* @async
* @param {string} url - Target URL
* @param {object} [options={}] - Additional options
* @param {object} [options.headers={}] - HTTP headers
* @param {number} [options.timeout=30] - Request timeout in seconds
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Response data
*/
async proxyDELETE(url, options = {}) {
return await this.proxyHTTP({
url,
method: 'DELETE',
...options
});
},
/**
* Make a JSON API request through the proxy
* @async
* @param {string} url - Target URL
* @param {object} [options={}] - Request options
* @param {string} [options.method='GET'] - HTTP method
* @param {object} [options.data] - JSON data to send
* @param {object} [options.headers={}] - Additional headers
* @param {number} [options.timeout=30] - Request timeout in seconds
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} - Parsed JSON response
*/
async proxyJSON(url, options = {}) {
const {
method = 'GET',
data = null,
headers = {},
timeout = 30
} = options;
const requestOptions = {
url,
method,
headers: {
'Content-Type': 'application/json',
...headers
},
timeout
};
if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
requestOptions.body = data;
}
return await this.proxyHTTP(requestOptions);
}
});
// === sypnex-api-llm.js ===
// SypnexAPI LLM - Universal LLM interface for user applications
// Extends SypnexAPI with LLM provider translation capabilities
Object.assign(SypnexAPI.prototype, {
/**
* Complete a chat conversation using any supported LLM provider
* Translates OpenAI format to provider-specific format and normalizes response
* @async
* @param {object} options - Configuration options
* @param {string} options.provider - Provider: 'openai', 'anthropic', 'google', 'ollama'
* @param {string} options.endpoint - API endpoint URL
* @param {string} [options.apiKey] - API key (not needed for Ollama)
* @param {string} options.model - Model name
* @param {Array} options.messages - Messages array in OpenAI format
* @param {number} [options.temperature=0.7] - Temperature (0-1)
* @param {number} [options.maxTokens=1000] - Maximum tokens to generate
* @param {boolean} [options.stream=false] - Whether to stream response
* @memberof SypnexAPI.prototype
* @returns {Promise<object>} Normalized response: {content, usage, model, provider}
*/
async llmComplete(options) {
try {
const {
provider,
endpoint,
apiKey,
model,
messages,
temperature = 0.7,
maxTokens = 1000,
stream = false
} = options;
// Validate required parameters
if (!provider || !endpoint || !model || !messages) {
throw new Error('Missing required parameters: provider, endpoint, model, messages');
}
if (!Array.isArray(messages) || messages.length === 0) {
throw new Error('Messages must be a non-empty array');
}
// Format request based on provider
const formattedRequest = this._formatLLMRequest(provider, {
model,
messages,
temperature,
maxTokens,
stream
});
// Prepare headers
const headers = this._getLLMHeaders(provider, apiKey);
// Make the request using existing proxyHTTP
const proxyRequest = {
url: endpoint,
method: 'POST',
headers: headers,
body: formattedRequest,
timeout: 60 // Longer timeout for LLM requests
};
const response = await this.proxyHTTP(proxyRequest);
if (!response || response.status < 200 || response.status >= 300) {
throw new Error(`LLM API request failed: ${response?.status || 'Unknown error'}`);
}
// Parse response
let responseData;
if (typeof response.content === 'string') {
responseData = JSON.parse(response.content);
} else {
responseData = response.content;
}
// Normalize response based on provider
const normalizedResponse = this._normalizeLLMResponse(provider, responseData);
return {
...normalizedResponse,
provider: provider
};
} catch (error) {
console.error('SypnexAPI: LLM completion error:', error);
throw error;
}
},
/**
* Format request for specific provider (private method)
* @memberof SypnexAPI.prototype
* @private
*/
_formatLLMRequest(provider, options) {
const { model, messages, temperature, maxTokens, stream } = options;
switch (provider.toLowerCase()) {
case 'openai':
return {
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens,
stream: stream
};
case 'anthropic':
// Separate system message for Anthropic
const anthropicMessages = [];
let systemMessage = null;
for (const msg of messages) {
if (msg.role === 'system') {
systemMessage = msg.content;
} else {
anthropicMessages.push({
role: msg.role,
content: msg.content
});
}
}
const anthropicRequest = {
model: model,
messages: anthropicMessages,
max_tokens: maxTokens,
temperature: temperature,
stream: stream
};
if (systemMessage) {
anthropicRequest.system = systemMessage;
}
return anthropicRequest;
case 'google':
// Convert to Google's parts format
const contents = [];
let systemInstruction = null;
for (const msg of messages) {
if (msg.role === 'system') {
systemInstruction = msg.content;
} else if (msg.role === 'user') {
contents.push({
role: 'user',
parts: [{ text: msg.content }]
});
} else if (msg.role === 'assistant') {
contents.push({
role: 'model',
parts: [{ text: msg.content }]
});
}
}
const googleRequest = {
contents: contents,
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens
}
};
if (systemInstruction) {
googleRequest.systemInstruction = {
parts: [{ text: systemInstruction }]
};
}
return googleRequest;
case 'ollama':
return {
model: model,
messages: messages,
stream: stream,
options: {
temperature: temperature,
num_predict: maxTokens
}
};
default:
throw new Error(`Unsupported LLM provider: ${provider}`);
}
},
/**
* Get headers for specific provider (private method)
* @memberof SypnexAPI.prototype
* @private
*/
_getLLMHeaders(provider, apiKey) {
const headers = {
'Content-Type': 'application/json'
};
switch (provider.toLowerCase()) {
case 'openai':
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
break;
case 'anthropic':
if (apiKey) {
headers['X-API-Key'] = apiKey;
headers['anthropic-version'] = '2023-06-01';
}
break;
case 'google':
if (apiKey) {
headers['X-goog-api-key'] = apiKey;
}
break;
case 'ollama':
// Ollama typically doesn't require authentication
break;
}
return headers;
},
/**
* Normalize response from provider to OpenAI format (private method)
* @memberof SypnexAPI.prototype
* @private
*/
_normalizeLLMResponse(provider, responseData) {
switch (provider.toLowerCase()) {
case 'openai':
return {
content: responseData.choices?.[0]?.message?.content || '',
model: responseData.model || '',
usage: responseData.usage || {}
};
case 'anthropic':
const anthropicContent = responseData.content?.[0]?.text || '';
return {
content: anthropicContent,
model: responseData.model || '',
usage: responseData.usage || {}
};
case 'google':
const googleContent = responseData.candidates?.[0]?.content?.parts?.[0]?.text || '';
return {
content: googleContent,
model: 'google-model', // Google doesn't return model in response
usage: responseData.usageMetadata || {}
};
case 'ollama':
return {
content: responseData.message?.content || '',
model: responseData.model || '',
usage: {
prompt_tokens: responseData.prompt_eval_count || 0,
completion_tokens: responseData.eval_count || 0,
total_tokens: (responseData.prompt_eval_count || 0) + (responseData.eval_count || 0)
}
};
default:
// Generic fallback
return {
content: JSON.stringify(responseData),
model: 'unknown',
usage: {}
};
}
}
});