@ with /another/ 'Click' handler inside that page, which
65 | -- returns 'Stop' from 'on'' to stop the 'Click' event from bubbling out to the
66 | -- outer handler when the inner @
@ is clicked.
67 | --
68 | -- @
69 | -- do 'on' 'Click' $ \\_ ->
70 | -- liftIO $ putStrLn "Clicked outside!"
71 | -- div ! style "background: lightblue;" '@@' do
72 | -- "Click here, or elsewhere..."
73 | -- 'on'' 'Click' $ \\_ -> do
74 | -- liftIO $ putStrLn "Clicked inside!"
75 | -- pure 'Stop'
76 | -- @
77 | (@@) :: (Html -> Html) -> ReactiveM model a -> ReactiveM model a
78 | wrap @@ ReactiveM inner = ReactiveM do
79 | builder <- get
80 | model <- ask
81 | let originalLoc = location builder
82 | (result, builder') =
83 | runState
84 | (runReaderT inner model)
85 | builder
86 | { location = 0 <| originalLoc -- descend a level in location cursor
87 | , pageMarkup = mempty
88 | -- handlers & pageTitle are threaded through and preserved
89 | }
90 | put builder'
91 | { location = let (h :| t) = originalLoc in (h + 1 :| t) -- move sideways
92 | , pageMarkup =
93 | do pageMarkup builder
94 | wrapInTarget originalLoc $ wrap (pageMarkup builder')
95 | -- handlers & pageTitle are threaded through and preserved
96 | }
97 | pure result
98 | where
99 | wrapInTarget :: NonEmpty Word -> Html -> Html
100 | wrapInTarget loc = (! dataAttribute clientDataAttr (showLoc loc))
101 | infixr 5 @@
102 |
103 | -- | Create a scope for event listeners without wrapping any enclosing HTML. Any
104 | -- listeners created via 'on' will apply only to HTML that is written inside
105 | -- this block, rather than to the enclosing broader scope.
106 | --
107 | -- >>> target === (id @@)
108 | target :: ReactiveM model a -> ReactiveM model a
109 | target = (id @@)
110 |
111 | -- | Return a piece of JavaScript code which looks up the object corresponding
112 | -- to the current scope's location in the page. This is suitable to be used in
113 | -- 'eval', for instance, to retrieve properties of a particular element.
114 | --
115 | -- If there is no enclosing '@@', then this is the @window@ object; otherwise,
116 | -- it is the outermost HTML element object created by the first argument to the
117 | -- enclosing '@@'. If there are multiple elements at the root of the enclosing
118 | -- '@@', then the first of these is selected.
119 | --
120 | -- For example, here's an input which reports its own contents:
121 | --
122 | -- @
123 | -- textbox :: Reactive Text
124 | -- textbox = input @@ do
125 | -- e <- this
126 | -- on Input \_ -> do
127 | -- value <- eval $ this <> ".value"
128 | -- put value
129 | -- @
130 | this :: ReactiveM model Text
131 | this = ReactiveM do
132 | loc <- gets (NonEmpty.tail . location)
133 | pure case loc of
134 | [] -> "window"
135 | h : t ->
136 | let selector = "[data-" <> clientDataAttr <> "=\"" <> showLoc (h :| t) <> "\"]"
137 | in "(document.querySelector('" <> selector <> "'))"
138 |
139 | -- | Write an atomic piece of HTML (or anything that can be converted to it) to
140 | -- the page in this location. Event listeners for its enclosing scope can be
141 | -- added by sequential use of 'on'. If you need sub-pieces of this HTML to have
142 | -- their own scoped event listeners, use '@@' to build a composite component.
143 | --
144 | -- >>> markup h === const (toMarkup h) @@ pure ()
145 | markup :: ToMarkup h => h -> Reactive model
146 | markup h = const (toMarkup h) @@ pure ()
147 |
148 | -- | Set the title for the page. If this function is called multiple times in
149 | -- one update, the most recent call is used.
150 | title :: Text -> Reactive model
151 | title t = ReactiveM (modify \wb -> wb { pageTitle = Last (Just t) })
152 |
153 | -- | Listen to a particular event and react to it by modifying the model for the
154 | -- page. This function's returned 'Propagation' value specifies whether or not
155 | -- to propagate the event outwards to other enclosing contexts. The event target
156 | -- is scoped to the enclosing '@@', or the whole page if at the top level.
157 | --
158 | -- When the specified 'EventType' occurs, the event handler will be called with
159 | -- that event type's corresponding property record, e.g. a 'Click' event's
160 | -- handler will receive a 'MouseEvent' record. A handler can modify the page's
161 | -- model via 'State'ful actions and perform arbitrary IO using 'liftIO'. In the
162 | -- context of a running page, a handler also has access to the 'eval' and
163 | -- 'evalBlock' functions to evaluate JavaScript in that page.
164 | --
165 | -- __Exception behavior:__ This function catches @PatternMatchFail@ exceptions
166 | -- thrown by the passed function. That is, if there is a partial pattern match
167 | -- in the pure function from event properties to stateful update, the stateful
168 | -- update will be silently skipped. This is useful as a shorthand to select only
169 | -- events of a certain sort, for instance:
170 | --
171 | -- @
172 | -- 'on'' 'Click' \\'MouseEvent'{shiftKey = True} ->
173 | -- do putStrLn "Shift + Click!"
174 | -- pure 'Bubble'
175 | -- @
176 | on' ::
177 | EventType props ->
178 | (props -> StateT model IO Propagation) ->
179 | Reactive model
180 | on' event reaction = ReactiveM do
181 | loc <- gets (NonEmpty.tail . location)
182 | let selector =
183 | case loc of
184 | [] -> window
185 | (h : t) -> ("data-" <> clientDataAttr) `attrIs`
186 | Text.toStrict (Text.pack (showLoc (h :| t)))
187 | modify \builder ->
188 | builder { handlers = mappend (handlers builder) $
189 | onEvent event [selector] $
190 | \props model ->
191 | -- We need to do a pure and impure catch, because GHC might
192 | -- decide to inline things inside the IO action, or it might
193 | -- not! So we check in both circumstances.
194 | case tryMatch (runStateT (reaction props) model) of
195 | Nothing -> pure (Bubble, model)
196 | Just io ->
197 | do result <- Exception.try @Exception.PatternMatchFail io
198 | case result of
199 | Left _ -> pure (Bubble, model)
200 | Right update -> pure update }
201 | where
202 | tryMatch = teaspoonWithHandles
203 | [Exception.Handler \(_ :: Exception.PatternMatchFail) -> pure Nothing]
204 |
205 | -- | Listen to a particular event and react to it by modifying the model for the
206 | -- page. This is a special case of 'on'' where the event is always allowed to
207 | -- bubble out to listeners in enclosing contexts.
208 | --
209 | -- See the documentation for 'on''.
210 | on ::
211 | EventType props ->
212 | (props -> StateT model IO ()) ->
213 | Reactive model
214 | on event action = on' event (\props -> action props >> pure Bubble)
215 |
216 | -- | Focus a reactive page fragment to manipulate a piece of a larger model,
217 | -- using a 'Traversal'' to specify what part(s) of the larger model to
218 | -- manipulate.
219 | --
220 | -- This is especially useful when creating generic components which can be
221 | -- re-used in the context of many different models. For instance, we can define
222 | -- a toggle button and specify separately which part of a model it toggles:
223 | --
224 | -- @
225 | -- toggle :: 'Reactive' Bool
226 | -- toggle =
227 | -- button '@@' do
228 | -- active <- 'ask'
229 | -- if active then \"ON\" else \"OFF\"
230 | -- 'on' 'Click' \\_ -> 'modify' not
231 | --
232 | -- twoToggles :: 'Reactive' (Bool, Bool)
233 | -- twoToggles = do
234 | -- _1 '##' toggle
235 | -- _2 '##' toggle
236 | -- @
237 | --
238 | -- This function takes a 'Traversal'', which is strictly more general than a
239 | -- 'Lens''. This means you can use traversals with zero or more than one target,
240 | -- and this many replicas of the given 'Reactive' fragment will be generated,
241 | -- each separately controlling its corresponding portion of the model. This
242 | -- means the above example could also be phrased:
243 | --
244 | -- @
245 | -- twoToggles :: 'Reactive' (Bool, Bool)
246 | -- twoToggles = 'each' '##' toggle
247 | -- @
248 | (##) :: Traversal' model model' -> Reactive model' -> Reactive model
249 | l ## ReactiveM action =
250 | ReactiveM $
251 | ReaderT \model ->
252 | iforOf_ (indexing l) model \i model' ->
253 | StateT \b@ReactiveBuilder{handlers = priorHandlers} ->
254 | let b' = flip execState (b {handlers = mempty}) $
255 | runReaderT action model'
256 | in Identity $
257 | ((), b' { handlers =
258 | priorHandlers <>
259 | focusHandlers (indexing l . index i) (handlers b')
260 | })
261 | -- TODO: make this more efficient using a pre-applied Traversal?
262 | infixr 5 ##
263 |
264 | -- | Evaluate a reactive component to produce a pair of 'Direct.PageContent' and
265 | -- 'Handlers'. This is the bridge between the 'Direct.runPage' abstraction and
266 | -- the 'Reactive' abstraction: use this to run a reactive component in a
267 | -- 'Myxine.Page'.
268 | reactive :: Reactive model -> model -> (Direct.PageContent, Handlers model)
269 | reactive (ReactiveM action) model =
270 | let ReactiveBuilder{handlers, pageMarkup, pageTitle = pageContentTitle} =
271 | execState (runReaderT action model) initialBuilder
272 | pageContentBody = renderMarkup pageMarkup
273 | in (Direct.pageBody (Text.toStrict pageContentBody)
274 | <> foldMap Direct.pageTitle pageContentTitle,
275 | handlers)
276 | where
277 | initialBuilder = ReactiveBuilder
278 | { location = (0 :| [])
279 | , handlers = mempty
280 | , pageMarkup = pure ()
281 | , pageTitle = Last Nothing }
282 |
283 | -- | 'Reactive' pages can be combined using '<>', which concatenates their HTML
284 | -- content and merges their sets of 'Handlers'.
285 | instance Semigroup a => Semigroup (ReactiveM model a) where
286 | m <> n = (<>) <$> m <*> n
287 |
288 | -- | The empty 'Reactive' page, with no handlers and no content, is 'mempty'.
289 | instance Monoid a => Monoid (ReactiveM model a) where mempty = pure mempty
290 |
291 | -- | You can apply an HTML attribute to any 'Reactive' page using '!'.
292 | instance Attributable (ReactiveM model a) where
293 | w ! a = (! a) @@ w
294 |
295 | -- | You can apply an HTML attribute to any function between 'Reactive' pages
296 | -- using '!'. This is useful when building re-usable widget libraries, allowing
297 | -- their attributes to be modified after the fact but before they are filled
298 | -- with contents.
299 | instance Attributable (ReactiveM model a -> ReactiveM model a) where
300 | f ! a = (! a) . f
301 |
302 | -- | A string literal is a 'Reactive' page containing that selfsame text.
303 | instance (a ~ ()) => IsString (ReactiveM model a) where
304 | fromString = markup . string
305 |
306 | -- | The in-browser name for the data attribute holding our tracking id. This is
307 | -- not the same as the @id@ attribute, because this means the user is free to
308 | -- use the _real_ @id@ attribute as they please.
309 | clientDataAttr :: IsString a => a
310 | clientDataAttr = "myxine-client-widget-id"
311 |
312 | -- | Helper function to show a location in the page: add hyphens between every
313 | -- number.
314 | showLoc :: IsString a => (NonEmpty Word) -> a
315 | showLoc = fromString . intercalate "-" . map show . NonEmpty.toList
316 |
--------------------------------------------------------------------------------
/clients/haskell/src/Myxine/Target.hs:
--------------------------------------------------------------------------------
1 | {-# options_haddock not-home #-}
2 |
3 | module Myxine.Target (Target, attribute, tag, TargetFact(..), targetFacts) where
4 |
5 | import Data.Hashable
6 | import Data.Text (Text)
7 | import qualified Data.Aeson as JSON
8 | import Data.HashMap.Lazy (HashMap)
9 | import qualified Data.HashMap.Lazy as HashMap
10 | import GHC.Generics
11 |
12 | -- | A 'Target' is a description of a single element node in the browser. When
13 | -- an event fires in the browser, Myxine tracks the path of nodes it touches,
14 | -- from the most specific element all the way up to the root. Each event handler
15 | -- is given access to this @['Target']@, ordered from most to least specific.
16 | --
17 | -- For any 'Target', you can query the value of any of an 'attribute', or you
18 | -- can ask for the 'tag' of that element.
19 | data Target = Target
20 | { tagName :: Text
21 | , attributes :: HashMap Text Text
22 | } deriving (Eq, Ord, Show, Generic, JSON.FromJSON)
23 |
24 | -- | Get the value, if any, of some named attribute of a 'Target'.
25 | attribute :: Text -> Target -> Maybe Text
26 | attribute name Target{attributes} = HashMap.lookup name attributes
27 | {-# INLINE attribute #-}
28 |
29 | -- | Get the name of the HTML tag for this 'Target'. Note that unlike in the
30 | -- browser itself, Myxine returns tag names in lower case, rather than upper.
31 | tag :: Target -> Text
32 | tag Target{tagName} = tagName
33 | {-# INLINE tag #-}
34 |
35 | -- | A fact about a 'Target', such as it having a particular tag or having a
36 | -- particular attribute equal to a particular value.
37 | --
38 | -- You can construct a 'TargetFact' using 'Myxine.tagIs', 'Myxine.attrIs', or
39 | -- 'Myxine.window'.
40 | data TargetFact
41 | = HasTag !Text
42 | | AttributeEquals !Text !Text
43 | | Window
44 | deriving (Eq, Ord, Show, Generic, Hashable)
45 |
46 | targetFacts :: Target -> [TargetFact]
47 | targetFacts Target{tagName, attributes} =
48 | HasTag tagName : map (uncurry AttributeEquals) (HashMap.toList attributes)
49 |
--------------------------------------------------------------------------------
/clients/haskell/stack.yaml:
--------------------------------------------------------------------------------
1 | # This file was automatically generated by 'stack init'
2 | #
3 | # Some commonly used options have been documented as comments in this file.
4 | # For advanced use and comprehensive documentation of the format, please see:
5 | # https://docs.haskellstack.org/en/stable/yaml_configuration/
6 |
7 | # Resolver to choose a 'specific' stackage snapshot or a compiler version.
8 | # A snapshot resolver dictates the compiler version and the set of packages
9 | # to be used for project dependencies. For example:
10 | #
11 | # resolver: lts-3.5
12 | # resolver: nightly-2015-09-21
13 | # resolver: ghc-7.10.2
14 | #
15 | # The location of a snapshot can be provided as a file or url. Stack assumes
16 | # a snapshot provided as a file might change, whereas a url resource does not.
17 | #
18 | # resolver: ./custom-snapshot.yaml
19 | # resolver: https://example.com/snapshots/2018-01-01.yaml
20 | resolver: lts-16.8
21 |
22 | # User packages to be built.
23 | # Various formats can be used as shown in the example below.
24 | #
25 | # packages:
26 | # - some-directory
27 | # - https://example.com/foo/bar/baz-0.0.2.tar.gz
28 | # - location:
29 | # git: https://github.com/commercialhaskell/stack.git
30 | # commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a
31 | # - location: https://github.com/commercialhaskell/stack/commit/e7b331f14bcffb8367cd58fbfc8b40ec7642100a
32 | # subdirs:
33 | # - auto-update
34 | # - wai
35 | packages:
36 | - .
37 | # Dependency packages to be pulled from upstream that are not in the resolver
38 | # using the same syntax as the packages field.
39 | # (e.g., acme-missiles-0.3)
40 | extra-deps:
41 | - dependent-map-0.4.0.0
42 | - constraints-extras-0.3.0.2
43 | - dependent-sum-0.7.1.0
44 |
45 | # Override default flag values for local packages and extra-deps
46 | # flags: {}
47 |
48 | # Extra package databases containing global packages
49 | # extra-package-dbs: []
50 |
51 | # Control whether we use the GHC we find on the path
52 | # system-ghc: true
53 | #
54 | # Require a specific version of stack, using version ranges
55 | # require-stack-version: -any # Default
56 | # require-stack-version: ">=1.9"
57 | #
58 | # Override the architecture used by stack, especially useful on Windows
59 | # arch: i386
60 | # arch: x86_64
61 | #
62 | # Extra directories used by stack for building
63 | # extra-include-dirs: [/path/to/dir]
64 | # extra-lib-dirs: [/path/to/dir]
65 | #
66 | # Allow a newer minor version of GHC than the snapshot specifies
67 | # compiler-check: newer-minor
68 |
69 | allow-newer: true
70 |
--------------------------------------------------------------------------------
/clients/haskell/stack.yaml.lock:
--------------------------------------------------------------------------------
1 | # This file was autogenerated by Stack.
2 | # You should not edit this file by hand.
3 | # For more information, please see the documentation at:
4 | # https://docs.haskellstack.org/en/stable/lock_files
5 |
6 | packages:
7 | - completed:
8 | hackage: dependent-map-0.4.0.0@sha256:ca2b131046f4340a1c35d138c5a003fe4a5be96b14efc26291ed35fd08c62221,1657
9 | pantry-tree:
10 | size: 551
11 | sha256: 5defa30010904d2ad05a036f3eaf83793506717c93cbeb599f40db1a3632cfc5
12 | original:
13 | hackage: dependent-map-0.4.0.0
14 | - completed:
15 | hackage: constraints-extras-0.3.0.2@sha256:013b8d0392582c6ca068e226718a4fe8be8e22321cc0634f6115505bf377ad26,1853
16 | pantry-tree:
17 | size: 594
18 | sha256: 3ce1012bfb02e4d7def9df19ce80b8cd2b472c691b25b181d9960638673fecd1
19 | original:
20 | hackage: constraints-extras-0.3.0.2
21 | - completed:
22 | hackage: dependent-sum-0.7.1.0@sha256:5599aa89637db434431b1dd3fa7c34bc3d565ee44f0519bfbc877be1927c2531,2068
23 | pantry-tree:
24 | size: 290
25 | sha256: 9cbfb32b5a8a782b7a1c941803fd517633cb699159b851c1d82267a9e9391b50
26 | original:
27 | hackage: dependent-sum-0.7.1.0
28 | snapshots:
29 | - completed:
30 | size: 532379
31 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/16/8.yaml
32 | sha256: 2ad3210d2ad35f3176005d68369a18e4d984517bfaa2caade76f28ed0b2e0521
33 | original: lts-16.8
34 |
--------------------------------------------------------------------------------
/clients/haskell/test/Test.hs:
--------------------------------------------------------------------------------
1 | module Main (main) where
2 |
3 | main :: IO ()
4 | main = putStrLn "Test suite not yet implemented."
5 |
--------------------------------------------------------------------------------
/clients/python/LICENSE:
--------------------------------------------------------------------------------
1 | ../../LICENSE
--------------------------------------------------------------------------------
/clients/python/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md LICENSE
2 |
--------------------------------------------------------------------------------
/clients/python/README.md:
--------------------------------------------------------------------------------
1 | # A Python client for Myxine
2 |
3 | [Myxine](https://github.com/kwf/myxine) is a language-agnostic local
4 | server that lets you build interactive applications in the browser using a
5 | RESTful API. This package defines simple Python bindings for using Myxine to
6 | quickly prototype surprisingly high-performance GUIs.
7 |
8 | Myxine itself runs as a local server, separately from these bindings. It is
9 | built in Rust, and can be installed using the standard Rust build tool cargo:
10 |
11 | ``` bash
12 | $ cargo install myxine
13 | ```
14 |
15 | This Python package does not manage the myxine server process; it assumes that
16 | it is already running in the background (either started by an end-user, or
17 | managed by your own Python application).
18 |
19 | **Package versioning and stability:** This package should be considered in
20 | "alpha" stability at present. No compatibility between alpha versions is
21 | guaranteed.
22 |
--------------------------------------------------------------------------------
/clients/python/myxine/__init__.py:
--------------------------------------------------------------------------------
1 | from .myxine import Target, Event, page_url, events, evaluate, update, static
2 |
--------------------------------------------------------------------------------
/clients/python/myxine/myxine.py:
--------------------------------------------------------------------------------
1 | from typing import Optional, Iterator, Dict, List, Any
2 | import requests
3 | from dataclasses import dataclass
4 | from requests import RequestException
5 | import json
6 | from copy import deepcopy
7 | import urllib.parse
8 | from semantic_version import Version, SimpleSpec
9 |
10 | # The default port on which myxine operates; can be overridden in the below
11 | # functions if the server is running on another port.
12 | DEFAULT_PORT = 1123
13 |
14 |
15 | # The supported versions of the myxine server
16 | SUPPORTED_SERVER_VERSIONS = SimpleSpec('>=0.2,<0.3')
17 |
18 |
19 | # The global session for all requests
20 | __GLOBAL_SESSION = requests.Session()
21 |
22 |
23 | @dataclass
24 | class Target:
25 | """A Target corresponds to an element in the browser's document. It
26 | contains a tag name and a mapping from attribute name to attribute value.
27 | """
28 | tag: str
29 | attributes: Dict[str, str]
30 |
31 |
32 | class Event:
33 | """An Event from a page has a type, a list of targets, and a set of
34 | properties keyed by strings, which may be any type. All properties of an
35 | event are accessible as fields of this object, though different event types
36 | may have different sets of fields.
37 | """
38 | __event: str
39 | __targets: List[Target]
40 | __properties: Dict[str, Any]
41 | __finalized: bool = False
42 |
43 | def __getattr__(self, name: str) -> Any:
44 | value = self.__properties[name]
45 | if value is None:
46 | raise AttributeError
47 | return value
48 |
49 | def __setattr__(self, name: str, value: Any) -> None:
50 | if self.__finalized:
51 | raise ValueError("Event objects are immutable once created")
52 | super(Event, self).__setattr__(name, value)
53 |
54 | def __dir__(self) -> List[str]:
55 | fields = dir(super(Event, self)) + \
56 | list(self.__properties.keys()) + \
57 | ['event', 'targets']
58 | return sorted(set(fields))
59 |
60 | def event(self) -> str:
61 | """Returns the event name for this event."""
62 | return self.__event
63 |
64 | def targets(self) -> List[Target]:
65 | """Returns the list of targets for this event, in order from most to
66 | least specific in the DOM tree."""
67 | return deepcopy(self.__targets)
68 |
69 | def __init__(self, value: Dict[str, Any]) -> None:
70 | """Parse a JSON-encoded event. Returns None if it can't be parsed."""
71 | try:
72 | self.__event = value['event']
73 | self.__targets = [Target(tag=j['tagName'],
74 | attributes=j['attributes'])
75 | for j in value['targets']]
76 | self.__properties = value['properties']
77 | except json.JSONDecodeError:
78 | raise ValueError("Could not parse event: " + str(value)) from None
79 | except KeyError:
80 | raise ValueError("Could not parse event: " + str(value)) from None
81 | self.__finalized = True
82 |
83 |
84 | def page_url(path: str, port: int = DEFAULT_PORT) -> str:
85 | """Normalize a port & path to give the localhost url for that location."""
86 | if len(path) > 0 and path[0] == '/':
87 | path = path[1:]
88 | return 'http://localhost:' + str(port) + '/' + path
89 |
90 |
91 | def events(path: str,
92 | subscription: Optional[List[str]] = None,
93 | port: int = DEFAULT_PORT,
94 | ignore_server_version: bool = False) -> Iterator[Event]:
95 | """Subscribe to a stream of page events from a myxine server, returning an
96 | iterator over the events returned by the stream as they become available.
97 | """
98 | base_url = page_url(path, port)
99 | try:
100 | # The base parameters of the request
101 | params: Dict[str, Any]
102 | if subscription is None:
103 | params = {'events': ''}
104 | else:
105 | params = {'events': subscription}
106 | params['next'] = '' # The first time around, /?next&events=...
107 |
108 | # The earliest event we will be willing to accept
109 | moment: str = ''
110 |
111 | while True:
112 | url = urllib.parse.urljoin(base_url, moment)
113 | response = __GLOBAL_SESSION.get(url, params=params)
114 | if response.encoding is None:
115 | response.encoding = 'utf-8'
116 | if not ignore_server_version:
117 | check_server_version(response)
118 | ignore_server_version = True
119 | event = Event(response.json())
120 | if event is not None:
121 | yield event
122 |
123 | # Set up the next request
124 | moment = response.headers['Content-Location']
125 |
126 | except RequestException:
127 | msg = "Connection issue with myxine server (is it running?)"
128 | raise ConnectionError(msg) from None
129 |
130 |
131 | def evaluate(path: str, *,
132 | expression: Optional[str] = None,
133 | statement: Optional[str] = None,
134 | port: int = DEFAULT_PORT,
135 | ignore_server_version: bool = False) -> None:
136 | """Evaluate the given JavaScript code in the context of the page."""
137 | bad_args_err = \
138 | ValueError('Input must be exactly one of a statement or an expression')
139 | if expression is not None:
140 | if statement is not None:
141 | raise bad_args_err
142 | url = page_url(path, port)
143 | params = {'evaluate': expression}
144 | data = b''
145 | elif statement is not None:
146 | if expression is not None:
147 | raise bad_args_err
148 | url = page_url(path, port) + '?evaluate'
149 | params = {}
150 | data = statement.encode()
151 | else:
152 | raise bad_args_err
153 | try:
154 | r = __GLOBAL_SESSION.post(url, data=data, params=params)
155 | if not ignore_server_version:
156 | check_server_version(r)
157 | if r.status_code == 200:
158 | return r.json()
159 | raise ValueError(r.text)
160 | except RequestException:
161 | msg = "Connection issue with myxine server (is it running?)"
162 | raise ConnectionError(msg) from None
163 |
164 |
165 | def update(path: str,
166 | body: str,
167 | title: Optional[str] = None,
168 | port: int = DEFAULT_PORT,
169 | ignore_server_version: bool = False) -> None:
170 | """Set the contents of the page at the given path to a provided body and
171 | title. If body or title is not provided, clears those elements of the page.
172 | """
173 | url = page_url(path, port)
174 | try:
175 | params = {'title': title}
176 | r = __GLOBAL_SESSION.post(url, data=body.encode(), params=params)
177 | if not ignore_server_version:
178 | check_server_version(r)
179 | except RequestException:
180 | msg = "Connection issue with myxine server (is it running?)"
181 | raise ConnectionError(msg) from None
182 |
183 |
184 | def static(path: str,
185 | body: bytes,
186 | content_type: str,
187 | port: int = DEFAULT_PORT,
188 | ignore_server_version: bool = False) -> None:
189 | """Set the contents of the page at the given path to the static content
190 | provided, as a bytestring. You must specify a content type, or else the
191 | browser won't necessarily know how to display this content.
192 | """
193 | url = page_url(path, port) + '?static'
194 | try:
195 | headers = {'Content-Type': content_type}
196 | r = __GLOBAL_SESSION.post(url, data=body, headers=headers)
197 | if not ignore_server_version:
198 | check_server_version(r)
199 | except RequestException:
200 | msg = "Connection issue with myxine server (is it running?)"
201 | raise ConnectionError(msg) from None
202 |
203 |
204 | def check_server_version(response: requests.Response) -> None:
205 | """Check to make sure the Server header in the given response is valid for
206 | the versions supported by this version of the client library, and throw an
207 | exception if not.
208 | """
209 | try:
210 | server_version = response.headers['server']
211 | if server_version is not None:
212 | try:
213 | server, version_string = server_version.split('/')
214 | if server == "myxine":
215 | try:
216 | version = Version.coerce(version_string)
217 | if version in SUPPORTED_SERVER_VERSIONS:
218 | return
219 | else:
220 | msg = f"Unsupported version of the myxine server: \
221 | {version}; supported versions are {str(SUPPORTED_SERVER_VERSIONS)}"
222 | raise ConnectionError(msg) from None
223 | except ValueError:
224 | msg = f"Could not parse myxine server version string: {version_string}"
225 | raise ConnectionError(msg) from None
226 | except ValueError:
227 | pass
228 | except KeyError:
229 | pass
230 |
231 | # Default:
232 | msg = "Server did not identify itself as a myxine server."
233 | raise ConnectionError(msg) from None
234 |
--------------------------------------------------------------------------------
/clients/python/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 |
4 | # Note: To use the 'upload' functionality of this file, you must:
5 | # $ pipenv install twine --dev
6 |
7 | import io
8 | import os
9 | import sys
10 | from shutil import rmtree
11 | from typing import List, Dict
12 |
13 | from setuptools import find_packages, setup, Command
14 |
15 | # Package meta-data.
16 | NAME = 'myxine-client'
17 | DESCRIPTION = 'A Python client for the Myxine GUI server.'
18 | URL = 'https://github.com/kwf/myxine'
19 | EMAIL = 'kwf@very.science'
20 | AUTHOR = 'Kenny Foner'
21 | REQUIRES_PYTHON = '>=3.6.0'
22 | VERSION = '0.2.1'
23 |
24 | # What packages are required for this module to be executed?
25 | REQUIRED = [
26 | 'requests',
27 | 'semantic_version'
28 | ]
29 |
30 | # What packages are optional?
31 | EXTRAS: Dict[str, str] = {
32 | # 'fancy feature': ['django'],
33 | }
34 |
35 | # The rest you shouldn't have to touch too much :)
36 | # ------------------------------------------------
37 | # Except, perhaps the License and Trove Classifiers!
38 | # If you do change the License, remember to change the Trove Classifier for that!
39 |
40 | here = os.path.abspath(os.path.dirname(__file__))
41 |
42 | # Import the README and use it as the long-description.
43 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file!
44 | try:
45 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
46 | long_description = '\n' + f.read()
47 | except FileNotFoundError:
48 | long_description = DESCRIPTION
49 |
50 | # Load the package's __version__.py module as a dictionary.
51 | about: Dict[str, str] = {}
52 | if not VERSION:
53 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_")
54 | with open(os.path.join(here, project_slug, '__version__.py')) as f:
55 | exec(f.read(), about)
56 | else:
57 | about['__version__'] = VERSION
58 |
59 |
60 | class UploadCommand(Command):
61 | """Support setup.py upload."""
62 |
63 | description = 'Build and publish the package.'
64 | user_options: List[str] = []
65 |
66 | @staticmethod
67 | def status(s):
68 | """Prints things in bold."""
69 | print('\033[1m{0}\033[0m'.format(s))
70 |
71 | def initialize_options(self):
72 | pass
73 |
74 | def finalize_options(self):
75 | pass
76 |
77 | def run(self):
78 | try:
79 | self.status('Removing previous builds…')
80 | rmtree(os.path.join(here, 'dist'))
81 | except OSError:
82 | pass
83 |
84 | self.status('Building Source and Wheel (universal) distribution…')
85 | os.system('{0} setup.py sdist bdist_wheel --universal'
86 | .format(sys.executable))
87 |
88 | self.status('Uploading the package to PyPI via Twine…')
89 | os.system('twine upload dist/*')
90 |
91 | # self.status('Pushing git tags…')
92 | # os.system('git tag v{0}'.format(about['__version__']))
93 | # os.system('git push --tags')
94 |
95 | sys.exit()
96 |
97 |
98 | # Where the magic happens:
99 | setup(
100 | name=NAME,
101 | version=about['__version__'],
102 | description=DESCRIPTION,
103 | long_description=long_description,
104 | long_description_content_type='text/markdown',
105 | author=AUTHOR,
106 | author_email=EMAIL,
107 | python_requires=REQUIRES_PYTHON,
108 | url=URL,
109 | packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]),
110 | # If your package is a single module, use this instead of 'packages':
111 | # py_modules=['myxine'],
112 |
113 | # entry_points={
114 | # 'console_scripts': ['mycli=mymodule:cli'],
115 | # },
116 | install_requires=REQUIRED,
117 | extras_require=EXTRAS,
118 | include_package_data=True,
119 | license='MIT',
120 | classifiers=[
121 | # Trove classifiers
122 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
123 | 'License :: OSI Approved :: MIT License',
124 | 'Programming Language :: Python',
125 | 'Programming Language :: Python :: 3',
126 | 'Programming Language :: Python :: 3.6',
127 | 'Programming Language :: Python :: Implementation :: CPython',
128 | 'Programming Language :: Python :: Implementation :: PyPy'
129 | ],
130 | # $ setup.py publish support.
131 | cmdclass={
132 | 'upload': UploadCommand,
133 | },
134 | )
135 |
--------------------------------------------------------------------------------
/core/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "myxine-core"
3 | version = "0.1.1"
4 | description = "The core library powering the Myxine GUI server."
5 | keywords = ["GUI", "web", "interface", "scripting", "tool"]
6 | categories = ["command-line-utilities", "graphics", "gui", "visualization", "web-programming"]
7 | authors = ["Kenny Foner
"]
8 | edition = "2018"
9 | homepage = "https://github.com/kwf/myxine"
10 | readme = "README.md"
11 | license = "MIT"
12 |
13 | [badges]
14 | maintenance = { status = "actively-developed" }
15 |
16 | [dependencies]
17 | hopscotch = "0.1.1"
18 | futures = "0.3"
19 | tokio = { version = "0.2.22", features = ["full"] }
20 | serde = { version = "1.0", features = ["derive"] }
21 | serde_json = "1.0"
22 | bytes = "0.5"
23 | uuid = { version = "0.8", features = ["v4", "serde"] }
24 |
--------------------------------------------------------------------------------
/core/LICENSE:
--------------------------------------------------------------------------------
1 | ../LICENSE
--------------------------------------------------------------------------------
/core/README.md:
--------------------------------------------------------------------------------
1 | This package provides the core library to represent and manipulate dynamic
2 | webpages in the [Myxine](https://github.com/kwf/myxine) GUI server.
3 |
4 | **Stability:** While this package will adhere to semantic versioning, do not
5 | expect its API to be very stable for the time being. It is in flux during the
6 | development process of the Myxine server itself.
7 |
--------------------------------------------------------------------------------
/core/src/lib.rs:
--------------------------------------------------------------------------------
1 | //! This package is the heart of the [Myxine GUI
2 | //! server](https://github.com/kwf/myxine). It does not contain any code
3 | //! for directly implementing the web server or browser-side JavaScript.
4 | //!
5 | //! You probably only need to depend on this library if you are yourself
6 | //! creating an alternative to the Myxine server: to build clients to the Myxine
7 | //! server, see the documentation for its various client libraries.
8 |
9 | mod page;
10 | mod session;
11 |
12 | pub use page::subscription::{Event, Subscription};
13 | pub use page::{content::Command, Page, RefreshMode, Response};
14 | pub use session::{Config, Session};
15 |
--------------------------------------------------------------------------------
/core/src/page/content.rs:
--------------------------------------------------------------------------------
1 | use bytes::Bytes;
2 | use serde::Serialize;
3 | use std::mem;
4 | use tokio::stream::{Stream, StreamExt};
5 | use tokio::sync::broadcast;
6 | use uuid::Uuid;
7 |
8 | use super::RefreshMode;
9 |
10 | /// The `Content` of a page is either `Dynamic` or `Static`. If it's dynamic, it
11 | /// has a title, body, and a set of SSE event listeners who are waiting for
12 | /// updates to the page. If it's static, it just has a fixed content type and a
13 | /// byte array of contents to be returned when fetched. `Page`s can be changed
14 | /// from dynamic to static and vice-versa: when changing from dynamic to static,
15 | /// the change is instantly reflected in the client web browser; in the other
16 | /// direction, it requires a manual refresh (because a static page has no
17 | /// injected javascript to make it update itself).
18 | #[derive(Debug, Clone)]
19 | pub enum Content {
20 | Dynamic {
21 | title: String,
22 | body: String,
23 | updates: broadcast::Sender,
24 | other_commands: broadcast::Sender,
25 | },
26 | Static {
27 | content_type: Option,
28 | raw_contents: Bytes,
29 | },
30 | }
31 |
32 | /// A command sent directly to the code running in the browser page to tell it
33 | /// to update or perform some other action.
34 | #[derive(Debug, Clone, Serialize)]
35 | #[serde(rename_all = "camelCase", tag = "type")]
36 | pub enum Command {
37 | /// Reload the page completely, i.e. via `window.location.reload()`.
38 | #[serde(rename_all = "camelCase")]
39 | Reload,
40 | /// Update the page's dynamic content by setting `window.title` to the given
41 | /// title, and setting the contents of the `` to the given body,
42 | /// either by means of a DOM diff (if `diff == true`) or directly by setting
43 | /// `.innerHTML` (if `diff == false`).
44 | #[serde(rename_all = "camelCase")]
45 | Update {
46 | /// The new title of the page.
47 | title: String,
48 | /// The new body of the page.
49 | body: String,
50 | /// Whether to use some diffing method to increase efficiency in the
51 | /// update (this is usually `true` outside of some debugging contexts.)
52 | diff: bool,
53 | },
54 | /// Evaluate some JavaScript code in the page.
55 | #[serde(rename_all = "camelCase")]
56 | Evaluate {
57 | /// The text of the JavaScript to evaluate.
58 | script: String,
59 | /// If `statement_mode == true`, then the given script is evaluated
60 | /// exactly as-is; otherwise, it is treated as an *expression* and
61 | /// wrapped in an implicit `return (...);`.
62 | statement_mode: bool,
63 | /// The unique id of the request for evaluation, which will be used to
64 | /// report the result once it is available.
65 | id: Uuid,
66 | },
67 | }
68 |
69 | /// The maximum number of updates to buffer before dropping an update. This is
70 | /// set to 1, because dropped updates are okay (the most recent update will
71 | /// always get through once things quiesce).
72 | const UPDATE_BUFFER_SIZE: usize = 1;
73 |
74 | /// The maximum number of non-update commands to buffer before dropping one.
75 | /// This is set to a medium sized number, because we don't want to drop a reload
76 | /// command or an evaluate command. Unlike the update buffer, clients likely
77 | /// won't fill this one, because it's used only for occasional full-reload
78 | /// commands and for evaluating JavaScript, neither of which should be done at
79 | /// an absurd rate.
80 | const OTHER_COMMAND_BUFFER_SIZE: usize = 16;
81 | // NOTE: This memory is allocated all at once, which means that the choice of
82 | // buffer size impacts myxine's memory footprint.
83 |
84 | impl Content {
85 | /// Make a new empty (dynamic) page
86 | pub fn new() -> Content {
87 | Content::Dynamic {
88 | title: String::new(),
89 | body: String::new(),
90 | updates: broadcast::channel(UPDATE_BUFFER_SIZE).0,
91 | other_commands: broadcast::channel(OTHER_COMMAND_BUFFER_SIZE).0,
92 | }
93 | }
94 |
95 | /// Test if this page is empty, where "empty" means that it is dynamic, with
96 | /// an empty title, empty body, and no subscribers waiting on its page
97 | /// events: that is, it's identical to `Content::new()`.
98 | pub fn is_empty(&self) -> bool {
99 | match self {
100 | Content::Dynamic {
101 | title,
102 | body,
103 | ref updates,
104 | ref other_commands,
105 | } if title == "" && body == "" => {
106 | updates.receiver_count() == 0 && other_commands.receiver_count() == 0
107 | }
108 | _ => false,
109 | }
110 | }
111 |
112 | /// Add a client to the dynamic content of a page, if it is dynamic. If it
113 | /// is static, this has no effect and returns None. Otherwise, returns the
114 | /// Body stream to give to the new client.
115 | pub fn commands(&self) -> Option> {
116 | let result = match self {
117 | Content::Dynamic {
118 | updates,
119 | other_commands,
120 | ..
121 | } => {
122 | let merged = updates.subscribe().merge(other_commands.subscribe());
123 | let stream_body = merged
124 | .filter_map(|result| {
125 | match result {
126 | // We ignore lagged items in the stream! If we don't
127 | // ignore these, we would terminate the Body on
128 | // every lag, which is undesirable.
129 | Err(broadcast::RecvError::Lagged(_)) => None,
130 | // Otherwise, if the stream is over, we end this stream.
131 | Err(broadcast::RecvError::Closed) => Some(Err(())),
132 | // But if the item is ok, forward it.
133 | Ok(item) => Some(Ok(item)),
134 | }
135 | })
136 | .take_while(|i| i.is_ok())
137 | .map(|i| i.unwrap());
138 | Some(stream_body)
139 | }
140 | Content::Static { .. } => None,
141 | };
142 | // Make sure the page is up to date
143 | self.refresh(RefreshMode::Diff);
144 | result
145 | }
146 |
147 | /// Tell all clients to refresh the contents of a page, if it is dynamic.
148 | /// This has no effect if it is (currently) static.
149 | pub fn refresh(&self, refresh: RefreshMode) {
150 | match self {
151 | Content::Dynamic {
152 | updates,
153 | other_commands,
154 | title,
155 | body,
156 | } => {
157 | let _ = match refresh {
158 | RefreshMode::FullReload => other_commands.send(Command::Reload),
159 | RefreshMode::SetBody | RefreshMode::Diff => updates.send(Command::Update {
160 | title: title.clone(),
161 | body: body.clone(),
162 | diff: refresh == RefreshMode::Diff,
163 | }),
164 | };
165 | }
166 | Content::Static { .. } => (),
167 | };
168 | }
169 |
170 | /// Set the contents of the page to be a static raw set of bytes with no
171 | /// self-refreshing functionality. All clients will be told to refresh their
172 | /// page to load the new static content (which will not be able to update
173 | /// itself until a client refreshes their page again).
174 | pub fn set_static(&mut self, content_type: Option, raw_contents: Bytes) {
175 | let mut content = Content::Static {
176 | content_type,
177 | raw_contents,
178 | };
179 | mem::swap(&mut content, self);
180 | content.refresh(RefreshMode::FullReload);
181 | }
182 |
183 | /// Tell all clients to change the body, if necessary. This converts the
184 | /// page into a dynamic page, overwriting any static content that previously
185 | /// existed, if any. Returns `true` if the page content was changed (either
186 | /// converted from static, or altered whilst dynamic).
187 | pub fn set(
188 | &mut self,
189 | new_title: impl Into,
190 | new_body: impl Into,
191 | refresh: RefreshMode,
192 | ) -> bool {
193 | let mut changed = false;
194 | loop {
195 | match self {
196 | Content::Dynamic {
197 | ref mut title,
198 | ref mut body,
199 | ..
200 | } => {
201 | let new_title = new_title.into();
202 | let new_body = new_body.into();
203 | if new_title != *title || new_body != *body {
204 | changed = true;
205 | }
206 | *title = new_title;
207 | *body = new_body;
208 | break; // values have been set
209 | }
210 | Content::Static { .. } => {
211 | *self = Content::new();
212 | changed = true;
213 | // and loop again to actually set
214 | }
215 | }
216 | }
217 | if changed {
218 | self.refresh(refresh);
219 | }
220 | changed
221 | }
222 | }
223 |
--------------------------------------------------------------------------------
/core/src/page/query.rs:
--------------------------------------------------------------------------------
1 | use futures::Future;
2 | use std::collections::HashMap;
3 | use tokio::sync::oneshot;
4 | use uuid::Uuid;
5 |
6 | /// A set of pending queries keyed by unique id, waiting to be responded to.
7 | #[derive(Debug)]
8 | pub struct Queries {
9 | pending: HashMap)>,
10 | }
11 |
12 | impl Queries {
13 | /// Create a new empty set of pending queries.
14 | pub fn new() -> Self {
15 | Queries {
16 | pending: HashMap::new(),
17 | }
18 | }
19 |
20 | /// Create an unfulfilled request and return its id and the future which
21 | /// waits on its fulfillment.
22 | pub fn request(&mut self, query: Q) -> (Uuid, impl Future