Everything in this guide comes from the app's code (the SDK your module sees is generated from it). If something doesn't match what happens when you try it, the code wins: let us know and we'll fix the guide.
1. What a module is #
A module is a cell in the NEXUS grid: the chat, the member list and voice are all modules, and yours lives right next to them. People add it to their space, move it, resize it and combine it with others.
You write your module in Dart, with Flutter-style widgets, but it does not run like a regular Flutter app: it runs inside a sandbox (a Dart interpreter, dart_eval). That has two consequences worth understanding from the start:
- It's safe for whoever installs it. Your code can't open files, connect to the internet or launch programs on its own. Everything it does "outside" goes through the SDK (
ModuleServer), which asks for permission when needed and enforces limits. - It has its own subset of Dart and of widgets. Most of what you'd write in Flutter works, but not all of it. Section 11 says exactly what to avoid.
If your idea needs something the sandbox doesn't offer (a game engine, heavy computation, audio), a module can also ship a native program in C/C++ that runs isolated on Windows (section 9). Averno, the shooter on the Market, is built that way.
What a module can do, broadly:
- show any interface built with the SDK's widgets, and draw freely on a canvas;
- know who's on the server, who's talking, who's streaming;
- save its data on the PC;
- share data in real time with the other members, and have the server remember it (a whiteboard, a board, a match);
- send messages to a channel, ask the person to join voice, open NEXUS's native menus;
- with permission, launch a program the person picked, check whether a process is running, start a game server from a folder the person picked, or run its own native program.
2. Your first module in five minutes #
- In NEXUS, open the Module creator (from the Market, «Create module»).
- Start from the template. It's already a working module:
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/src/widgets/basic.dart';
import 'package:flutter/src/painting/edge_insets.dart';
import 'package:lannexus_sdk/module.dart';
import 'package:lannexus_sdk/widgets.dart';
class MyModule extends CustomGridModule {
MyModule();
@override
Widget build(BuildContext context, double width, double height) {
return Center(
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Hello from my module!',
style: TextStyle(fontSize: 14, color: Color(0xFFFFFFFF)),
),
),
);
}
}
// The entry point: NEXUS calls this function to create your module.
CustomGridModule createModule() => MyModule();- The preview next to the editor really runs it, with fake members and channels. Change the text and watch it update.
- Save it: it shows up in your inventory and you can add it to a space on any server.
Every module has these two things, no exceptions:
- a class that **extends
CustomGridModule** and definesbuild(context, width, height); - a top-level function **
CustomGridModule createModule()** that returns it.
width and height are the size of your cell in logical pixels, margin already subtracted. Use them to adapt what you show (section 4).
3. How a project is laid out #
A small module can be a single file. A big one is a folder:
my_module/
├─ manifest.json what NEXUS needs to know about the module
├─ lib/
│ ├─ module.dart the entry point (defines createModule)
│ └─ src/ the rest of your code, however you like
│ ├─ ui.dart
│ └─ game.dart
├─ native/ (optional) C/C++ source and CMake for your native program
├─ README.md
├─ LICENSE
└─ tools/ (optional) your own scripts: NOT packagedmanifest.json #
{
"displayName": "My module",
"defaultSize": [6, 4],
"entryPoint": "lib/module.dart",
"permissions": ["syncChannel"],
"minAppVersion": "2.0.19"
}| Field | What it is |
|---|---|
displayName | The name people see. |
defaultSize | Size when added, in cells [width, height]. A cell is 72 px. |
entryPoint | The file that defines createModule(). Defaults to lib/module.dart. |
busShared | (optional) Bus spaces for talking to other modules on this PC: ["maps"]. See section 7.4. |
permissions | What the module will ask for, declared up front: native.run, network.bridge, game.server, syncChannel. See section 10. |
native | Only with a native program: {"executables": ["bin/my_program.exe"], "build": "cmake"}. For networked matches, "maxPeers": N (since 2.0.68): players per match, from 2 to 16 (10 if omitted). |
minAppVersion | The oldest NEXUS version that has everything your module uses. Raise it when you use a new API. For something optional, it's better to ask with server.supports(...) and degrade gracefully (see below). |
names | (optional) The name per language for the «Add module» menu: {"en": "Game Servers", "es": "Servidores de juego"}. Without the person's language it falls back to English, and without English, to displayName. Codes like en, es or pt-BR, up to 16 languages, 1 to 80 characters each, no line breaks or invisible characters. They go through the same reserved-name check as displayName ("NEXUS", "ZENISO"…). |
descriptions | (optional) What it does, in one line, per language (same format, up to 200 characters). A plain description counts as English. Without it, the menu uses the short description from your Market page. |
category | (optional) The Market category (juegos, utilidades, multimedia, chat_comunicacion, estetico, general) for projects without a published page. Anything else rejects the package. |
icon | (optional) Your own icon: an image shipped in the package inside assets/ (assets/icon.png; png, jpg or webp). A path that isn't in the package rejects the submission. |
Market review validates these four fields and keeps them in the published package; if one doesn't have the shape above, the submission is rejected with the reason.
Degrade gracefully instead of requiring a version #
Since 2.0.68, server.supports('id') tells you whether the app running your module can do something, and server.appFeatures lists them all (server.appVersion is the version). The ids:
| Id | What it is |
|---|---|
join.<template> | «Join game» templates (launch: on ModuleBridgeAddress). Today: join.steam.connect. |
open.<rule> | What server.openExternal accepts: open.https, open.steam.install, open.steam.store. |
ping.<protocol> | ModuleServerPing protocols: ping.a2s, ping.tcp, ping.icmp. |
draw.<letter> | DrawSurface commands (draw.G…). |
native.maxPeers | Honors native.maxPeers from the manifest. |
Careful: your module is compiled in each person's app, so a parameter or method that their app doesn't have won't compile. New SDK features come in as data (a string you choose: launch: 'steam.connect', protocol: 'tcp', the G command): if the app doesn't know it, it ignores it and your module keeps working. supports itself needs 2.0.68.
final String? join = server.supports('join.steam.connect') ? 'steam.connect' : null;
ModuleBridgeAddress(memberId: host, port: port, launch: join); // no template: just «Copy»Multiple files #
Inside lib/, files import each other with relative paths:
import 'src/ui.dart'; // good
import 'package:my_module/src/ui.dart'; // bad: the sandbox doesn't know your packageSDK imports are always the same: package:lannexus_sdk/module.dart, widgets.dart and server.dart, plus the package:flutter/src/... files for the widgets you use (see 11).
4. Drawing the interface #
build returns a widget tree, just like in Flutter. You can use your own StatelessWidget and StatefulWidget classes (with setState, initState and dispose).
Available widgets #
On top of the basic Flutter widgets the sandbox includes (Center, Padding, SizedBox, Expanded, Align, Positioned…), the SDK adds or trims these:
| Widget | What for |
|---|---|
Text(data, style, maxLines, ellipsis, center) | Text. ellipsis: true cuts it off with "…". |
TextStyle(color, fontSize, bold, italic, height…) | Text style (bold/italic are booleans). |
Row, Column, Stack, Flexible | Layout. |
Container(color, padding, margin, width, height, child) | A simple box. |
ModuleBox(color, radius, circle, borderColor, borderWidth, padding, margin, width, height, child) | A box with rounded corners and a border: the base for cards and buttons. |
ColoredBox, FittedBox, Baseline, Directionality, Tooltip | The usual. |
Icon(IconData(codepoint, fontFamily: 'MaterialIcons'), size, color) | Material icons by code point. |
ModuleScrollView(child, horizontal) | A scrollable area (mouse wheel included). |
ModuleTextField(value, onChanged, hintText, multiline, fontSize, autofocus, bare, onSubmitted) | A text field. |
GestureDetector(...) | Taps, drags, wheel and keyboard (section 5). |
DrawSurface(onPaint) | A canvas for free drawing (section 6). |
ModuleAvatar(memberId, size, speakingRing, statusDot) | A member's picture, with the "speaking" ring and the status dot. |
ModuleStreamView(memberId, cover) | Someone's screen share (or the button to start watching it). |
ModuleNativeSurface(session, capture) | What your native program draws. |
ModuleBridgeAddress(memberId, port, color, fontSize, cta, launch) | A member's NexusBridge address (for game servers), with «Copy» and, with launch, «Play» using that template ('steam.connect'; since 2.0.68, before that join: true). A template the app doesn't have leaves just «Copy». |
ModuleServerPing(memberId, port, fontSize, protocol) | How long a member's server takes to answer. protocol: 'a2s' (default), 'tcp' or 'icmp' (since 2.0.68). |
Colors and theme #
- Colors are
Color(0xAARRGGBB). - Use the theme color for highlights:
Color(server.accentColor). That way your module matches the background and color each person picked. - Speak each person's language:
server.appLanguageis'es'or'en'(since 2.0.36; set"minAppVersion": "2.0.36"if you use it). Read it on everybuildand pick your strings from your own table: if the person switches language, the next build already uses the new one.
Sizes: your module has to look good small #
People shrink cells. A cell is 72 px and a module can end up one or two cells wide.
- Read
widthandheightinbuildand choose what to show. Averno, for example, shows a compact version below 300×180. - If something might not fit (a toolbar, a list), put it in a
ModuleScrollView. - When you need more room,
server.requestMaximize()puts your module in the whole window, andserver.requestRestore()brings it back.server.isMaximizedtells you which mode you're in.
Your state survives moving the module #
Moving your module, resizing it, entering and leaving edit mode or combining it with another one does not restart it: your StatefulWidgets keep their state. What does restart it is going to another server and coming back, or closing the app. For something to survive that, save it (7.3) or share it over the sync channel, which the server remembers (section 8).
5. Tapping, dragging and typing #
GestureDetector is the SDK's version, with these callbacks:
| Callback | Receives | When |
|---|---|---|
onTap, onDoubleTap, onLongPress | nothing | Single, double, long tap. |
onTapUp, onDoubleTapDown | DragInfo | Same, but with the position (localX, localY). |
onPanStart, onPanUpdate, onPanEnd | DragInfo | Dragging: position and how far it moved since last time (deltaX, deltaY). |
onScroll | DragInfo | Mouse wheel: localX/localY is the cursor and deltaY < 0 is up. |
onKey | String → bool | Keyboard. Receives the key name ('A', 'Delete', 'Escape', 'Arrow Left'…). Return true if you used it, so it doesn't reach NEXUS's shortcuts. The area takes keyboard focus when tapped. |
GestureDetector(
onPanUpdate: (DragInfo d) {
setState(() {
x = x + d.deltaX;
y = y + d.deltaY;
});
},
onKey: (String key) {
if (key == 'Delete') {
deleteSelection();
return true;
}
return false;
},
child: DrawSurface(onPaint: () => paint()),
)To type text, use ModuleTextField: pass it the current value and it tells you through onChanged(text) (and onSubmitted when Enter is pressed).
6. Free drawing with DrawSurface #
For whiteboards, maps, charts or simple games, DrawSurface gives you a canvas. Because of how the sandbox works, you do not get a Canvas to call methods on. It's the other way around: onPaint is a function with no arguments that returns a string of drawing commands, and NEXUS draws them.
;separates commands and|separates the fields of each one.- Colors are written as
0xAARRGGBB. onPaintis called on every repaint: draw from your state.
| Command | Fields | Draws |
|---|---|---|
B | background|dotColor|spacing|radius[|offsetX|offsetY] | The whole canvas background and, if spacing > 2, a dot grid (the offset moves it with your view's pan). |
G | spacing|offsetX|offsetY|size|box|color|stroke|s x y x y …|f x y … | Since 2.0.68. Your own tiled pattern (a background with your logo), moved with the pan. Each field from the 8th on is a polygon in a box of side box centered on 0,0: s is an outline (stroke in box units) and f a fill; it's drawn at size px. Up to 16 polygons and 256 points. An older app ignores it. |
V | offsetX|offsetY|scale | Since 2.0.50. From here on, everything is translated and scaled: you send world coordinates and NEXUS applies the view (pan/zoom). That way you can keep your objects' strings and reuse them every frame instead of recomputing every coordinate when the view moves, which is the expensive part in the sandbox. Closed with U. Set "minAppVersion": "2.0.50" if you use it. |
L | x1|y1|x2|y2|color|stroke | A line. |
R | x1|y1|x2|y2|color|stroke|fill[|radius] | Rectangle (fill 1 or 0; with a radius, rounded corners). |
O | x1|y1|x2|y2|color|stroke|fill | An oval inside that rectangle. |
P | color|stroke|fill|x1|y1|x2|y2|… | A closed polygon (3 points or more). |
T | x|y|color|size|text | One line of text. |
W | x|y|color|size|maxWidth|text | Text that wraps within maxWidth. |
X | x|y|color|size|maxWidth|maxLines|bold|text | Wrapped text with a line cap (…) and bold (1/0). |
C | x1|y1|x2|y2 | From here on, only draw inside that rectangle. |
U | — | Closes the last C or V. |
String paint() {
String s = 'B|0xFF1E1E2E|0xFF3A3A55|24|1';
for (final t in strokes) {
s = s + ';L|${t.x1}|${t.y1}|${t.x2}|${t.y2}|0xFFFFFFFF|3';
}
return s + ';T|10|10|0xCCFFFFFF|12|Whiteboard';
}- **The text in
T,WandXcan contain|, but not;**: a;would split it into another command. - If your
onPaintthrows an error, the canvas shows the error in red instead of staying blank.
7. Talking to NEXUS: ModuleServer #
Everything your module knows and does outside its cell goes through ModuleServer:
final ModuleServer server = ModuleServer(context);Create it in build (or wherever you have a BuildContext). Reading it is cheap. When something you read changes (someone joins, someone talks, a channel op arrives), NEXUS calls your build again.
Something important about how calls respond: SDK calls are synchronous or callback-based, never Future. If something fails (you're missing a permission, you're sending too fast), the exception is thrown right away and you can catch it with try/catch. Anything that travels over the network is "fire and forget": the result reaches you later, in a following build.
7.1 Who's here #
server.myId | Your id on this server. |
server.members | The members: id, nickname, online, roleName, roleColor, roleOrder, status, statusText, inVoice, speaking. |
server.canModerate | true if you can change or delete other people's things (owner or administrator). Use it to decide which buttons to show; the server checks it again anyway. |
server.statusTextOf(id) / server.statusLabel(status) | Status strings ready to show. |
server.openMemberMenu(id) / server.openMyProfile() | Open a member's native menu or your profile. |
server.openExternal(uri) | Offers to open an https://… page or steam://install/<appid> / steam://store/<appid> (since 2.0.50: set "minAppVersion": "2.0.50" and declare "openExternal" in permissions). The app validates the destination and the person decides in a native dialog (open, copy for Win+R, or cancel); any other scheme throws an error. |
Reading
speakingorinVoicemakes your module redraw on every voice change, which happens many times per second during a call. Only read them if you use them.
7.2 Channels and messages #
server.channels: the text channels (id,name).server.sendMessage(channelId, text): writes to a channel as the person. It asks for permission the first time and is rate-limited.
7.3 Saving data on this PC: storage #
server.storage.set('preferences', jsonEncode(prefs));
final String? raw = server.storage.get('preferences');
server.storage.remove('preferences');- It's private to your module and to this PC: it isn't shared with anyone and doesn't travel to another computer.
- It stores text (if you need structure,
jsonEncode/jsonDecode). - Limit: 256 KB per module. Going over throws
ModuleStorageQuotaExceededError. - It's written to disk with a short delay (back-to-back writes are batched): calling it often isn't expensive.
For things the whole server should see, use the sync channel (section 8).
7.4 Talking to other modules on this PC: bus #
// Your own space: server.bus.prefix is 'custom.<your module id>.'
server.bus.publish('${server.bus.prefix}changed', '{"x":1}');
// A shared space you declared in the manifest ("busShared": ["maps"])
final cancel = server.bus.subscribeWithSender('shared.maps.*', (topic, payload, sender) { ... });
server.bus.publish('shared.maps.new', '{"id":3}');- Each module has its own private space, **
custom.<id>.** (server.bus.prefix): nobody else publishes or listens there. - To talk to other modules, declare spaces in
manifest.json("busShared": ["maps"]) and useshared.<space>.…. Only modules that declare the same space get in. subscribe(topic, (topic, payload) {...})works as before. WithsubscribeWithSenderthe handler also receives the id of the module that published (set by NEXUS, not by the publisher);nullif the app published it.- Publishing is rate-limited (about 10 per second, with bursts of 20); going over throws
ModuleBusRateLimitedError. - Data travels as text.
- It's local: it never leaves this PC.
7.5 The server's shared list: moduleEntries #
A simple list per module (for example, "the game servers we're watching"): server.moduleEntries, addModuleEntry(name, process) and removeModuleEntry(id). Each entry can have an online state (or null if it isn't known yet). For richer data, the sync channel is more flexible.
7.6 Voice and streams #
server.voiceChannels(with theirmembers:speaking,muted,deafened),server.myVoiceChannelId(''if you're not in one),server.myMutedandserver.myDeafened.server.requestJoinVoice(id)asks for confirmation in a native dialog: a module never turns the microphone on by itself.server.leaveVoice()andserver.muteMe()don't ask for anything (they only protect the person).server.streams: who's streaming (memberId,nickname,watching,mine). Show it withModuleStreamView: starting to watch is a native button, never the module.
7.7 Programs on the PC #
server.pickExecutablePath((String? path) { ... })opens the native dialog to pick an.exe. It doesn't ask for permission: the person chose the path.server.launchExecutable(path)opens it. It asks for permission, and **only accepts paths that came frompickExecutablePath**: a made-up path is rejected.server.checkProcess('game.exe')tells you whether that process is running. It asks for permission for that exact name. It answers immediately with the last known value (falseat first): the check runs in the background and your module rebuilds when it changes.
7.8 Game servers: server.gameServer #
For dedicated server panels (Minecraft today; more games with the «Game Servers» module). The person picks the server folder with pickFolder (that's what authorizes it) and from there you can start, stop, read the console (lines, history), send commands (command) and read or write the server's allowed files (readFile, writeFile). It asks for the game.server permission. The process belongs to the app, not to your module: if the cell closes, the server keeps running.
8. Real-time sharing and server memory #
This is what makes the Whiteboard, the Organizer or a match possible: data every member sees at the same time, and that the server remembers. If you draw a Z, go to another server and come back, the Z is still there. And people who join later see it too.
The model: objects with ids #
Each topic of your module (a name you pick, like strokes or cards) is a set of live objects, each with its own id:
// Add or replace an object (you pick the id).
server.publishTopicOp('strokes', 'stroke_${server.myId}_$n', jsonEncode({'pts': points, 'color': c}));
// Remove objects.
server.removeTopicOps('strokes', ['stroke_1', 'stroke_2']);
// Read everything there is right now.
for (final ModuleTopicOp op in server.topicOps('strokes')) {
final Map data = jsonDecode(op.dataJson);
// op.id, op.authorId (who created it), op.editorId (who touched it last)
}- The first time,
topicOpsreturns[]and asks the server for the data. When it arrives, yourbuildis called again with everything. publishTopicOpis "fire and forget": your change comes back as an echo from the server, and that's when it shows up intopicOps. If you want it on screen instantly, keep it aside as "pending" until the echo arrives (the Whiteboard does it this way).- Using the same id replaces the object: that's how you move a card or edit a note.
The rules (enforced by the server, not by your code) #
- Each object belongs to whoever created it. Only its author can replace or remove it… or someone with
server.canModerate(owner or administrator). - Shared topics: if the name starts with
shared.(for exampleshared.cards), anyone can replace someone else's object (move another person's card), but removing it is still up to the author or a moderator. - Limits:
- each object can take up to 16 KB;
- up to 5000 live objects per topic;
- the topic name, up to 64 characters, no
:or line breaks; - rate: 30 at once, then one every half second per module. If you draw a lot, pack the points into a single object per stroke.
- There's no history: removing an object really deletes it. If you want "undo", keep it yourself.
Where it's stored, depending on the server #
| Server | Where the memory lives |
|---|---|
| Dedicated server (NEXUS Dedicated Server) | On the server, on disk. |
| Official NEXUS server | In the NEXUS database. |
| LAN network | On every PC on the network: each one keeps that network's data, and the one opening the module fills in its copy with what the others have. |
Your code is the same in all three cases.
For things that have to travel many times per second (positions in a match), the sync channel isn't ideal: it's for state that gets remembered. A real networked match works better with a native program (section 9), whose packets the app relays without storing them.
9. Native programs (C/C++) #
When you need real performance (a 3D engine, an emulator, audio), your module can ship a program in C/C++.
How it works:
- You upload the source code (
native/, with CMake). ZENISO builds it in an environment without network access, and the executable travels signed and verified by its hash. Binaries are never uploaded. - It runs on Windows, isolated in its own AppContainer:
- it can't read your documents, has no internet, can't create processes and can't use the clipboard;
- it can write to its own data folder and it does have audio;
- if NEXUS closes, the program closes too.
- It draws to a shared surface that your module shows with
ModuleNativeSurface(session: 's1'). - Your module drives it with:
server.startNative(session, 'bin/my.exe', configJson);nativeState(session):none,starting,running,exited:Norerror:…;nativeSend(session, json)to send it messages, andnativeMessages(session)to read its own;stopNative(session).
- Networking without a network: the program has no network of its own. With
startNativeNetworked(...)and thenetwork.bridgepermission, the app relays its packets to the other players, through the cell's server or through NexusBridge (server.bridgeReadytells you whether it's possible). The program never sees an IP. Slots go from 0 to the manifest'snative.maxPeers - 1(2 to 16; 10 if not declared). startNativeWithFolder(...)lets it read (read only) a folder the person authorized, for example a Minecraft server's world to draw the map.
What saved us the most time while building Averno:
- Link the C/C++ runtime statically (
/MTin MSVC,-staticin MinGW). Isolated, your program only loads DLLs signed by Microsoft: your own DLL next to the.exewon't load. - For networked games, make the simulation deterministic: same inputs, same result on every PC. Compare a hash of the state every so often and end the match with "out of sync" instead of carrying on with different worlds.
Native programs don't run on phones. A module with a native program is installed and used from the PC.
10. Permissions: what people are asked #
There are two kinds of permissions.
**The ones declared in manifest.json** (asked for at install time or the first time):
| Permission | What it allows | What the person sees |
|---|---|---|
syncChannel | Publishing to the sync channel. | "Send and receive real-time data with other members" |
native.run | Running its native program. | "Run its own program on your PC, isolated: it cannot see your files or your other programs" |
network.bridge | Letting that program play over the network. | "Connect its program with other members over the server's NexusBridge network (never the internet)" |
game.server | Starting game servers (Minecraft, Source, 7 Days to Die, Arma 3, or an .exe with arguments the person confirms) from a chosen folder, with the user's permissions and listening on the network. | "Open game servers (Minecraft, Source, 7 Days to Die, Arma 3 or any .exe) from a folder you choose, not isolated, and manage their console and settings" |
The ones asked for when the module uses them: sending messages to a channel, opening a chosen program, and checking whether a process is running (the last one, per process name).
What never asks for permission, because it exposes nothing: reading members, channels and voice, opening native menus, maximizing, picking an executable or a folder (the person picks them), leaving voice or muting yourself.
Market review compares what you declare with what your code actually uses: if you use something you didn't declare, the module isn't published.
11. Sandbox rules (the biggest time-saver) #
The sandbox is dart_eval (a version with our own fixes). Almost all of Dart works, but these cases are confirmed and worth avoiding. The creator's linter flags most of them with the line number, when you save and when you publish.
Structure and imports
- Import explicitly what you use from Flutter. For example,
Stackneedspackage:flutter/src/rendering/stack.dartbesides the widget. Otherwise the sandbox says "Class not found". - Between your own files, relative paths, not your own
package:(section 3). - An odd error like "Class/enum X not found" or "Cannot resolve top level variable" in code that looks fine usually comes from how the sandbox drops what it thinks is unused. If it happens to you, send us the code: it's a sandbox bug, not yours.
**State and time**
- **Your
Statehas nomountedorcontext**: onlywidget,setState,initState,disposeandbuild. For callbacks that arrive late (a dialog, a timer), keep your ownbool disposedand check it beforesetState. TimerandTimer.periodicwork. Cancel them indispose.- To measure time in game logic, count your timer's ticks instead of using
DateTime.now().
Types
- **
int + doublegivesnum**: assigning it to adoublewon't compile. Writex.toDouble() + 0.5. - **
ison anullblows up.** A missing value in a map fromjsonDecodeis anull: always checkv != null && v is String. - Declare types on variables and lists (
final List<Widget> rows = [];). It helps the sandbox and the analyzer.
Closures inside loops
- A function created **inside a
forthat uses the loop variable and** another variable that gets reassigned reads the wrong value. The linter flags it. The way out is a newfinalvariable, or creating the function in a separate method that takes the value as a parameter:
// Bad: the closure uses i and also `total`, which gets reassigned
for (var i = 0; i < n; i++) {
total = total + 1;
buttons.add(button(() => pick(i, total)));
}
// Good
for (var i = 0; i < n; i++) {
total = total + 1;
final int t = total;
buttons.add(button(() => pick(i, t)));
}What isn't there
- No
dart:io, no network, no files, noProcess: all of that goes throughModuleServer. - No
Future/asyncin SDK calls (section 7). - No pinch or two-finger zoom in
GestureDetector.
Performance
- Your
buildcan be called often (every time something on the server changes). Keep it light: build lists, don't do heavy computation. - Heavy work belongs in a native program.
12. Testing #
- The creator's preview: runs your module with fake members, channels and sync channel, in memory. It's the fastest way to iterate.
- On a real server: save it, add it to a space and try it with someone else. The sync channel is best tested with two people.
- The creator's linter: when you save and when you publish, it flags the cases from section 11 with the line number. Fix them before submitting: Market review runs the same linter.
- Several sizes: try your module at 1–2 cells, at normal size and maximized.
- Native programs: test them from the PC, on a real server and, if they play over the network, with two PCs.
13. Publishing on the Market #
- Package it: from the creator, «Publish»; or, if you built it in your IDE, «Upload project .zip» in the Creator dashboard. The package carries
manifest.json,lib/,native/(source only),READMEandLICENSE. Thetools/,bin/andbuild/folders don't travel. - Automatic review:
- it compiles your module in the sandbox and runs the linter;
- it compares declared permissions with the ones you use;
- if there's a native program, ZENISO builds it and checks that it doesn't import forbidden functions (creating processes, the registry, services).
- Team review: a person looks at it before it goes public.
- Market page: title, description, images and price (free or with coins).
- Updates: you upload a new version and it goes through the same review. Apps download it on their own; if the new version asks for a new permission, the person is asked again.
Before publishing:
minAppVersionpoints to a version that has everything you use.- The manifest's
permissionsare exactly the ones you use. - It looks good at 1–2 cells, normal and maximized.
- Colors come from
server.accentColor(nothing that clashes with light or dark backgrounds). - If it uses the sync channel, you tested it with two people and you respect the limits.
- The
READMEsays what it does and theLICENSEwhat can be done with the code.
Where it can be used afterwards:
- On a dedicated server, the owner chooses which modules are allowed.
- On LAN networks and official servers, Market modules are used with a NEXUS membership.
14. Reference modules #
Official ZENISO modules use the same public SDK as yours. Add them from the Market to see how each one solves things:
| Module | What it shows |
|---|---|
| Whiteboard | DrawSurface and the sync channel: strokes, shapes, notes, selection, "pending" until the echo, deleting as a moderator. |
| Organizer | A kanban-style board with shared. topics (everyone moves cards). |
| Game Servers | server.gameServer, ModuleBridgeAddress and ModuleServerPing. |
| Averno | A complete game with a native program, lockstep networking and audio. |
15. SDK quick reference #
What your module can use, exactly as the sandbox sees it (generated from the app's code).
package:lannexus_sdk/module.dart #
abstract class CustomGridModule {
Widget build(BuildContext context, double width, double height);
}
// Your entry point: CustomGridModule createModule() => MyModule();package:lannexus_sdk/server.dart #
class ModuleServer {
ModuleServer(BuildContext context);
// Who and where
String get myId;
List<ModuleMember> get members; // id, nickname, online, roleName, roleColor, roleOrder,
// status, statusText, inVoice, speaking
List<ModuleChannel> get channels; // id, name
bool get canModerate;
int get accentColor;
String get appLanguage; // 'es' or 'en' (since 2.0.36)
String statusTextOf(String peerId);
String statusLabel(String status);
void openMemberMenu(String memberId);
void openMyProfile();
void openExternal(String uri); // https or steam://install|store/<appid>, with confirmation (2.0.50)
// What this app can do (2.0.68)
String get appVersion;
List<String> get appFeatures; // 'join.steam.connect', 'ping.tcp', 'draw.G'...
bool supports(String feature);
// Saving and communicating
ModuleStorage get storage; // get / set / remove (256 KB, this PC)
ModuleBus get bus; // prefix, publish / subscribe / subscribeWithSender (custom.<id>.… or shared.<space>.…)
void sendMessage(String channelId, String content);
// Sync channel (what the server remembers)
List<ModuleTopicOp> topicOps(String topic); // id, authorId, editorId, dataJson
void publishTopicOp(String topic, String opId, String dataJson);
void removeTopicOps(String topic, List<String> ids);
// Shared list
List<ModuleEntry> get moduleEntries; // id, name, processName, addedByMemberId, online
void addModuleEntry(String name, String processName);
void removeModuleEntry(String entryId);
// Window
bool get isMaximized;
void requestMaximize();
void requestRestore();
// Voice and streams
List<ModuleVoiceChannel> get voiceChannels; // id, name, members(id, nickname, speaking, muted, deafened)
String get myVoiceChannelId;
bool get myMuted;
bool get myDeafened;
void requestJoinVoice(String channelId); // with confirmation
void leaveVoice();
void muteMe();
List<ModuleStream> get streams; // memberId, nickname, watching, mine
// Programs on the PC
bool checkProcess(String processName);
void pickExecutablePath(Function onPicked); // (String? path)
void launchExecutable(String path);
// Your own native program
void startNative(String session, String exe, String configJson);
void startNativeNetworked(String session, String exe, String configJson, String networkJson);
void startNativeWithFolder(String session, String exe, String configJson, String folder);
void stopNative(String session);
String nativeState(String session);
bool nativeSend(String session, String json);
List<String> nativeMessages(String session);
bool get bridgeReady;
// Game servers
ModuleGameServer get gameServer; // folders, pickFolder, start, stop, kill, state, lines,
// history, command, readFile, writeFile, versions, install…
}package:lannexus_sdk/widgets.dart #
Text(String data, {TextStyle? style, int? maxLines, bool? ellipsis, bool? center})
TextStyle({Color? color, Color? backgroundColor, double? fontSize, double? letterSpacing,
double? wordSpacing, double? height, bool? bold, bool? italic})
Row / Column({mainAxisAlignment, mainAxisSize, crossAxisAlignment, verticalDirection, children})
Container({alignment, padding, color, width, height, margin, child})
ColoredBox({required Color color, Widget? child})
Icon(IconData icon, {double? size, Color? color, String? semanticLabel})
Stack({alignment, fit, children})
Flexible({int? flex, required Widget child})
FittedBox({fit, alignment, child}) · Baseline(...) · Directionality(...) · Tooltip({message, child})
GestureDetector({child, onTap, onDoubleTap, onLongPress, onPanStart, onPanUpdate, onPanEnd,
onScroll, onTapUp, onDoubleTapDown, onKey}) // DragInfo: localX, localY, deltaX, deltaY
ModuleTextField({required String value, required Function onChanged, hintText, colorHex,
multiline, fontSize, autofocus, bare, onSubmitted})
ModuleScrollView({required Widget child, bool? horizontal})
DrawSurface({required Function onPaint}) // onPaint: () => String of commands
ModuleBox({color, radius, circle, borderColor, borderWidth, padding, margin, width, height, child})
ModuleAvatar({required String memberId, double? size, bool? speakingRing, bool? statusDot})
ModuleStreamView({required String memberId, bool? cover})
ModuleNativeSurface({required String session, bool? capture})
ModuleBridgeAddress({required String memberId, required int port, Color? color, double? fontSize,
bool? join, bool? cta, String? launch}) // launch: 'steam.connect' (2.0.68)
ModuleServerPing({required String memberId, required int port, double? fontSize,
String? protocol}) // 'a2s' | 'tcp' | 'icmp' (2.0.68)Stuck on something this guide doesn't cover? Chances are another creator will get stuck there too. Tell us and we'll add it.