Skip to content

@guestcraft/mobile

The GuestCraft mobile app: one app that connects to any GuestCraft instance by its address, like Immich or Mattermost (ADR 0041, analysis chapter 14). MIT licensed, like the SDKs. Expo SDK 58, React Native 0.88, Expo Router.

  • Connect. Type hotel.example.com; the app reads https://hotel.example.com/.well-known/guestcraft without credentials and saves the instance. It refuses plain http (except in development builds), unreachable addresses, servers that are not GuestCraft or too old (no mobile_sessions capability), an app older than the server’s min_app_version, API or sign-in URLs on another origin, and instances that do not allow its redirect scheme. Several instances can be saved, opened and removed (removing also signs the device out there), one action at a time. An instance is its canonical address: saving the same address again keeps its entries.
  • Sign in in the system browser (ASWebAuthenticationSession, Custom Tabs) on the instance’s own page, with PKCE S256. The instance returns a one-time code to dev.guestcraft.app://auth; the app exchanges it for a device token, kept in expo-secure-store (this device only, never in a backup) with the address that issued it: the app sends a token to that address only. Sign out revokes the device session, then deletes the token. A token the instance no longer accepts (401) is forgotten at once.
  • Pair by scanning the QR code of Settings → Account → Pair a phone on the web (“Scan a pairing code” on Connect and Sign in): the camera reads https://<instance>/mobile/pair#gcp_… inside the app, the instance at that address is checked like a typed one and must declare mobile_pairing, the app shows its name and address and, once the person confirms, exchanges the one-time code with its own device name for a device session of its own (never the browser’s). A used or expired code is refused; an instance the phone is already signed in to is refused too (“sign out first”).
  • Choose a workspace and a property among those the person reaches (GET /api/v1/workspaces); a workspace with more than 100 properties shows them a page at a time (“Show more properties”).
  • Today: arrivals, departures and guests in house of the property’s business date, which the screen names (it stays on the previous day until the night audit closes it), with pull to refresh. When the date in the property’s time zone (not the phone’s) is already later, a notice says the night audit has not closed the business date yet. A stay opens its reservation.
  • Housekeeping: rooms with their status; dirty, clean or inspected, one room at a time. The new status shows at once and goes through the outbox (below): without a network it is kept on the phone and sent later; if the room changed meanwhile or the instance refuses, the room shows its current status again with a message, and the change waits for the person’s decision. Each room opens its detail: the status, its tasks of the business date (whoever has them) and “Report a fault”; it reads both again when the person comes back to it from a task.
  • My tasks (instances with the housekeeping_tasks capability, ADR 0046): the tasks given to the signed-in member at the property on its business date, rooms with a guest arriving first, status in a symbol and words. One tap starts or finishes a task; Skip asks for a reason (“Do not disturb” in one tap). For 10 seconds Undo sends the inverse change. A task opens its detail: due date, assignee, reservation, every action (reopen too), its note and “Report a fault”. Status and note changes go through the outbox with the task’s version (If-Match): without a network they are kept on the phone, and a later change of the same task (an Undo too) follows the version the earlier one created. A change that someone else’s overtook (409, 412) or that the instance refuses becomes a card with “Show the current task” or “Dismiss”, which discards it: it is never sent again by itself.
  • Fault reports: what is wrong, a description, the priority and photos from the camera or the library, re-encoded on the phone as JPEGs of at most 1,600 pixels (quality 0.8) that leave the location behind (expo-image-picker, expo-image-manipulator). The report is a maintenance ticket (linked to the task it came from), then each photo: the report and all its photos are saved on the phone together, in one statement, before any is sent, so an app stopped at any moment keeps all of them or none; the outbox then sends them in order, the report first, now or when the instance can be reached. The ticket and each photo carry an identifier made on the phone (mtk_…, mph_…), so a resend never makes a second one. When the instance refuses the report (4xx), the person corrects it and it goes as a new report with a new identifier; a photo refused for good (too large, or otherwise) says so. A block that overbooks the room type shows the nights it overbooks. Photos need the maintenance_photos capability. A role with blocks.manage_ooo or blocks.manage_oos can also take the room out of order or out of service with the report, in the same request: from today until tomorrow at the property by default, with a reason; the instance’s refusals (overlap, an assigned stay, a past block) show next to the date they concern.
  • Offline: Today, Search, Housekeeping and the reservation and guest screens show the last copy saved on the phone at once, and keep it with “Offline · last updated …” when the instance cannot be reached; a read inside a screen (a balance, what check-out would leave owed, a guest’s stays) says so for itself, with its own time, also when the screen’s own read is fresh. Every work screen says how many changes wait to be sent (“Send now”) or need a decision (“Review”); Account lists them with Discard, a refused one with the instance’s explanation; a sign-out or an instance removal that would delete unsent changes says how many first.
  • Search: one text, sent when submitted (at least 2 characters), finds the property’s reservations (code, guest, dates, rooms, status) and the workspace’s guests (name, contact), 20 of each at most; each kind shows only with reservations.read or guests.read.
  • Reservation, opened from Today, Search or a guest: the guest and how to reach them, status, stay and nights, each room (type, unit, occupancy, stay status, check-in and check-out dates), the folio balance (with folios.read) and the notes. A guest shows their contact and stays; a stay at another property says so instead of opening.
  • Front-desk actions on a room, each sent with the reservation’s version (If-Match), online only: assign one of the free active rooms of the booked type for the whole stay, or remove the assignment (reservations.modify, and inventory.read to list them); check in after the conditions the instance checks are shown (confirmed, business date within the stay, a room for tonight, its housekeeping status), a dirty room only with stays.override_readiness and a reason; check out with what would remain owed, and when the last room leaves with money owed only with folios.approve_open_balance and a reason; undo a check-in or a check-out made on the business date. A role with the override or the approval but without the read that shows the need in advance (inventory.read, folios.read), or whose read failed, sends the action without a reason; when the instance refuses it (check_in.unit_not_ready, folio.open_balance), the reason field shows and the action is sent again with it. After each action, and after each failure but a 401, the reservation is read again; a reservation changed meanwhile (412), a request without an answer (which the instance may still have applied: the person checks before trying again) and the instance’s refusals are explained. The list of free rooms is never kept on the phone.
  • Tabs and functions follow the instance’s discovery: Today needs front_desk, Search reservations, Housekeeping housekeeping, My tasks and fault reports housekeeping_tasks, the balance folios. All four front-desk actions (assignment, check-in, check-out, undo) and the reads only they need (the rooms’ status, what check-out would leave owed) need front_desk as well as the grants, also on a reservation opened from a link; the reservation itself follows reservations. A capability that a fresh read of discovery drops disappears from an open screen.
  • Roles that cannot read or change something see why instead of an error; an action the role cannot take is disabled with the reason as text.

Every string comes from the shared English catalog (mobile namespace of packages/i18n/catalogs/en.json), read with use-intl; colours, spacing and type come from @guestcraft/tokens (light and dark). Controls have accessibility roles and labels and at least 48 × 48 points. The data layer is @guestcraft/sdk with an injected fetch and the bearer token.

Every housekeeping and maintenance write goes through one function in src/lib/housekeeping-writes.ts, which submits it to the outbox (operations in src/offline/room-status.ts and src/offline/housekeeping.ts).

Planned, not built: push notifications, passkeys inside the app, branded builds and the EAS workflow, the demo instance, store listings, check-in with identification and collection with a payment link. The front-desk actions are online only.

Path What
src/app/ Expo Router routes: index (where to go next), connect, pair, sign-in, workspace, and the (work) stack: its (tabs) today, search, housekeeping, tasks, account, with reservations/[id], guests/[id], task/[id], room/[id] and fault opened above them
src/screens/ The screens the routes render
src/screens/desk/ Search, the reservation and guest screens, room assignment, check-in, check-out and their undo, and the front-desk rules they show (model.ts)
src/lib/ Address normalization, discovery checks, the PKCE sign-in, QR pairing, keystore storage, versions, dates in a time zone, identifiers made on the device, the housekeeping writes
src/state/ The app state over the keystore, the request states every screen shows, task changes with Undo, the fault report
src/offline/ The offline database (expo-sqlite), its schema, saved reads, the outbox, its operations (room status, task changes, fault reports and photos) and its React provider
src/services/native.ts Expo’s modules behind the Services interface; tests inject fakes instead (the camera of src/screens/pair.tsx is mocked at its module)
modules/private-storage/ The app’s own Expo module (Swift, Kotlin): the directory of the offline database, outside device backups
src/ui/ Tokens, the few building blocks (screen, button, field, notice) and the catalog

Everything kept offline lives in one SQLite database (guestcraft-offline.db, schema steps counted by PRAGMA user_version in src/offline/schema.ts), and every row carries its partition: the instance’s key on the device, the person’s identifier and the workspace. Nothing is read or sent outside its partition.

  • Saved reads. useResource(session, (signal) => load, key, enabled, work.partition) saves each successful answer under key; load passes signal to the SDK request. On opening, the saved copy shows while the fresh read runs; a failure without an answer of the instance keeps it, marked offline (state.saved, shown by WorkHeader); a 403 deletes it; a 401 ends the session. A copy is kept 72 hours at most and is read only by the app version that saved it. Reads without a partition (the workspace picker) are not saved.
  • Deletion. Signing out, a 401 and removing an instance delete the account’s (or the instance’s) saved reads and changes in every workspace, nothing of other accounts. The session is forgotten first; each start deletes what accounts without a session still keep (retainAccounts), so a deletion cut short by the app stopping is finished then.
  • Generations. The session and workspace in use work in one generation of their partition (work.partition, src/offline/generations.ts); it ends when either changes, and a deletion ends the generations of what it deletes before deleting the rows. Ending one aborts its requests (the signal above) and deletes its row in live_generations; every save inserts only while its generation’s row exists, checked in the save’s own statement (INSERT … SELECT … WHERE EXISTS). So a save already running when a person signs out, gets a 401 or removes the instance inserts nothing once the deletion has finished, also when the same or another person signs in again meanwhile.
  • Backups. The file and SQLite’s -wal, -shm and -journal files beside it live in a directory that modules/private-storage gives the app: on iOS Library/Application Support/offline, marked isExcludedFromBackup (with its contents, set again and read back on every opening; Application Support is never purged by the system, so unsent changes stay); on Android the no-backup directory, which Auto Backup and device-to-device transfers leave out whatever the backup rules say (and the app also sets allowBackup: false). A phone’s backup therefore holds no saved read and no unsent change, and uninstalling the app deletes them. CI checks the JavaScript side, that Expo’s autolinking links the module on both platforms and that the generated Android manifest has allowBackup="false" (test/offline-backup.unit.test.ts). The Swift and Kotlin code is compiled, and the exclusion observed, only in a device build: no CI job builds the app natively (the iOS function fails the opening rather than answer a directory whose exclusion it cannot read back).
  • Outbox. A change is saved with a random idempotency key before its first attempt, then sent in order, one at a time, for the partition in use only, with its own API client; on a change of account or workspace the request in flight is aborted and the old entries wait for their own partition. Sent again on reconnection, return to the foreground, a new change, the partition becoming active, and “Send now”. Without an answer, 408, 429 and 5xx keep the entry; 401 ends the session; 409 and 412 hold it as a conflict, other 4xx as refused, and a held entry is never sent again by itself and stops the later entries of its subject until the person discards it.

An offline change is an operation (OutboxOperation in src/offline/outbox.ts) added to OPERATIONS in src/offline/operations.ts; the engine does not change. Its name is saved with each entry, so a released operation is never renamed or removed. A task status operation, for example, could read:

export const taskStatus: OutboxOperation<TaskStatusChange, Task> = {
name: "tasks.status",
subject: (change) => change.taskId, // later changes of the task wait behind a held one
send: (api, change, { idempotencyKey, signal }) =>
api.tasks.status({
path: { id: change.taskId },
body: { status: change.status },
headers: { "idempotency-key": idempotencyKey, "if-match": change.etag },
signal,
}),
// A later change of the same task, made on the phone, follows the version this one created.
rebase: (later, task) => ({ ...later, etag: task.etag }),
describe: (change, t) => t("tasks_outbox_status", { task: change.title, status: change.status }),
};

A screen calls useOutbox().submit(taskStatus, change) and gets sent (with the result), queued (waiting for a connection or for a decision on an earlier change), held (conflict or refused, with the problem code) or expired. send must fail with the SDK’s GuestCraftError, and must make a repeated attempt harmless: a creation can carry an identifier derived from the key, a change the key as Idempotency-Key or a precondition on the version it read, as room status does with expected_housekeeping_version, which every change of the room’s status advances.

You need Node.js 24 and pnpm from the repository root (pnpm install), and a running instance. No Expo account is needed for local development.

Terminal window
pnpm --filter @guestcraft/mobile start # expo start: scan the QR code with Expo Go, or press a/i

Expo Go returns to exp://… URIs, not dev.guestcraft.app://. For a local instance, allow that scheme and use the development build’s plain-http exception:

Terminal window
# .env of the instance, development only
GUESTCRAFT_MOBILE_APP_SCHEMES=dev.guestcraft.app,exp
GUESTCRAFT_BASE_URL=http://192.0.2.10:3000 # an address the phone reaches

A device build (npx expo run:ios, npx expo run:android) uses the dev.guestcraft.app scheme of app.json and needs Xcode or Android Studio locally.

A stored device token without the address that issued it is read as signed out: sign in again, and sign the old device out under Settings → Account → Devices on the instance (it also ends by itself after 30 days unused).

Terminal window
pnpm --filter @guestcraft/mobile test # Jest with jest-expo and React Native Testing Library
pnpm --filter @guestcraft/mobile test:coverage # with the thresholds of jest.config.js
pnpm --filter @guestcraft/mobile typecheck

The tests run the real routes against a fake instance and device (test/fakes.ts): no network, keystore or browser. React Native does not run under Vitest, so the repository’s Vitest coverage leaves apps/mobile out and this Jest run measures every file of src/ instead, at 85/80/85/85; pnpm quality and the CI quality job run it. Maestro flows on installed builds are not part of CI.

Nothing in this repository needs Expo, Apple or Google accounts. To publish the app you need:

  • an EAS account for cloud builds (free plan: 15 Android and 15 iOS builds a month), or local native builds with npx expo prebuild;
  • an Apple Developer Program membership (99 USD a year) and a Google Play developer account (25 USD once), with your own signing credentials;
  • for a branded app, your own bundle identifier, scheme (added to the instance’s GUESTCRAFT_MOBILE_APP_SCHEMES) and store listing.

Over-the-air updates are off (updates.enabled: false in app.json): the app contacts nothing but the instances it is pointed at.