Skip to content

Commit 331d9e7

Browse files
KernelDeimosjelveh
andauthored
feat: allow apps to add a menubar via puter.js
* 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 <[email protected]>
1 parent ec31007 commit 331d9e7

File tree

9 files changed

+361
-11
lines changed

9 files changed

+361
-11
lines changed

packages/backend/src/routers/_default.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ router.all('*', async function(req, res, next) {
176176
const user = await get_user({uuid: req.query.user_uuid})
177177

178178
// more validation
179-
if(user === undefined || user === null || user === false)
179+
if(!user)
180180
h += '<p style="text-align:center; color:red;">User not found.</p>';
181181
else if(user.unsubscribed === 1)
182182
h += '<p style="text-align:center; color:green;">You are already unsubscribed.</p>';

packages/puter-js/src/index.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Auth from './modules/Auth.js';
99
import FSItem from './modules/FSItem.js';
1010
import * as utils from './lib/utils.js';
1111
import path from './lib/path.js';
12+
import Util from './modules/Util.js';
1213

1314
window.puter = (function() {
1415
'use strict';
@@ -168,14 +169,17 @@ window.puter = (function() {
168169

169170
// Initialize submodules
170171
initSubmodules = function(){
172+
// Util
173+
this.util = new Util();
174+
171175
// Auth
172176
this.auth = new Auth(this.authToken, this.APIOrigin, this.appID, this.env);
173177
// OS
174178
this.os = new OS(this.authToken, this.APIOrigin, this.appID, this.env);
175179
// FileSystem
176180
this.fs = new FileSystem(this.authToken, this.APIOrigin, this.appID, this.env);
177181
// UI
178-
this.ui = new UI(this.appInstanceID, this.parentInstanceID, this.appID, this.env);
182+
this.ui = new UI(this.appInstanceID, this.parentInstanceID, this.appID, this.env, this.util);
179183
// Hosting
180184
this.hosting = new Hosting(this.authToken, this.APIOrigin, this.appID, this.env);
181185
// Apps

packages/puter-js/src/lib/xdrpc.js

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* This module provides a simple RPC mechanism for cross-document
3+
* (iframe / window.postMessage) communication.
4+
*/
5+
6+
// Since `Symbol` is not clonable, we use a UUID to identify RPCs.
7+
const $SCOPE = '9a9c83a4-7897-43a0-93b9-53217b84fde6';
8+
9+
/**
10+
* The CallbackManager is used to manage callbacks for RPCs.
11+
* It is used by the dehydrator and hydrator to store and retrieve
12+
* the functions that are being called remotely.
13+
*/
14+
export class CallbackManager {
15+
#messageId = 0;
16+
17+
constructor () {
18+
this.callbacks = new Map();
19+
}
20+
21+
register_callback (callback) {
22+
const id = this.#messageId++;
23+
this.callbacks.set(id, callback);
24+
return id;
25+
}
26+
27+
attach_to_source (source) {
28+
source.addEventListener('message', event => {
29+
const { data } = event;
30+
console.log(
31+
'test-app got message from window',
32+
data,
33+
);
34+
debugger;
35+
if (data && typeof data === 'object' && data.$SCOPE === $SCOPE) {
36+
const { id, args } = data;
37+
const callback = this.callbacks.get(id);
38+
if (callback) {
39+
callback(...args);
40+
}
41+
}
42+
});
43+
}
44+
}
45+
46+
/**
47+
* The dehydrator replaces functions in an object with identifiers,
48+
* so that hydrate() can be called on the other side of the frame
49+
* to bind RPC stubs. The original functions are stored in a map
50+
* so that they can be called when the RPC is invoked.
51+
*/
52+
export class Dehydrator {
53+
constructor ({ callbackManager }) {
54+
this.callbackManager = callbackManager;
55+
}
56+
dehydrate (value) {
57+
return this.dehydrate_value_(value);
58+
}
59+
dehydrate_value_ (value) {
60+
if (typeof value === 'function') {
61+
const id = this.callbackManager.register_callback(value);
62+
return { $SCOPE, id };
63+
} else if (Array.isArray(value)) {
64+
return value.map(this.dehydrate_value_.bind(this));
65+
} else if (typeof value === 'object' && value !== null) {
66+
const result = {};
67+
for (const key in value) {
68+
result[key] = this.dehydrate_value_(value[key]);
69+
}
70+
return result;
71+
} else {
72+
return value;
73+
}
74+
}
75+
}
76+
77+
/**
78+
* The hydrator binds RPC stubs to the functions that were
79+
* previously dehydrated. This allows the RPC to be invoked
80+
* on the other side of the frame.
81+
*/
82+
export class Hydrator {
83+
constructor ({ target }) {
84+
this.target = target;
85+
}
86+
hydrate (value) {
87+
return this.hydrate_value_(value);
88+
}
89+
hydrate_value_ (value) {
90+
if (
91+
value && typeof value === 'object' &&
92+
value.$SCOPE === $SCOPE
93+
) {
94+
const { id } = value;
95+
return (...args) => {
96+
console.log('sending message', { $SCOPE, id, args });
97+
console.log('target', this.target);
98+
this.target.postMessage({ $SCOPE, id, args }, '*');
99+
};
100+
} else if (Array.isArray(value)) {
101+
return value.map(this.hydrate_value_.bind(this));
102+
} else if (typeof value === 'object' && value !== null) {
103+
const result = {};
104+
for (const key in value) {
105+
result[key] = this.hydrate_value_(value[key]);
106+
}
107+
return result;
108+
}
109+
return value;
110+
}
111+
}

packages/puter-js/src/modules/UI.js

+18-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,19 @@ class UI extends EventListener {
149149
this.#callbackFunctions[msg_id] = resolve;
150150
}
151151

152-
constructor (appInstanceID, parentInstanceID, appID, env) {
152+
#postMessageWithObject = function(name, value) {
153+
const dehydrator = this.util.rpc.getDehydrator({
154+
target: this.messageTarget
155+
});
156+
this.messageTarget?.postMessage({
157+
msg: name,
158+
env: this.env,
159+
appInstanceID: this.appInstanceID,
160+
value: dehydrator.dehydrate(value),
161+
}, '*');
162+
}
163+
164+
constructor (appInstanceID, parentInstanceID, appID, env, util) {
153165
const eventNames = [
154166
'localeChanged',
155167
'themeChanged',
@@ -160,6 +172,7 @@ class UI extends EventListener {
160172
this.parentInstanceID = parentInstanceID;
161173
this.appID = appID;
162174
this.env = env;
175+
this.util = util;
163176

164177
if(this.env === 'app'){
165178
this.messageTarget = window.parent;
@@ -641,6 +654,10 @@ class UI extends EventListener {
641654
})
642655
}
643656

657+
setMenubar = function(spec) {
658+
this.#postMessageWithObject('setMenubar', spec);
659+
}
660+
644661
/**
645662
* Asynchronously extracts entries from DataTransferItems, like files and directories.
646663
*

packages/puter-js/src/modules/Util.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { CallbackManager, Dehydrator, Hydrator } from "../lib/xdrpc";
2+
3+
/**
4+
* The Util module exposes utilities within puter.js itself.
5+
* These utilities may be used internally by other modules.
6+
*/
7+
export default class Util {
8+
constructor () {
9+
// This is in `puter.util.rpc` instead of `puter.rpc` because
10+
// `puter.rpc` is reserved for an app-to-app RPC interface.
11+
// This is a lower-level RPC interface used to communicate
12+
// with iframes.
13+
this.rpc = new UtilRPC();
14+
}
15+
}
16+
17+
class UtilRPC {
18+
constructor () {
19+
this.callbackManager = new CallbackManager();
20+
this.callbackManager.attach_to_source(window);
21+
}
22+
23+
getDehydrator () {
24+
return new Dehydrator({ callbackManager: this.callbackManager });
25+
}
26+
27+
getHydrator ({ target }) {
28+
return new Hydrator({ target });
29+
}
30+
}

src/IPC.js

+114
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
2727
import UIPrompt from './UI/UIPrompt.js';
2828
import download from './helpers/download.js';
2929
import path from "./lib/path.js";
30+
import UIContextMenu from './UI/UIContextMenu.js';
3031

3132
/**
3233
* In Puter, apps are loaded in iframes and communicate with the graphical user interface (GUI) aand each other using the postMessage API.
@@ -352,6 +353,119 @@ window.addEventListener('message', async (event) => {
352353
}, '*');
353354
}
354355
//--------------------------------------------------------
356+
// setMenubar
357+
//--------------------------------------------------------
358+
else if(event.data.msg === 'setMenubar') {
359+
const el_window = window_for_app_instance(event.data.appInstanceID);
360+
361+
console.error(`EXPERIMENTAL: setMenubar is a work-in-progress`);
362+
const hydrator = puter.util.rpc.getHydrator({
363+
target: target_iframe.contentWindow,
364+
});
365+
const value = hydrator.hydrate(event.data.value);
366+
console.log('hydrated value', value);
367+
368+
// Show menubar
369+
const $menubar = $(el_window).find('.window-menubar')
370+
$menubar.show();
371+
372+
const sanitize_items = items => {
373+
return items.map(item => {
374+
return {
375+
html: item.label,
376+
action: item.action,
377+
items: item.items && sanitize_items(item.items),
378+
};
379+
});
380+
};
381+
382+
// This array will store the menubar button elements
383+
const menubar_buttons = [];
384+
385+
// Add menubar items
386+
let current = null;
387+
let current_i = null;
388+
let state_open = false;
389+
const open_menu = ({ i, pos, parent_element, items }) => {
390+
let delay = true;
391+
if ( state_open ) {
392+
if ( current_i === i ) return;
393+
394+
delay = false;
395+
current && current.cancel({ meta: 'menubar', fade: false });
396+
}
397+
398+
// Set this menubar button as active
399+
menubar_buttons.forEach(el => el.removeClass('active'));
400+
menubar_buttons[i].addClass('active');
401+
402+
// Open the context menu
403+
const ctxMenu = UIContextMenu({
404+
delay,
405+
parent_element,
406+
position: {top: pos.top + 28, left: pos.left},
407+
items: sanitize_items(items),
408+
});
409+
410+
state_open = true;
411+
current = ctxMenu;
412+
current_i = i;
413+
414+
ctxMenu.onClose = (cancel_options) => {
415+
if ( cancel_options?.meta === 'menubar' ) return;
416+
menubar_buttons.forEach(el => el.removeClass('active'));
417+
ctxMenu.onClose = null;
418+
current_i = null;
419+
current = null;
420+
state_open = false;
421+
}
422+
};
423+
const add_items = (parent, items) => {
424+
for (let i=0; i < items.length; i++) {
425+
const I = i;
426+
const item = items[i];
427+
const label = html_encode(item.label);
428+
const el_item = $(`<div class="window-menubar-item"><span>${label}</span></div>`);
429+
const parent_element = el_item.parent()[0];
430+
el_item.on('click', () => {
431+
if ( state_open ) {
432+
state_open = false;
433+
current && current.cancel({ meta: 'menubar' });
434+
current_i = null;
435+
current = null;
436+
return;
437+
}
438+
if (item.action) {
439+
item.action();
440+
} else if (item.items) {
441+
const pos = el_item[0].getBoundingClientRect();
442+
open_menu({
443+
i,
444+
pos,
445+
parent_element,
446+
items: item.items,
447+
});
448+
}
449+
});
450+
el_item.on('mouseover', () => {
451+
if ( ! state_open ) return;
452+
if ( ! item.items ) return;
453+
454+
const pos = el_item[0].getBoundingClientRect();
455+
open_menu({
456+
i,
457+
pos,
458+
parent_element,
459+
items: item.items,
460+
});
461+
});
462+
$menubar.append(el_item);
463+
menubar_buttons.push(el_item);
464+
}
465+
};
466+
add_items($menubar, value.items);
467+
}
468+
//--------------------------------------------------------
355469
// setWindowWidth
356470
//--------------------------------------------------------
357471
else if(event.data.msg === 'setWindowWidth' && event.data.width !== undefined){

0 commit comments

Comments
 (0)