Add class registry (second pass)

ExportService gets removed and instead a global class registry is added.
The `init.js` file is split into `init_sync.js` and `init_async.js`
so that synchronous code that isn't dependent on imports is guarenteed
to run before initgui.js. The globalThis scope and service-script API
now expose `def`, a function for registering class definitions, and
`use`, a function for obtaining registered classes.
This commit is contained in:
KernelDeimos
2024-05-28 18:12:07 -04:00
parent 51bac4486f
commit e050506a05
27 changed files with 265 additions and 156 deletions

View File

@@ -114,6 +114,12 @@ class PuterHomepageService extends BaseService {
const bundled = env != 'dev' || use_bundled_gui;
const writeScriptTag = path =>
`<script type="${
Array.isArray(path) ? 'text/javascirpt' : 'module'
}" src="${Array.isArray(path) ? path[0] : path}"></script>\n`
;
return `<!DOCTYPE html>
<html lang="en">
@@ -231,13 +237,16 @@ class PuterHomepageService extends BaseService {
${
((!bundled && manifest?.js_paths)
? manifest.js_paths.map(path => `<script type="module" src="${path}"></script>\n`)
? manifest.js_paths.map(path => writeScriptTag(path))
: []).join('')
}
<!-- Load the GUI script -->
<script ${ !bundled ? ' type="module"' : ''} src="${(!bundled && manifest?.index) || '/dist/gui.js'}"></script>
<script ${
// !bundled ? ' type="module"' : ''
' type="module"'
} src="${(!bundled && manifest?.index) || '/dist/gui.js'}"></script>
<!-- Initialize GUI when document is loaded -->
<script>
<script type="module">
window.addEventListener('load', function() {
gui(${
// TODO: override JSON.stringify to ALWAYS to this...

View File

@@ -24,7 +24,8 @@
"/css/theme.css"
],
"js_paths": [
"/src/initgui.js",
"/src/init_sync.js",
"/src/init_async.js",
"/src/helpers.js",
"/src/IPC.js",
"/src/globals.js",

View File

@@ -1,6 +1,6 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
export default class Button extends Component {
export default def(class Button extends Component {
static ID = 'ui.component.Button';
static PROPERTIES = {
@@ -65,6 +65,4 @@ export default class Button extends Component {
$(this.dom_).find('button').prop('disabled', ! enabled);
});
}
}
defineComponent(Button);
});

View File

@@ -1,6 +1,6 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
export default class CodeEntryView extends Component {
export default def(class CodeEntryView extends Component {
static ID = 'ui.component.CodeEntryView';
static PROPERTIES = {
@@ -215,6 +215,4 @@ export default class CodeEntryView extends Component {
}
});
}
}
defineComponent(CodeEntryView);
})

View File

@@ -1,9 +1,9 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* Display a list of checkboxes for the user to confirm.
*/
export default class ConfirmationsView extends Component {
export default def(class ConfirmationsView extends Component {
static ID = 'ui.component.ConfirmationsView';
static PROPERTIES = {
@@ -58,6 +58,4 @@ export default class ConfirmationsView extends Component {
}
});
}
}
defineComponent(ConfirmationsView);
});

View File

@@ -1,10 +1,10 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* Allows a flex layout of composed components to be
* treated as a component.
*/
export default class Flexer extends Component {
export default def(class Flexer extends Component {
static ID = 'ui.component.Flexer';
static PROPERTIES = {
@@ -38,6 +38,4 @@ export default class Flexer extends Component {
$(this.dom_).find('div').first().css('gap', gap);
});
}
}
defineComponent(Flexer);
});

View File

@@ -1,9 +1,9 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* Allows using an HTML string as a component.
*/
export default class JustHTML extends Component {
export default def(class JustHTML extends Component {
static ID = 'ui.component.JustHTML';
static PROPERTIES = { html: { value: '' } };
@@ -15,6 +15,4 @@ export default class JustHTML extends Component {
$(this.dom_).find('span').html(html);
});
}
}
defineComponent(JustHTML);
});

View File

@@ -1,6 +1,6 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
export default class PasswordEntry extends Component {
export default def(class PasswordEntry extends Component {
static ID = 'ui.component.PasswordEntry';
static PROPERTIES = {
@@ -133,6 +133,4 @@ export default class PasswordEntry extends Component {
$(this.dom_).find("#toggle-show-password").attr("src", show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"])
});
}
}
defineComponent(PasswordEntry);
});

View File

@@ -1,7 +1,7 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
import UIComponentWindow from "../UIComponentWindow.js";
export default class QRCodeView extends Component {
export default def(class QRCodeView extends Component {
static ID = 'ui.component.QRCodeView';
static PROPERTIES = {
@@ -78,6 +78,4 @@ export default class QRCodeView extends Component {
}
});
}
}
defineComponent(QRCodeView);
});

View File

@@ -1,6 +1,6 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
export default class RecoveryCodeEntryView extends Component {
export default def(class RecoveryCodeEntryView extends Component {
static ID = 'ui.component.RecoveryCodeEntryView';
static PROPERTIES = {
value: {},
@@ -83,6 +83,4 @@ export default class RecoveryCodeEntryView extends Component {
}
});
}
}
defineComponent(RecoveryCodeEntryView);
});

View File

@@ -1,6 +1,6 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
export default class RecoveryCodesView extends Component {
export default def(class RecoveryCodesView extends Component {
static ID = 'ui.component.RecoveryCodesView';
static PROPERTIES = {
@@ -91,6 +91,4 @@ export default class RecoveryCodesView extends Component {
print_frame.contentWindow.window.print();
});
}
}
defineComponent(RecoveryCodesView);
});

View File

@@ -16,12 +16,12 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* Slider: A labeled slider input.
*/
export default class Slider extends Component {
export default def(class Slider extends Component {
static ID = 'ui.component.Slider';
static PROPERTIES = {
@@ -111,6 +111,4 @@ export default class Slider extends Component {
input.value = value;
});
}
}
defineComponent(Slider);
});

View File

@@ -0,0 +1,41 @@
const Component = use('util.Component');
export default def(class Spinner extends Component {
static ID = 'ui.component.Spinner';
static PROPERTIES = {}
// static RENDER_MODE = Component.NO_SHADOW;
create_template ({ template }) {
console.log('template?', template);
template.innerHTML = /*html*/`
<div>
<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24">
<title>circle anim</title>
<g fill="#212121" class="nc-icon-wrapper">
<g class="nc-loop-circle-24-icon-f">
<path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path>
<path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path>
</g>
<style>
.nc-loop-circle-24-icon-f{
--animation-duration:0.5s;
transform-origin:12px 12px;
animation:nc-loop-circle-anim var(--animation-duration) infinite linear
}
@keyframes nc-loop-circle-anim{
0%{
transform:rotate(0)
}
100%{
transform:rotate(360deg)
}
}
</style>
</g>
</svg>
</div>
`;
}
});

View File

@@ -1,11 +1,11 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* StepHeading renders a heading with a leading symbol.
* The leading symbol is styled inside a cricle and is
* optimized for single-digit numbers.
*/
export default class StepHeading extends Component {
export default def(class StepHeading extends Component {
static ID = 'ui.component.StepHeading';
static PROPERTIES = {
@@ -58,6 +58,4 @@ export default class StepHeading extends Component {
</div>
`);
}
}
defineComponent(StepHeading);
});

View File

@@ -1,6 +1,6 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
export default class StepView extends Component {
export default def(class StepView extends Component {
static ID = 'ui.component.StepView';
static PROPERTIES = {
@@ -64,6 +64,4 @@ export default class StepView extends Component {
}
this.set('position', this.get('position') + 1);
}
}
defineComponent(StepView);
});

View File

@@ -1,10 +1,10 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* A simple component that displays a string in the
* specified style.
*/
export default class StringView extends Component {
export default def(class StringView extends Component {
static ID = 'ui.component.StringView';
static PROPERTIES = {
@@ -42,6 +42,4 @@ export default class StringView extends Component {
either({ heading: this.get('heading'), text });
});
}
}
defineComponent(StringView);
});

View File

@@ -1,9 +1,9 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* A table with a sticky header
*/
export default class Table extends Component {
export default def(class Table extends Component {
static ID = 'ui.component.Table';
static PROPERTIES = {
@@ -80,6 +80,4 @@ export default class Table extends Component {
}
});
}
}
defineComponent(Table);
});

View File

@@ -1,9 +1,9 @@
import { Component, defineComponent } from "../../util/Component.js";
const Component = use('util.Component');
/**
* A simple component when you just need to test something.
*/
export default class TestView extends Component {
export default def(class TestView extends Component {
static ID = 'ui.component.TestView';
static CSS = `
@@ -19,6 +19,4 @@ export default class TestView extends Component {
<div>I am a test view</div>
`);
}
}
defineComponent(TestView);
});

View File

@@ -18,6 +18,7 @@
*/
window.puter_gui_enabled = true;
/**
* Initializes and configures the GUI (Graphical User Interface) settings based on the provided options.
*

8
src/init_async.js Normal file
View File

@@ -0,0 +1,8 @@
// Note: this logs AFTER all imports because imports are hoisted
logger.info('start -> async initialization');
import './util/TeePromise.js';
import './util/Component.js';
logger.info('end -> async initialization');
globalThis.init_promise.resolve();

119
src/init_sync.js Normal file
View File

@@ -0,0 +1,119 @@
// An initial logger to log do before we get a more fancy logger
// (which we never really do yet, at the time of writing this);
// something like this was also done in backend and it proved useful.
(scope => {
globalThis.logger = {
info: (...a) => console.log('%c[INIT/INFO]', 'color: #4287f5', ...a),
};
})(globalThis);
logger.info('start -> blocking initialization');
// A global promise (like TeePromise, except we can't import anything yet)
// that will be resolved by `init_async.js` when it completes.
(scope => {
scope.init_promise = (() => {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
promise.resolve = resolve;
promise.reject = reject;
return promise;
})();
})(globalThis);
// This is where `use()` and `def()` are defined.
//
// A global registry for class definitions. This allows us to expose
// classes to service scripts even when the frontend code is bundled.
// Additionally, it allows us to create hooks upon class registration,
// which we use to turn classes which extend HTMLElement into components
// (i.e. give them tag names because that is required).
//
// It's worth noting `use()` and `def()` for service scripts is exposed
// in initgui.js, in the `launch_services()` function. (at the time this
// comment was written)
(scope => {
const registry_ = {
classes_m: {},
classes_l: [],
hooks_on_register: [],
};
const on_self_registered_api = {
on_other_registered: hook => registry_.hooks_on_register.push(hook),
}
scope.lib = {
is_subclass (subclass, superclass) {
if (subclass === superclass) return true;
let proto = subclass.prototype;
while (proto) {
if (proto === superclass.prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
};
scope.def = (cls, id) => {
id = id || cls.ID;
if ( id === undefined ) {
throw new Error('Class must have an ID');
}
if ( registry_.classes_m[id] ) {
throw new Error(`Class with ID ${id} already registered`);
}
registry_.classes_m[id] = cls;
registry_.classes_l.push(cls);
registry_.hooks_on_register.forEach(hook => hook({ cls }));
console.log('registered class', id, registry_);
// Find class that owns 'on_self_registered' hook
let owner = cls;
while (
owner.__proto__ && owner.__proto__.on_self_registered
&& owner.__proto__.on_self_registered === cls.on_self_registered
) {
owner = owner.__proto__;
}
if ( cls.on_self_registered ) {
cls.on_self_registered.call(cls, {
...on_self_registered_api,
is_owner: cls === owner,
});
}
return cls;
};
scope.use = id => {
console.log('use called with id: ', id);
if ( id === undefined ) {
return registry_.classes_m;
}
if ( !registry_.classes_m[id] ) {
throw new Error(`Class with ID ${id} not registered`);
}
console.log(
'okay it\'s going to return:',
registry_.classes_m[id],
'and the entire map is this: ',
registry_.classes_m
)
return registry_.classes_m[id];
}
})(globalThis);
logger.info('end -> blocking initialization');

View File

@@ -40,10 +40,11 @@ import { ProcessService } from './services/ProcessService.js';
import { PROCESS_RUNNING } from './definitions.js';
import { LocaleService } from './services/LocaleService.js';
import { SettingsService } from './services/SettingsService.js';
import { ExportService } from './services/ExportService.js';
import UIComponentWindow from './UI/UIComponentWindow.js';
import Spinner from './UI/Components/Spinner.js';
const launch_services = async function () {
// === Services Data Structures ===
const services_l_ = [];
@@ -56,8 +57,7 @@ const launch_services = async function () {
services_m_[name] = instance;
}
const svc_export = new ExportService();
svc_export.register('UIComponentWindow', UIComponentWindow);
globalThis.def(UIComponentWindow, 'ui.UIComponentWindow');
// === Hooks for Service Scripts from Backend ===
const service_script_deferred = { services: [], on_ready: [] };
@@ -66,8 +66,8 @@ const launch_services = async function () {
on_ready: fn => service_script_deferred.on_ready.push(fn),
// Some files can't be imported by service scripts,
// so this hack makes that possible.
use: svc_export.get.bind(svc_export),
exp: svc_export.register.bind(svc_export),
def: globalThis.def,
use: globalThis.use,
// use: name => ({ UIWindow, UIComponentWindow })[name],
};
globalThis.service_script_api_promise.resolve(service_script_api);
@@ -78,7 +78,6 @@ const launch_services = async function () {
register('process', new ProcessService());
register('locale', new LocaleService());
register('settings', new SettingsService());
register('export', svc_export);
// === Service-Script Services ===
for (const [name, script] of service_script_deferred.services) {

View File

@@ -1,24 +0,0 @@
import { Service } from "../definitions.js";
/**
* This service is responsible for exporting definitions to the
* service script SDK. This is the SDK that services provided by
* the backend will use.
*/
export class ExportService extends Service {
constructor () {
super();
this.exports_ = {};
}
register (name, definition) {
this.exports_[name] = definition;
}
get (name) {
if ( name ) {
return this.exports_[name];
}
return this.exports_;
}
}

View File

@@ -44,6 +44,8 @@ const css_paths = [
// Ordered list of JS scripts
const js_paths = [
'/init_sync.js',
'/init_async.js',
'/initgui.js',
'/helpers.js',
'/IPC.js',

View File

@@ -1,7 +1,8 @@
import ValueHolder from "./ValueHolder.js";
import { register } from "./register.js";
export class Component extends HTMLElement {
export const Component = def(class Component extends HTMLElement {
static ID = 'util.Component';
#has_created_element = false;
#has_called_on_ready = false;
@@ -12,6 +13,27 @@ export class Component extends HTMLElement {
'value bindings for create_template',
]
static on_self_registered ({ is_owner, on_other_registered }) {
// Only invoked for Component itself, not subclasses
if ( ! is_owner ) return;
// Automatically define components for all HTML elements
on_other_registered(({ cls }) => {
console.log('detected class', cls.ID);
if ( cls.ID === 'ui.component.StepHeading' ) {
globalThis.sh_shouldbe = cls;
console.log(
'this is what StepHeading should be',
cls
);
}
if ( globalThis.lib.is_subclass(cls, HTMLElement) ) {
console.log('registering as an element');
defineComponent(cls);
}
});
}
constructor (property_values) {
super();
@@ -145,41 +167,21 @@ export class Component extends HTMLElement {
}
};
}
}
// TODO: move this somewhere more useful
function is_subclass(subclass, superclass) {
if (subclass === superclass) return true;
let proto = subclass.prototype;
while (proto) {
if (proto === superclass.prototype) return true;
proto = Object.getPrototypeOf(proto);
}
return false;
}
});
export const defineComponent = (component) => {
// Web components need tags (despite that we never use the tags)
// because it was designed this way.
if ( is_subclass(component, HTMLElement) ) {
console.log('defining', component);
if ( globalThis.lib.is_subclass(component, HTMLElement) ) {
let name = component.ID;
name = 'c-' + name.split('.').pop().toLowerCase();
// TODO: This is necessary because files can be loaded from
// both `/src/UI` and `/UI` in the URL; we need to fix that
console.log('[maybe] defining', name, 'as', component);
if ( customElements.get(name) ) return;
console.log('[surely] defining', name, 'as', component);
// console.log('[surely] defining', name, 'as', component);
customElements.define(name, component);
component.defined_as = name;
}
// Service scripts aren't able to import anything when the
// GUI code is bundled, so we need to use a custom export
// mechanism for them.
register(component);
};

View File

@@ -1,6 +1,6 @@
import { register } from "./register.js";
export default def(class TeePromise {
static ID = 'util.TeePromise';
export default class TeePromise {
static STATUS_PENDING = {};
static STATUS_RUNNING = {};
static STATUS_DONE = {};
@@ -42,6 +42,4 @@ export default class TeePromise {
onComplete(fn) {
return this.then(fn);
}
}
register(TeePromise, 'TeePromise');
});

View File

@@ -1,17 +0,0 @@
/**
* register registers a class with things that need classes
* to be registered. When in doubt, register your class.
*
* More specifically this function is here to handle such
* situations as service scripts not being able to import
* classes when the frontend is bundled.
*
* @param {*} cls
* @param {*} opt_name
*/
export const register = (cls, opt_name) => {
(async () => {
const api = await globalThis.service_script_api_promise;
api.exp(opt_name || cls.ID.split('.').pop(), cls);
})()
};