Todo lo que dice esta guía sale del código de la app (el SDK que ve tu módulo se genera desde ahí). Si algo no coincide con lo que pasa al probar, gana el código: avísanos y se corrige la guía.
1. Qué es un módulo #
Un módulo es una celda del grid de NEXUS: el chat, la lista de miembros y la voz son módulos, y el tuyo vive al lado de ellos. La persona lo agrega a su espacio, lo mueve, le cambia el tamaño y lo combina con otros.
Tu módulo se escribe en Dart con widgets al estilo Flutter, pero no corre como una app de Flutter normal: corre dentro de un sandbox (un intérprete de Dart, dart_eval). Eso tiene dos consecuencias que conviene tener claras desde el principio:
- Es seguro para quien lo instala. Tu código no puede abrir archivos, ni conectarse a internet, ni lanzar programas por su cuenta. Todo lo que hace «afuera» pasa por el SDK (
ModuleServer), que pide permiso cuando hace falta y pone topes. - Tiene su propio subconjunto de Dart y de widgets. La mayoría de lo que escribirías en Flutter funciona, pero no todo. La sección 11 dice exactamente qué evitar.
Si tu idea necesita algo que el sandbox no da (un motor de juego, mucho cálculo, audio), un módulo puede traer además un programa nativo en C/C++, que corre aislado en Windows (sección 9). Averno, el juego de disparos del Mercado, está hecho así.
Qué puede hacer un módulo, a grandes rasgos:
- mostrar cualquier interfaz hecha con los widgets del SDK, y dibujar libre en un lienzo;
- saber quién está en el servidor, quién habla, quién transmite;
- guardar sus datos en la PC;
- compartir datos en tiempo real con los demás miembros, y que el servidor los recuerde (una pizarra, un tablero, una partida);
- mandar mensajes a un canal, pedirle a la persona que se una a la voz, abrir menús nativos de NEXUS;
- con permiso, lanzar un programa que la persona eligió, ver si un proceso está abierto, abrir un servidor de juego en una carpeta que la persona eligió, o correr su propio programa nativo.
2. Tu primer módulo en cinco minutos #
- En NEXUS, abre el Creador de módulos (desde el Mercado, «Crear módulo»).
- Empieza con la plantilla. Ya trae un módulo que funciona:
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(
'Hola desde mi módulo!',
style: TextStyle(fontSize: 14, color: Color(0xFFFFFFFF)),
),
),
);
}
}
// La entrada: NEXUS llama a esta función para crear tu módulo.
CustomGridModule createModule() => MyModule();- La vista previa al lado del editor lo corre de verdad, con miembros y canales de mentira. Cambia el texto y mira cómo se actualiza.
- Guárdalo: aparece en tu inventario y lo puedes agregar a un espacio de cualquier servidor.
Las dos cosas que todo módulo tiene sí o sí:
- una clase que **extiende
CustomGridModule** y definebuild(context, width, height); - una función de nivel superior **
CustomGridModule createModule()** que la devuelve.
width y height son el tamaño de tu celda en píxeles lógicos, ya descontado el margen. Úsalos para adaptar lo que muestras (sección 4).
3. Cómo está armado un proyecto #
Un módulo chico puede ser un solo archivo. Uno grande es una carpeta:
mi_modulo/
├─ manifest.json lo que NEXUS necesita saber del módulo
├─ lib/
│ ├─ module.dart la entrada (define createModule)
│ └─ src/ el resto de tu código, como quieras
│ ├─ ui.dart
│ └─ juego.dart
├─ native/ (opcional) fuente C/C++ y CMake de tu programa nativo
├─ README.md
├─ LICENSE
└─ tools/ (opcional) tus scripts: NO se empaquetanmanifest.json #
{
"displayName": "Mi módulo",
"defaultSize": [6, 4],
"entryPoint": "lib/module.dart",
"permissions": ["syncChannel"],
"minAppVersion": "2.0.19"
}| Campo | Qué es |
|---|---|
displayName | El nombre que ve la gente. |
defaultSize | Tamaño al agregarlo, en celdas [ancho, alto]. Una celda mide 72 px. |
entryPoint | El archivo que define createModule(). Por defecto lib/module.dart. |
busShared | (opcional) Espacios del bus para hablar con otros módulos de esta PC: ["mapas"]. Ver la sección 7.4. |
permissions | Lo que el módulo va a pedir y que se declara de antemano: native.run, network.bridge, game.server, syncChannel. Ver la sección 10. |
native | Solo con programa nativo: {"executables": ["bin/mi_programa.exe"], "build": "cmake"}. Para partidas en red, "maxPeers": N (desde la 2.0.68): jugadores por partida, de 2 a 16 (sin él, 10). |
minAppVersion | La versión más vieja de NEXUS que tiene lo que tu módulo usa. Súbela cuando uses una API nueva. Para algo opcional, mejor pregunta con server.supports(...) y degrada (ver abajo). |
names | (opcional) El nombre por idioma para el menú «Agregar módulo»: {"en": "Game Servers", "es": "Servidores de juego"}. Sin el idioma de la persona sale el inglés, y sin inglés, displayName. Códigos como en, es o pt-BR, hasta 16 idiomas, de 1 a 80 caracteres cada uno, sin saltos de línea ni caracteres invisibles. Pasan el mismo control de nombres reservados que displayName («NEXUS», «ZENISO»…). |
descriptions | (opcional) Qué hace, en una línea, por idioma (mismo formato, hasta 200 caracteres). description suelto cuenta como el inglés. Si no hay, el menú usa la descripción corta de tu página del Mercado. |
category | (opcional) La categoría del Mercado (juegos, utilidades, multimedia, chat_comunicacion, estetico, general) para los proyectos sin página publicada. Otra cosa rechaza el paquete. |
icon | (opcional) Un ícono propio: una imagen que viaje en el paquete dentro de assets/ (assets/icon.png; png, jpg o webp). Una ruta que no está en el paquete rechaza el envío. |
La revisión del Mercado valida estos cuatro campos y los deja en el paquete publicado; si alguno no tiene la forma de arriba, el envío se rechaza con el motivo.
Que tu módulo degrade en vez de exigir una versión #
Desde la 2.0.68, server.supports('id') dice si la app que lo corre sabe hacer algo, y server.appFeatures las lista todas (server.appVersion es la versión). Los ids:
| Id | Qué es |
|---|---|
join.<plantilla> | Plantillas de «Unirse al juego» (launch: de ModuleBridgeAddress). Hoy: join.steam.connect. |
open.<regla> | Lo que acepta server.openExternal: open.https, open.steam.install, open.steam.store. |
ping.<protocolo> | Protocolos de ModuleServerPing: ping.a2s, ping.tcp, ping.icmp. |
draw.<letra> | Comandos de DrawSurface (draw.G…). |
native.maxPeers | Respeta native.maxPeers del manifest. |
Ojo: tu módulo se compila en la app de cada persona, así que un parámetro o método que esa app no tiene no compila. Lo nuevo del SDK entra como datos (un texto que eliges: launch: 'steam.connect', protocol: 'tcp', el comando G): si la app no lo conoce, lo ignora y tu módulo sigue andando. supports mismo necesita la 2.0.68.
final String? unirse = server.supports('join.steam.connect') ? 'steam.connect' : null;
ModuleBridgeAddress(memberId: host, port: puerto, launch: unirse); // sin plantilla: solo «Copiar»Varios archivos #
Dentro de lib/ los archivos se importan con rutas relativas:
import 'src/ui.dart'; // bien
import 'package:mi_modulo/src/ui.dart'; // mal: el sandbox no conoce tu paqueteLo que viene del SDK se importa siempre igual: package:lannexus_sdk/module.dart, widgets.dart y server.dart, más los package:flutter/src/... de los widgets que uses (ver 11).
4. Dibujar la interfaz #
build devuelve un árbol de widgets, igual que en Flutter. Puedes usar StatelessWidget y StatefulWidget propios (con setState, initState y dispose).
Widgets disponibles #
Además de los básicos de Flutter que el sandbox trae (Center, Padding, SizedBox, Expanded, Align, Positioned…), el SDK agrega o recorta estos:
| Widget | Para qué |
|---|---|
Text(data, style, maxLines, ellipsis, center) | Texto. ellipsis: true corta con «…». |
TextStyle(color, fontSize, bold, italic, height…) | Estilo del texto (bold/italic son booleanos). |
Row, Column, Stack, Flexible | Layout. |
Container(color, padding, margin, width, height, child) | Caja simple. |
ModuleBox(color, radius, circle, borderColor, borderWidth, padding, margin, width, height, child) | Caja con bordes redondeados y borde: la base para tarjetas y botones. |
ColoredBox, FittedBox, Baseline, Directionality, Tooltip | Lo de siempre. |
Icon(IconData(codepoint, fontFamily: 'MaterialIcons'), size, color) | Íconos de Material por su código. |
ModuleScrollView(child, horizontal) | Un área con scroll (y la rueda del mouse). |
ModuleTextField(value, onChanged, hintText, multiline, fontSize, autofocus, bare, onSubmitted) | Campo de texto. |
GestureDetector(...) | Toques, arrastres, rueda y teclado (sección 5). |
DrawSurface(onPaint) | Lienzo para dibujar libre (sección 6). |
ModuleAvatar(memberId, size, speakingRing, statusDot) | La foto de un miembro, con el anillo de «hablando» y el puntito de estado. |
ModuleStreamView(memberId, cover) | La transmisión de pantalla de alguien (o el botón para empezar a verla). |
ModuleNativeSurface(session, capture) | Lo que dibuja tu programa nativo. |
ModuleBridgeAddress(memberId, port, color, fontSize, cta, launch) | La dirección de NexusBridge de un miembro (para servidores de juego), con «Copiar» y, con launch, «Jugar» con esa plantilla ('steam.connect'; desde la 2.0.68, antes join: true). Una plantilla que la app no tiene deja solo «Copiar». |
ModuleServerPing(memberId, port, fontSize, protocol) | Cuánto tarda en contestar el server de un miembro. protocol: 'a2s' (por defecto), 'tcp' o 'icmp' (desde la 2.0.68). |
Colores y tema #
- Los colores son
Color(0xAARRGGBB). - Usa el color del tema para lo destacado:
Color(server.accentColor). Así tu módulo combina con el fondo y el color que eligió cada persona. - Habla el idioma de cada persona:
server.appLanguagees'es'o'en'(desde la 2.0.36; pon"minAppVersion": "2.0.36"si lo usas). Léelo en cadabuildy elige tus textos con una tabla propia: si la persona cambia el idioma, el próximo build ya sale en el nuevo.
Tamaños: tu módulo tiene que verse bien chico #
La gente achica las celdas. Una celda mide 72 px y un módulo puede quedar de una o dos celdas.
- Lee
widthyheightenbuildy elige qué mostrar. Averno, por ejemplo, muestra una versión compacta por debajo de 300×180. - Si algo puede no entrar (una barra de herramientas, una lista), ponlo en un
ModuleScrollView. - Cuando necesitas más espacio,
server.requestMaximize()pone tu módulo en toda la ventana, yserver.requestRestore()lo devuelve.server.isMaximizedte dice en qué modo estás.
Tu estado no se pierde al mover el módulo #
Mover tu módulo, cambiarle el tamaño, entrar y salir del modo edición o combinarlo con otro no lo reinicia: tus StatefulWidget conservan su estado. Lo que sí lo reinicia es irse a otro servidor y volver, o cerrar la app. Para que algo sobreviva a eso, guárdalo (7.3) o compártelo por el canal de sync, que el servidor recuerda (sección 8).
5. Tocar, arrastrar y escribir #
GestureDetector es la versión del SDK, con estos callbacks:
| Callback | Recibe | Cuándo |
|---|---|---|
onTap, onDoubleTap, onLongPress | nada | Toque simple, doble, largo. |
onTapUp, onDoubleTapDown | DragInfo | Igual, pero con la posición (localX, localY). |
onPanStart, onPanUpdate, onPanEnd | DragInfo | Arrastrar: posición y cuánto se movió desde la última vez (deltaX, deltaY). |
onScroll | DragInfo | Rueda del mouse: localX/localY es el cursor y deltaY < 0 es hacia arriba. |
onKey | String → bool | Teclado. Recibe el nombre de la tecla ('A', 'Delete', 'Escape', 'Arrow Left'…). Devuelve true si la usaste, para que no llegue a los atajos de NEXUS. El área toma el foco del teclado al tocarla. |
GestureDetector(
onPanUpdate: (DragInfo d) {
setState(() {
x = x + d.deltaX;
y = y + d.deltaY;
});
},
onKey: (String key) {
if (key == 'Delete') {
borrarSeleccion();
return true;
}
return false;
},
child: DrawSurface(onPaint: () => dibujar()),
)Para escribir texto usa ModuleTextField: le pasas el value actual y te avisa con onChanged(texto) (y onSubmitted al apretar Enter).
6. Dibujo libre con DrawSurface #
Para pizarras, mapas, gráficos o juegos simples, DrawSurface te da un lienzo. Por cómo funciona el sandbox, no recibes un Canvas para llamar métodos. Al revés: onPaint es una función sin argumentos que devuelve un texto con comandos de dibujo, y NEXUS los dibuja.
;separa comandos y|separa los campos de cada uno.- Los colores van como
0xAARRGGBB. onPaintse llama en cada repintado: dibuja según tu estado.
| Comando | Campos | Dibuja |
|---|---|---|
B | fondo|colorPuntos|espaciado|radio[|desplX|desplY] | Fondo de todo el lienzo y, si espaciado > 2, una grilla de puntos (el desplazamiento la mueve con el «pan» de tu vista). |
G | espaciado|desplX|desplY|tamaño|caja|color|grosor|s x y x y …|f x y … | Desde la 2.0.68. Tu propio motivo repetido en mosaico (un fondo con tu logo), movido con el pan. Cada campo desde el 8.º es un polígono en una caja de lado caja centrada en 0,0: s es contorno (grosor en unidades de la caja) y f relleno; se dibuja a tamaño px. Hasta 16 polígonos y 256 puntos. Una app vieja lo ignora. |
V | desplX|desplY|escala | Desde la 2.0.50. Desde acá, todo se traslada y escala: mandas coordenadas de mundo y la vista (pan/zoom) la aplica NEXUS. Así puedes guardar el texto de tus objetos y reusarlo en cada frame en vez de recalcular cada coordenada al mover la vista, que en el sandbox es lo caro. Se cierra con U. Pon "minAppVersion": "2.0.50" si lo usas. |
L | x1|y1|x2|y2|color|grosor | Una línea. |
R | x1|y1|x2|y2|color|grosor|relleno[|radio] | Rectángulo (relleno 1 o 0; con radio, esquinas redondeadas). |
O | x1|y1|x2|y2|color|grosor|relleno | Óvalo dentro de ese rectángulo. |
P | color|grosor|relleno|x1|y1|x2|y2|… | Polígono cerrado (3 puntos o más). |
T | x|y|color|tamaño|texto | Texto de una línea. |
W | x|y|color|tamaño|anchoMax|texto | Texto que se ajusta dentro de anchoMax. |
X | x|y|color|tamaño|anchoMax|maxLineas|negrita|texto | Texto ajustado con tope de líneas (…) y negrita (1/0). |
C | x1|y1|x2|y2 | Desde acá, solo se dibuja dentro de ese rectángulo. |
U | — | Cierra el último C o V. |
String dibujar() {
String s = 'B|0xFF1E1E2E|0xFF3A3A55|24|1';
for (final t in trazos) {
s = s + ';L|${t.x1}|${t.y1}|${t.x2}|${t.y2}|0xFFFFFFFF|3';
}
return s + ';T|10|10|0xCCFFFFFF|12|Pizarra';
}- **El texto de
T,WyXpuede llevar|, pero no;**: un;lo cortaría en otro comando. - Si tu
onPainttira un error, el lienzo muestra el error en rojo en vez de quedar en blanco.
7. Hablar con NEXUS: ModuleServer #
Todo lo que tu módulo sabe y hace afuera de su celda pasa por ModuleServer:
final ModuleServer server = ModuleServer(context);Créalo en build (o donde tengas un BuildContext). Leerlo es barato. Cuando cambia algo que leíste (alguien entra, alguien habla, llega un op del canal), NEXUS vuelve a llamar tu build.
Algo importante sobre cómo responden las llamadas: las del SDK son síncronas o con callback, nunca Future. Si algo falla (te falta un permiso, mandas demasiado rápido), la excepción salta en el momento y la puedes atrapar con try/catch. Lo que viaja por la red es «dispara y olvida»: el resultado te llega después, en un próximo build.
7.1 Quién está #
server.myId | Tu id en este servidor. |
server.members | Los miembros: id, nickname, online, roleName, roleColor, roleOrder, status, statusText, inVoice, speaking. |
server.canModerate | true si puedes modificar o borrar lo de otros (dueño o administrador). Úsalo para decidir qué botones mostrar; el servidor igual lo vuelve a verificar. |
server.statusTextOf(id) / server.statusLabel(status) | Textos de estado listos para mostrar. |
server.openMemberMenu(id) / server.openMyProfile() | Abren el menú nativo de un miembro o tu perfil. |
server.openExternal(uri) | Propone abrir una página https://… o steam://install/<appid> / steam://store/<appid> (desde la 2.0.50: pon "minAppVersion": "2.0.50" y declara "openExternal" en permissions). La app valida el destino y la persona decide en un diálogo nativo (abrir, copiar para Win+R o cancelar); otro esquema tira un error. |
Leer
speakingoinVoicehace que tu módulo se redibuje con cada cambio de voz, que pasa muchas veces por segundo en una llamada. Léelo solo si lo usas.
7.2 Canales y mensajes #
server.channels: los canales de texto (id,name).server.sendMessage(channelId, texto): escribe en un canal como la persona. Pide permiso la primera vez y tiene un límite de ritmo.
7.3 Guardar datos en esta PC: storage #
server.storage.set('preferencias', jsonEncode(prefs));
final String? raw = server.storage.get('preferencias');
server.storage.remove('preferencias');- Es privado de tu módulo y de esta PC: no se comparte con nadie ni viaja a otra computadora.
- Guarda texto (si necesitas estructura,
jsonEncode/jsonDecode). - Tope: 256 KB por módulo. Pasarlo tira
ModuleStorageQuotaExceededError. - Se escribe a disco con una pequeña demora (agrupa escrituras seguidas): llamarlo seguido no es caro.
Para lo que tiene que ver todo el servidor, usa el canal de sync (sección 8).
7.4 Hablar con otros módulos de esta PC: bus #
// Lo tuyo: server.bus.prefix es 'custom.<id de tu módulo>.'
server.bus.publish('${server.bus.prefix}cambio', '{"x":1}');
// Un espacio compartido que declaraste en el manifest ("busShared": ["mapas"])
final cancelar = server.bus.subscribeWithSender('shared.mapas.*', (topic, payload, remitente) { ... });
server.bus.publish('shared.mapas.nuevo', '{"id":3}');- Cada módulo tiene su espacio privado, **
custom.<id>.** (server.bus.prefix): nadie más publica ni escucha ahí. - Para hablar con otros módulos, declara espacios en el
manifest.json("busShared": ["mapas"]) y usashared.<espacio>.…. Solo entran los módulos que declaran el mismo espacio. subscribe(topic, (topic, payload) {...})sigue igual. ConsubscribeWithSenderel handler recibe además el id del módulo que publicó (lo pone NEXUS, no el que publica);nullsi lo publicó la app.- Publicar tiene un límite de frecuencia (unas 10 por segundo, con ráfagas de 20); pasarlo tira
ModuleBusRateLimitedError. - Los datos viajan como texto.
- Es local: no sale de esta PC.
7.5 Lista compartida del servidor: moduleEntries #
Una lista simple por módulo (por ejemplo, «los servidores de juego que vigilamos»): server.moduleEntries, addModuleEntry(nombre, proceso) y removeModuleEntry(id). Cada entrada puede tener un estado online (o null si todavía no se sabe). Para datos más ricos, el canal de sync es más flexible.
7.6 Voz y transmisiones #
server.voiceChannels(con susmembers:speaking,muted,deafened),server.myVoiceChannelId(''si no estás en ninguno),server.myMutedyserver.myDeafened.server.requestJoinVoice(id)pide confirmación en un diálogo nativo: un módulo nunca enciende el micrófono por su cuenta.server.leaveVoice()yserver.muteMe()no piden nada (solo protegen a la persona).server.streams: quién transmite (memberId,nickname,watching,mine). Muéstralo conModuleStreamView: empezar a ver es un botón nativo, nunca el módulo.
7.7 Programas de la PC #
server.pickExecutablePath((String? path) { ... })abre el diálogo nativo para elegir un.exe. No pide permiso: la ruta la eligió la persona.server.launchExecutable(path)lo abre. Pide permiso, y **solo acepta rutas que salieron depickExecutablePath**: una ruta inventada se rechaza.server.checkProcess('juego.exe')dice si ese proceso está abierto. Pide permiso para ese nombre exacto. Responde al instante con lo último que se sabe (falseal principio): el chequeo corre en segundo plano y tu módulo se reconstruye cuando cambia.
7.8 Servidores de juego: server.gameServer #
Para paneles de servidores dedicados (Minecraft hoy; más juegos con el módulo «Servidores de juego»). La persona elige la carpeta del servidor con pickFolder (eso es lo que la autoriza) y desde ahí puedes start, stop, leer la consola (lines, history), mandar comandos (command) y leer o escribir los archivos permitidos del servidor (readFile, writeFile). Pide el permiso game.server. El proceso es de la app, no de tu módulo: si se cierra la celda, el servidor sigue.
8. Compartir en tiempo real y la memoria del servidor #
Esto es lo que hace posible la Pizarra, el Tablero o una partida: datos que ven todos los miembros a la vez, y que el servidor recuerda. Si dibujas una Z, te vas a otro servidor y vuelves, la Z sigue ahí. Y la ven también los que entren después.
El modelo: objetos con id #
Cada topic de tu módulo (un nombre que tú eliges, como strokes o cards) es un conjunto de objetos vivos, cada uno con su id:
// Agregar o reemplazar un objeto (el id lo eliges tú).
server.publishTopicOp('strokes', 'trazo_${server.myId}_$n', jsonEncode({'pts': puntos, 'color': c}));
// Sacar objetos.
server.removeTopicOps('strokes', ['trazo_1', 'trazo_2']);
// Leer todo lo que hay ahora.
for (final ModuleTopicOp op in server.topicOps('strokes')) {
final Map datos = jsonDecode(op.dataJson);
// op.id, op.authorId (quién lo creó), op.editorId (quién lo tocó último)
}topicOpsla primera vez devuelve[]y pide los datos al servidor. Cuando llegan, tubuildse vuelve a llamar con todo.publishTopicOpes «dispara y olvida»: lo tuyo vuelve como eco del servidor y ahí aparece entopicOps. Si quieres que se vea al instante, guárdalo aparte como «pendiente» hasta que llegue el eco (la Pizarra lo hace así).- Usar el mismo id reemplaza el objeto: así se mueve una tarjeta o se edita una nota.
Las reglas (las aplica el servidor, no tu código) #
- Cada objeto es de quien lo creó. Reemplazarlo o sacarlo solo puede su autor… o alguien con
server.canModerate(dueño o administrador). - Topics compartidos: si el nombre empieza con
shared.(por ejemploshared.cards), cualquiera puede reemplazar un objeto ajeno (mover la tarjeta de otro), pero sacarlo sigue siendo del autor o de un moderador. - Topes:
- cada objeto puede ocupar hasta 16 KB;
- hasta 5000 objetos vivos por topic;
- el nombre del topic, hasta 64 caracteres, sin
:ni saltos de línea; - de ritmo: 30 de golpe y después uno cada medio segundo por módulo. Si dibujas mucho, junta los puntos en un solo objeto por trazo.
- No hay historial: sacar un objeto lo borra de verdad. Si quieres «deshacer», guárdalo tú.
Dónde se guarda, según el servidor #
| Servidor | Dónde vive la memoria |
|---|---|
| Servidor dedicado (NEXUS Dedicated Server) | En el servidor, en disco. |
| Servidor oficial de NEXUS | En la base de datos de NEXUS. |
| Red LAN | En cada PC de la red: cada una guarda lo de esa red, y la que abre el módulo completa lo suyo con lo que tengan las demás. |
Tu código es el mismo en los tres casos.
Para lo que tiene que viajar muchas veces por segundo (posiciones de una partida), el canal de sync no es lo ideal: es para estado que se recuerda. Una partida en red de verdad va mejor con un programa nativo (sección 9), cuyos paquetes la app transmite sin guardarlos.
9. Programas nativos (C/C++) #
Cuando necesitas rendimiento de verdad (un motor 3D, un emulador, audio), tu módulo puede traer un programa en C/C++.
Cómo funciona:
- Subes el código fuente (
native/, con CMake). ZENISO lo compila en un entorno sin red, y el ejecutable viaja firmado y verificado por su hash. Nunca se suben binarios. - Corre en Windows, aislado en un AppContainer propio:
- no lee tus documentos, no tiene internet, no crea procesos, no usa el portapapeles;
- sí escribe su propia carpeta de datos y sí tiene audio;
- si NEXUS se cierra, el programa también.
- Dibuja en una superficie compartida que tu módulo muestra con
ModuleNativeSurface(session: 's1'). - Tu módulo lo maneja con:
server.startNative(session, 'bin/mi.exe', configJson);nativeState(session):none,starting,running,exited:Noerror:…;nativeSend(session, json)para mandarle mensajes, ynativeMessages(session)para leer los suyos;stopNative(session).
- Red sin red: el programa no tiene red propia. Con
startNativeNetworked(...)y el permisonetwork.bridge, la app transmite sus paquetes a los otros jugadores, por el servidor de la celda o por NexusBridge (server.bridgeReadydice si se puede). El programa nunca ve una IP. Los slots van de 0 anative.maxPeers - 1del manifest (2 a 16; sin declararlo, 10). startNativeWithFolder(...)le deja leer (solo leer) una carpeta que la persona autorizó, por ejemplo el mundo de un servidor de Minecraft para dibujar el mapa.
Lo que más tiempo nos ahorró armando Averno:
- Enlaza el runtime de C/C++ estático (
/MTen MSVC,-staticen MinGW). Aislado, tu programa solo carga DLL firmadas por Microsoft: una DLL propia junto al.exeno carga. - Para juegos en red, haz la simulación determinista: con las mismas entradas, el mismo resultado en cada PC. Compara un hash del estado cada tanto y corta la partida con «desincronización» en vez de seguir con mundos distintos.
En el teléfono los programas nativos no corren. Un módulo con programa nativo se instala y se usa desde la PC.
10. Permisos: qué se le pregunta al usuario #
Hay dos clases de permisos.
**Los que se declaran en el manifest.json** (y se piden al instalar o la primera vez):
| Permiso | Qué permite | Qué ve la persona |
|---|---|---|
syncChannel | Publicar en el canal de sync. | «Enviar y recibir datos en tiempo real con otros miembros» |
native.run | Correr su programa nativo. | «Ejecutar su propio programa en tu PC, aislado: no ve tus archivos ni tus otros programas» |
network.bridge | Que ese programa juegue en red. | «Conectar su programa con otros miembros por la red NexusBridge del servidor (nunca con internet)» |
game.server | Abrir servidores de juego (Minecraft, Source, 7 Days to Die, Arma 3 o un .exe con argumentos que confirma la persona) de una carpeta elegida, con los permisos del usuario y escuchando en la red. | «Abrir servidores de juego (Minecraft, Source, 7 Days to Die, Arma 3 o cualquier .exe) de una carpeta que elijas, sin aislar, y manejar su consola y configuración» |
Los que se piden cuando el módulo los usa: mandar mensajes a un canal, abrir un programa elegido y ver si un proceso está abierto (este último, por nombre de proceso).
Lo que nunca pide permiso, porque no expone nada: leer miembros, canales y voz, abrir menús nativos, maximizar, elegir un ejecutable o una carpeta (los elige la persona), salir de la voz o mutearse.
La revisión del Mercado compara lo que declaras con lo que tu código usa de verdad: si usas algo que no declaraste, el módulo no se publica.
11. Las reglas del sandbox (lo que más tiempo ahorra) #
El sandbox es dart_eval (una versión con arreglos propios). Casi todo Dart funciona, pero estos casos están comprobados y conviene evitarlos. La mayoría los marca el linter del creador con el número de línea, al guardar y al publicar.
Estructura e imports
- Importa explícitamente lo que usas de Flutter. Por ejemplo,
Stacknecesitapackage:flutter/src/rendering/stack.dartademás del widget. Si no, el sandbox dice «Class not found». - Entre tus archivos, rutas relativas, no
package:propio (sección 3). - Un error raro como «Class/enum X not found» o «Cannot resolve top level variable» en código que se ve bien suele venir de cómo el sandbox descarta lo que cree que no se usa. Si te pasa, avísanos con el código: es un bug del sandbox, no tuyo.
**State y tiempo**
- **Tu
Stateno tienemountednicontext**: solowidget,setState,initState,disposeybuild. Para callbacks que llegan tarde (un diálogo, un timer), lleva tu propiobool disposedy míralo antes desetState. TimeryTimer.periodicfuncionan. Cancélalos endispose.- Para medir tiempos en lógica de juego, cuenta ticks de tu timer en vez de usar
DateTime.now().
Tipos
- **
int + doubledanum**: asignarlo a undoubleno compila. Escribex.toDouble() + 0.5. - **
issobre unnullrevienta.** Un valor que falta en un mapa dejsonDecodees unnull: chequea siemprev != null && v is String. - Declara los tipos en variables y listas (
final List<Widget> rows = [];). Ayuda al sandbox y al analizador.
Closures dentro de bucles
- Una función creada **dentro de un
forque usa la variable del bucle y** otra variable que se reasigna lee un valor equivocado. El linter lo marca. La salida es una variable nueva confinal, o crear la función en un método aparte que reciba el valor como parámetro:
// Mal: la closure usa i y también `total`, que se reasigna
for (var i = 0; i < n; i++) {
total = total + 1;
botones.add(boton(() => elegir(i, total)));
}
// Bien
for (var i = 0; i < n; i++) {
total = total + 1;
final int t = total;
botones.add(boton(() => elegir(i, t)));
}Lo que no hay
- No hay
dart:ioni red, ni archivos, niProcess: todo eso pasa porModuleServer. - No hay
Future/asyncen las llamadas al SDK (sección 7). - No hay pinch ni zoom con dos dedos en
GestureDetector.
Rendimiento
- Tu
buildpuede llamarse seguido (cada vez que cambia algo del servidor). Mantenlo liviano: arma listas, no hagas cálculos pesados. - Lo pesado va en un programa nativo.
12. Probar #
- Vista previa del creador: corre tu módulo con miembros, canales y un canal de sync de mentira, en memoria. Es lo más rápido para iterar.
- En un servidor de verdad: guárdalo, agrégalo a un espacio y pruébalo con alguien más. El canal de sync se prueba mejor con dos personas.
- El linter del creador: al guardar y al publicar marca con el número de línea los casos de la sección 11. Arréglalos antes de enviar: la revisión del Mercado pasa el mismo linter.
- Varios tamaños: prueba tu módulo de 1–2 celdas, de tamaño normal y maximizado.
- Programas nativos: pruébalos desde la PC, en un servidor de verdad y, si juegan en red, con dos PC.
13. Publicar en el Mercado #
- Empaqueta: desde el creador, «Publicar»; o, si lo hiciste en tu IDE, «Subir proyecto .zip» en el Panel de creador. El paquete lleva
manifest.json,lib/,native/(solo fuente),READMEyLICENSE. Las carpetastools/,bin/ybuild/no viajan. - Revisión automática:
- compila tu módulo en el sandbox y pasa el linter;
- compara los permisos declarados con los que usa;
- si hay programa nativo, lo compila ZENISO y revisa que no importe funciones prohibidas (crear procesos, registro, servicios).
- Revisión del equipo: una persona lo mira antes de que salga al público.
- Página del Mercado: título, descripción, imágenes y precio (gratis o con monedas).
- Actualizaciones: subes una versión nueva y pasa por la misma revisión. Las apps la bajan solas; si la versión nueva pide un permiso nuevo, se le pregunta de nuevo a la persona.
Antes de publicar:
minAppVersionapunta a una versión que tiene todo lo que usas.- Los
permissionsdel manifest son exactamente los que usas. - Se ve bien de 1–2 celdas, normal y maximizado.
- Los colores salen de
server.accentColor(nada que choque con los fondos claros u oscuros). - Si usa el canal de sync, lo probaste con dos personas y respetas los topes.
READMEdice qué hace yLICENSEqué se puede hacer con el código.
Dónde se puede usar después:
- En un servidor dedicado, el dueño elige qué módulos se admiten.
- En las redes LAN y los servidores oficiales, los módulos del Mercado se usan con la membresía de NEXUS.
14. Módulos de referencia #
Los módulos oficiales de ZENISO usan el mismo SDK público que el tuyo. Agrégalos desde el Mercado para ver cómo resuelven cada cosa:
| Módulo | Qué muestra |
|---|---|
| Pizarra | DrawSurface y el canal de sync: trazos, figuras, notas, selección, «pendientes» hasta el eco, borrar como moderador. |
| Tablero | Un tablero estilo kanban con topics shared. (todos mueven tarjetas). |
| Servidores de juego | server.gameServer, ModuleBridgeAddress y ModuleServerPing. |
| Averno | Un juego completo con programa nativo, red en lockstep y audio. |
15. Referencia rápida del SDK #
Lo que tu módulo puede usar, tal como lo ve el sandbox (se genera del código de la app).
package:lannexus_sdk/module.dart #
abstract class CustomGridModule {
Widget build(BuildContext context, double width, double height);
}
// Tu entrada: CustomGridModule createModule() => MiModulo();package:lannexus_sdk/server.dart #
class ModuleServer {
ModuleServer(BuildContext context);
// Quién y dónde
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' o 'en' (desde la 2.0.36)
String statusTextOf(String peerId);
String statusLabel(String status);
void openMemberMenu(String memberId);
void openMyProfile();
void openExternal(String uri); // https o steam://install|store/<appid>, con confirmación (2.0.50)
// Qué sabe hacer esta app (2.0.68)
String get appVersion;
List<String> get appFeatures; // 'join.steam.connect', 'ping.tcp', 'draw.G'...
bool supports(String feature);
// Guardar y comunicarse
ModuleStorage get storage; // get / set / remove (256 KB, esta PC)
ModuleBus get bus; // prefix, publish / subscribe / subscribeWithSender (custom.<id>.… o shared.<espacio>.…)
void sendMessage(String channelId, String content);
// Canal de sync (lo que el servidor recuerda)
List<ModuleTopicOp> topicOps(String topic); // id, authorId, editorId, dataJson
void publishTopicOp(String topic, String opId, String dataJson);
void removeTopicOps(String topic, List<String> ids);
// Lista compartida
List<ModuleEntry> get moduleEntries; // id, name, processName, addedByMemberId, online
void addModuleEntry(String name, String processName);
void removeModuleEntry(String entryId);
// Ventana
bool get isMaximized;
void requestMaximize();
void requestRestore();
// Voz y transmisiones
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); // con confirmación
void leaveVoice();
void muteMe();
List<ModuleStream> get streams; // memberId, nickname, watching, mine
// Programas de la PC
bool checkProcess(String processName);
void pickExecutablePath(Function onPicked); // (String? path)
void launchExecutable(String path);
// Programa nativo propio
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;
// Servidores de juego
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 de comandos
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)¿Te trabaste con algo que esta guía no cubre? Lo más probable es que otro creador también se trabe. Cuéntanos y lo agregamos.