diff --git a/info.rkt b/info.rkt index e0ee659..3f06c16 100644 --- a/info.rkt +++ b/info.rkt @@ -1,14 +1,18 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.1.0") -(define license 'GPL-3.0-or-later) ; The liboa library has this license +(define version "0.1.3") +(define license 'MIT) (define collection "web-racket") -(define pkg-desc "web-racket - A Web Based GUI library, based on web-wire") +(define pkg-desc "web-racket - A Web Based GUI library, based on webui-wire") (define scribblings '( ("scribblings/web-racket.scrbl" () (gui-library) "web-racket") + ("scribblings/web-wire.scrbl" () (gui-library) "web-wire") + ("scribblings/webui-wire-download.scrbl" () (gui-library) "webui-wire-download") + ("scribblings/webui-wire-ipc.scrbl" () (gui-library) "webui-wire-ipc") + ("scribblings/webui-wire-version.scrbl" () (gui-library) "webui-wire-version") ) ) diff --git a/lib/dll/libwebui-wire.dll b/lib/dll/libwebui-wire.dll deleted file mode 100644 index d22cf42..0000000 Binary files a/lib/dll/libwebui-wire.dll and /dev/null differ diff --git a/private/web-racket-version.rkt b/private/web-racket-version.rkt index f9c0daa..4a0ce7e 100644 --- a/private/web-racket-version.rkt +++ b/private/web-racket-version.rkt @@ -33,7 +33,7 @@ (define ww-version (mk-version ww-version-major ww-version-minor ww-version-patch)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; - ;; Web Wire FFI Version + ;; Web Wire IPC Version ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define ww-wire-version-major 0) diff --git a/scribblings/web-racket-version.scrbl b/scribblings/web-racket-version.scrbl new file mode 100644 index 0000000..c917e85 --- /dev/null +++ b/scribblings/web-racket-version.scrbl @@ -0,0 +1,108 @@ +#lang scribble/manual + +@(require scribble/manual + (for-label racket/base + racket/string + "../private/web-racket-version.rkt")) + +@title{Web Racket Version Information} +@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] + +@defmodule[web-racket-version] + +The @racket[web-racket-version] module centralises version information +for the Web Racket framework and the associated @emph{Web Wire} FFI +layer. It provides both numeric version components and a +pre-formatted string for each of these version identifiers. + +The numeric bindings are useful when you want to compare versions in +code; the string bindings are convenient for display in logs, about +dialogs, and diagnostic output. + +Internally, versions are formatted as @tt{"MAJOR.MINOR.PATCH"}. + +@section{Web Racket version} + +@defthing[ww-version-major exact-nonnegative-integer?]{ + +The major version number of the Web Racket framework. + +This component is incremented for changes that are not backwards +compatible or that represent a significant evolution of the system. +} + +@defthing[ww-version-minor exact-nonnegative-integer?]{ + +The minor version number of the Web Racket framework. + +This component is typically incremented for backwards-compatible +feature additions. +} + +@defthing[ww-version-patch exact-nonnegative-integer?]{ + +The patch version number of the Web Racket framework. + +This component is incremented for small, backwards-compatible +bug fixes and maintenance releases. +} + +@defthing[ww-version string?]{ + +A human-readable string representation of the Web Racket framework +version, combining @racket[ww-version-major], +@racket[ww-version-minor], and @racket[ww-version-patch] in the form: + +@verbatim{"MAJOR.MINOR.PATCH"} + +For example, if the numeric components are @racket[0], @racket[1], +and @racket[3], then @racket[ww-version] is @racket["0.1.3"]. +} + +@section{Web Wire IPC version} + +The Web Wire IPC version describes the version of the low-level +interface between Racket and the embedded web UI (for example, a +shared library or external command used to drive the webview). + +This version is tracked separately from the main Web Racket framework +version so that protocol or binary compatibility changes can be +managed independently. + +@defthing[ww-wire-version-major exact-nonnegative-integer?]{ + +The major version number of the Web Wire IPC interface. + +A change in the major version generally indicates that the IPC +protocol is not backwards compatible with previous major versions. +} + +@defthing[ww-wire-version-minor exact-nonnegative-integer?]{ + +The minor version number of the Web Wire IPC interface. + +This component is typically incremented for backwards-compatible +extensions or enhancements to the IPC protocol. +} + +@defthing[ww-wire-version-patch exact-nonnegative-integer?]{ + +The patch version number of the Web Wire IPC interface. + +This is usually incremented for small, backwards-compatible bug +fixes or internal refinements that do not change the protocol +surface. +} + +@defthing[ww-wire-version string?]{ + +A human-readable string representation of the Web Wire IPC version, +combining @racket[ww-wire-version-major], +@racket[ww-wire-version-minor], and @racket[ww-wire-version-patch] +in the form: + +@verbatim{"MAJOR.MINOR.PATCH"} + +For example, with numeric components @racket[0], @racket[2], and +@racket[8], the resulting version string is @racket["0.2.8"]. +} diff --git a/scribblings/web-racket.scrbl b/scribblings/web-racket.scrbl new file mode 100644 index 0000000..91ee39c --- /dev/null +++ b/scribblings/web-racket.scrbl @@ -0,0 +1,641 @@ +#lang scribble/manual + +@(require scribble/manual + ;scribble/class + (for-label racket/base + racket/class + racket/gui/base + json + ;gregor + ;gregor/time + net/sendurl + racket/path + "../private/web-wire.rkt" + "../private/webui-wire-download.rkt" + "../private/webui-wire-ipc.rkt" + "../private/css.rkt" + "../private/menu.rkt" + "../private/web-racket.rkt")) + +@title{High-Level Web Racket GUI API} +@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] + +@defmodule[web-racket] + +The @racket[web-racket] module provides a higher-level GUI layer on top +of the low-level @racketmodname[web-wire] backend. It is intended as the +main entry point for desktop applications that embed a WebUI. + +The module exports: + +@itemlist[ + @item{OO wrappers for DOM elements and form inputs + (@racket[ww-element%], @racket[ww-input%]);} + @item{classes for main windows and dialogs + (@racket[ww-webview%], @racket[ww-webview-dialog%], + @racket[ww-webview-message%]);} + @item{an abstract settings interface (@racket[ww-settings%]);} + @item{backend control and logging helpers re-exported from + @racketmodname[web-wire];} + @item{backend installation/override helpers re-exported from + @racketmodname[webui-wire-download];} + @item{CSS and menu construction helpers re-exported from + @racket["css.rkt"] and @racket["menu.rkt"].} +] + +The goal of this layer is: + +@itemlist[ + @item{You rarely call @racket[web-wire] directly;} + @item{you work with windows, elements, and inputs as Racket objects;} + @item{only when you need fine-grained control do you drop to the + underlying wire API.} +] + +@section{Re-exported backend control and logging} + +The following bindings are re-exported directly from +@racketmodname[web-wire]. See that module’s documentation for full +details; here is a short summary for convenience. + +@defproc[(ww-start [log-level symbol?]) web-rkt?]{ + +Start the Web Wire backend if it is not running already, and return the +current backend handle. + +Most applications do not call this directly: @racket[ww-webview%] +constructors will automatically call @racket[ww-start] when the first +window is created. +} + +@defproc[(ww-stop) void?]{ + +Stop the Web Wire backend. + +This is called automatically when the last window created via +@racket[ww-webview%] is closed; you normally do not need to call it +directly, but you can use it for explicit shutdown. +} + +@defproc[(ww-set-debug [on? boolean?]) void?]{ + +Enable or disable internal debug logging in the @racket[web-wire] +layer. This affects @racket[ww-debug] output. +} + +@defform[(ww-debug msg-expr) #:contracts ([msg-expr any/c])] +@defform[(ww-debug id-expr msg-expr) + #:contracts ([id-expr any/c] [msg-expr any/c])]{ + +Debug logging macros that write to the shared log buffer when debug is +enabled. +} + +@defform[(ww-error msg-expr) #:contracts ([msg-expr any/c])] +@defform[(ww-error id-expr msg-expr) + #:contracts ([id-expr any/c] [msg-expr any/c])]{ + +Error logging macros that always log, regardless of the debug setting. +} + +@defproc[(ww-display-log + [filter (or/c #f string? regexp? (listof string?)) #f]) + void?]{ + +Display the in-memory log buffer on @racket[current-output-port], +optionally filtered. +} + +@defproc[(ww-tail-log [args any/c] ...) void?]{ + +Show the tail of the log buffer and follow new entries (like +@tt{tail -f}). Optional arguments control how many lines to show +initially and which lines to filter. +} + +@section{Re-exported WebUI helper command control} + +The following bindings are re-exported from +@racketmodname[webui-wire-download]; they are documented in more detail +in that module. Briefly: + +@defproc[(ww-set-custom-webui-wire-command! + [cmd string?]) + string?]{ + +Override the command used to start the @tt{webui-wire} helper. Useful in +development or when the helper is installed in a non-standard location. +} + +@defproc[(ww-get-webui-wire-version) string?]{ + +Return the version string reported by the installed @tt{webui-wire} +helper. +} + +@section{Re-exported CSS and menu helpers} + +The module also re-exports: + +@itemlist[ + @item{@racket[(all-from-out "css.rkt")] — CSS helper functions and + @racket[css-style] values;} + @item{@racket[(all-from-out "menu.rkt")] — menu construction helpers + for building menu structures passed to @racket[ww-webview%].} +] + +See the documentation of those modules for the exact API. + +@section{Element wrapper classes} + +These classes provide OO wrappers around DOM elements managed by +@tt{webui-wire}. They work on top of the lower-level @racket[web-wire] +functions that address elements by id. + +@subsection{Generic element} + +@defclass[ww-element% object% ()]{ + +Base class for element wrappers. + +@defconstructor/auto-super[([win-id ww-win?] + [id (or/c symbol? string?)])] + +The constructor normally isn’t called directly by user code. Objects are +created automatically by @racket[ww-webview%] when you bind inputs and +buttons or when you explicitly request an element. + +Fields: + +@itemlist[ + @item{@racket[win-id] — a @racket[ww-win] handle identifying the WebUI + window that owns this element} + @item{@racket[id] — the element id (symbol or string), as used in the + DOM.} +] + +Public methods: + +@defmethod[(get-win-id) ww-win?]{ +Return the window handle used for this element.} + +@defmethod[(get-id) (or/c symbol? string?)]{ +Return the element id.} + +@defmethod[(win) (or/c ww-webview% #f)]{ +Look up the @racket[ww-webview%] instance for @racket[win-id] in the +global window table. Returns @racket[#f] if the window no longer +exists.} + +@defmethod[(callback [evt symbol?] [args any/c] ...) any]{ + +Call a previously registered callback for @racket[evt] with the +given @racket[args]. This is used internally when DOM events +arrive from @tt{webui-wire}.} + +@defmethod[(connect [evt symbol?] [func (-> any)]) void?]{ + +Register @racket[func] as the handler for event kind @racket[evt] on +this element.} + +@defmethod[(disconnect [evt symbol?]) void?]{ + +Remove any callback registered for @racket[evt].} + +@defmethod[(add-style! [st css-style?]) void?]{ +Merge the given style into the element’s existing style.} + +@defmethod[(set-style! [st css-style?]) void?]{ +Replace the element’s style with @racket[st].} + +@defmethod[(style) css-style?]{ +Return the current style as a @racket[css-style] value.} + +@defmethod[(get-attr [a (or/c symbol? string?)]) jsexpr?]{ +Get a single attribute value as JSON.} + +@defmethod[(set-attr! [a (or/c symbol? string?)] [val any/c]) void?]{ +Set an attribute value.} + +@defmethod[(del-attr [a (or/c symbol? string?)]) void?]{ +Remove an attribute.} + +@defmethod[(get-attrs) (hash/c symbol? any/c)]{ +Return all attributes as a hash table (symbol → value).} + +@defmethod[(add-class! [cl (or/c symbol? string?)]) void?]{ +Add a CSS class to the element.} + +@defmethod[(remove-class! [cl (or/c symbol? string?)]) void?]{ +Remove a CSS class.} + +@defmethod[(has-class? [cl (or/c symbol? string?)]) boolean?]{ +Test whether the element has a given CSS class.} + +@defmethod[(enable) void?]{ +Remove the @tt{disabled} state from the element +(by manipulating its CSS/attributes).} + +@defmethod[(enabled?) boolean?]{ +Return @racket[#t] when the element is not disabled.} + +@defmethod[(disable) void?]{ +Mark the element as disabled (e.g. by adding a @tt{disabled} attribute +and/or class).} + +@defmethod[(disabled?) boolean?]{ +Return @racket[#t] when the element is disabled.} + +@defmethod[(display [d string? "block"]) void?]{ +Set the CSS @tt{display} style; default is @tt{"block"}.} + +@defmethod[(hide) void?]{ +Convenience for @racket[(display "none")].} + +@defmethod[(show) void?]{ +Show the element again (typically restoring display to a non-@tt{"none"} +value).} + +} + +@subsection{Form input element} + +@defclass[ww-input% ww-element% ()]{ + +Wrapper for input-type elements (fields that have a “value” and that +emit change/input events). Instances are usually created by +@racket[ww-webview%] when you call @racket[bind-inputs]. + +Internally, a @racket[ww-input%] keeps track of: + +@itemlist[ + @item{the current value (mirrored from the DOM);} + @item{an optional change callback.} +] + +Public methods: + +@defmethod[(get) any/c]{ +Return the last known value of the input.} + +@defmethod[(on-change! [callback (-> any/c any)]) void?]{ + +Register a change callback: + +@itemlist[ + @item{The callback is stored;} + @item{It is immediately called once with the current value;} + @item{Later, when an @tt{input} event with a @tt{value} field arrives + from the browser, the stored callback is called with the new + value.} +] +} + +@defmethod[(set! [v any/c]) void?]{ + +Set the input’s value both locally and in the DOM. This will normally +update the form control on screen and may also trigger change/input +events in the browser. +} + +@defmethod[(disable) void?]{Disable the input (overrides base behaviour).} + +@defmethod[(enable) void?]{Enable the input again (overrides base +behaviour).} +} + +@section{Webview windows} + +@subsection{Main windows} + +@defclass[ww-webview% object% ()]{ + +Represents a WebUI window driven by the @tt{webui-wire} backend. + +@defconstructor/auto-super[ + ([profile symbol? 'default-profile] + [settings (or/c #f ww-settings%) #f] + [use-browser boolean? #f] + [parent-id (or/c #f ww-win?) #f] + [parent (or/c #f ww-webview%) #f] + [title string? "Racket HTML Window"] + [x number?] + [y number?] + [width number?] + [height number?] + [icon (or/c #f path? string?) #f] + [menu any/c #f] + [html-file (or/c #f path? string?) #f]) +]{ + +The numeric geometry defaults (@racket[x], @racket[y], @racket[width], +@racket[height]) are taken either from @racket[settings] or from builtin +defaults. + +If @racket[parent] or @racket[parent-id] is provided, a child window +is created and positioned relative to the parent; otherwise a normal +top-level window is created. + +If @racket[menu], @racket[icon], or @racket[html-file] are provided, +they are applied after window creation. +} + +Important behaviour during construction: + +@itemlist[ + @item{when the first window is created, @racket[ww-start] is called;} + @item{the new window is registered in the global @racket[windows] + table;} + @item{an event handler is installed so that incoming events from + @tt{webui-wire} are dispatched to methods like + @racket[handle-click] and @racket[handle-change];} + @item{when the last window is destroyed, @racket[ww-stop] is called.} +] + +Key public methods (grouped by responsibility): + +@subsubsection{Settings and cloning} + +@defmethod[(clone-settings [section symbol?]) (or/c #f ww-settings%)]{ + +If @racket[settings] is non-@racket[#f], call @racket[clone] on it +to obtain a section-specific settings object, otherwise return +@racket[#f]. +} + +@subsubsection{Event handling hooks} + +These are normally invoked internally when events arrive from +@tt{webui-wire}, but can be overridden in subclasses. + +@defmethod[(handle-click [element-id symbol?] [data jsexpr?]) void?]{ +Find the element object for @racket[element-id] (if present) and call +its @racket[callback] method with the @racket['click] event.} + +@defmethod[(handle-change [element-id symbol?] [data jsexpr?]) void?]{ +Find the element object and delegate a @racket['change] event.} + +@defmethod[(handle-input [element-id symbol?] [data jsexpr?]) void?]{ +Find the element object and delegate an @tt{input} event.} + +@defmethod[(handle-navigate [url string?] + [type symbol?] + [kind symbol?]) + void?]{ + +Handle navigation events (for example link clicks) as reported by +@tt{webui-wire}. The default implementation may call +@racket[send-url] depending on @racket[type] and @racket[kind].} + +@subsubsection{Element management} + +@defmethod[(get-win-id) ww-win?]{ +Return the underlying @racket[ww-win] handle.} + +@defmethod[(element [id (or/c symbol? string?)]) ww-element%]{ + +Return (and lazily create) an element wrapper for a given DOM id. +The concrete class is chosen based on the element’s tag and type +(@racket[ww-input%] for input fields, or @racket[ww-element%] +otherwise). +} + +@defmethod[(get-elements [selector selector?]) + (listof ww-element%)]{ + +Query elements matching @racket[selector] via @racket[ww-get-elements] +and return a list of element wrapper objects. +} + +@defmethod[(bind [event symbol?] + [selector selector?] + [forced-cl (or/c #f (is-a?/c ww-element%)) #f]) + void?]{ + +Bind @racket[event] to all elements matching @racket[selector]. + +A suitable element class is chosen automatically based on tag/type, +unless @racket[forced-cl] is provided; in that case the given class +is used for all matched elements. +} + +@defmethod[(bind-inputs) boolean?]{ +Convenience: bind @racket['change] events for @tt{input} and +@tt{textarea} elements and create @racket[ww-input%] wrappers.} + +@defmethod[(bind-buttons) void?]{ +Convenience: bind @racket['click] events for @tt{button} elements.} + +@subsubsection{Window geometry and title} + +@defmethod[(move [x number?] [y number?]) void?]{ +Move the window to (@racket[x], @racket[y]).} + +@defmethod[(resize [x number?] [y number?]) void?]{ +Resize the window to width @racket[x], height @racket[y].} + +@defmethod[(get-x) number?]{Current x position (cached).} +@defmethod[(get-y) number?]{Current y position (cached).} +@defmethod[(get-width) number?]{Current width (cached).} +@defmethod[(get-height) number?]{Current height (cached).} + +@defmethod[(geom) (list number? number? number? number?)]{ +Return @racket[(list x y width height)].} + +@defmethod[(set-title! [t string?]) void?]{ +Set the window title (cached locally and sent to the backend).} + +@defmethod[(get-title) string?]{ +Return the last title set.} + +@subsubsection{Show/hide and lifetime} + +@defmethod[(show) void?]{ +Show the window (set show state to @racket['show]).} + +@defmethod[(hide) void?]{ +Hide the window.} + +@defmethod[(maximize) void?]{ +Maximise the window.} + +@defmethod[(normalize) void?]{ +Restore the window to its normal state.} + +@defmethod[(minimize) void?]{ +Minimise the window.} + +@defmethod[(fullscreen) void?]{ +Request a fullscreen display (if supported).} + +@defmethod[(show-state) symbol?]{ +Return the current show state as reported by the backend.} + +@defmethod[(can-close?) boolean?]{ +Return @racket[#t] if the window may be closed; subclasses can +override this to veto close requests.} + +@defmethod[(close) void?]{ + +Close the window, unregister it from the global tables, and if this is +the last window, stop the backend by calling @racket[ww-stop]. +} + +@subsubsection{Menus} + +@defmethod[(set-menu! [menu-def is-menu?]) void?]{ +Set the window menu using a structure created with the helpers from +@racket["menu.rkt"].} + +@defmethod[(connect-menu! [id symbol?] [cb (-> any)]) void?]{ + +Associate a callback @racket[cb] with a menu item id. When the menu +item is activated, the callback is called. +} + +@subsubsection{HTML and navigation} + +@defmethod[(set-icon! [icn (or/c path? string?)]) void?]{ +Set the window icon; the file is validated by @racket[ww-set-icon].} + +@defmethod[(set-html-file! [file (or/c path? string?)]) void?]{ + +Load the given HTML file. The directory part is used to adjust +@racket[ww-cwd], and the file name is passed to +@racket[ww-set-html-file].} + +@defmethod[(set-html [html string?]) void?]{ + +Write @racket[html] to a temporary file and call +@racket[set-html-file!] with it. Intended for simple dynamic content.} + +@defmethod[(set-url [url string?]) void?]{ +Open the given URL using @racket[send-url].} + +@defmethod[(html-loaded) void?]{ + +Hook that is called when a page is fully loaded and matches the +current HTML handle. The default implementation binds buttons and +inputs by calling @racket[bind-buttons] and @racket[bind-inputs]. +} + +@subsubsection{File and directory dialogs} + +@defmethod[(file-open [caption string?] + [base-dir string?] + [filters string?]) + (or/c #f path?)]{ + +Show a file-open dialog and return the selected path as a @racket[path], +or @racket[#f] if the operation failed or was cancelled. +} + +@defmethod[(file-save [caption string?] + [base-dir string?] + [filters string?] + [overwrite boolean? #f]) + (or/c #f path?)]{ + +Show a file-save dialog. The @racket[overwrite] flag controls whether +overwriting existing files is allowed. Returns the chosen path, or +@racket[#f] if cancelled or if the command failed. +} + +@defmethod[(choose-dir [caption string?] + [base-dir string?]) + (or/c #f path?)]{ + +Show a directory chooser dialog; return the chosen directory, or +@racket[#f] on cancel/failure. +} + +@subsubsection{Hook for subclasses} + +@defmethod[(inherit-checks) boolean?]{ + +Called early during construction to allow subclasses to enforce +preconditions (for example: “dialog windows must have a parent”). +The default implementation simply returns @racket[#t]. +} +} + +@subsection{Dialog windows} + +@defclass[ww-webview-dialog% ww-webview% ()]{ + +Subclass of @racket[ww-webview%] representing dialog-style windows. + +The constructor is the same as @racket[ww-webview%], but +@racket[inherit-checks] is overridden to enforce that a @racket[parent] +was supplied: + +@racketblock[ + (define/override (inherit-checks) + (when (eq? parent #f) + (error "A parent must be given"))) +] + +So, to create a dialog, always pass a parent window: + +@racketblock[ + (define main (new ww-webview%)) + (define dlg (new ww-webview-dialog% [parent main])) +] +} + +@subsection{Message dialogs} + +@defclass[ww-webview-message% ww-webview-dialog% ()]{ + +Simple message-dialog subclass. + +The constructor: + +@itemlist[ + @item{calls the @racket[ww-webview-dialog%] constructor;} + @item{sets a minimal HTML page containing a message header and a + submessage, with known element ids (e.g. @tt{"msg"} and + @tt{"submsg"}).} +] + +You can then obtain element wrappers for those ids via +@racket[send] @racket[this] @racket[element] and update their inner +HTML or text using the usual element methods. +} + +@section{Settings abstraction} + +@defclass[ww-settings% object% ()]{ + +Abstract base class for storing and retrieving configuration values +used by @racket[ww-webview%] (for example window geometry). + +The default implementation only signals errors; you are expected to +subclass @racket[ww-settings%] and override the methods. + +Public methods: + +@defmethod[(set [key symbol?] [value any/c]) void?]{ +Set a configuration value for @racket[key]. Default implementation +raises an error.} + +@defmethod[(get [key symbol?] [default any/c]) any/c]{ + +Look up a configuration value for @racket[key]. If the key is not +present, return @racket[default] (if provided) or raise an error. The +default implementation always raises an error. +} + +@defmethod[(clone [new-section symbol?]) ww-settings%]{ + +Return a “cloned” settings object for a given section or profile. +Used by @racket[ww-webview%] to derive per-window settings from a +shared base. Default implementation raises an error. +} + +@defmethod[(set! [key symbol?] [value any/c]) void?]{ + +Convenience that forwards to @racket[set]. Provided for symmetry with +Racket’s @tt{set!} naming style. +} +} diff --git a/scribblings/web-wire.scrbl b/scribblings/web-wire.scrbl new file mode 100644 index 0000000..c2d9034 --- /dev/null +++ b/scribblings/web-wire.scrbl @@ -0,0 +1,965 @@ +#lang scribble/manual + +@(require scribble/manual + ;scribble/class + (for-label racket/base + racket/class + racket/gui/base + json + "../private/css.rkt" + "../private/menu.rkt" + "../private/webui-wire-download.rkt" + "../private/webui-wire-ipc.rkt" + "../private/web-wire.rkt")) + +@title{Web Wire: Low-Level WebUI Command Layer} +@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] + +@defmodule[web-wire] + +The @racket[web-wire] module is the low-level bridge between Racket and +the external @tt{webui-wire} helper process. + +On the Racket side it exposes functions like: + +@itemlist[ + @item{starting and stopping the backend (@racket[ww-start], + @racket[ww-stop]);} + @item{log inspection and debugging helpers;} + @item{window management (create, move, resize, set title/icon/menu);} + @item{DOM operations (HTML content, attributes, CSS, classes, values);} + @item{element queries and event binding;} + @item{file/directory chooser dialogs.} +] + +Most of these functions ultimately send a textual command to +@tt{webui-wire} (via @racket[webui-ipc]) and convert the reply into a +convenient Racket value. + +@section{Startup and shutdown} + +@defproc[(ww-start [log-level symbol?]) web-rkt?]{ + +Starts the Web Wire backend (if it is not already running) and returns +the current @racket[web-rkt] handle. + +Internally this: + +@itemlist[ + @item{creates queues and semaphores for events and logs;} + @item{starts an IPC connection via @racket[webui-ipc];} + @item{spawns a background thread that handles incoming events and + log lines;} + @item{stores the handle in an internal variable.} +] + +If @racket[log-level] is supplied, it is passed to +@racket[ww-log-level] to configure the remote log level. + +Calling @racket[ww-start] when the backend is already running is a +no-op: the existing handle is returned. + +The backend type is currently always @racket['ipc]; FFI integration is +not implemented yet. +} + +@defproc[(ww-stop) void?]{ + +Stops the Web Wire backend. + +This function: + +@itemlist[ + @item{notifies registered window event handlers that windows are being + destroyed;} + @item{sends an @tt{"exit"}-style command to @tt{webui-wire};} + @item{stops the event-handling thread;} + @item{clears the current backend handle.} +] + +After @racket[ww-stop], calling window/DOM functions will fail until +@racket[ww-start] is used again. +} + +@section{Debugging and log inspection} + +These functions control and inspect the logging used by this module and +its event thread. + +@defproc[(ww-set-debug [on? boolean?]) void?]{ + +Enables or disables verbose internal debug logging in +@racket[web-wire]. This affects calls through @racket[ww-debug], but +does not change the remote log level of @tt{webui-wire}; for that, use +@racket[ww-log-level]. +} + +@defform[(ww-debug msg-expr) + #:contracts ([msg-expr any/c])] + +@defform[(ww-debug id-expr msg-expr) + #:contracts ([id-expr any/c] [msg-expr any/c])]{ + +Debug logging macros used inside the module. + +When @racket[ww-set-debug] has been enabled, these macros: + +@itemlist[ + @item{format @racket[msg-expr] (and optionally @racket[id-expr]);} + @item{enqueue a debug log line in the in-memory log buffer.} +] + +They are primarily intended for development and diagnostics. +} + +@defform[(ww-error msg-expr) + #:contracts ([msg-expr any/c])] + +@defform[(ww-error id-expr msg-expr) + #:contracts ([id-expr any/c] [msg-expr any/c])]{ + +Error logging macros. + +These always log, regardless of the debug flag, and are used for +internal error conditions. Log lines are stored in the same log +buffer used by @racket[ww-display-log] and @racket[ww-tail-log]. +} + +@defproc[(ww-set-log-lines! [n exact-nonnegative-integer?]) void?]{ + +Sets the maximum number of log lines kept in the in-memory ring buffer. + +The value is clamped between 10 and 10,000. When the buffer exceeds +this limit, the oldest entries are dropped. +} + +@defproc[(ww-display-log + [filter (or/c #f string? regexp? (listof string?)) #f]) + void?]{ + +Prints the contents of the log buffer to @racket[current-output-port]. + +The optional @racket[filter] controls which lines are shown: + +@itemlist[ + @item{@racket[#f] — show all entries;} + @item{@racket[string?] — case-insensitive substring match;} + @item{@racket[regexp?] — regular expression match;} + @item{@racket[(listof string?)] — OR-combination of substring filters.} +] +} + +@defproc[(ww-tail-log [args any/c] ...) void?]{ + +Shows the last part of the log buffer and then follows new entries, +similar to @tt{tail -f}. + +The optional @racket[args] are interpreted as: + +@itemlist[ + @item{a number: how many last lines to show initially;} + @item{a filter (string, regexp, or list of strings) as in + @racket[ww-display-log];} + @item{a boolean @racket[#f] to stop an existing tail session.} +] + +This is mainly intended for interactive debugging. +} + +@section{Backend settings and protocol info} + +@defproc[(ww-log-level [level symbol?]) symbol?]{ + +Configures the log level inside the @tt{webui-wire} helper. + +This function is defined via: + +@racketblock[ + (def-cmd ww-log-level + loglevel () ((level symbol?)) -> symbol) +] + +so @racket[level] is optional: + +@itemlist[ + @item{with an argument, the remote log level is set to @racket[level] + and the effective level is returned;} + @item{without arguments, the current remote log level is queried and + returned.} +] +} + +@defproc[(ww-protocol) exact-integer?]{ + +Returns the protocol version spoken by the @tt{webui-wire} helper. + +Definition (simplified): + +@racketblock[ + (def-cmd ww-protocol + protocol () () -> int) +] + +The result is an integer that should match the protocol version that +the Racket side expects. +} + +@defproc[(ww-cwd [path path-or-string?]) path?]{ + +Gets or sets the current working directory used by @tt{webui-wire}. + +This function is defined as: + +@racketblock[ + (def-cmd ww-cwd + cwd () [(path path-or-string?)] -> path) +] + +Semantics: + +@itemlist[ + @item{With a @racket[path], the helper’s working directory is set and + the new directory (as a path) is returned.} + @item{Without arguments, the current directory (as seen by the helper) + is returned.} +] + +This directory is used to resolve relative file names (HTML files, +icons, etc.). +} + +@section{Global stylesheet} + +@defproc[(ww-set-stylesheet [st stylesheet-or-string?]) void?]{ + +Sets the global stylesheet used by WebUI windows: + +@racketblock[ + (def-cmd ww-set-stylesheet + set-stylesheet ((st stylesheet-or-string?)) () -> void) +] + +The argument @racket[st] may be either: + +@itemlist[ + @item{a stylesheet value understood by the WebUI side;} + @item{a raw CSS string.} +] +} + +@defproc[(ww-get-stylesheet) stylesheet?]{ + +Returns the currently configured global stylesheet: + +@racketblock[ + (def-cmd ww-get-stylesheet + get-stylesheet () () -> stylesheet) +] +} + +@section{Window handles and window management} + +@defstruct*[ww-win ([id exact-integer?])]{ + +Opaque handle representing a WebUI window. + +The internal @racket[id] field corresponds to the numeric window id +used by the @tt{webui-wire} backend. +} + +@defproc[(ww-win-id [win ww-win?]) exact-integer?]{ + +Accessor for the underlying numeric id of a @racket[ww-win] struct. +} + +@defproc[(ww-new + [profile symbol?] + [use-browser boolean?] + [parent ww-win?]) + ww-win?]{ + +Creates a new WebUI window: + +@racketblock[ + (def-cmd ww-new + new ((profile symbol?)) [(use-browser boolean?) (parent ww-win?)] + -> ww-win) +] + +Arguments: + +@itemlist[ + @item{@racket[profile] symbolic profile name used by the helper to + select configuration (e.g. @racket['default]).} + @item{@racket[use-browser] when true, prefers an external browser + instead of an embedded webview where supported.} + @item{@racket[parent] optional parent window (for dialog-like windows).} +] + +Returns a @racket[ww-win] handle. +} + +@defproc[(ww-close [win-id ww-win?]) void?]{ + +Closes the given window: + +@racketblock[ + (def-cmd ww-close + close ((win-id ww-win?)) [] -> void) +] +} + +@defproc[(ww-move [win-id ww-win?] + [x number?] + [y number?]) + void?]{ + +Moves the window to position (@racket[x], @racket[y]) in pixels: + +@racketblock[ + (def-cmd ww-move + move ((win-id ww-win?) (x number?) (y number?)) [] -> void) +] +} + +@defproc[(ww-resize [win-id ww-win?] + [width number?] + [height number?]) + void?]{ + +Resizes the window to the given pixel @racket[width] and +@racket[height]: + +@racketblock[ + (def-cmd ww-resize + resize ((win-id ww-win?) (width? number?) (height number?)) [] -> void) +] +} + +@defproc[(ww-set-title [win-id ww-win?] + [title string?]) + void?]{ + +Sets the window title: + +@racketblock[ + (def-cmd ww-set-title + set-title ((win-id ww-win?) (title string?)) [] -> void) +] +} + +@defproc[(ww-set-icon [win-id ww-win?] + [svg-file (is-icon-file? 'svg)] + [png-file (is-icon-file? 'png)]) + void?]{ + +Sets the window icon, given paths to an SVG and PNG file: + +@racketblock[ + (def-cmd ww-set-icon + set-icon ((win-id ww-win?) + (svg-file (is-icon-file? 'svg)) + (png-file (is-icon-file? 'png))) [] -> void) +] + +The predicates @racket[(is-icon-file? 'svg)] and +@racket[(is-icon-file? 'png)] ensure that the paths refer to existing +files with the correct file extension. +} + +@defproc[(ww-set-menu [win-id ww-win?] + [menu is-menu?]) + void?]{ + +Configures the window menu: + +@racketblock[ + (def-cmd ww-set-menu + set-menu ((win-id ww-win?) + (menu is-menu?)) [] -> void) +] + +The @racket[menu] value is a menu structure as defined in +@racket["menu.rkt"]. +} + +@defproc[(ww-set-show-state [win-id ww-win?] + [state symbol?]) + void?]{ + +Sets the show state of the window: + +@racketblock[ + (def-cmd ww-set-show-state + set-show-state ((win-id ww-win?) + (state symbol?)) () -> void) +] + +Typical states are things like @racket['normal], @racket['maximized], +etc., depending on what the backend supports. +} + +@defproc[(ww-get-show-state [win-id ww-win?]) symbol?]{ + +Returns the current show state of the window: + +@racketblock[ + (def-cmd ww-get-show-state + show-state ((win-id ww-win?)) () -> symbol) +] +} + +@section{HTML / URL content} + +@defproc[(ww-set-html-file [win-id ww-win?] + [html-file html-file-exists?]) + exact-integer?]{ + +Loads the given HTML file into the window: + +@racketblock[ + (def-cmd ww-set-html-file + set-html ((win-id ww-win?) + (html-file html-file-exists?)) () + -> number) +] + +The @racket[html-file] argument must exist (resolved against +@racket[ww-cwd]); otherwise an error is raised. The return value is a +numeric status code from the helper. +} + +@defproc[(ww-set-url [win-id ww-win?] + [url string?]) + void?]{ + +Navigates the window to the given URL: + +@racketblock[ + (def-cmd ww-set-url + set-url ((win-id ww-win?) + (url string?)) () -> void) +] +} + +@defproc[(ww-set-inner-html [win-id ww-win?] + [element-id symbol-or-string?] + [html-of-file html-or-file?]) + void?]{ + +Replaces the @tt{innerHTML} of the given element: + +@racketblock[ + (def-cmd ww-set-inner-html + set-inner-html ((win-id ww-win?) + (element-id symbol-or-string?) + (html-of-file html-or-file?)) () -> void) +] + +The @racket[html-of-file] value is either a string containing HTML or +a path to a file whose contents are used as HTML. +} + +@defproc[(ww-get-inner-html [win-id ww-win?] + [element-id symbol-or-string?]) + jsexpr?]{ + +Gets the @tt{innerHTML} of the element as JSON: + +@racketblock[ + (def-cmd ww-get-inner-html + get-inner-html ((win-id ww-win?) + (element-id symbol-or-string?)) () -> json) +] +} + +@section{Attributes, CSS, classes} + +@defproc[(ww-set-attr [win-id ww-win?] + [element-id symbol-or-string?] + [attr symbol-or-string?] + [val any?]) + void?]{ + +Sets an attribute on an element: + +@racketblock[ + (def-cmd ww-set-attr + set-attr ((win-id ww-win?) + (element-id symbol-or-string?) + (attr symbol-or-string?) + (val any?)) () -> void) +] +} + +@defproc[(ww-get-attr [win-id ww-win?] + [element-id symbol-or-string?] + [attr symbol-or-string?]) + jsexpr?]{ + +Returns the value of a specific attribute as JSON: + +@racketblock[ + (def-cmd ww-get-attr + get-attr ((win-id ww-win?) + (element-id symbol-or-string?) + (attr symbol-or-string?)) () -> json) +] +} + +@defproc[(ww-get-attrs [win-id ww-win?] + [element-id symbol-or-string?]) + jsexpr?]{ + +Returns all attributes of the element in JSON form, converted on the +Racket side by a helper: + +@racketblock[ + (def-cmd ww-get-attrs + get-attrs ((win-id ww-win?) + (element-id symbol-or-string?)) () -> json + -> mk-attrs) +] +} + +@defproc[(ww-del-attr [win-id ww-win?] + [element-id symbol-or-string?] + [attr symbol-or-string?]) + void?]{ + +Removes an attribute from the element: + +@racketblock[ + (def-cmd ww-del-attr + del-attr ((win-id ww-win?) + (element-id symbol-or-string?) + (attr symbol-or-string?)) () -> void) +] +} + +@defproc[(ww-add-style [win-id ww-win?] + [element-id symbol-or-string?] + [css-style css-style?]) + void?]{ + +Adds or merges CSS style for an element: + +@racketblock[ + (def-cmd ww-add-style + add-style ((win-id ww-win?) + (element-id symbol-or-string?) + (css-style css-style?)) () -> void) +] +} + +@defproc[(ww-set-style [win-id ww-win?] + [element-id symbol-or-string?] + [css-style css-style?]) + void?]{ + +Replaces the CSS style of an element: + +@racketblock[ + (def-cmd ww-set-style + set-style ((win-id ww-win?) + (element-id symbol-or-string?) + (css-style css-style?)) () -> void) +] +} + +@defproc[(ww-get-style [win-id ww-win?] + [element-id symbol-or-string?]) + css-style?]{ + +Gets the CSS style of an element: + +@racketblock[ + (def-cmd ww-get-style + get-style ((win-id ww-win?) + (element-id symbol-or-string?)) () + -> css-style) +] +} + +@defproc[(ww-add-class [win-id ww-win?] + [element-id symbol-or-string?] + [class symbol-or-string?]) + void?]{ + +Adds a CSS class: + +@racketblock[ + (def-cmd ww-add-class + add-class ((win-id ww-win?) + (element-id symbol-or-string?) + (class symbol-or-string?)) + () -> void) +] +} + +@defproc[(ww-remove-class [win-id ww-win?] + [element-id symbol-or-string?] + [class symbol-or-string?]) + void?]{ + +Removes a CSS class: + +@racketblock[ + (def-cmd ww-remove-class + remove-class ((win-id ww-win?) + (element-id symbol-or-string?) + (class symbol-or-string?)) + () -> void) +] +} + +@defproc[(ww-has-class? [win-id ww-win?] + [element-id symbol-or-string?] + [class symbol-or-string?]) + boolean?]{ + +Tests for a CSS class: + +@racketblock[ + (def-cmd ww-has-class? + has-class ((win-id ww-win?) + (element-id symbol-or-string?) + (class symbol-or-string?)) () + -> bool) +] +} + +@section{Values and element queries} + +@defproc[(ww-get-value [win-id ww-win?] + [element-id symbol-or-string?]) + string?]{ + +Gets the “value” of an element: + +@racketblock[ + (def-cmd ww-get-value + value ((win-id ww-win?) + (element-id symbol-or-string?)) () -> string) +] +} + +@defproc[(ww-set-value [win-id ww-win?] + [element-id symbol-or-string?] + [value any?]) + void?]{ + +Sets the “value” of an element: + +@racketblock[ + (def-cmd ww-set-value + value ((win-id ww-win?) + (element-id symbol-or-string?) + (value any?)) () -> void) +] +} + +@defproc[(ww-get-elements [win-id ww-win?] + [selector selector?]) + jsexpr?]{ + +Queries elements matching a CSS-like selector: + +@racketblock[ + (def-cmd ww-get-elements + get-elements ((win-id ww-win?) + (selector selector?)) () -> json + -> (λ (r) ...)) +] + +The raw JSON result is converted by a small helper into a more usable +Racket structure (see the implementation). +} + +@defproc[(ww-element-info [win-id ww-win?] + [element-id symbol-or-string?]) + jsexpr?]{ + +Returns structural information about an element: + +@racketblock[ + (def-cmd ww-element-info + element-info ((win-id ww-win?) + (element-id symbol-or-string?)) () + -> json + -> (λ (r) ...)) +] +} + +@section{Events} + +@defproc[(ww-bind [win-id ww-win?] + [event symbol-or-string?] + [selector selector?]) + jsexpr?]{ + +Binds an @racket[event] to all elements matching @racket[selector]: + +@racketblock[ + (def-cmd ww-bind + bind ((win-id ww-win?) + (event symbol-or-string?) + (selector selector?)) () -> json + -> (λ (r) ...)) +] + +The JSON result includes information about the bound elements. +} + +@defproc[(ww-on [win-id ww-win?] + [event symbol-or-string?] + [id symbol-or-string?]) + void?]{ + +Binds @racket[event] for a single element id: + +@racketblock[ + (def-cmd ww-on + on ((win-id ww-win?) + (event symbol-or-string?) + (id symbol-or-string?)) () -> void) +] +} + +@section{File and directory dialogs} + +@defproc[(ww-file-open [win-id ww-win?] + [caption string?] + [dir string?] + [file-filters string?]) + string?]{ + +Opens a file-open dialog: + +@racketblock[ + (def-cmd ww-file-open + file-open ((win-id ww-win?) + (caption string?) + (dir string?) + (file-filters string?)) () + -> string) +] + +Returns the selected path as a string, or an empty string on cancel. +} + +@defproc[(ww-file-save [win-id ww-win?] + [caption string?] + [dir string?] + [file-filters string?] + [overwrite boolean?]) + string?]{ + +Opens a file-save dialog: + +@racketblock[ + (def-cmd ww-file-save + file-save ((win-id ww-win?) + (caption string?) + (dir string?) + (file-filters string?) + (overwrite boolean?)) () + -> string) +] + +Returns the chosen file path as a string, or an empty string if the +user cancels. +} + +@defproc[(ww-choose-dir [win-id ww-win?] + [caption string?] + [dir string?]) + string?]{ + +Opens a directory chooser dialog: + +@racketblock[ + (def-cmd ww-choose-dir + choose-dir ((win-id ww-win?) + (caption string?) + (dir string?)) () + -> string) +] + +Returns the selected directory path as a string, or an empty string +on cancel. +} + +@section{Low-level command access} + +@defproc[(ww-cmd [cmd string?]) cmdr?]{ + +Sends a raw command string @racket[cmd] to @tt{webui-wire} and returns +a @racket[cmdr] struct describing the reply. + +This is the lowest-level escape hatch; all @racket[def-cmd]-generated +functions use this under the hood. +} + +@defproc[(ww-cmd-nok? [r any/c]) boolean?]{ + +Predicate that recognises a failed command reply: + +@itemlist[ + @item{returns @racket[#t] if @racket[r] is a @racket[cmdr] with a + false @tt{ok} field, or the symbol @racket['cmd-nok];} + @item{returns @racket[#f] otherwise.} +] +} + +@defproc[(ww-from-string [s string?]) string?]{ + +Helper that “unquotes” and unescapes a string previously encoded for +transmission over the wire: + +@itemlist[ + @item{removes surrounding quotes;} + @item{replaces escaped quotes (@tt{\"\\\"\"}) by plain quotes.} +] +} + +@section{Window tables} + +@defthing[windows (hash/c exact-integer? ww-win?)]{ + +Hash table containing the currently known windows, keyed by numeric id. + +This is mainly useful for diagnostics and advanced introspection; most +application code keeps explicit @racket[ww-win] handles instead. +} + +@defthing[windows-evt-handlers + (hash/c exact-integer? (-> symbol? any/c any))]{ + +Hash table mapping window ids to their registered event handlers. +Handlers are called with an event kind (a symbol) and associated data. +} + +@defproc[(ww-get-window-for-id [win-id exact-integer?]) + (or/c ww-win? #f)]{ + +Looks up the @racket[ww-win] struct for a numeric @racket[win-id] in +the @racket[windows] table. Returns @racket[#f] if no such window is +known. +} + +@section{Helper predicates used in contracts} + +The following predicates are used as argument checkers in the +@racket[def-cmd]-generated functions above. + +@defproc[(stylesheet-or-string? [st any/c]) boolean?]{ + +Returns @racket[#t] if @racket[st] is either a stylesheet value or a +string: + +@racketblock[ + (define (stylesheet-or-string? st) + (or (stylesheet? st) (string? st))) +] +} + +@defproc[(is-icon-file? [ext symbol?]) (-> any/c boolean?)]{ + +Returns a predicate that checks “existing file with the right +extension”: + +@racketblock[ + (define (is-icon-file? ext) + (lambda (v) + (and (string? v) + (string-suffix? v (string-append "." (symbol->string ext))) + (file-exists? v)))) +] + +Used in @racket[ww-set-icon] with @racket['svg] and @racket['png]. +} + +@defproc[(html-file-exists? [f path-or-string?]) boolean?]{ + +Checks whether @racket[f] refers to an existing file, either directly +or resolved relative to @racket[ww-cwd]: + +@racketblock[ + (define (html-file-exists? f) + (if (file-exists? f) + #t + (let* ((cwd (ww-cwd)) + (full-file (build-path cwd f))) + (ww-debug (format "file-exists? '~a'" full-file)) + (file-exists? full-file))) + ) +] +} + +@defproc[(html-or-file? [v any/c]) boolean?]{ + +Returns @racket[#t] if @racket[v] is either an existing file or a +string: + +@racketblock[ + (define (html-or-file? v) + (if (file-exists? v) + #t + (string? v))) +] +} + +@defproc[(symbol-or-string? [s any/c]) boolean?]{ + +True if @racket[s] is a symbol or a string: + +@racketblock[ + (define (symbol-or-string? s) + (or (symbol? s) (string? s))) +] +} + +@defproc[(any? [v any/c]) boolean?]{ + +Predicate that always returns @racket[#t]; used in contracts when no +extra checking is required. +} + +@defproc[(path-or-string? [s any/c]) boolean?]{ + +True if @racket[s] is a path or string: + +@racketblock[ + (define (path-or-string? s) + (or (path? s) (string? s))) +] +} + +@defproc[(selector? [s any/c]) boolean?]{ + +Predicate for element selectors: + +@itemlist[ + @item{@racket[symbol?] — a single symbolic selector;} + @item{@racket[string?] — a string selector;} + @item{a non-empty list of symbols/strings.} +] + +Implementation sketch: + +@racketblock[ + (define (selector? s) + (or (symbol? s) (string? s) + (and (list? s) + (not (null? s)) + (letrec ([f (λ (l) + (if (null? l) + #t + (and (or (symbol? (car l)) + (string? (car l))) + (f (cdr l)))))] + (f s))))) + ) +] +} diff --git a/scribblings/webui-wire-download.scrbl b/scribblings/webui-wire-download.scrbl new file mode 100644 index 0000000..beddea0 --- /dev/null +++ b/scribblings/webui-wire-download.scrbl @@ -0,0 +1,187 @@ +#lang scribble/manual + +@(require scribble/manual + (for-label racket/base + racket/file + racket/path + racket/system + racket/string + racket/port + "../private/webui-wire-download.rkt")) + +@title{WebUI Wire: Automatic Downloader} +@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] + +@defmodule[webui-wire-download] + +This module centralises how the @tt{webui-wire} helper executable is +invoked and how its version is obtained. + +It takes care of: + +@itemlist[ + @item{determining the command used to run @tt{webui-wire} + (platform-specific, Flatpak on Linux, a direct executable on + other platforms);} + @item{checking whether the helper is installed and matches the + expected protocol version (see @racket[ww-wire-version]);} + @item{optionally allowing user code to override the command string.} +] + +The public surface of this module is intentionally small and stable: +most callers only need “get me the command” and “tell me the version”. + +@section{Getting the @tt{webui-wire} command} + +@defproc[(ww-get-webui-wire-command) string?]{ + +Returns the command that should be used to invoke the @tt{webui-wire} +helper. + +Typical usage is: + +@racketblock[ + (define cmd (ww-get-webui-wire-command)) + (define-values (proc in out err) (process cmd)) +] + +Internally, this function: + +@itemlist[ + @item{checks whether a custom command has been installed via + @racket[ww-set-custom-webui-wire-command!]; if so, that value + is returned immediately;} + @item{otherwise, determines a platform-specific default command: + on Linux this is typically a @tt{flatpak run ...} invocation, + while on other platforms it is the path to the installed + executable;} + @item{ensures that the helper is actually present and executable;} + @item{verifies that the running helper reports a compatible + @racket[ww-wire-version] (from @racketmodname[web-racket-version]).} +] + +If the helper is not installed, cannot be found, or reports an +unexpected version, an error is raised with a message indicating that +@tt{webui-wire} needs to be installed (or upgraded) in order to use +Web Racket. + +The returned string is intended to be passed to @racket[process] or +similar functions from @racketmodname[racket/system]. The result may +contain spaces (for example for Flatpak invocations), so treat it as a +single shell command, not as a list of arguments. +} + +@section{Getting the installed version} + +@defproc[(ww-get-webui-wire-version) string?]{ + +Returns the version string of the installed @tt{webui-wire} helper. + +Conceptually, the function: + +@itemlist[ + @item{obtains the command via @racket[ww-get-webui-wire-command];} + @item{invokes @tt{ --version};} + @item{reads the stdout of that process;} + @item{trims whitespace and returns the resulting string.} +] + +The returned value is the version as reported by the helper itself, +for example: + +@racketblock[ + "0.2.3" +] + +In normal operation, this version is expected to match (or be +compatible with) the protocol version @racket[ww-wire-version] that +the current Web Racket library was built for. + +If the helper is missing or cannot be executed, the same error +conditions as @racket[ww-get-webui-wire-command] apply, and an +exception is raised. +} + +@section{Overriding the command} + +@defproc[(ww-set-custom-webui-wire-command! + [cmd string?]) + string?]{ + +Installs a custom command string @racket[cmd] that will be used by +@racket[ww-get-webui-wire-command] instead of the automatically +computed, platform-specific default. + +This is useful when: + +@itemlist[ + @item{@tt{webui-wire} is installed in a non-standard location;} + @item{you want to run a wrapper script around the real executable;} + @item{you are developing or testing against a different build of the + helper.} +] + +The command string should be something that can be passed directly to +@racket[process], for example: + +@itemlist[ + @item{@racket["/opt/webui-wire/bin/webui-wire"]} + @item{@racket["flatpak run --user nl.dijkewijk.webui-wire"]} +] + +The function stores the override and returns it. + +Subsequent calls to @racket[ww-get-webui-wire-command] will use this +custom command as-is; the internal platform detection and default +location logic are bypassed. The version check performed by +@racket[ww-get-webui-wire-version] still applies, so your custom +binary must speak the expected protocol version. +} + +@section{Internal behaviour (informative)} + +The details in this section describe the typical internal behaviour of +the module. They are not part of the public API and may change +without notice, but they are useful to understand how error messages +and version checks arise. + +@subsection{Platform and installation lookup} + +When no custom command is installed, the module roughly follows these +steps: + +@itemlist[ + @item{Determine the host operating system and, if relevant, whether + it is running under Flatpak or a similar sandbox;} + @item{Construct a candidate command: + on Linux this is usually a @tt{flatpak run} incantation; + on other platforms it is the full path to the installed + @tt{webui-wire} executable;} + @item{Attempt to run @tt{ --version} to confirm that the + helper is present and working;} + @item{Compare the reported version with @racket[ww-wire-version]; if + the versions are incompatible, an error is raised.} +] + +The resolved command may be cached internally so that subsequent calls +to @racket[ww-get-webui-wire-command] do not repeat all checks. + +@subsection{Error reporting} + +When a problem occurs (no helper, wrong version, command not +executable, etc.), errors are raised with human-readable messages that +aim to explain: + +@itemlist[ + @item{what went wrong (e.g. @emph{command not found}, + @emph{unsupported version});} + @item{which command was attempted;} + @item{which version was expected (via @racket[ww-wire-version]).} +] + +Callers of @racket[ww-get-webui-wire-command] and +@racket[ww-get-webui-wire-version] are encouraged to catch these +exceptions near the application entry point and present a friendly +message to the end user (for example: “The WebUI helper needs to be +installed or upgraded”). +} diff --git a/scribblings/webui-wire-ipc.scrbl b/scribblings/webui-wire-ipc.scrbl new file mode 100644 index 0000000..d8e6a54 --- /dev/null +++ b/scribblings/webui-wire-ipc.scrbl @@ -0,0 +1,259 @@ +#lang scribble/manual + +@(require scribble/manual + (for-label racket/base + racket/system + racket/string + "../private/webui-wire-download.rkt" + "../private/webui-wire-ipc.rkt") + ) + +@title{WebUI Wire IPC Bridge} +@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] + +@defmodule[webui-wire-ipc] + +This module provides a small IPC abstraction around the external +@tt{webui-wire} executable. It is responsible for: + +@itemlist[ + @item{starting the @tt{webui-wire} process (via + @racket[ww-get-webui-wire-command]);} + @item{reading and decoding its stdout and stderr streams;} + @item{dispatching incoming events to a callback;} + @item{serialising access to the process so it can be shared by + multiple threads.} +] + +The public API consists of a single function, @racket[webui-ipc], which +starts the helper process and returns a function for sending commands +and receiving replies. + +The helper process itself implements a simple text-based protocol; this +module hides those details and presents a synchronous “send a string, +get a string back” interface. + +@section{IPC entry point} + +@defproc[ + (webui-ipc + [event-queuer (-> string? any)] + [log-processor (-> symbol? string? any)]) + (-> string? string?) +]{ + +Starts the @tt{webui-wire} executable and returns a function that can be +used to send commands to it. + +The arguments: + +@itemlist[ + @item[@racket[event-queuer]]{ + A callback that receives event payloads produced by the helper + process. + + Whenever the process emits an @tt{EVENT} line on stderr, the + textual payload after the @tt{"EVENT:"} tag is passed to this + callback as: + + @racketblock[ + (event-queuer line) + ] + + The callback is executed in the context of a dedicated reader + thread and should return quickly (for example by placing the event + on a queue to be handled elsewhere). + } + @item[@racket[log-processor]]{ + A callback used for all non-event messages and error conditions. + + It is called as: + + @racketblock[ + (log-processor kind msg) + ] + + where @racket[kind] is a symbol describing the source or category + of the message (for example @racket['stderr-reader] or a tag from + the incoming line) and @racket[msg] is a human-readable string. + } +] + +The return value is a @emph{command function} with the contract +@racket[(-> string? string?)]: + +@itemlist[ + @item{When you call it with a command string, it sends that command + to the @tt{webui-wire} process and blocks until one reply is + received.} + @item{The reply payload (decoded as UTF-8) is returned as a plain + string.} + @item{If anything goes wrong with the IPC protocol (invalid prefix, + unexpected EOF, etc.), an exception is raised.} +] + +A typical usage pattern: + +@racketblock[ + (define (enqueue-event line) + ;; Handle incoming EVENT messages from webui-wire + (printf "EVENT: ~a\n" line)) + + (define (log-message kind msg) + ;; Central logging hook + (printf "[~a] ~a\n" kind msg)) + + ;; Start the IPC bridge and obtain the command function: + (define send-cmd + (webui-ipc enqueue-event log-message)) + + ;; Send a command (no newline required) and wait for the reply: + (define reply + (send-cmd "HELLO 42")) + + (printf "Reply was: ~a\n" reply) +] + +Notes: + +@itemlist[ + @item{The command function writes a line to the process stdin using + @racket[displayln]; callers do @emph{not} need to append a + newline themselves.} + @item{Calls to the command function are executed one at a time, even + when invoked from multiple threads (see + @secref["ipc-internal-concurrency"]).} +] + +} + +@section[#:tag "ipc-protocol"]{Protocol overview (informative)} + +The @tt{webui-wire} process uses a simple length-prefixed text protocol +on both stdout and stderr. This section describes the format for +reference; the details are handled internally by this module. + +@subsection{Message framing} + +Each message from the helper has the following structure: + +@itemlist[ + @item{An 8-character decimal length prefix (ASCII digits),} + @item{followed by a single colon character @tt{":"},} + @item{followed by @emph{exactly} that many bytes of payload,} + @item{followed by a single newline character.} +] + +The payload is treated as UTF-8 text by this module. + +@subsection{Stdout replies} + +On @bold{stdout}: + +@itemlist[ + @item{Each command sent via the returned command function results in + exactly one reply on stdout with the framing described above.} + @item{The payload is decoded as UTF-8 and returned to the caller as a + plain string.} +] + +If the helper sends malformed output (for example, a non-numeric +prefix, incorrect length, or missing colon), the IPC code raises an +error indicating “unexpected input from webui-wire executable”. + +@subsection{Stderr events and logs} + +On @bold{stderr}: + +@itemlist[ + @item{Each payload is interpreted as a single logical line;} + @item{The line is expected to start with a symbolic tag followed by + a colon, e.g. @tt{"EVENT:..."} or @tt{"INFO:..."}.} +] + +The module distinguishes: + +@itemlist[ + @item{@tt{EVENT} lines: the text after the @tt{"EVENT:"} prefix is + delivered to the @racket[event-queuer] callback.} + @item{All other tags (and untagged lines): turned into + @racket[(kind msg)] calls to @racket[log-processor], where + @racket[kind] is a symbol derived from the tag or from the + stderr reader, and @racket[msg] is the remaining text.} +] + +If the stderr reader encounters EOF, it reports this via +@racket[log-processor] (using an appropriate @racket[kind] symbol) and +then terminates its reader thread. + +@section[#:tag "ipc-internal"]{Internal behaviour (informative)} + +This section summarises how @racket[webui-ipc] is implemented. The +details are not part of the public API, but they can help to interpret +log messages and errors. + +@subsection{Process startup} + +When you call @racket[webui-ipc], the following steps are performed: + +@itemlist[ + @item{Obtain the command string for @tt{webui-wire} by calling + @racket[ww-get-webui-wire-command] from + @racketmodname[webui-wire-download].} + @item{Launch the helper process using @racket[process] from + @racketmodname[racket/system].} + @item{Keep handles to the process stdin, stdout, and stderr ports.} + @item{Spawn a background thread dedicated to reading and decoding + stderr events and log lines.} +] + +If the process cannot be started, an exception is raised immediately +from @racket[webui-ipc]. + +@subsection[#:tag "ipc-internal-concurrency"]{Concurrency and serialisation} + +The command function returned by @racket[webui-ipc] may be called from +multiple threads. + +Internally, a semaphore is used to ensure that only one caller at a +time: + +@itemlist[ + @item{writes a command line to the process stdin,} + @item{waits for a single, complete reply on stdout,} + @item{returns that reply to the caller.} +] + +This guarantees that requests and replies do not interleave: the reply +string you receive always corresponds to the command you sent in that +call. + +The stderr reader runs independently in its own thread and does not +block command calls. + +@subsection{Error handling and shutdown} + +Several error conditions can occur: + +@itemlist[ + @item{EOF or broken pipe on stdin/stdout/stderr;} + @item{malformed length prefix or framing errors;} + @item{helper process exiting unexpectedly.} +] + +In these cases, the module: + +@itemlist[ + @item{raises an exception from the command function on the next call + that attempts to send a command or read a reply;} + @item{reports context via @racket[log-processor] (for example, that + the stderr reader hit EOF or that the executable exited).} +] + +It is the responsibility of the caller to decide how to react: +typically by catching such exceptions at a higher level, informing the +user, and possibly restarting the application or IPC bridge. + +Once the @tt{webui-wire} process has exited, the previously returned +command function is no longer usable; further calls are expected to +fail with an error.