diff options
15 files changed, 1210 insertions, 86 deletions
diff --git a/applications/luci-app-example/BUILDING.md b/applications/luci-app-example/BUILDING.md new file mode 100644 index 0000000000..3ed65e3a0d --- /dev/null +++ b/applications/luci-app-example/BUILDING.md @@ -0,0 +1,47 @@ +# Building a LuCI package + +Essentially, you follow the [build system](https://openwrt.org/docs/guide-developer/toolchain/use-buildsystem) instructions to fetch the OpenWrt repository, update the `feeds.conf.default` to point `luci` at a local directory, build out the full toolchain, and then follow the instructions for a [single package](https://openwrt.org/docs/guide-developer/toolchain/single.package) to build the `.opkg` file for the example app. + +Wiki documentation overrides this file. + +## Setup + +* Create a working directory, like `~/src` +* Clone the OpenWrt repository into `~/src/openwrt` +* Clone the LuCI repository into `~/src/luci` + +From here on you'll be working in `~/src/openwrt` + +## Remapping LuCI source to local disk + +* Edit `~/src/openwrt/feeds.conf.default` and comment out the `src-git luci` entry +* Add a `src-link luci` entry pointing to your luci checkout - for example `src-link luci /home/myuser/src/luci` +* Use the `scripts/feeds` tool per the [documentation](https://openwrt.org/docs/guide-developer/toolchain/use-buildsystem#updating_feeds) to update and install all feeds; you should see the local directory get used for luci + +If you're doing a whole new application, instead of editing this one, you can use the `src-link custom` example instead as a basis, leaving `src-git luci` alone. + +## Selecting the app + +* Run `make menuconfig` +* Change the Target system to match your test environment (x86 for QEMU for instance) +* Select the LuCI option +* Select the Applications option +* Navigate the list to find `luci-app-example` +* Press `m` to make the selection be `<M>` - modular build +* Choose Exit all the way back out, and save the configuration + +## Toolchain build + +Even though you're only building a simple JS + Lua package, you'll need the whole toolchain. Though the command says "install", nothing is actually installed outside of the working directory (`~/src/openwrt` in this case). + +* Run `make tools/install` +* Run `make toolchain/install` + +## Package build + +This will trigger the build of all the dependencies, such as **ubus**, **libjson-c**, **rpcd** etcetera. + +* Run `make package/luci-app-example/compile` + +The IPK file will be produced in `bin/packages/<architecture>/luci/`. This file can be copied to your test environment (QEMU, real hardware etcetera), and installed with `opkg`. + diff --git a/applications/luci-app-example/Makefile b/applications/luci-app-example/Makefile index 70834ad909..5ba6c6e467 100644 --- a/applications/luci-app-example/Makefile +++ b/applications/luci-app-example/Makefile @@ -5,6 +5,7 @@ include $(TOPDIR)/rules.mk LUCI_TITLE:=LuCI example app for js based luci LUCI_DEPENDS:=+luci-base +LUCI_PKGARCH:=all include ../../luci.mk diff --git a/applications/luci-app-example/README.md b/applications/luci-app-example/README.md index 23a3a3a179..f12a62b464 100644 --- a/applications/luci-app-example/README.md +++ b/applications/luci-app-example/README.md @@ -1,20 +1,15 @@ # Example app for js based Luci -This app is meant to be a kind of template, example or starting point for developing new luci apps. +This app is meant to be a starting point for developing new LuCI apps using the modern JavaScript client-rendered approach (versus the older Lua server-side render approach). -It provides two pages in the admin backend: -* [htmlview.js](./htdocs/luci-static/resources/view/example/htmlview.js) is based on a view with a form and makes use of internal models. -* [form.js](./htdocs/luci-static/resources/view/example/form.js) uses the `E()` method to create more flexible pages. +# Installation -The view based page is used to modify the example configuration. +In all cases, you'll want to log out of the web interface and back in to force a cache refresh after installing the new package. -The html view page just shows the configured values. +## From git -The configuration is stored in `/etc/config/example`. -The file must exist and created on device boot by UCI defaults script in `/root/etc/uci-defaults/80_example`. -More details about the UCI defaults https://openwrt.org/docs/guide-developer/uci-defaults +To install the luci-app-example to your OpenWrt instance (assuming your OpenWRT instance is on 192.168.1.1): -To install the luci-app-example to your OpenWrt instance use: ``` scp -r root/* root@192.168.1.1:/ scp -r htdocs/* root@192.168.1.1:/www/ @@ -22,4 +17,74 @@ scp -r htdocs/* root@192.168.1.1:/www/ ssh root@192.168.1.1 "sh /etc/uci-defaults/80_example" ``` -Then you need to re-login to LUCI and you'll see a new Example item in main menu. +## From packages + +Install the app on your OpenWrt installation. This can be an actual router/device, or something like a QEMU virtual machine. + +`opkg install luci-app-example` + +Visit the web UI for the device/virtual machine where the package was installed, log in to OpenWrt, and **Example** should be present in the navigation menu. + +# Application structure + +See `structure.md` for details on how to lay out a LuCI application. + +# Code format + +The LuCI Javascript code should be indented with tabs. js-beautify/jsbeautifier can help with this; the examples in this application were formatted with + +`js-beautify -t -a -j -w 110 -r <filename>` + +# Editing the code + +You can either do direct editing on the device/virtual machine, or use something like sshfs to have remote access from your development computer. + +By default, the code is minified by the build process, which makes editing it non-trivial. You can either change the build process, or just copy the file content from the git repository and replace the content on disk. + +Javascript code can be found on the device/virtual machine in `/www/luci-static/resources/view/example/`. + +## [form.js](./htdocs/luci-static/resources/view/example/form.js) + +This is a JS view that uses the **form.Map** approach to providing a form that can change the configuration. It relies on UCI access, and the relevant ACL declarations are in `root/usr/share/rpcd/acl.d/luci-app-example.json`. + +The declarations are `luci-app-example > read > uci` and `luci-app-example > write > uci`. Note that for both permissions, the node name "example" is provided as a list argument to the interface type (**uci**); this maps to `/etc/config/example`. + +Since form.Map and form.JSONMap create Promises, you cannot embed them inside a `E()`-built structure. + +## [htmlview.js](./htdocs/luci-static/resources/view/example/htmlview.js) + +This is a read-only view that uses `E()` to create DOM nodes. + +Data is fetched via the function defined in `load()` - these loads are done as **Promises**, with the promise results stored in an array. Multiple load functions results are available in the array, and can be accessed via a single argument passed to the `render()` function. + +This code relies on the same ACL grants as form.js. + +The signature for `E()` is `E(node_type, {node attributes}, [child nodes])`. + +## [rpc.js](./htdocs/luci-static/resources/view/example/rpc.js) + +The RPC JS page is read-only, and demonstrates using RPC calls to get data. It also demonstrates using the JSONMap form object for mapping a configuration to a form, but makes the form read-only for display purposes. + +The configuration is stored in `/etc/config/example`. The file must exist and created on device boot by UCI defaults script in `/root/etc/uci-defaults/80_example`. The [developer guide](https://openwrt.org/docs/guide-developer/uci-defaults) has more details about UCI defaults. + +The RPCd script is stored as `/usr/libexec/rpcd/luci.example`, and can be called via ubus. + +It relies on RPC access, and the relevant ACL declarations are in `root/usr/share/rpcd/acl.d/luci-app-example.json`. + +The declaration is `luci-app-example > read > ubus > luci.example`; the list of names under this key is the list of APIs that can be called. + +# ACLs + +A small note on ACLs. They are global for the entire web UI - the declaration of **luci-app-example** in a file called `acl.d/luci-app-example` is just a naming convention; nothing enforces that only the code in **luci-app-example** is mutating `/etc/config/example`. Once the ACL is defined to allow reads/writes to a UCI node, any code running from the web UI can make changes to that node. + +# YAML + +You may wish to work with YAML data. See [YAML.md](YAML.md) for details on how to integrate YAML read support. + +# Translations + +For a real world application (or changes to this example one that you wish to submit upstream), translations should be kept up to date. + +To rebuild the translations file, from the root of the repository execute `./build/i18n-scan.pl applications/luci-app-example > applications/luci-app-example/po/templates/example.pot` + +If the scan command fails with an error about being unable to open/find `msguniq`, install the GNU `gettext` package for your operating system. diff --git a/applications/luci-app-example/YAML.md b/applications/luci-app-example/YAML.md new file mode 100644 index 0000000000..36f5ba8e6c --- /dev/null +++ b/applications/luci-app-example/YAML.md @@ -0,0 +1,151 @@ +# Processing YAML in Lua + +You may need to deal with YAML data in your Lua code. + +## root/usr/libexec/rpcd/luci.example +These are the changes you would need in the `usr/libexec/rpcd/luci.example` file. + +First, declare that you want YAML libraries: + +``` +-- If you need to process YAML, opkg install lyaml +local lyaml = require "lyaml" +``` + +Then, declare a function to handle the YAML data, and a helper to read the file + +``` +local function readfile(path) + local s = fs.readfile(path) + return s and (s:gsub("^%s+", ""):gsub("%s+$", "")) +end + +local function reading_from_yaml() + -- Use the locally declared readfile() function to read in the + -- sample YAML file that ships with this package. + local example_config = readfile("/etc/luci.example.yaml") + + -- Map that string to a Lua table via lyaml's load() method + local example_table = lyaml.load(example_config) + + -- Convert the table to JSON + local example_json = jsonc.stringify(example_table) + + -- Pass the JSON back + return example_json +end +``` + +Declare the method in the `methods` table + +``` + -- Converts the AGH YAML configuration into JSON for consumption by + -- the LuCI app. + get_yaml_file_sample = { + -- A special key of 'call' points to a function definition for execution. + call = function() + + local r = {} + r.result = reading_from_yaml() + -- The 'call' handler will refer to '.code', but also defaults if not found. + r.code = 0 + -- Return the table object; the call handler will access the attributes + -- of the table. + return r + end + }, +``` + +## htdocs/luci-static/resources/view/example/rpc.js + +These are the changes you need in the `rpc.js` file. + +Declare the RPC call + +``` +var load_sample_yaml = rpc.declare({ + object: 'luci.example', + method: 'get_yaml_file_sample' +}); +``` + +Add this declaration to the `view.extend()` call + +``` + render_sample_yaml: function(sample) { + console.log('render_sample_yaml()'); + console.log(sample); + + if (sample.error) { + return this.generic_failure(sample.error) + } + // Basically, a fully static table declaration. + var table = E('table', { 'class': 'table', 'id': 'sample-yaml' }, [ + E('tr', {}, [ + E('td', { 'class': 'td left', 'width': '33%' }, _("Top Level Int")), + E('td', { 'class': 'td left' }, sample.top_level_int), + ]), + E('tr', {}, [ + E('td', { 'class': 'td left', 'width': '33%' }, _("Top Level String")), + E('td', { 'class': 'td left' }, sample.top_level_string), + ]) + ]); + return table; + }, +``` + +Add a call to the `load` function in `view.extend()` + +``` + load: function () { + return Promise.all([ + load_sample_yaml(), + load_sample1() + ]); + }, +``` + +Add this code to the `render` function in `view.extend()` + +``` + E('div', { 'class': 'cbi-section', 'id': 'cbi-sample-yaml' }, [ + E('div', { 'class': 'left' }, [ + E('h3', _('Sample YAML via RPC')), + E('div', {}), _("YAML transformed to JSON, table built explicitly"), + this.render_sample_yaml(sample_yaml), + ]), + ]), +``` + +## root/usr/share/rpcd/acl.d/luci-app-example.json + +Allow access to the new RPC API + +``` + "read": { + "ubus": { + "luci.example": [ + "get_yaml_file_sample", + "get_sample1", + "get_sample2" + ] + }, +``` + +## root/etc/luci.example.yaml + +Set up the sample YAML file, by placing it either in `root/etc` of the development tree, or directly +in `/etc` on the target machine and call it `luci.example.yaml` to match up to the `reading_from_yaml` +function's expectations. + +``` +top_level_string: example +top_level_int: 8080 +top_level: + list_elements: + - foo + - bar +``` + +That's it. Don't forget to also update the `LUCI_DEPENDS` segment of the `Makefile` to include +`+lyaml` so that the packaging system knows your code needs the YAML parsing package. diff --git a/applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js b/applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js index 75fa3c3079..976df08088 100644 --- a/applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js +++ b/applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js @@ -2,35 +2,58 @@ 'require view'; 'require form'; +// Project code format is tabs, not spaces return view.extend({ - render: function() { - var m, s, o; - - m = new form.Map('example', _('Example Form'), - _('Example Form Configuration.')); - - s = m.section(form.TypedSection, 'first', _('first section')); - s.anonymous = true; - - s.option(form.Value, 'first_option', _('First Option'), - _('Input for the first option')); - - s = m.section(form.TypedSection, 'second', _('second section')); - s.anonymous = true; - - o = s.option(form.Flag, 'flag', _('Flag Option'), - _('A boolean option')); - o.default = '1'; - o.rmempty = false; - - o = s.option(form.ListValue, 'select', _('Select Option'), - _('A select option')); - o.placeholder = 'placeholder'; - o.value('key1', 'value1'); - o.value('key2', 'value2'); - o.rmempty = false; - o.editable = true; - - return m.render(); - }, + render: function() { + var m, s, o; + + /* + The first argument to form.Map() maps to the configuration file available + via uci at /etc/config/. In this case, 'example' maps to /etc/config/example. + + If the file is completely empty, the form sections will indicate that the + section contains no values yet. As such, your package installation (LuCI app + or software that the app configures) should lay down a basic configuration + file with all the needed sections. + + The relevant ACL path for reading a configuration with UCI this way is + read > uci > ["example"] + + The relevant ACL path for writing back the configuration is + write > uci > ["example"] + */ + m = new form.Map('example', _('Example Form'), + _('Example Form Configuration.')); + + s = m.section(form.TypedSection, 'first', _('first section')); + s.anonymous = true; + + s.option(form.Value, 'first_option', _('First Option'), + _('Input for the first option')); + + s = m.section(form.TypedSection, 'second', _('second section')); + s.anonymous = true; + + o = s.option(form.Flag, 'flag', _('Flag Option'), + _('A boolean option')); + o.default = '1'; + o.rmempty = false; + + o = s.option(form.ListValue, 'select', _('Select Option'), + _('A select option')); + o.placeholder = 'placeholder'; + o.value('key1', 'value1'); + o.value('key2', 'value2'); + o.rmempty = false; + o.editable = true; + + s = m.section(form.TypedSection, 'third', _('third section')); + s.anonymous = true; + o = s.option(form.Value, 'password_option', _('Password Option'), + _('Input for a password (storage on disk is not encrypted)')); + o.password = true; + o = s.option(form.DynamicList, 'list_option', _('Dynamic list option')); + + return m.render(); + }, }); diff --git a/applications/luci-app-example/htdocs/luci-static/resources/view/example/htmlview.js b/applications/luci-app-example/htdocs/luci-static/resources/view/example/htmlview.js index feae899a17..7dd8a4137b 100644 --- a/applications/luci-app-example/htdocs/luci-static/resources/view/example/htmlview.js +++ b/applications/luci-app-example/htdocs/luci-static/resources/view/example/htmlview.js @@ -3,28 +3,38 @@ 'require view'; return view.extend({ - handleSaveApply: null, - handleSave: null, - handleReset: null, - load: function() { - return Promise.all([ - uci.load('example') - ]); - }, - render: function(data) { - var body = E([ - E('h2', _('Example HTML Page')) - ]); - var sections = uci.sections('example'); - var listContainer = E('div'); - var list = E('ul'); - list.appendChild(E('li', { 'class': 'css-class' }, ['First Option in first section: ', E('em', {}, [sections[0].first_option])])); - list.appendChild(E('li', { 'class': 'css-class' }, ['Flag in second section: ', E('em', {}, [sections[1].flag])])); - list.appendChild(E('li', { 'class': 'css-class' }, ['Select in second section: ', E('em', {}, [sections[1].select])])); - listContainer.appendChild(list); - body.appendChild(listContainer); - console.log(sections); - return body; - } - }); - + handleSaveApply: null, + handleSave: null, + handleReset: null, + load: function () { + return Promise.all([ + // The relevant ACL path for reading a configuration with UCI this way is + // read > uci > ["example"] + uci.load('example') + ]); + }, + render: function (data) { + var body = E([ + E('h2', _('Example HTML Page')) + ]); + var sections = uci.sections('example'); + var listContainer = E('div'); + var list = E('ul'); + // Note that this is pretty error-prone, because sections might be missing + // etcetera. + list.appendChild(E('li', { + 'class': 'css-class' + }, ['First Option in first section: ', E('em', {}, [sections[0] + .first_option])])); + list.appendChild(E('li', { + 'class': 'css-class' + }, ['Flag in second section: ', E('em', {}, [sections[1].flag])])); + list.appendChild(E('li', { + 'class': 'css-class' + }, ['Select in second section: ', E('em', {}, [sections[1].select])])); + listContainer.appendChild(list); + body.appendChild(listContainer); + console.log(sections); + return body; + } +});
\ No newline at end of file diff --git a/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js b/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js new file mode 100644 index 0000000000..4a009782e4 --- /dev/null +++ b/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js @@ -0,0 +1,109 @@ +'use strict'; +'require form'; +'require rpc'; +'require view'; + +/* +Declare the RPC calls that are needed. The object value maps to the name +listed by the shell command + +$ ubus list + +Custom scripts can be placed in /usr/libexec/rpcd, and must emit JSON. The name of the file +in that directory will be the value for the object key in the declared map. + +Permissions to make these calls must be granted in /usr/share/rpcd/acl.d +via a file named the same as the application package name (luci-app-example) +*/ +var load_sample2 = rpc.declare({ + object: 'luci.example', + method: 'get_sample2' +}); + +function capitalize(message) { + var arr = message.split(" "); + for (var i = 0; i < arr.length; i++) { + arr[i] = arr[i].charAt(0).toUpperCase() + arr[i].slice(1); + } + return arr.join(" "); +} + +return view.extend({ + generic_failure: function (message) { + // Map an error message into a div for rendering + return E('div', { + 'class': 'error' + }, [_('RPC call failure: '), message]) + }, + render_sample2_using_jsonmap: function (sample) { + console.log('render_sample2_using_jsonmap()'); + console.log(sample); + + // Handle errors as before + if (sample.error) { + return this.generic_failure(sample.error) + } + + // Variables you'll usually see declared in LuCI JS apps; forM, Section, Option + var m, s, o; + + /* + LuCI has the concept of a JSONMap. This will map a structured object to + a configuration form. Normally you'd use this with a UCI-powered result, + since saving via an RPC call is going to be more difficult than using the + built-in UCI/ubus libraries. + + https://openwrt.github.io/luci/jsapi/LuCI.form.JSONMap.html + + Execute ubus call luci.example get_sample2 to see the JSON being used. + */ + m = new form.JSONMap(sample, _('JSONMap TableSection Sample'), _( + 'See browser console for raw data')); + // Make the form read-only; this only matters if the apply/save/reset handlers + // are not replaced with null to disable them. + m.readonly = true; + // Set up for a tabbed display + m.tabbed = false; + + const option_names = Object.keys(sample); + for (var i = option_names.length - 1; i >= 0; i--) { + var option_name = option_names[i]; + var display_name = option_name.replace("_", " "); + s = m.section(form.TableSection, option_name, capitalize(display_name), _( + 'Description for this table section')) + o = s.option(form.Value, 'name', _('Option name')); + o = s.option(form.Value, 'value', _('Option value')); + o = s.option(form.DynamicList, 'parakeets', 'Parakeets'); + } + return m; + }, + /* + load() is called on first page load, and the results of each promise are + placed in an array in the call order. This array is passed to the render() + function as the first argument. + */ + load: function () { + return Promise.all([ + load_sample2(), + ]); + }, + // render() is called by the LuCI framework to do any data manipulation, and the + // return is used to modify the DOM that the browser shows. + render: function (data) { + // data[0] will be the result from load_sample2 + var sample2 = data[0] || {}; + return this.render_sample2_using_jsonmap(sample2).render(); + }, + /* + Since this is a view-only screen, the handlers are disabled + Normally, when using something like Map or JSONMap, you would + not null out these handlers, so that the form can be saved/applied. + + With a RPC data source, you would need to define custom handlers + that verify the changes, and make RPC calls to a backend that can + process the request. + */ + handleSave: null, + handleSaveApply: null, + handleReset: null +})
\ No newline at end of file diff --git a/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js b/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js new file mode 100644 index 0000000000..58958054ac --- /dev/null +++ b/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js @@ -0,0 +1,109 @@ +'use strict'; +'require form'; +'require rpc'; +'require view'; + +/* +Declare the RPC calls that are needed. The object value maps to the name +listed by the shell command + +$ ubus list + +Custom scripts can be placed in /usr/libexec/rpcd, and must emit JSON. The name of the file +in that directory will be the value for the object key in the declared map. + +Permissions to make these calls must be granted in /usr/share/rpcd/acl.d +via a file named the same as the application package name (luci-app-example) +*/ +var load_sample2 = rpc.declare({ + object: 'luci.example', + method: 'get_sample2' +}); + +function capitalize(message) { + var arr = message.split(" "); + for (var i = 0; i < arr.length; i++) { + arr[i] = arr[i].charAt(0).toUpperCase() + arr[i].slice(1); + } + return arr.join(" "); +} + +return view.extend({ + generic_failure: function (message) { + // Map an error message into a div for rendering + return E('div', { + 'class': 'error' + }, [_('RPC call failure: '), message]) + }, + render_sample2_using_jsonmap: function (sample) { + console.log('render_sample2_using_jsonmap()'); + console.log(sample); + + // Handle errors as before + if (sample.error) { + return this.generic_failure(sample.error) + } + + // Variables you'll usually see declared in LuCI JS apps; forM, Section, Option + var m, s, o; + + /* + LuCI has the concept of a JSONMap. This will map a structured object to + a configuration form. Normally you'd use this with a UCI-powered result, + since saving via an RPC call is going to be more difficult than using the + built-in UCI/ubus libraries. + + https://openwrt.github.io/luci/jsapi/LuCI.form.JSONMap.html + + Execute ubus call luci.example get_sample2 to see the JSON being used. + */ + m = new form.JSONMap(sample, _('JSONMap TypedSection Sample'), _( + 'See browser console for raw data')); + // Make the form read-only; this only matters if the apply/save/reset handlers + // are not replaced with null to disable them. + m.readonly = true; + // Set up for a tabbed display + m.tabbed = true; + + const option_names = Object.keys(sample); + for (var i = option_names.length - 1; i >= 0; i--) { + var option_name = option_names[i]; + var display_name = option_name.replace("_", " "); + s = m.section(form.TypedSection, option_name, capitalize(display_name), _( + 'Description for this typed section')) + o = s.option(form.Value, 'name', _('Option name')); + o = s.option(form.Value, 'value', _('Option value')); + o = s.option(form.DynamicList, 'parakeets', 'Parakeets'); + } + return m; + }, + /* + load() is called on first page load, and the results of each promise are + placed in an array in the call order. This array is passed to the render() + function as the first argument. + */ + load: function () { + return Promise.all([ + load_sample2(), + ]); + }, + // render() is called by the LuCI framework to do any data manipulation, and the + // return is used to modify the DOM that the browser shows. + render: function (data) { + // data[0] will be the result from load_sample2 + var sample2 = data[0] || {}; + return this.render_sample2_using_jsonmap(sample2).render(); + }, + /* + Since this is a view-only screen, the handlers are disabled + Normally, when using something like Map or JSONMap, you would + not null out these handlers, so that the form can be saved/applied. + + With a RPC data source, you would need to define custom handlers + that verify the changes, and make RPC calls to a backend that can + process the request. + */ + handleSave: null, + handleSaveApply: null, + handleReset: null +})
\ No newline at end of file diff --git a/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js b/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js new file mode 100644 index 0000000000..cf5885b5a2 --- /dev/null +++ b/applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js @@ -0,0 +1,150 @@ +'use strict'; +'require form'; +'require rpc'; +'require view'; + +/* +Declare the RPC calls that are needed. The object value maps to the name +listed by the shell command + +$ ubus list + +Custom scripts can be placed in /usr/libexec/rpcd, and must emit JSON. The name of the file +in that directory will be the value for the object key in the declared map. + +Permissions to make these calls must be granted in /usr/share/rpcd/acl.d +via a file named the same as the application package name (luci-app-example) +*/ +var load_sample1 = rpc.declare({ + object: 'luci.example', + method: 'get_sample1' +}); +// Out of the box, this one will be blocked by the framework because there is +// no ACL granting permission. +var load_sample3 = rpc.declare({ + object: 'luci.example', + method: 'get_sample3' +}); + + +return view.extend({ + generic_failure: function (message) { + // Map an error message into a div for rendering + return E('div', { + 'class': 'error' + }, [_('RPC call failure: '), message]) + }, + render_sample1_using_array: function (sample) { + console.log('render_sample1_using_array()'); + console.log(sample); + /* + Some simple error handling. If the RPC APIs return a JSON structure of the + form {"error": "Some error message"} when there's a failure, then the UI + can check for the presence of the error attribute, and render a failure + widget instead of breaking completely. + */ + if (sample.error) { + return this.generic_failure(sample.error) + } + + /* + Approach 1 for mapping JSON data to a simple table for display. The listing looks + a bit like key/value pairs, but is actually just an array. The loop logic later + on must iterate by 2 to get the labels. + */ + const fields = [ + _('Cats'), sample.num_cats, + _('Dogs'), sample.num_dogs, + _('Parakeets'), sample.num_parakeets, + _('Should be "Not found"'), sample.not_found, + _('Is this real?'), sample.is_this_real ? _('Yes') : _('No'), + ]; + + /* + Declare a table element using an automatically available function - E(). E() + produces a DOM node, where the first argument is the type of node to produce, + the second argument is an object of attributes for that node, and the third + argument is an array of child nodes (which can also be E() calls). + */ + var table = E('table', { + 'class': 'table', + 'id': 'approach-1' + }); + + // Loop over the array, starting from index 0. Every even-indexed second element is + // the label (left column) and the odd-indexed elements are the value (right column) + for (var i = 0; i < fields.length; i += 2) { + table.appendChild( + E('tr', { + 'class': 'tr' + }, [ + E('td', { + 'class': 'td left', + 'width': '33%' + }, [fields[i]]), + E('td', { + 'class': 'td left' + }, [(fields[i + 1] != null) ? fields[i + 1] : _('Not found')]) + ])); + } + return table; + }, + + /* + load() is called on first page load, and the results of each promise are + placed in an array in the call order. This array is passed to the render() + function as the first argument. + */ + load: function () { + return Promise.all([ + load_sample1() + ]); + }, + // render() is called by the LuCI framework to do any data manipulation, and the + // return is used to modify the DOM that the browser shows. + render: function (data) { + // data[0] will be the result from load_sample1 + var sample1 = data[0] || {}; + // data[1] will be the result from load_sample_yaml + var sample_yaml = data[1] || {}; + + // Render the tables as individual sections. + return E('div', {}, [ + E('div', { + 'class': 'cbi-section warning' + }, _('See browser console for raw data')), + E('div', { + 'class': 'cbi-map', + 'id': 'map' + }, [ + E('div', { + 'class': 'cbi-section', + 'id': 'cbi-sample-js' + }, [ + E('div', { + 'class': 'left' + }, [ + // _() notation on strings is used for translation detection + E('h3', _('Sample JS via RPC')), + E('div', {}), _( + "JSON converted to table via array building and loop" + ), + this.render_sample1_using_array(sample1) + ]), + ]), + ]), + ]); + }, + /* + Since this is a view-only screen, the handlers are disabled + Normally, when using something like Map or JSONMap, you would + not null out these handlers, so that the form can be saved/applied. + + With a RPC data source, you would need to define custom handlers + that verify the changes, and make RPC calls to a backend that can + process the request. + */ + handleSave: null, + handleSaveApply: null, + handleReset: null +})
\ No newline at end of file diff --git a/applications/luci-app-example/po/templates/example.pot b/applications/luci-app-example/po/templates/example.pot index 264f4f35e4..09c9e5a3d1 100644 --- a/applications/luci-app-example/po/templates/example.pot +++ b/applications/luci-app-example/po/templates/example.pot @@ -1,35 +1,55 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:22 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:38 msgid "A boolean option" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:27 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:43 msgid "A select option" msgstr "" +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:56 +msgid "Cats" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js:73 +msgid "Description for this table section" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js:73 +msgid "Description for this typed section" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:57 +msgid "Dogs" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:55 +msgid "Dynamic list option" +msgstr "" + #: applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json:3 msgid "Example" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:9 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:25 msgid "Example Form" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:10 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:26 msgid "Example Form Configuration." msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/htmlview.js:16 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/htmlview.js:18 msgid "Example HTML Page" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:15 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:31 msgid "First Option" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:21 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:37 msgid "Flag Option" msgstr "" @@ -38,25 +58,111 @@ msgid "Form View" msgstr "" #: applications/luci-app-example/root/usr/share/rpcd/acl.d/luci-app-example.json:3 -msgid "Grant UCI access to LuCI app example" +msgid "Grant UCI and RPC access to LuCI app example" msgstr "" #: applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json:23 msgid "HTML Page" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:16 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:53 +msgid "Input for a password (storage on disk is not encrypted)" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:32 msgid "Input for the first option" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:26 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:60 +msgid "Is this real?" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:130 +msgid "JSON converted to table via array building and loop" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js:60 +msgid "JSONMap TableSection Sample" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js:60 +msgid "JSONMap TypedSection Sample" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:60 +msgid "No" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:87 +msgid "Not found" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js:74 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js:74 +msgid "Option name" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js:75 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js:75 +msgid "Option value" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:58 +msgid "Parakeets" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:52 +msgid "Password Option" +msgstr "" + +#: applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json:32 +msgid "RPC Array Example" +msgstr "" + +#: applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json:41 +msgid "RPC JSONMap Table Example" +msgstr "" + +#: applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json:50 +msgid "RPC JSONMap Typed Example" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js:36 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js:36 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:35 +msgid "RPC call failure:" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:128 +msgid "Sample JS via RPC" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-tablesection.js:61 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc-jsonmap-typedsection.js:61 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:115 +msgid "See browser console for raw data" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:42 msgid "Select Option" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:12 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:59 +msgid "Should be \"Not found\"" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/rpc.js:60 +msgid "Yes" +msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:28 msgid "first section" msgstr "" -#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:18 +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:34 msgid "second section" msgstr "" + +#: applications/luci-app-example/htdocs/luci-static/resources/view/example/form.js:50 +msgid "third section" +msgstr "" diff --git a/applications/luci-app-example/root/etc/uci-defaults/80_example b/applications/luci-app-example/root/etc/uci-defaults/80_example index 529e6b0bd4..f896943169 100644 --- a/applications/luci-app-example/root/etc/uci-defaults/80_example +++ b/applications/luci-app-example/root/etc/uci-defaults/80_example @@ -3,6 +3,7 @@ touch /etc/config/example uci set example.first=first uci set example.second=second +uci set example.third=third uci commit return 0 diff --git a/applications/luci-app-example/root/usr/libexec/rpcd/luci.example b/applications/luci-app-example/root/usr/libexec/rpcd/luci.example new file mode 100755 index 0000000000..e3e5f8795e --- /dev/null +++ b/applications/luci-app-example/root/usr/libexec/rpcd/luci.example @@ -0,0 +1,248 @@ +#!/usr/bin/env lua + +-- If you need filesystem access, use nixio.fs +local fs = require "nixio.fs" + +-- LuCI JSON is used for checking the arguments and converting tables to JSON. +local jsonc = require "luci.jsonc" + +-- Nixio provides syslog functionality +local nixio = require "nixio" + +-- To access /etc/config files, use the uci module +local UCI = require "luci.model.uci" + +-- Slight overkill, but leaving room to do log_info etcetera. +local function log_to_syslog(level, message) nixio.syslog(level, message) end + +local function log_error(message) + log_to_syslog("err", "[luci.example]: " .. message) +end + +local function using_uci_directly(section) + -- Rather than parse files in /etc/config, you can rely on the + -- luci.model.uci module. + local uci = UCI.cursor() + + -- https://openwrt.github.io/luci/api/modules/luci.model.uci.html + local config_name = uci:get("example", section) + + uci.unload("example") + + if not config_name then + local msg = "'" .. section .. "' not found in /etc/config/example" + -- Send the log message to syslog so it can be found with logread + log_error(msg) + + -- Convert a lua table into JSON notation and print to stdout + -- .stringify() is equivalent to cjson's .encode() + print(jsonc.stringify({uci_error = msg})) + + -- Indicate failure in the return code + os.exit(1) + end + + return config_name +end + +-- The methods table defines all of the APIs to expose to rpcd. +-- rpcd will execute this Lua file with the 'list' argument to discover the +-- method names that can be presented over ubus, as well as any arguments +-- those methods take. +local methods = { + -- How to call this API: + -- echo '{"section": "first"}' | lua /usr/libexec/rpcd/luci.example call get_uci_value + -- echo '{"section": "does_not_exist"}' | lua /usr/libexec/rpcd/luci.example call get_uci_value + get_uci_value = { + -- Args are specified as a table, where the argument type is specified by example + -- The value is not used as a default. + args = {section = "a_string"}, + -- A special key of 'call' points to a function definition for execution. + call = function(args) + -- A table for the result. + local r = {} + r.result = jsonc.stringify({ + example_section = using_uci_directly(args.section) + }) + -- The 'call' handler will refer to '.code', but also defaults if not found. + r.code = 0 + -- Return the table object; the call handler will access the attributes + -- of the table. + return r + end + }, + -- How to call this API: + -- echo '{}' | lua /usr/libexec/rpcd/luci.example call get_sample1 + -- ubus call luci.example get_sample1 + get_sample1 = { + call = function() + local r = {} + -- This structure does not map well to a JSONMap in the LuCI form setup. + -- It can be rendered as a table easily enough with loops. + r.result = jsonc.stringify({ + num_cats = 1, + num_dogs = 2, + num_parakeets = 4, + is_this_real = false, + not_found = nil + }) + return r + end + }, + -- How to call this API: + -- echo '{}' | lua /usr/libexec/rpcd/luci.example call get_sample2 + -- ubus call luci.example get_sample2 + get_sample2 = { + call = function() + local r = {} + -- This is the structural data that JSONMap will work with in the JS file + local data = { + option_one = { + name = "Some string value", + value = "A value string", + parakeets = {"one", "two", "three"}, + }, + option_two = { + name = "Another string value", + value = "And another value", + parakeets = {3, 4, 5}, + } + } + r.result = jsonc.stringify(data) + return r + end + } +} + +local function parseInput() + -- Input parsing - the RPC daemon calls the Lua script and + -- sends input to it via stdin, not as an argument on the CLI. + -- Thus, any testing via the lua interpreter needs to be in the form + -- echo '{jsondata}' | lua /usr/libexec/rpcd/script call method_name + local parse = jsonc.new() + local done, err + + while true do + local chunk = io.read(4096) + if not chunk then + break + elseif not done and not err then + done, err = parse:parse(chunk) + end + end + + if not done then + print(jsonc.stringify({ + error = err or "Incomplete input for argument parsing" + })) + os.exit(1) + end + + return parse:get() +end + +local function validateArgs(func, uargs) + -- Validates that arguments picked out by parseInput actually match + -- up to the arguments expected by the function being called. + local method = methods[func] + if not method then + print(jsonc.stringify({error = "Method not found in methods table"})) + os.exit(1) + end + + -- Lua has no length operator for tables, so iterate to get the count + -- of the keys. + local n = 0 + for _, _ in pairs(uargs) do n = n + 1 end + + -- If the method defines an args table (so empty tables are not allowed), + -- and there were no args, then give a useful error message about that. + if method.args and n == 0 then + print(jsonc.stringify({ + error = "Received empty arguments for " .. func .. + " but it requires " .. jsonc.stringify(method.args) + })) + os.exit(1) + end + + uargs.ubus_rpc_session = nil + + local margs = method.args or {} + for k, v in pairs(uargs) do + if margs[k] == nil or (v ~= nil and type(v) ~= type(margs[k])) then + print(jsonc.stringify({ + error = "Invalid argument '" .. k .. "' for " .. func .. + " it requires " .. jsonc.stringify(method.args) + })) + os.exit(1) + end + end + + return method +end + +if arg[1] == "list" then + -- When rpcd starts up, it executes all scripts in /usr/libexec/rpcd + -- passing 'list' as the first argument. This block of code examines + -- all of the entries in the methods table, and looks for an attribute + -- called 'args' to see if there are arguments for the method. + -- + -- The end result is a JSON struct like + -- { + -- "api_name": {}, + -- "api2_name": {"host": "some_string"} + -- } + -- + -- Which will be converted by ubus to + -- "api_name":{} + -- "api2_name":{"host":"String"} + local _, rv = nil, {} + for _, method in pairs(methods) do rv[_] = method.args or {} end + print((jsonc.stringify(rv):gsub(":%[%]", ":{}"))) +elseif arg[1] == "call" then + -- rpcd will execute the Lua script with a first argument of 'call', + -- a second argument of the method name, and a third argument that's + -- stringified JSON. + -- + -- To debug your script, it's probably easiest to start with direct + -- execution, as calling via ubus will hide execution errors. For example: + -- echo '{}' | lua /usr/libexec/rpcd/luci.example call get_sample2 + -- + -- or + -- + -- echo '{"section": "firstf"}' | /usr/libexec/rpcd/luci.example call get_uci_value + -- + -- See https://openwrt.org/docs/techref/ubus for more details on using + -- ubus to call your RPC script (which is what LuCI will be doing). + local args = parseInput() + local method = validateArgs(arg[2], args) + local run = method.call(args) + -- Use the result from the table which we know to be JSON already. + -- Anything printed on stdout is sent via rpcd to the caller. Use + -- the syslog functions, or logging to a file, if you need debug + -- logs. + print(run.result) + -- And exit with the code supplied. + os.exit(run.code or 0) +elseif arg[1] == "help" then + local helptext = [[ +Usage: + + To see what methods are exported by this script: + + lua luci.example list + + To call a method that has no arguments: + + echo '{}' | lua luci.example call method_name + + To call a method that takes arguments: + + echo '{"valid": "json", "argument": "value"}' | lua luci.example call method_name + + To call this script via ubus: + + ubus call luci.example method_name '{"valid": "json", "argument": "value"}' +]] + print(helptext) +end diff --git a/applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json b/applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json index 7b4772328a..4c82dd988a 100644 --- a/applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json +++ b/applications/luci-app-example/root/usr/share/luci/menu.d/luci-app-example.json @@ -26,5 +26,32 @@ "type": "view", "path": "example/htmlview" } + }, + + "admin/example/rpc-array": { + "title": "RPC Array Example", + "order": 3, + "action": { + "type": "view", + "path": "example/rpc" + } + }, + + "admin/example/rpc-jsonmap-table": { + "title": "RPC JSONMap Table Example", + "order": 4, + "action": { + "type": "view", + "path": "example/rpc-jsonmap-tablesection" + } + }, + + "admin/example/rpc-jsonmap-typed": { + "title": "RPC JSONMap Typed Example", + "order": 5, + "action": { + "type": "view", + "path": "example/rpc-jsonmap-typedsection" + } } } diff --git a/applications/luci-app-example/root/usr/share/rpcd/acl.d/luci-app-example.json b/applications/luci-app-example/root/usr/share/rpcd/acl.d/luci-app-example.json index 136f9aed55..b6849ffe6c 100644 --- a/applications/luci-app-example/root/usr/share/rpcd/acl.d/luci-app-example.json +++ b/applications/luci-app-example/root/usr/share/rpcd/acl.d/luci-app-example.json @@ -1,10 +1,11 @@ { "luci-app-example": { - "description": "Grant UCI access to LuCI app example", + "description": "Grant UCI and RPC access to LuCI app example", "read": { "ubus": { - "uci": [ - "get" + "luci.example": [ + "get_sample1", + "get_sample2" ] }, "uci": [ @@ -12,11 +13,6 @@ ] }, "write": { - "ubus": { - "uci": [ - "set", "commit" - ] - }, "uci": [ "example" ] diff --git a/applications/luci-app-example/structure.md b/applications/luci-app-example/structure.md new file mode 100644 index 0000000000..e03847f424 --- /dev/null +++ b/applications/luci-app-example/structure.md @@ -0,0 +1,81 @@ +# Application structure + +``` +. +├── htdocs +│ └── luci-static +│ └── resources +│ └── view +│ └── example +│ ├── form.js +│ ├── htmlview.js +│ └── rpc.js +├── Makefile +├── po +│ ├── templates +│ │ └── example.pot +├── README.md +└── root + ├── etc + │ ├── luci.example.yaml + │ └── uci-defaults + │ └── 80_example + └── usr + ├── libexec + │ └── rpcd + │ └── luci.example + └── share + ├── luci + │ └── menu.d + │ └── luci-app-example.json + └── rpcd + └── acl.d + └── luci-app-example.json + +``` + +Your starting point for this layout is the `applications` directory in the LuCI git repository. + +A folder must be created with a name like `luci-app-appname`. + +For the rest of this documentation, `appname` is `example`. + +## Root files + +At least one file must exist in `applications/luci-app-example` - a Makefile. This defines what license is to be applied to the code, and what packages are required for the package to be installed. In this example app, YAML is processed by the Lua code, so **lyaml** is marked as a dependency. + +A `README.md` file is also recommended. It should provide context on what the app does, and perhaps instructions on how to test things like RPC calls. + +## Javascript code + +All JS code is placed under `htdocs/luci-static/resources/view/appname/`, where *appname* is the name without *luci-app-* prepended (in this case, `htdocs/luci-static-resources/view/example`). + +## Menu mapping + +The JSON file that maps `view/example/*` to menu items is defined in `root/usr/share/luci/menu.d`. The file is named the same as the containing folder for the app, with a **.json** extension - `luci-app-example.json`. + +## ACL mapping + +The JSON file that defines what APIs may be called is defined in `root/usr/share/rpcd/acl.d/`. The file is named the same as the containing folder for the app, with a **.json** extension - `luci-app-example.json`. + +If ACL rights are not granted correctly, the web UI will show an error indicating "Access denied". Fix the ACL file, deploy it to the device/virtual machine, and restart `rpcd`. + +Note that there may be legacy UCI (luci-compat) grants ACL in place, permitting read and write for all applications to all UCI resources. This should not be taken as a reason to skip granting the correct ACLs in your application. To ensure your ACLs are correct, you can move `acl.d/luci-compat` out of the way and restart `rpcd`. Put the file back when you've finished testing, as other LuCI applications may depend on it. + +## Additional files + +LuCI apps do not have to have any additional files such as Lua scripts or UCI default setup. However, here's how you deal with those if needed. + +### Installing additional files + +Any additional files needed by this application should be placed in `root/` using the directory tree that applies. This example application needs a RPCd script to be installed, so it places a file in `root/usr/libexec/rpcd/` and calls it `luci.example`. Scripts must have their execution bit set, and committed to the git repository with the bit set. + +This example application also installs a file in `/etc/` by putting it in `root/etc/luci.example.yaml`. + +The OpenWrt packaging system will install these files automatically. + +### UCI defaults + +UCI defaults are documented in the [OpenWrt wiki](https://openwrt.org/docs/guide-developer/uci-defaults). They create default files in the running system's `/etc/config/` directory. + +Place any defaults in the file `root/etc/uci-defaults/appname`, possibly with a number prepended to control sequencing - this example package uses `80_example` as the filename. |