diff --git a/EWC/AddResource.aplf b/EWC/AddResource.aplf new file mode 100644 index 0000000..c5d5a01 --- /dev/null +++ b/EWC/AddResource.aplf @@ -0,0 +1,21 @@ + alias AddResource folder;m;rc +⍝ Serve a folder over HTTP as a virtual folder, e.g. +⍝ 'kendo' AddResource '/path/to/plugin/assets' +⍝ makes that folder's contents available at /kendo/. Takes effect immediately - +⍝ wss reads its mappings per request - so this works before or after Init. +⍝ +⍝ An alias is a single path segment: wss matches on the first '/' of the URL. + 'Alias must not contain "/"'⎕SIGNAL('/'∊alias)/11 + :If 2≠⎕NC'RESOURCES' ⋄ RESOURCES←0 2⍴⊂'' ⋄ :EndIf + :If 2≠⍴⍴RESOURCES ⋄ RESOURCES←1 2⍴RESOURCES ⋄ :EndIf + + m←(0≠≢¨RESOURCES[;1])∧~RESOURCES[;1]∊⊂alias + RESOURCES←(m⌿RESOURCES)⍪alias folder + + :If 9=⌊⎕NC'WSS' + WSS.Aliases←RESOURCES + rc←WSS.CheckAliases + :If 0≠⊃rc + 'W'Log'AddResource ',alias,': ',2⊃rc + :EndIf + :EndIf diff --git a/EWC/GetClass.aplf b/EWC/GetClass.aplf index 6863d77..4bf5456 100644 --- a/EWC/GetClass.aplf +++ b/EWC/GetClass.aplf @@ -1,8 +1,9 @@ -r←GetClass (ns name);t;c;m +r←GetClass (ns name);t;m +r←⎕NULL :If 9=⎕NC 'ns' :AndIf ∨/m←0≠ns.⎕NC t←name∘,¨'.type' '.Type' -:AndIf 9=⎕NC c←'classes.',⎕C ns⍎⊃m/t - r←⍎c -:Else + r←findClass ns⍎⊃m/t +:EndIf +:If ⎕NULL≡r ('Not a supported GUI object: ',name) ⎕SIGNAL 6 :EndIf diff --git a/EWC/Init.aplf b/EWC/Init.aplf index 64c35e0..d335742 100644 --- a/EWC/Init.aplf +++ b/EWC/Init.aplf @@ -74,6 +74,7 @@ ⎕←'Using client found at ',JSClientFolder setDefaults + ensurePlugins ⍝ Plugins may already have registered; keep them detached from Link :If DIRECT 'D' Log 'Direct mode enabled' @@ -102,6 +103,7 @@ :EndIf WSS.HTMLFolder←JSClientFolder WSS.ShowHTMLRenderer←0 + applyPluginResources ⍝ RESOURCES may have been reassigned since a plugin registered WSS.Aliases←RESOURCES WSS.OnConnect←'' WSS.OnWSUpgrade←(⍕⎕THIS),'.newSession' diff --git a/EWC/RegisterPlugin.aplf b/EWC/RegisterPlugin.aplf new file mode 100644 index 0000000..e24d7d5 --- /dev/null +++ b/EWC/RegisterPlugin.aplf @@ -0,0 +1,30 @@ + {r}←RegisterPlugin plugin;name;nm;target +⍝ Register a plugin with EWC. Safe before or after Init. +⍝ +⍝ plugin is a namespace ref providing: +⍝ Name plugin id; also the key its classes are tagged with +⍝ Classes ref to a namespace whose sub-namespaces are EWC class definitions +⍝ Folder the plugin's root folder on disk +⍝ Inject (optional) niladic fn returning the JavaScript to evaluate in the +⍝ client, the first time one of the plugin's classes is created +⍝ Resources (optional) an (alias folder) pair, or a 2-column matrix of them +⍝ +⍝ Class definitions are copied rather than referenced, so a plugin can be +⍝ re-registered after editing its source without stale state. + ensurePlugins + 'Plugin must supply a Name'⎕SIGNAL(2≠plugin.⎕NC'Name')/11 + 'Plugin must supply Classes'⎕SIGNAL(9≠plugin.⎕NC'Classes')/11 + r←name←,plugin.Name + + ⍎'PLUGINS.',name,'←plugin' + + :For nm :In plugin.Classes.⎕NL ¯9 + target←(⍕PLUGINS),'.classes.',nm + target ⎕NS plugin.Classes⍎nm + (⍎target).PluginName←name + :EndFor + ensurePlugins ⍝ the class namespaces are new, so detach those too + + applyResources plugin + + 'D'Log'Registered plugin "',name,'", classes: ',⍕plugin.Classes.⎕NL ¯9 diff --git a/EWC/WaitForWG.aplf b/EWC/WaitForWG.aplf index 864c2ae..948e758 100644 --- a/EWC/WaitForWG.aplf +++ b/EWC/WaitForWG.aplf @@ -1,12 +1,15 @@ - R←msg WaitForWG(Props ID WGID);j;⎕TRAP;p;seqok;nl;nlc;m;t;token;wx;z;ref;ok + R←msg WaitForWG arg;Props;ID;WGID;timeout;j;⎕TRAP;p;seqok;nl;nlc;m;t;token;wx;z;ref;ok +⍝ Right argument is (Props ID WGID), with an optional 4th element giving the +⍝ seconds to wait. Defaults to WG_TIMEOUT. + (Props ID WGID timeout)←4↑arg,⊂WG_TIMEOUT wx←Props≡'WX' ⍝ A WX rather than a WG token←WG_TOKENBASE+TOKENSTEP×1⌈ID×MODE=2 AGAIN: - j←WG_TIMEOUT ⎕TGET token + j←timeout ⎕TGET token :If 0=≢j - 'W' Log 'Client did not respond within ',(⍕WG_TIMEOUT),' seconds.' + 'W' Log 'Client did not respond within ',(⍕timeout),' seconds.' 'W' Log 'Request was: ',msg ⎕SIGNAL 6 :EndIf diff --git a/EWC/applyPluginResources.aplf b/EWC/applyPluginResources.aplf new file mode 100644 index 0000000..a994546 --- /dev/null +++ b/EWC/applyPluginResources.aplf @@ -0,0 +1,12 @@ + applyPluginResources;nm +⍝ Re-mount every registered plugin's resources. +⍝ +⍝ RESOURCES is an ordinary variable and applications assign it wholesale +⍝ (demo/Run.aplf does), which would drop mounts made by a plugin that registered +⍝ before Init. Init calls this once RESOURCES has settled. + :If 9≠⎕NC'PLUGINS' ⋄ →0 ⋄ :EndIf + :For nm :In PLUGINS.⎕NL ¯9 + :If 'classes'≢nm + applyResources PLUGINS⍎nm + :EndIf + :EndFor diff --git a/EWC/applyResources.aplf b/EWC/applyResources.aplf new file mode 100644 index 0000000..0b3a8f4 --- /dev/null +++ b/EWC/applyResources.aplf @@ -0,0 +1,9 @@ + applyResources plugin;res;alias;folder +⍝ Mount whatever a plugin declared in Resources: an (alias folder) pair, or a +⍝ 2-column matrix of them. + :If 2≠plugin.⎕NC'Resources' ⋄ →0 ⋄ :EndIf + res←plugin.Resources + :If 2≠⍴⍴res ⋄ res←1 2⍴res ⋄ :EndIf + :For (alias folder) :In ↓res + alias AddResource folder + :EndFor diff --git a/EWC/dWC.aplf b/EWC/dWC.aplf index d71bd5d..3acbc7d 100644 --- a/EWC/dWC.aplf +++ b/EWC/dWC.aplf @@ -17,7 +17,8 @@ →0⊣R←nsname caller.⎕WC Props :EndIf - :If 9≠classes.⎕NC type + class←findClass type + :If ⎕NULL≡class Warning 'WC ',Name,' - unsupported class "',type,'"' ⎕←⎕SE.UCMD 'disp ⍪Props' nsname caller.⎕NS'' @@ -25,7 +26,10 @@ →0 :EndIf - class←classes⍎type + :If 2=class.⎕NC'PluginName' ⍝ First use of a plugin class loads its JS + caller injectPlugin class.PluginName Name + :EndIf + proplist←⎕C PropList←class.PropList valid←(lcp←⎕C propnames)∊allprops←⎕C AllProps←PropList,objProps,deprecatedprops values←valid↓¨Props diff --git a/EWC/dWX.aplf b/EWC/dWX.aplf index e004c53..19ad183 100644 --- a/EWC/dWX.aplf +++ b/EWC/dWX.aplf @@ -1,5 +1,8 @@ - {R}←dWX args;Name;Method;Info;caller;ID;conn;WX;wx;msg;z;⎕TRAP;txt + {R}←{timeout}dWX args;Name;Method;Info;caller;ID;conn;WX;wx;msg;z;⎕TRAP;txt +⍝ Optional left argument: seconds to wait for the client, defaulting to +⍝ WG_TIMEOUT. Loading a plugin's JavaScript can take longer than a property read. ⎕TRAP←0 'S' + :If 0=⎕NC'timeout' ⋄ timeout←WG_TIMEOUT ⋄ :EndIf (Name Method)←2↑args ⋄ Info←2↓args :If 'gettextsize'≡⎕C Method @@ -25,7 +28,7 @@ :EndIf :Trap 6 ⍝ If we get no response - R←msg WaitForWG 'WX' ID(⍕WGID) + R←msg WaitForWG 'WX' ID(⍕WGID)timeout :Else ⎕DMX.EM ⎕SIGNAL ⎕DMX.EN :EndTrap diff --git a/EWC/ensurePlugins.aplf b/EWC/ensurePlugins.aplf new file mode 100644 index 0000000..ee303d1 --- /dev/null +++ b/EWC/ensurePlugins.aplf @@ -0,0 +1,21 @@ + {r}←ensurePlugins +⍝ The plugin registry: PLUGINS.classes holds class definitions contributed by +⍝ plugins, PLUGINS. the record for each registered plugin. +⍝ +⍝ It is detached from Link. #.EWC is Link-managed, so without this a plugin's +⍝ classes would be serialised into the EWC source tree. This function is the +⍝ single place that decides where the registry lives; if breaking a child of a +⍝ live link ever proves unreliable, move it out of #.EWC here and nothing else +⍝ needs to change - everything reaches it through the PLUGINS ref. +⍝ Registration is allowed before Init, so the globals the plugin path touches - +⍝ RESOURCES to mount against, LOGMODES to log through - may not exist yet. +⍝ setDefaults only fills in names that are undefined, so this cannot clobber +⍝ anything the application has already set. + setDefaults + + :If 9≠⎕NC'PLUGINS' + 'PLUGINS'⎕NS'' + 'PLUGINS.classes'⎕NS'' + :EndIf + {}{0::⍬ ⋄ ⎕SE.Link.Break ⍵}'#.EWC.PLUGINS' ⍝ reports "Not linked" when already detached + r←PLUGINS diff --git a/EWC/findClass.aplf b/EWC/findClass.aplf new file mode 100644 index 0000000..02eb052 --- /dev/null +++ b/EWC/findClass.aplf @@ -0,0 +1,19 @@ + r←findClass type;names;i +⍝ Resolve an EWC class name to its definition namespace, or ⎕NULL. +⍝ +⍝ Core classes live in #.EWC.classes under lowercase names. Plugin classes live +⍝ in PLUGINS.classes and keep the casing their author gave them, so they are +⍝ matched case-insensitively. See RegisterPlugin and ensurePlugins. + type←⎕C type + :If 9=classes.⎕NC type + →0⊣r←classes⍎type + :EndIf + + r←⎕NULL + :If 9=⎕NC'PLUGINS' + :AndIf 9=PLUGINS.⎕NC'classes' + names←PLUGINS.classes.⎕NL ¯9 + :If 0≠≢i←⍸(⎕C names)∊⊂type + r←PLUGINS.classes⍎(⊃i)⊃names + :EndIf + :EndIf diff --git a/EWC/getConnection.aplf b/EWC/getConnection.aplf index a7bf64b..bbf8492 100644 --- a/EWC/getConnection.aplf +++ b/EWC/getConnection.aplf @@ -1,23 +1,14 @@ - r←getConnection(caller names);name;top;n;p - name←,⊃⊆names - n←≢p←(⍕caller),'.' - name←(n×p≡n↑name)↓name ⍝ drop caller prefix - top←(¯1+name⍳'.')↑name + r←getConnection(caller names);ns + ns←caller sessionNS names - :If 9=caller.⎕NC n←top,'.HR._EWC' - r←(caller⍎n).(ID conn) - - :ElseIf MODE=2 ⍝ Multi - :If 9=caller.⎕NC'_EWC' - r←caller._EWC.(ID conn) - :Else - ∘∘∘ ⍝ Unable to locate app session - :EndIf - - :Else + :If ⎕NULL≢ns :Trap 0 - r←CODELOCATION._EWC.(ID conn) + r←ns.(ID conn) :Else r←0 ¯1 ⍝ no connection: ¯1 is an invalid conn (0 is a VALID Conga connection) :EndTrap + :ElseIf MODE=2 ⍝ Multi + ∘∘∘ ⍝ Unable to locate app session + :Else + r←0 ¯1 :EndIf diff --git a/EWC/getObj.aplf b/EWC/getObj.aplf index a4f5624..ca22e45 100644 --- a/EWC/getObj.aplf +++ b/EWC/getObj.aplf @@ -1,11 +1,9 @@ - (type class objref)←getObj Name;t + (type class objref)←getObj Name :Trap 0 objref←caller⍎Name type←objref.Type - :If 9=classes.⎕NC t←⎕C type - class←classes⍎t - :Else + :If ⎕NULL≡class←findClass type Warning'Unsupported class: ',type ⎕←Props →0 diff --git a/EWC/injectPlugin.aplf b/EWC/injectPlugin.aplf new file mode 100644 index 0000000..b6afaf0 --- /dev/null +++ b/EWC/injectPlugin.aplf @@ -0,0 +1,35 @@ + caller injectPlugin(name objname);sns;plugin;js;r;top +⍝ Send a plugin's JavaScript to this session's client, once per session. +⍝ +⍝ Called from ∆WC the first time one of the plugin's classes is created, before +⍝ that object's WC message goes out. dWX blocks until the client replies, and +⍝ the client holds every later frame until the injected code has resolved - so +⍝ the component is registered by the time the WC is handled. +⍝ +⍝ Load state lives in the session's own _EWC namespace: per clone in Multi mode, +⍝ per HTMLRenderer in Desktop, one in CODELOCATION otherwise. + sns←caller sessionNS objname + :If ⎕NULL≡sns ⋄ →0 ⋄ :EndIf ⍝ no client yet + :If 0=sns.⎕NC'PLUGINS_LOADED' ⋄ sns.PLUGINS_LOADED←⍬ ⋄ :EndIf + +⍝ Keyed by connection, not just name: outside Multi mode _EWC outlives any one +⍝ browser, and a reload gives the client a fresh, empty registry. Entries for +⍝ connections that have gone are dropped rather than accumulating. + sns.PLUGINS_LOADED←(sns.conn=⊃¨sns.PLUGINS_LOADED)/sns.PLUGINS_LOADED + :If (⊂sns.conn name)∊sns.PLUGINS_LOADED ⋄ →0 ⋄ :EndIf + :If 9≠PLUGINS.⎕NC name ⋄ →0 ⋄ :EndIf + + plugin←PLUGINS⍎name + sns.PLUGINS_LOADED,←⊂sns.conn name ⍝ marked before injecting: a failure must not retry per object + :If ~(⌊plugin.⎕NC'Inject')∊2 3 ⋄ →0 ⋄ :EndIf + + js←,⊆plugin.Inject ⍝ a niladic function, or the JavaScript itself + top←(¯1+objname⍳'.')↑objname + :Trap 6 85 + r←PLUGIN_TIMEOUT dWX(top 'EvalJS'),js + :If ∨/¯1=⊃¨,⊆r + 'E'Log'Plugin "',name,'" injection failed: ',⍕r + :EndIf + :Else + 'E'Log'Plugin "',name,'" injection got no response from the client' + :EndTrap diff --git a/EWC/sessionNS.aplf b/EWC/sessionNS.aplf new file mode 100644 index 0000000..f0c0b19 --- /dev/null +++ b/EWC/sessionNS.aplf @@ -0,0 +1,28 @@ + r←caller sessionNS names;name;top;n;p +⍝ The per-session _EWC namespace for an object name, or ⎕NULL if there is none. +⍝ +⍝ Desktop gives each Form's HTMLRenderer its own copy; Multi clones the whole +⍝ application namespace per connection; otherwise there is a single one, in +⍝ CODELOCATION. getConnection reads (ID conn) out of whatever this returns. + name←,⊃⊆names + n←≢p←(⍕caller),'.' + name←(n×p≡n↑name)↓name ⍝ drop caller prefix + top←(¯1+name⍳'.')↑name + + :If 9=caller.⎕NC n←top,'.HR._EWC' + r←caller⍎n + + :ElseIf MODE=2 ⍝ Multi + :If 9=caller.⎕NC'_EWC' + r←caller._EWC + :Else + r←⎕NULL + :EndIf + + :Else + :Trap 0 + r←CODELOCATION._EWC + :Else + r←⎕NULL + :EndTrap + :EndIf diff --git a/EWC/setDefaults.aplf b/EWC/setDefaults.aplf index 63ef638..f356bb5 100644 --- a/EWC/setDefaults.aplf +++ b/EWC/setDefaults.aplf @@ -10,3 +10,4 @@ setDefaults 'BREAK' default ⍬ ⍬ ⍝ IDs Events 'SHOWDEVTOOLS' default 0 'FONTMAP' default FontMap + 'PLUGIN_TIMEOUT' default 30 ⍝ Seconds to wait while a plugin's JS loads diff --git a/demo/DemoPluginRegistry.aplf b/demo/DemoPluginRegistry.aplf new file mode 100644 index 0000000..c2c925a --- /dev/null +++ b/demo/DemoPluginRegistry.aplf @@ -0,0 +1,57 @@ + DemoPluginRegistry connected;plugin;cls;js;r +⍝ The plugin mechanism end to end, with no external dependency: an EWC class +⍝ registered at runtime, its React component injected as JavaScript the first +⍝ time the class is used, and an event travelling from that component to APL. + EWC.CONNECTED←connected + + CBPluginBox←{'F1.STATUS'eWS'Caption'('Select from ',(⊃⍵),' at ',⍕3↑3↓⎕TS)} + + 'F1'eWC'Form' 'Plugin registry'(50 50)(220 460)('Coord' 'Pixel') + 'F1.L1'eWC'Label' 'The box below is drawn by injected JavaScript.'('Posn' 10 10)('Size' 20 440) + 'F1.STATUS'eWC'Label' 'no event yet'('Posn' 170 10)('Size' 20 440) + + plugin←⎕NS'' + plugin.Name←'DemoBox' + plugin.Folder←EWC.FOLDER + + plugin.Classes←⎕NS'' + 'PluginBox'plugin.Classes.⎕NS'' + cls←plugin.Classes.PluginBox + cls.ClassName←'PluginBox' + cls.PropList←'Type' 'Posn' 'Size' 'Caption' 'Event' 'MethodList' 'ChildList' 'EventList' 'PropList' + cls.Defaults←('PluginBox')(10 10)(50 100)('')(⍬)(⍬)(⍬)(⍬)(cls.PropList) + cls.Supported←cls.PropList + cls.Dynamic←⍬ + cls.methodlist←⍬ + cls.SupportedEvents←,⊂'Select' + +⍝ The delay is deliberate: it proves the client waits for an asynchronous +⍝ injection before handling the WC that follows. + js←'(async () => {' + js,←' await new Promise(r => setTimeout(r, 250));' + js,←' const R = window.EWC.React;' + js,←' window.EWC.registerComponent("PluginBox", ({ data, ewc }) => {' + js,←' const P = data.Properties || {};' + js,←' const style = Object.assign({}, ewc.utils.setStyle(P), {' + js,←' background: "#cfe8ff", border: "2px solid #0066cc", borderRadius: "6px",' + js,←' display: "flex", alignItems: "center", justifyContent: "center",' + js,←' cursor: "pointer", userSelect: "none" });' + js,←' const fire = () => ewc.socket.send(JSON.stringify(' + js,←' { Event: { EventName: "Select", ID: data.ID } }));' + js,←' return R.createElement("div", { id: data.ID, style, onClick: fire }, P.Caption || "");' + js,←' });' + js,←' return "PluginBox registered";' + js,←'})()' + plugin.Inject←js + + EWC.RegisterPlugin plugin + + 'F1.BOX'eWC'PluginBox'('Posn' 60 10)('Size' 55 440)('Caption' 'Click me - I am a plugin')('Event' 'Select' 'CBPluginBox') + +⍝ A second object of the same class must reuse the loaded plugin: the log +⍝ should show one EvalJS, not two. + 'F1.BOX2'eWC'PluginBox'('Posn' 120 10)('Size' 40 440)('Caption' 'and so am I')('Event' 'Select' 'CBPluginBox') + +⍝ An injection that rejects must come back as ¯1, not hang until the timeout. + r←2 eNQ'F1' 'EvalJS' 'import("/no-such-plugin-bundle.js")' + 'F1.REJECT'eWC'Label'('Caption'('rejected import rc=',⍕⊃⊃,⊆r))('Posn' 195 10)('Size' 20 440)