Fuente: handbook/ en el repositorio. Cada bloque de código compila: el CI corre ray check sobre cada uno.

App multiplataforma

Este capítulo continúa el de la app móvil. La misma Notes corre ahora también como app de escritorio en macOS, Linux y Windows, con un solo src/ y una sola interfaz para las cinco plataformas. En el camino cambia el almacén: las notas pasan de std/kv a SQLite, con búsqueda, y se guardan en la carpeta que cada sistema espera.

El proyecto completo está en examples/apps/notes-everywhere, con sus tests. Como en el capítulo anterior, cada bloque de raylang está copiado de ese proyecto y el CI comprueba que siga siéndolo.

Qué cambia respecto a la app móvil

App móvil Multiplataforma
Plataformas iOS y Android macOS, Linux, Windows, iOS y Android
Datos std/kv, un archivo clave-valor SQLite con el paquete db
Carpeta de datos $HOME/Documents/Notes la de cada sistema (sección 2)
Escritorio ventana sin más menús nativos, atajos, diálogos, tamaño mínimo
Interfaz una columna una columna en el teléfono; lista y editor lado a lado en pantallas anchas

El modelo de la app es el mismo: el programa raylang y el webview viven en un proceso, y la página habla con el programa por window.ray.request. Lo nuevo se añade alrededor del mismo bucle de eventos, y casi todo es del lado del programa:

Lo decide el programa Lo decide la página
dónde y cómo se guardan los datos el diseño y la navegación
los menús, sus atajos y los diálogos del sistema qué hace cada comando de menú
en qué plataforma corre qué mostrar en cada una
leer y escribir archivos el estado de lo que se está editando

Esa frontera es la que permite una sola interfaz: la página no sabe si hay menús ni dónde está la base de datos, y el programa no sabe cómo se dibuja una nota.

1. SQLite con el paquete db

SQLite no viene en la biblioteca estándar: es parte del paquete db, publicado en el índice de paquetes de raylang. Se añade con un comando:

ray add db

ray add busca la última versión, la descarga a .ray-deps/ y la declara en el ray.toml:

[dependencies]
db = "^0.5.1"

La versión exacta y su hash quedan fijados en ray.lock, que va al control de versiones. El paquete compila SQLite dentro del binario, así que no hace falta ninguna librería del sistema en ninguna de las cinco plataformas.

Al abrir la base se crea el esquema si no existe. El modo WAL hace que una lectura nunca espere a una escritura, y que un corte a mitad de escritura no corrompa el archivo:

const SCHEMA: string = `CREATE TABLE IF NOT EXISTS notes (
    id         TEXT PRIMARY KEY,
    title      TEXT NOT NULL,
    body       TEXT NOT NULL,
    updated_ms INTEGER NOT NULL
)`;

/// Opens (or creates) the database at `path` and makes sure the schema exists.
pub fn open(path: string) -> Result<Conn, string> {
    let conn = sqlite.connect(path)?;
    // WAL: readers never wait for the writer, and a crash mid-write cannot corrupt the file.
    let _ = sqlite.query(conn, "PRAGMA journal_mode=WAL", [])?;
    let _ = sqlite.exec(conn, SCHEMA, [])?;
    let _ = sqlite.exec(
        conn,
        "CREATE INDEX IF NOT EXISTS notes_by_date ON notes (updated_ms DESC)",
        []
    )?;
    Result.Ok(conn)
}

Las consultas usan parámetros ?1, ?2, que se enlazan aparte del SQL. Por eso buscar con una comilla no rompe nada: es dato, no código. La búsqueda es un LIKE sobre el título y el cuerpo:

/// Notes whose title or body contains `text` (all of them if it is empty), newest first.
pub fn search(conn: Conn, text: string) -> Result<[Note], string> {
    let rows = if (text.trim() == "") {
        sqlite.query(
            conn,
            "SELECT id, title, body, updated_ms FROM notes ORDER BY updated_ms DESC",
            []
        )?
    } else {
        let pattern = "%" + text.trim() + "%";
        sqlite.query(conn, `SELECT id, title, body, updated_ms FROM notes
             WHERE title LIKE ?1 OR body LIKE ?1 ORDER BY updated_ms DESC`, [pattern])?
    };
    Result.Ok(rows.map(from_row))
}

db devuelve cada celda como texto, de modo que la fecha se convierte con parse_int(). El resto del módulo (save, remove) sigue la misma forma que en la versión móvil, con INSERT, UPDATE y DELETE.

2. La carpeta de datos de cada sistema

Una app de escritorio no debe escribir en la carpeta Documentos del usuario. Cada sistema tiene la suya para datos de aplicaciones, y platform() dice en cuál estás:

/// The folder for the app's data on this platform. `NOTES_DIR` overrides it (tests, CI).
pub fn data_dir(app: string) -> string {
    match (env("NOTES_DIR")) {
        Option.Some(d) => return d,
        Option.None => { },
    }
    let home = env("HOME").unwrap_or(".");
    let os = platform();
    if (os == "macos") {
        return home + "/Library/Application Support/" + app;
    }
    if (os == "windows") {
        return env("APPDATA").unwrap_or(home) + "\\" + app;
    }
    if (os == "linux") {
        return match (env("XDG_DATA_HOME")) {
            Option.Some(d) => d + "/" + app,
            Option.None => home + "/.local/share/" + app,
        };
    }
    // iOS and Android
    home + "/Documents/" + app
}
Sistema Carpeta
macOS ~/Library/Application Support/Notes
Linux $XDG_DATA_HOME/Notes, o ~/.local/share/Notes
Windows %APPDATA%\Notes
iOS y Android dentro del sandbox de la app, como en el capítulo móvil

fs.mkdir crea las carpetas intermedias que falten.

3. Menús nativos

En el escritorio, una app sin menús se siente extraña: sin el menú Edición, en macOS ni siquiera funcionan copiar y pegar en los campos de texto. Los menús se declaran como datos, y solo en el escritorio:

// The menus exist only on the desktop; on a phone the page has its own buttons.
fn install_menus() -> Result<int, string> {
    ui.app_menu(APP, [ui.item("role:about", "", "")])?;
    ui.menu(
        "File",
        [
            ui.item("new", "New Note", "cmd+n"),
            ui.item("find", "Search", "cmd+f"),
            ui.separator(),
            ui.item("export", "Export…", "cmd+e")
        ]
    )?;
    ui.edit_menu(
        [
            ui.item("role:undo", "", ""),
            ui.item("role:redo", "", ""),
            ui.separator(),
            ui.item("role:cut", "", ""),
            ui.item("role:copy", "", ""),
            ui.item("role:paste", "", ""),
            ui.item("role:select_all", "", "")
        ]
    )
}
  • Los elementos con etiqueta role: hacen lo que hace el sistema (deshacer, cortar, copiar, pegar, seleccionar todo) y no generan eventos. role:about es el «Acerca de» nativo de macOS.
  • Los demás generan un evento "menu" con su etiqueta. Los atajos se escriben como cmd+n: en Windows cmd es Ctrl. En Linux los menús de la barra responden al clic pero todavía no a los atajos de teclado.

Cuando llega un evento de menú, el programa se lo pasa a la página como un evento del DOM, y React lo escucha:

// A menu command goes to the page as a DOM event; the React app listens for `ray-menu`.
fn tell_page(window: int, command: string) {
    let js = "window.dispatchEvent(new CustomEvent('ray-menu', {detail: '" + command + "'}))";
    let _ = ui.eval_js(window, js);
}

En la página, el comando acaba en las mismas funciones que llaman los botones:

  // Commands from the native menu (desktop): the program dispatches a `ray-menu` event.
  useEffect(() => {
    const onMenu = (e: Event) => {
      const command = (e as CustomEvent<string>).detail
      if (command === 'new') open(empty)
      if (command === 'find') search.current?.focus()
      if (command === 'export') void doExport()
    }
    window.addEventListener('ray-menu', onMenu)
    return () => window.removeEventListener('ray-menu', onMenu)
  }, [doExport])

Así los menús no añaden un segundo camino en la interfaz: «Nueva nota» hace lo mismo desde el menú, desde el atajo y desde el botón, y en el teléfono, donde no hay menús, no falta nada.

eval_js ejecuta el texto que recibe. Aquí el comando es una de las etiquetas que el propio programa declaró, así que es seguro concatenarlo. Un texto que venga del usuario nunca se concatena en JavaScript: se pasa como JSON, o se devuelve como respuesta de una petición.

4. Diálogos nativos

Dos operaciones necesitan una ventana del sistema, así que el programa las atiende antes de pasar la petición a api.handle. Una función mira qué pide la página y decide quién responde:

// One request from the page. Most go to `api.handle`; the ones that need a window live here.
fn answer(conn: Conn, body: string) -> string {
    let op = match (json.parse(body)) {
        Result.Ok(j) => json.get_string(j, "op").unwrap_or(""),
        Result.Err(_) => "",
    };
    if (op == "hello") {
        return json.render(json.obj()
            .field("ok", true)
            .field("platform", platform())
            .field("desktop", places.is_desktop()));
    }
    if (op == "export") {
        return export(conn);
    }
    if (op == "delete" && !confirmed_delete(body)) {
        return json.render(json.obj().field("ok", false).field("error", ""));
    }
    api.handle(conn, body, time.now())
}

api.handle sigue sin conocer ninguna ventana, así que sus tests no cambian. Lo que depende de la plataforma queda en main.ray, en una función corta que se lee de arriba abajo.

Confirmar antes de borrar. En el escritorio, un diálogo nativo con botones propios:

// The desktop asks before deleting with a native dialog; the phone page asks on its own.
fn confirmed_delete(body: string) -> bool {
    if (!places.is_desktop()) {
        return true;
    }
    let title = match (json.parse(body)) {
        Result.Ok(j) => json.get_string(j, "title").unwrap_or("this note"),
        Result.Err(_) => "this note",
    };
    ui.confirm("Delete “" + title + "”?", "This cannot be undone.", "Delete", "Cancel")
        .unwrap_or(false)
}

En el teléfono, la página pide un segundo toque («Tap again to delete»). El webview de iOS no muestra window.confirm() por su cuenta, así que es más fiable resolverlo en la propia página.

Exportar. El comando «Export…» abre el diálogo de guardado del sistema y escribe todas las notas en un archivo Markdown:

    match (ui.save_file_with(o)) {
        Result.Ok(Option.Some(path)) => match (fs.write_file(path, notes.to_markdown(list))) {
            Result.Ok(_) => json.render(json.obj().field("ok", true).field("exported", path)),
            Result.Err(e) => api.fail(e),
        },
        Result.Ok(Option.None) => json.render(json.obj().field("ok", true).field("exported", "")),
        Result.Err(e) => api.fail(e),
    }
}

Un Option.None significa que el usuario canceló el diálogo: no es un error.

5. La ventana

En el escritorio la ventana tiene un tamaño mínimo, y autosave hace que el sistema recuerde su tamaño y su posición entre ejecuciones. En el teléfono esas opciones no hacen nada: la página ocupa la pantalla entera.

    if (places.is_desktop()) {
        match (install_menus()) {
            Result.Err(e) => eprint("menus: " + e),
            Result.Ok(_) => { },
        }
    }
    // On the desktop: a minimum size, and the system remembers size and position ("main").
    var o = ui.options(900, 640);
    o.min_width = 560;
    o.min_height = 420;
    o.autosave = "main";
    let window = match (ui.open_with(APP, "app://index.html", o)) {
        Result.Err(e) => {
            eprint("ui: " + e);
            return 1;
        },
        Result.Ok(w) => w,
    };

6. Una interfaz para todas las pantallas

La página pregunta al programa en qué plataforma corre (op: "hello"), y con eso decide qué mostrar: el botón «Export…» solo existe en el escritorio. El diseño lo decide el CSS:

  • en el teléfono, un panel cada vez: la lista, o el editor mientras editas;
  • en pantallas anchas (escritorio o tablet), la lista a la izquierda y el editor a la derecha.

Basta una media query sobre el ancho; la lógica de React es la misma en todos los casos:

@media (min-width: 720px) {
  .list-pane {
    flex: 0 0 300px;
    overflow-y: auto;
  }
  .phone.editing .list-pane {
    display: flex;
  }
}

La regla mira el ancho y no la plataforma: una tablet, o una ventana de escritorio estrecha, reciben el diseño que les cabe. El código está en frontend/src/App.tsx y frontend/src/index.css.

7. Tests

Los tests abren una base en una carpeta temporal, así que no tocan tus datos. Uno de ellos comprueba que una búsqueda con comillas no se interpreta como SQL:

@test
fn quotes_in_a_search_are_data_not_sql() {
    let c = fresh("quotes");
    let _ = notes.save(c, "", "It's fine", "", 1000).unwrap();
    assert_eq(notes.search(c, "' OR 1=1 --").unwrap().len(), 0);
    assert_eq(notes.search(c, "It's").unwrap().len(), 1);
}
ray test

8. Empaquetar para cada sistema

ray bundle                 # en macOS: Notes.app
ray bundle                 # en Linux: el binario y su lanzador Notes.desktop
ray bundle                 # en Windows: Notes.exe, sin consola, con icono y acceso directo
ray bundle --ios           # el proyecto Xcode, como en el capítulo móvil
ray bundle --android       # el proyecto Gradle

ray bundle empaqueta para el sistema en el que se ejecuta: el .exe se construye en Windows y el .desktop en Linux. Lo habitual es una matriz de CI con un job por sistema.

Sistema Qué produce En la máquina del usuario hace falta
macOS Notes.app nada
Linux una carpeta con el binario y Notes.desktop GTK 3 y WebKitGTK
Windows una carpeta con Notes.exe, sin consola, con icono, y el acceso directo Notes.lnk el WebView2 Runtime, que Windows 11 ya trae
iOS el proyecto Xcode Notes-ios/
Android el proyecto Gradle Notes-android/

El programa, SQLite y el frontend van dentro del ejecutable: no hay un runtime que instalar aparte. El .app de macOS de Notes ocupa unos 3 MB. Una app empaquetada arranca con el directorio actual en /, y por eso los datos van a la carpeta de la sección 2 y los recursos van embebidos.

Para distribuir la app fuera de tu máquina, macOS pide firmarla y notarizarla, y Windows muestra un aviso de SmartScreen si no está firmada. Lo explica el capítulo Distribuir, junto con las actualizaciones automáticas.

Siguiente paso

Ventanas a fondo: todo lo demás que std/ui ofrece a una app de escritorio, con un editor de texto de ejemplo.