Skip to content

Commit

Permalink
feat: allow apps to add a menubar via puter.js
Browse files Browse the repository at this point in the history
* Begin work on menubar and dropdowns

* Improve menubar

* Fix pointer event behavior

* Fix labels

* Fix active button

* Eliminate flicker

* Update _default.js

---------

Co-authored-by: Nariman Jelveh <n.jelveh@gmail.com>
  • Loading branch information
KernelDeimos and jelveh authored Apr 23, 2024
1 parent ec31007 commit 331d9e7
Show file tree
Hide file tree
Showing 9 changed files with 361 additions and 11 deletions.
2 changes: 1 addition & 1 deletion packages/backend/src/routers/_default.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ router.all('*', async function(req, res, next) {
const user = await get_user({uuid: req.query.user_uuid})

// more validation
if(user === undefined || user === null || user === false)
if(!user)
h += '<p style="text-align:center; color:red;">User not found.</p>';
else if(user.unsubscribed === 1)
h += '<p style="text-align:center; color:green;">You are already unsubscribed.</p>';
Expand Down
6 changes: 5 additions & 1 deletion packages/puter-js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Auth from './modules/Auth.js';
import FSItem from './modules/FSItem.js';
import * as utils from './lib/utils.js';
import path from './lib/path.js';
import Util from './modules/Util.js';

window.puter = (function() {
'use strict';
Expand Down Expand Up @@ -168,14 +169,17 @@ window.puter = (function() {

// Initialize submodules
initSubmodules = function(){
// Util
this.util = new Util();

// Auth
this.auth = new Auth(this.authToken, this.APIOrigin, this.appID, this.env);
// OS
this.os = new OS(this.authToken, this.APIOrigin, this.appID, this.env);
// FileSystem
this.fs = new FileSystem(this.authToken, this.APIOrigin, this.appID, this.env);
// UI
this.ui = new UI(this.appInstanceID, this.parentInstanceID, this.appID, this.env);
this.ui = new UI(this.appInstanceID, this.parentInstanceID, this.appID, this.env, this.util);
// Hosting
this.hosting = new Hosting(this.authToken, this.APIOrigin, this.appID, this.env);
// Apps
Expand Down
111 changes: 111 additions & 0 deletions packages/puter-js/src/lib/xdrpc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* This module provides a simple RPC mechanism for cross-document
* (iframe / window.postMessage) communication.
*/

// Since `Symbol` is not clonable, we use a UUID to identify RPCs.
const $SCOPE = '9a9c83a4-7897-43a0-93b9-53217b84fde6';

/**
* The CallbackManager is used to manage callbacks for RPCs.
* It is used by the dehydrator and hydrator to store and retrieve
* the functions that are being called remotely.
*/
export class CallbackManager {
#messageId = 0;

constructor () {
this.callbacks = new Map();
}

register_callback (callback) {
const id = this.#messageId++;
this.callbacks.set(id, callback);
return id;
}

attach_to_source (source) {
source.addEventListener('message', event => {
const { data } = event;
console.log(
'test-app got message from window',
data,
);
debugger;
if (data && typeof data === 'object' && data.$SCOPE === $SCOPE) {
const { id, args } = data;
const callback = this.callbacks.get(id);
if (callback) {
callback(...args);
}
}
});
}
}

/**
* The dehydrator replaces functions in an object with identifiers,
* so that hydrate() can be called on the other side of the frame
* to bind RPC stubs. The original functions are stored in a map
* so that they can be called when the RPC is invoked.
*/
export class Dehydrator {
constructor ({ callbackManager }) {
this.callbackManager = callbackManager;
}
dehydrate (value) {
return this.dehydrate_value_(value);
}
dehydrate_value_ (value) {
if (typeof value === 'function') {
const id = this.callbackManager.register_callback(value);
return { $SCOPE, id };
} else if (Array.isArray(value)) {
return value.map(this.dehydrate_value_.bind(this));
} else if (typeof value === 'object' && value !== null) {
const result = {};
for (const key in value) {
result[key] = this.dehydrate_value_(value[key]);
}
return result;
} else {
return value;
}
}
}

/**
* The hydrator binds RPC stubs to the functions that were
* previously dehydrated. This allows the RPC to be invoked
* on the other side of the frame.
*/
export class Hydrator {
constructor ({ target }) {
this.target = target;
}
hydrate (value) {
return this.hydrate_value_(value);
}
hydrate_value_ (value) {
if (
value && typeof value === 'object' &&
value.$SCOPE === $SCOPE
) {
const { id } = value;
return (...args) => {
console.log('sending message', { $SCOPE, id, args });
console.log('target', this.target);
this.target.postMessage({ $SCOPE, id, args }, '*');
};
} else if (Array.isArray(value)) {
return value.map(this.hydrate_value_.bind(this));
} else if (typeof value === 'object' && value !== null) {
const result = {};
for (const key in value) {
result[key] = this.hydrate_value_(value[key]);
}
return result;
}
return value;
}
}
19 changes: 18 additions & 1 deletion packages/puter-js/src/modules/UI.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,19 @@ class UI extends EventListener {
this.#callbackFunctions[msg_id] = resolve;
}

constructor (appInstanceID, parentInstanceID, appID, env) {
#postMessageWithObject = function(name, value) {
const dehydrator = this.util.rpc.getDehydrator({
target: this.messageTarget
});
this.messageTarget?.postMessage({
msg: name,
env: this.env,
appInstanceID: this.appInstanceID,
value: dehydrator.dehydrate(value),
}, '*');
}

constructor (appInstanceID, parentInstanceID, appID, env, util) {
const eventNames = [
'localeChanged',
'themeChanged',
Expand All @@ -160,6 +172,7 @@ class UI extends EventListener {
this.parentInstanceID = parentInstanceID;
this.appID = appID;
this.env = env;
this.util = util;

if(this.env === 'app'){
this.messageTarget = window.parent;
Expand Down Expand Up @@ -641,6 +654,10 @@ class UI extends EventListener {
})
}

setMenubar = function(spec) {
this.#postMessageWithObject('setMenubar', spec);
}

/**
* Asynchronously extracts entries from DataTransferItems, like files and directories.
*
Expand Down
30 changes: 30 additions & 0 deletions packages/puter-js/src/modules/Util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { CallbackManager, Dehydrator, Hydrator } from "../lib/xdrpc";

/**
* The Util module exposes utilities within puter.js itself.
* These utilities may be used internally by other modules.
*/
export default class Util {
constructor () {
// This is in `puter.util.rpc` instead of `puter.rpc` because
// `puter.rpc` is reserved for an app-to-app RPC interface.
// This is a lower-level RPC interface used to communicate
// with iframes.
this.rpc = new UtilRPC();
}
}

class UtilRPC {
constructor () {
this.callbackManager = new CallbackManager();
this.callbackManager.attach_to_source(window);
}

getDehydrator () {
return new Dehydrator({ callbackManager: this.callbackManager });
}

getHydrator ({ target }) {
return new Hydrator({ target });
}
}
114 changes: 114 additions & 0 deletions src/IPC.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
import UIPrompt from './UI/UIPrompt.js';
import download from './helpers/download.js';
import path from "./lib/path.js";
import UIContextMenu from './UI/UIContextMenu.js';

/**
* In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI) aand each other using the postMessage API.
Expand Down Expand Up @@ -352,6 +353,119 @@ window.addEventListener('message', async (event) => {
}, '*');
}
//--------------------------------------------------------
// setMenubar
//--------------------------------------------------------
else if(event.data.msg === 'setMenubar') {
const el_window = window_for_app_instance(event.data.appInstanceID);

console.error(`EXPERIMENTAL: setMenubar is a work-in-progress`);
const hydrator = puter.util.rpc.getHydrator({
target: target_iframe.contentWindow,
});
const value = hydrator.hydrate(event.data.value);
console.log('hydrated value', value);

// Show menubar
const $menubar = $(el_window).find('.window-menubar')
$menubar.show();

const sanitize_items = items => {
return items.map(item => {
return {
html: item.label,
action: item.action,
items: item.items && sanitize_items(item.items),
};
});
};

// This array will store the menubar button elements
const menubar_buttons = [];

// Add menubar items
let current = null;
let current_i = null;
let state_open = false;
const open_menu = ({ i, pos, parent_element, items }) => {
let delay = true;
if ( state_open ) {
if ( current_i === i ) return;

delay = false;
current && current.cancel({ meta: 'menubar', fade: false });
}

// Set this menubar button as active
menubar_buttons.forEach(el => el.removeClass('active'));
menubar_buttons[i].addClass('active');

// Open the context menu
const ctxMenu = UIContextMenu({
delay,
parent_element,
position: {top: pos.top + 28, left: pos.left},
items: sanitize_items(items),
});

state_open = true;
current = ctxMenu;
current_i = i;

ctxMenu.onClose = (cancel_options) => {
if ( cancel_options?.meta === 'menubar' ) return;
menubar_buttons.forEach(el => el.removeClass('active'));
ctxMenu.onClose = null;
current_i = null;
current = null;
state_open = false;
}
};
const add_items = (parent, items) => {
for (let i=0; i < items.length; i++) {
const I = i;
const item = items[i];
const label = html_encode(item.label);
const el_item = $(`<div class="window-menubar-item"><span>${label}</span></div>`);
const parent_element = el_item.parent()[0];
el_item.on('click', () => {
if ( state_open ) {
state_open = false;
current && current.cancel({ meta: 'menubar' });
current_i = null;
current = null;
return;
}
if (item.action) {
item.action();
} else if (item.items) {
const pos = el_item[0].getBoundingClientRect();
open_menu({
i,
pos,
parent_element,
items: item.items,
});
}
});
el_item.on('mouseover', () => {
if ( ! state_open ) return;
if ( ! item.items ) return;

const pos = el_item[0].getBoundingClientRect();
open_menu({
i,
pos,
parent_element,
items: item.items,
});
});
$menubar.append(el_item);
menubar_buttons.push(el_item);
}
};
add_items($menubar, value.items);
}
//--------------------------------------------------------
// setWindowWidth
//--------------------------------------------------------
else if(event.data.msg === 'setWindowWidth' && event.data.width !== undefined){
Expand Down
Loading

0 comments on commit 331d9e7

Please sign in to comment.