├── menus ├── empty.json ├── echo.php ├── test.json ├── test2.json ├── info.php └── fs.php ├── .gitignore ├── README.md └── SPECIFICATION.md /menus/empty.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /menus/echo.php: -------------------------------------------------------------------------------- 1 | echo; 6 | -------------------------------------------------------------------------------- /menus/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test", 3 | "groups": [ 4 | { 5 | "items": [ 6 | {"label": "self", "menu": {"request": {"url": "test.json"}}}, 7 | {"label": "flip", "menu": {"request": {"url": "test2.json"}}} 8 | ] 9 | }, 10 | { 11 | "items": [ 12 | {"label": "foo"}, 13 | {"label": "bar"}, 14 | {"label": "baz"} 15 | ] 16 | }, 17 | { 18 | "items": [ 19 | {"label": "run", "action": {"request": {"url": "empty.json"}}}, 20 | {"label": "replace", "action": {"replace": {"request": {"url": "test2.json"}}}}, 21 | {"label": "delete", "destructive": true, "action": {"back": 1}}, 22 | {"label": "home", "destructive": false, "action": {"back": 0}} 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /menus/test2.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Test 2", 3 | "groups": [ 4 | { 5 | "items": [ 6 | {"label": "self", "menu": {"request": {"url": "test2.json"}}}, 7 | {"label": "flop", "menu": {"request": {"url": "test.json"}}} 8 | ] 9 | }, 10 | { 11 | "items": [ 12 | {"label": "foo"}, 13 | {"label": "bar"}, 14 | {"label": "baz"} 15 | ] 16 | }, 17 | { 18 | "items": [ 19 | {"label": "run", "action": {"request": {"url": "empty.json"}}}, 20 | {"label": "replace", "action": {"replace": {"request": {"url": "test.json"}}}}, 21 | {"label": "delete", "destructive": true, "action": {"back": 1}}, 22 | {"label": "home", "destructive": false, "action": {"back": 0}} 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /menus/info.php: -------------------------------------------------------------------------------- 1 | [], 5 | ]; 6 | 7 | $body = file_get_contents("php://input"); 8 | $bodyBytes = strlen($body); 9 | $actions->items []= (object) [ 10 | 'label' => "Show Request Body", 11 | 'detail' => "{$bodyBytes} bytes", 12 | 'file' => echoRequest($body), 13 | ]; 14 | 15 | $more = []; 16 | $more['Method'] = $_SERVER['REQUEST_METHOD']; 17 | 18 | $menu = (object) [ 19 | 'title' => 'Info', 20 | 'groups' => [ 21 | arrayToGroup($more), 22 | $actions, 23 | arrayToGroup(getallheaders(), 'Headers'), 24 | ], 25 | ]; 26 | 27 | function arrayToGroup($arr, $header = null) { 28 | foreach ($arr as $k => $v) { 29 | $items []= (object) [ 30 | 'label' => $k, 31 | 'detail' => $v 32 | ]; 33 | } 34 | return (object) [ 35 | "header" => $header, 36 | "items" => $items, 37 | ]; 38 | } 39 | 40 | function echoRequest($content) { 41 | $request = (object) ['url' => 'echo.php']; 42 | $request->body = (object) ['echo' => $content]; 43 | return $request; 44 | } 45 | 46 | function requestObj($url) { 47 | return (object) ['request' => (object) ['url' => $url]]; 48 | } 49 | 50 | echo json_encode($menu, JSON_PRETTY_PRINT); 51 | -------------------------------------------------------------------------------- /menus/fs.php: -------------------------------------------------------------------------------- 1 | [], 26 | ]; 27 | 28 | $fileGroup = (object) [ 29 | "items" => [], 30 | ]; 31 | 32 | $menu = (object) [ 33 | 'groups' => [], 34 | ]; 35 | 36 | if ($path === '') { 37 | $menu->title = $title; 38 | } 39 | 40 | $invisible = isset($_GET['i']) && $_GET['i'] === '1'; 41 | 42 | foreach (scandir($fullPath) as $p) { 43 | if (!$invisible && substr($p, 0, 1) === '.') continue; 44 | if ($p === '.' || $p === '..') continue; 45 | $itemPath = "{$path}/{$p}"; 46 | if (is_dir("{$basePath}{$itemPath}")) { 47 | $folderGroup->items []= (object) [ 48 | 'label' => basename($p), 49 | 'menu' => requestObj(menuUrl($itemPath, $invisible)), 50 | ]; 51 | } else { 52 | $fileGroup->items []= (object) [ 53 | 'label' => basename($p), 54 | 'file' => (object) ['url' => "fs.php?p=" . urlencode($itemPath)], 55 | ]; 56 | } 57 | } 58 | 59 | if (count($folderGroup->items) > 0) { 60 | $menu->groups []= $folderGroup; 61 | } 62 | if (count($fileGroup->items) > 0) { 63 | $menu->groups []= $fileGroup; 64 | } 65 | 66 | $showItem = (object) ['label' => "Show Invisible Files", 'action' => replaceActionObj(menuUrl($path, true))]; 67 | $hideItem = (object) ['label' => "Hide invisible Files", 'action' => replaceActionObj(menuUrl($path, false))]; 68 | $menu->groups []= (object) [ 69 | 'items' => [$invisible ? $hideItem : $showItem] 70 | ]; 71 | 72 | function requestObj($url) { 73 | return (object) ['request' => (object) ['url' => $url]]; 74 | } 75 | 76 | function replaceActionObj($url) { 77 | return (object) ['replace' => requestObj($url)]; 78 | } 79 | 80 | function menuUrl($path, $showInvisible = false) { 81 | $params = $showInvisible ? '&i=1' : ''; 82 | return "fs.php?p=" . urlencode($path) . $params; 83 | } 84 | 85 | echo json_encode($menu); 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HyperMenu 2 | 3 | HyperMenu is a JSON based format to describe interlinked menu structures. It is designed to be simple and compact, and can be used to create something that feels like a native app within minutes. You can think of it as HTML for hierarchical menus. 4 | 5 | Some examples how HyperMenu can be used: 6 | 7 | * Control dashboard for an embedded device. 8 | * System status monitor and script launcher. 9 | * File system browser. 10 | * Cross platform app prototype. 11 | * Well defined notation to describe menu structures. 12 | 13 | The [HyperMenu Client for iOS](http://hypermenu.heap.ch/) is considered the reference implementation. 14 | 15 | ## Example 16 | 17 | The following JSON object represents a menu: 18 | 19 | ```js 20 | { 21 | "title": "Lights", 22 | "groups": [ 23 | { 24 | "header": "Rooms", 25 | "items": [ 26 | {"label": "Bedroom", "menu": {"request": {"url": "bedroom.json"}}}, 27 | {"label": "Office", "menu": {"request": {"url": "office.json"}}} 28 | ] 29 | }, 30 | { 31 | "items": [ 32 | {"label": "All Off", "action": {"request": {"url": "off.php"}}} 33 | ] 34 | } 35 | ] 36 | } 37 | ``` 38 | 39 | The iOS app will render it as follows: 40 | 41 | ![Example Menu](http://hypermenu.heap.ch/assets/example.png) 42 | 43 | More examples can be found in the `menus` folder. To access them, use the builtin PHP webserver. 44 | 45 | ```sh 46 | php -S 0.0.0.0:8080 -t menus 47 | ``` 48 | 49 | You can now access the menus using `http://laptop.local:8080/test.json`. Use `hostname` to find out your hostname. 50 | 51 | ### test.json 52 | 53 | A simple circular menu structure with demonstrates some of the features. 54 | 55 | ### fs.php 56 | 57 | A simple file system browser menu. Be aware that using this might expose sensitive data to the network. Use the following JSON object to access the menu using a predefined header. The hostname has to be changed accordingly. 58 | 59 | ```js 60 | {"request": {"url": "http://laptop.local:8080/fs.php", "stickyHeaders": {"Authorization": "Bearer 106842b48d349a7f"}}} 61 | ``` 62 | 63 | ### info.php 64 | 65 | Returns infos about the request made to fetch it. Useful to debug header values set using `headers` or `stickyHeaders`. 66 | 67 | ## Specification 68 | 69 | The full HyperMenu specification can be found in [SPECIFICATION.md](https://github.com/stepmuel/hypermenu/blob/master/SPECIFICATION.md). 70 | 71 | ## Participate 72 | 73 | For feedback, ideas or to show me your menus, please contact me at *stephan (at) heap.ch* or on twitter [@stepmuel](https://twitter.com/stepmuel). I am also looking for someone interested in doing an implementation for Android. 74 | -------------------------------------------------------------------------------- /SPECIFICATION.md: -------------------------------------------------------------------------------- 1 | # HyperMenu Specification 2 | 3 | A HyperMenu always starts with a root object of type `Menu`. A *menu* can be thought of as a *screen*, which contains *items* that are enclosed in *groups*. An `Item` can contain another menu, a different *action* or simply represent information. 4 | 5 | Menus and actions might contain `Request` objects which describe HTTP requests. The menu or action objects will be replaced by the result of that request. Those requests enable: 6 | 7 | * Menu hierarchies can either be a single JSON object (inline) or divided at will, even spread across multiple servers. 8 | * Actions can trigger events on remote servers, and the servers can give feedback. 9 | 10 | ## Type Definitions 11 | 12 | The different object types are represented as Swift classes. All properties other than `Request.url` are optional. 13 | 14 | ```swift 15 | class Menu { 16 | var request: Request? 17 | var title: String? 18 | var groups: [Group]? 19 | } 20 | ``` 21 | 22 | * Represents a menu screen. 23 | * `title` is used as the screen title. 24 | * If no title is given, the label of the enclosing item will be used (if available). 25 | * The whole `Menu` object is replaced by the result of `request`. 26 | * Requests returning a menu with another request can be used to implement slow-polling. 27 | * Implementations might show a *loading indicator* while waiting for the first request, if `groups` doesn't exist. 28 | * Implementations might provide a *reload* function if `request` exists. 29 | * Implementations might cancel an ongoing request when leaving the menu (*back button*). 30 | 31 | ```swift 32 | class Group { 33 | var header: String? 34 | var items: [Item]? 35 | } 36 | ``` 37 | 38 | * Used to group items into different *sections*. 39 | * `header` is used as the section title. 40 | * To not use sections, put all elements into a single group with no header. 41 | 42 | ```swift 43 | class Item { 44 | var label: String? 45 | var detail: String? 46 | var destructive: Bool? 47 | var action: Action? 48 | var menu: Menu? 49 | var web: String? 50 | var file: Request? 51 | } 52 | ``` 53 | 54 | * Represents a menu item. 55 | * `label` is used as the title of the item. `detail` adds secondary text. 56 | * `action`, `menu`, `web`, or `file` define what happens if the item is selected (*selectable properties*). 57 | * Only one of those options can be used at once. 58 | * When multiple properties are given, all but the first available one are ignored (in the order listed here). 59 | * `action` items will evaluate the associated `Action` object when selected. 60 | * `detail` is ignored for action items. 61 | * Implementations might allow multiple actions to be triggered simultaneously (before the previous action finishes). 62 | * Implementations might visually highlight `destructive` actions (e.g. with red color). 63 | * `menu` items will navigate to the given menu when selected. 64 | * `web` items will navigate to the given URL when selected. 65 | * URL needs to be absolute. 66 | * Implementations might open URL in a browser or in a specialized app. 67 | * Might not be implemented by all HyperMenu clients. 68 | * `file` items will open the file returned by the given request when selected. 69 | * Unlike `web`, this allows additional request parameters and relative paths. 70 | * Implementations might show a preview of the file or offer to open it with a specialized app. 71 | * Might not be implemented by all HyperMenu clients. 72 | * If none of the selectable properties is available, the item can not be selected (*info item*). 73 | 74 | ```swift 75 | class Request { 76 | var method: String? 77 | var url: String 78 | var headers: [String: String?]? 79 | var stickyHeaders: [String: String?]? 80 | var body: JSONValue? 81 | } 82 | ``` 83 | 84 | * Auxiliary data structure to represent HTTP requests. 85 | * `method` defaults to `GET`, or `POST` if `body` exists. 86 | * `headers` is a dictionary of request headers. 87 | * Header order is not preserved. 88 | * The behavior when using the same key multiple time is undefined. 89 | * `stickyHeaders` are added to all requests down the hierarchy (e.g. authentication). 90 | * Headers can be replaced, or removed (using `null`). 91 | * Replacement priority is: `headers`, `stickyHeaders`, inherited `stickyHeaders`. 92 | * `stickyHeaders` are ignored if the request protocol, domain, or port don't match (prevent cross site attacks). 93 | * `body` is arbitrary JSON data which is added to the request body. 94 | * Setting this will set `method` to `POST` and the `Content-Type` header to `application/json`, unless defined otherwise. 95 | * The root body object should either be of type array or object; other values might have special meanings in the future. 96 | 97 | ```swift 98 | class Action { 99 | var request: Request? 100 | var alert: Alert? 101 | var replace: Menu? 102 | var back: Int? 103 | var invalidate: Int? 104 | } 105 | ``` 106 | 107 | * Represents an action that can be triggered by selecting an action item. 108 | * `request` fetches a new action object. 109 | * Unless the new action object is *empty* (no valid properties), it will *replace* the current action. 110 | * The new action can't contain another request (currently ignored). 111 | * `alert` shows an alert. 112 | * `replace` replaces the current menu. 113 | * `back` will pop the current menu and move up the hierarchy. 114 | * `1` will go back to the parent menu; `2` to it's parent, etc. 115 | * `0` will pop the whole menu hierarchy (*exit*); `-1` will go the first menu (root), etc. 116 | * `invalidate` signals that parent menus are no longer valid and have to be reloaded. 117 | * `1` means the parent needs a refresh, `2` the parent and its parent, etc. 118 | * Used for example when an action deletes an item that was included in the parent menu. 119 | * Current menu is never invalidated (use `replace`). 120 | * Menus that haven't been fetched using a request (inline menus) are ignored. 121 | * All navigation properties are evaluated relative to the current menu, even when combined. 122 | * `{"replace": {}, "back": 2, "invalidate": 1}` would go back two menus. The replaced and invalidated menus are dropped immediately. 123 | 124 | ```swift 125 | class Alert { 126 | var title: String? 127 | var message: String? 128 | var button: String? 129 | } 130 | ``` 131 | 132 | * Defines an alert to be shown by an action. 133 | * `button` is used as label for the button to dismiss the alert (default: `OK`). 134 | --------------------------------------------------------------------------------