QML / one toolchain / zero third party servers
Build complete web systems with QML, with no third party servers to stand up.
SynQt (pronounced synced) is built from entities: a browser client, a web edge, a database, and whatever else your system needs, each its own binary, sharing one toolchain and one security model.
Why SynQt¶
-
One language, front to back
Write the UI and the server side logic in QML. The boundary between any two components is a set of typed connect points, named and access controlled by configuration.
-
Live by default
A value that updates across every browser the instant it changes, with no manual wiring and no client side polling, is a few lines of QML.
-
Batteries included, no third party servers
Add a database, cache, document store, gateway, or jobs runner as a first party entity. Back it with an embedded engine, or mask PostgreSQL, MongoDB, or Redis behind it with one config value.
-
Web and desktop, one codebase
The client is a Qt app. Ship it to the browser as WebAssembly and, from the same QML, as a native app for Windows, macOS, and Linux, against the same edge and the same security model.
-
Secure at every link
Every link is encrypted and authenticated from the first build. There is no plaintext connection type to reach for by mistake, only the one every entity already speaks.
-
One click, one trace
Add a monitor and every entity reports to it, so a click in the browser becomes one trace running through each entity it touched. Nothing is recorded until you add one, and turning a category up during an incident is a restart, not a rebuild.
A closer look¶
Contracts and connect points¶
Two entities talk through a connect point: a named, typed, live object one entity owns and the others see a live copy of. Properties and signals flow from the owner out to every consumer; slots flow the other way, and the owner always decides.
Security by default¶
TLS everywhere, mutual TLS between entities, a deny by default topology, and data minimization built into the contract format itself. There is no insecure middle state a project can accidentally ship in.
One toolchain¶
The synqt CLI installs and pins the exact Qt and Emscripten versions your project
needs, builds every entity, native and WebAssembly alike, and runs them all
together with file watching and hot reload.
What it looks like¶
A chat room, because everyone already knows what one does. Somebody types a line and it appears in every window that has the room open, including the ones on other machines. That is the part SynQt is for, and below is all of it.
A finished system is a small mesh of entities. The drawing below is that mesh, drawn by the design editor from this very project: three boxes saying which side of the wire each entity is on, a shape per entity, and on every line the contract the two ends share. Only the web edge faces the internet; everything else sits in the mesh box and is reachable only by the entities you allow.
A visitor who has signed in as nobody gets a sign-in page, and the room is absent rather
than hidden: the point that carries it is gated scope: user, so their session never
acquires it and there is nothing on their side to get past. Signing in fills the same
window with the room. A moderator gets one member more than everybody else, erase, and
gets it because the contract says so and not because the client decided to offer it.
Seven files are the whole system: one configuration file, which says what crosses each link, one QML file per entity, two more the client's window opens, and the table the database keeps the messages in. Hover (or focus) an entity to read the file it is, or the mark on a line to read the block that says what crosses it, and the card that opens says the rest. The project tree under the drawing opens the same files, and one stays open until you move to another. The database opens two, its QML and the table that QML queries, since neither says much without the other. The files carry no explanatory comments: a line with something to say about itself is marked down its left instead, and hovering it says the thing. A line that ends in an arrow opens the page covering it, whether that is a page of this guide or the class in the C++ reference.
The button under it opens this same drawing in the online designer, which runs in the browser with nothing installed. It is the same code that drew it here, so nothing is lost on the way: pull the mesh apart there, add an entity, and export the result as a project.
- synqt.yaml
- client
- app
- Main.qml
- User.qml
- Admin.qml
- web
- edge
- Edge.qml
- db/relational
- store
- Store.qml
- schema.sql
project:
name: chat
qt_version: 6.12.0
scopes: { order: [anonymous, user, admin], default: anonymous }
identity:
providers: [{ name: github, client_id: ..., client_secret: env:SECRET }]
mapping: web/edge/identity/map.qml
entities:
- { name: app, type: client }
- name: edge
type: web_edge
identity: true
public: { port: 8443, sync_route: /sync }
- { name: store, type: relational, provider: { name: sqlite } }
connect_points:
- owner: edge
consumers: [app]
scope: user
export: |
model messages(int id, string[40] who, string[280] body, bool staff)
slot say(string[280] body)
<admin> slot erase(int id)
- owner: store
consumers: [edge]
export: |
prop var[24000] lines
slot say(string[40] who, string[280] body, bool staff)
slot erase(int id)
- One version pins the whole toolchain: Qt, the Emscripten it is built against, and every entity built from them.
- The scope ladder. Every session sits on one rung, and a connect point can demand a minimum.
- The one place anybody is decided to be a moderator. It turns a verified login into a scope, and everything else in the system reads that answer rather than making it.
- The room, built to WebAssembly. The same QML also builds as a native app for Windows, macOS, and Linux, against this same edge.
- The one entity allowed to face the internet, on the one public port. Nothing else gets one.
- The switch the whole project hangs off: this edge runs the OAuth exchange and keeps the sessions, which is what makes `Session.login()` in the client reach anything at all.
- A database entity: embedded SQLite by default, PostgreSQL or MySQL behind the same interface with one config value.
- The gate on the whole point. A session that has signed in as nobody never acquires it, so there is no room on their side to get past.
- The browser's one way in, and deny by default: an entity that is not on this list cannot open this connect point at all.
- The gate is on the member. A caller without the scope does not have the slot, so it is refused before it runs and there is no check to write, or forget, in the QML behind it.
- The roles listed here are the whole of what a message may carry to a browser. `said_at` is in the table and not in this line, so it never leaves the mesh.
- The database is reachable by the edge and by nothing else. The browser is on no list here, so there is no request it can make.
- The room, held in one place and mirrored by the edge. The bracketed number is the limit the owner holds it to at the boundary, in bytes on the wire.
import SynQt
import QtQuick.Controls
import QtQuick.Layouts
ApplicationWindow {
id: window
visible: true
title: qsTr("The chat room")
ColumnLayout {
anchors.centerIn: parent
visible: !Session.hasScope("user")
spacing: 24
Label {
Layout.alignment: Qt.AlignHCenter
font.pixelSize: 32
text: qsTr("One room. Everybody in it sees the same thing.")
}
Button {
Layout.alignment: Qt.AlignHCenter
text: qsTr("Sign in with GitHub")
onClicked: Session.login()
}
}
User {
anchors.fill: parent
visible: Session.hasScope("user")
}
}
- Brings in the runtime accessors: Server, Session, Router, and the contracts this entity consumes.
- One window, and signing in swaps what is in it and nothing else. There is no second page and no redirect, because the room's point is gated `scope: user` and a signed-out session has no `Server` to reach.
- The sign-in page, and the whole of what a signed-out visitor has. It is a binding, so it lifts by itself the moment the session is elevated.
- The flow runs on the edge. This browser never sees a token and never holds a secret; what it ends up with is a session cookie.
- The room, in the file next to this one. A `*.qml` beside `Main.qml` is a type named after it: `synqt build` compiles every one of them into the entity's QML module, so there is nothing to import and nothing to register.
import SynQt
import QtQuick.Controls
import QtQuick.Layouts
ColumnLayout {
ListView {
id: messages
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
model: Server.messages
delegate: Item {
id: line
required property var model
width: messages.width
height: 26
Label {
x: 8
width: 132
height: parent.height
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
color: line.model.staff ? "#d0342c" : line.palette.windowText
font.bold: line.model.staff
text: line.model.who
}
Label {
x: 148
width: parent.width - 148 - 88
height: parent.height
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
text: line.model.body
}
Admin {
x: parent.width - 84
y: 1
width: 76
height: parent.height - 2
messageId: line.model.id
}
}
}
TextField {
id: draft
Layout.fillWidth: true
placeholderText: qsTr("Say something")
onAccepted: {
Server.say(draft.text);
draft.clear();
}
}
}
- A live model, and the whole of the sync. Somebody says something, the owner replaces the rows, and every open tab redraws itself. Nothing here polls.
- A delegate is recycled, so it holds no state of its own. `var model` rather than a property per role, because one of the roles is called `id`, which is a QML keyword.
- `staff` is stamped on the row by the edge, from the session it verified, and the edge is the only thing in the system that can set it. This browser has no say in it.
- It runs on the owner, which is free to refuse it. The browser sends the text and nothing else.
import SynQt
import QtQuick.Controls
Button {
id: control
required property int messageId
visible: Session.hasScope("admin")
text: qsTr("Erase")
onClicked: Server.erase(control.messageId)
}
- Which message this erases, handed down by the row. A delegate is recycled, so the button holds no idea of its own about which line it is sitting on.
- A courtesy rather than a gate. `erase` is not a member of the surface an ordinary session acquired, so hiding the button is only about not offering it.
- The contract writes this member `<admin> slot erase`. An ordinary session never acquired it, so there is nothing there to call and nothing to refuse.
import SynQt
Edge {
messagesRows: Store.lines
function say(body) {
Store.say(Caller.identity.login, body, Caller.hasScope("admin"));
}
function erase(id) {
Store.erase(id);
}
}
- The Source of the point this entity owns, and where everything about a message except its text is decided. The type is the entity's own name capitalised, because a point is named by whoever owns it.
- Bind the model once and the room is live. Every browser holds a mirror of it, so reassigning the list on the database redraws all of them, and nobody wrote a broadcast.
- Who is asking, taken from the session this edge verified. A browser cannot read this value, let alone set it, which is why `say` takes a line of text and no name.
- The one place in the system that can mark a message as staff. A user cannot speak in a moderator's voice by asking to: nothing they send reaches this argument.
- Reached only by a caller who holds `admin`, and the contract is what says so. There is no check here refusing anybody, because nobody else has the slot.
import SynQt
Store {
id: log
function say(who, body, staff) {
Db.exec("INSERT INTO messages (who, body, staff, said_at) "
+ "VALUES (?, ?, ?, datetime('now'))",
[who, body, staff ? 1 : 0]);
log.refresh();
}
function erase(id) {
Db.exec("UPDATE messages SET body = 'deleted by a moderator' WHERE id = ?",
[id]);
log.refresh();
}
function refresh() {
const rows = Db.query("SELECT id, who, body, staff FROM messages "
+ "ORDER BY id DESC LIMIT 50");
log.lines = rows.map(row => ({ id: row.id, who: row.who, body: row.body,
staff: row.staff !== 0 }));
}
lines: []
Component.onCompleted: log.refresh()
}
- The Source of the point the database owns, and the only surface it has. The conversation is here, and it is the only thing in the system that survives a restart.
- Parameterized, always. The values travel beside the statement, so an apostrophe in a message is an apostrophe and never a second statement. Writes are serialized on this entity's own event loop.
- Erasing leaves the line where it was, saying who wrote it and that it is gone, rather than opening a hole in a conversation everybody is reading.
- Read the room and hand it over, in one go. Called after every write, and once at startup, so there is one path by which the room changes and every window on it redraws from that one path.
- `said_at` is in the table and not in this SELECT, and not in the contract either: the boundary keeps the declared roles and drops the rest.
- The room as it stands, held once and reassigned in one go. That reassignment is the whole of the synchronisation.
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
who TEXT NOT NULL,
body TEXT NOT NULL,
staff INTEGER NOT NULL DEFAULT 0,
said_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS messages_by_time
ON messages (said_at);
- Applied at startup and forward only. A migration adds a file; nothing here is ever rewritten under a running system.
- In the table and not in the contract, so it stays on this side of the mesh. A column a browser is not told about is a column it never receives.
Hover a marked line for what it does. A line ending in an arrow opens the page covering it.
Where to go next¶
- Getting started: install
synqtand run your first project. - Framework: the full reference, from the entity model to the security design.
- Examples: complete worked systems.
- Contributing: the codebase map, for working on SynQt itself.
- C++ reference: the generated class and member reference for the runtime.