desktop M10.5: wire the offline local source; boot to board with no server
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 57s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 57s
adapters/local.ts implements the repository seam over the M10.4 Tauri commands via invoke (camelCase args -> the commands' snake_case params); adapters/index.ts now selects local when running in the desktop shell, rest on web. bridge.ts exports invoke for it. This is the commit that resolves the black screen: a fresh desktop launch reads config + session + notes from the on-device SQLite core, so the router's auth gate passes with a synthetic local user and the board renders with zero server and zero account. Account auth, device linking, attachment upload, URL unfurl and file import reject with a "connect a server" message (no offline meaning yet); everything else — board, editor, capture, search, filters, labels, checklists, reminders — works fully offline. desktop.yml also now rebuilds the app on frontend adapter/bridge changes, since the desktop bundle embeds the frontend. Task 1994. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -13,6 +13,10 @@ on:
|
|||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
paths:
|
paths:
|
||||||
- "desktop/**"
|
- "desktop/**"
|
||||||
|
# The desktop app embeds the frontend, and the data seam / Tauri bridge are
|
||||||
|
# what the offline core rides on — rebuild the app when those change too.
|
||||||
|
- "frontend/src/adapters/**"
|
||||||
|
- "frontend/src/desktop/**"
|
||||||
- ".forgejo/workflows/desktop.yml"
|
- ".forgejo/workflows/desktop.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
// The data source the whole app uses. Import `repo` from here — never reach for
|
// The data source the whole app uses. Import `repo` from here — never reach for
|
||||||
// `api/client` in stores/views.
|
// `api/client` in stores/views.
|
||||||
//
|
//
|
||||||
// Today it always resolves to the REST source. M10.5 adds the offline desktop
|
// The desktop shell runs fully offline against the on-device SQLite core
|
||||||
// source (`local.ts`, over Tauri `invoke`) and flips this to a runtime choice:
|
// (`local.ts`, over Tauri `invoke`); the web build uses REST. A server-connected
|
||||||
//
|
// desktop (opt-in sync) comes in M10.7 — at which point this becomes
|
||||||
// import { isDesktop } from "../desktop/bridge";
|
// `isDesktop() && !hasConfiguredServer() ? local : rest`.
|
||||||
// import { local } from "./local";
|
|
||||||
// export const repo: Repo = isDesktop() && !hasConfiguredServer() ? local : rest;
|
|
||||||
//
|
|
||||||
// so a fresh desktop launch runs fully offline, and a server-connected one keeps
|
|
||||||
// using REST. Web is unaffected.
|
|
||||||
|
|
||||||
|
import { isDesktop } from "../desktop/bridge";
|
||||||
import { rest } from "./rest";
|
import { rest } from "./rest";
|
||||||
|
import { local } from "./local";
|
||||||
import type { Repo } from "./repo";
|
import type { Repo } from "./repo";
|
||||||
|
|
||||||
export const repo: Repo = rest;
|
export const repo: Repo = isDesktop() ? local : rest;
|
||||||
|
|
||||||
export type { Repo } from "./repo";
|
export type { Repo } from "./repo";
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Offline desktop implementation of the repository seam: maps each operation to a
|
||||||
|
// Tauri command backed by the on-device SQLite store (desktop/src-tauri/src/local).
|
||||||
|
// Selected by index.ts when running inside the desktop shell with no server.
|
||||||
|
//
|
||||||
|
// Argument keys are camelCase; Tauri converts them to the Rust commands' snake_case
|
||||||
|
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
|
||||||
|
// yet (account auth, device linking, attachment upload, URL unfurl, file import) —
|
||||||
|
// those reject with a clear message rather than silently failing; the board, editor,
|
||||||
|
// capture, search, filters, labels, checklists and reminders all work fully offline.
|
||||||
|
|
||||||
|
import { invoke } from "../desktop/bridge";
|
||||||
|
import type { Note, NoteRevision } from "../stores/notes";
|
||||||
|
import type { Label } from "../stores/labels";
|
||||||
|
import type { SavedFilter } from "../stores/savedFilters";
|
||||||
|
import type { Device } from "../stores/devices";
|
||||||
|
import type { TitleEntry } from "../stores/titles";
|
||||||
|
import type { User } from "../stores/session";
|
||||||
|
import type { PublicConfig } from "../stores/config";
|
||||||
|
import type { Backlink, DeviceToken, ImportResult, Repo } from "./repo";
|
||||||
|
|
||||||
|
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
|
||||||
|
|
||||||
|
export const local: Repo = {
|
||||||
|
config: {
|
||||||
|
get: () => invoke<PublicConfig>("config_get"),
|
||||||
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
me: () => invoke<User>("auth_me"),
|
||||||
|
login: () => Promise.reject(new Error("You're offline — there's no account to sign in to.")),
|
||||||
|
register: () => Promise.reject(new Error("You're offline — accounts are created on a server.")),
|
||||||
|
logout: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
|
||||||
|
devices: {
|
||||||
|
list: () => Promise.resolve<Device[]>([]),
|
||||||
|
create: () => Promise.reject<DeviceToken>(new Error(NEEDS_SERVER)),
|
||||||
|
remove: () => Promise.resolve(),
|
||||||
|
},
|
||||||
|
|
||||||
|
labels: {
|
||||||
|
list: () => invoke<Label[]>("labels_list"),
|
||||||
|
create: (name) => invoke<Label>("labels_create", { name }),
|
||||||
|
rename: (id, name) => invoke<Label>("labels_rename", { id, name }),
|
||||||
|
setColor: (id, color) => invoke<Label>("labels_set_color", { id, color }),
|
||||||
|
remove: (id) => invoke<void>("labels_remove", { id }),
|
||||||
|
merge: (sourceId, into) => invoke<Label>("labels_merge", { sourceId, into }),
|
||||||
|
},
|
||||||
|
|
||||||
|
notes: {
|
||||||
|
list: (query) => invoke<Note[]>("notes_list", { query }),
|
||||||
|
get: (id) => invoke<Note>("notes_get", { id }),
|
||||||
|
create: (input) => invoke<Note>("notes_create", { input }),
|
||||||
|
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
|
||||||
|
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
|
||||||
|
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
||||||
|
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
||||||
|
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
|
||||||
|
addItem: (id, text) => invoke<Note>("notes_add_item", { id, text }),
|
||||||
|
updateItem: (id, itemId, changes) => invoke<Note>("notes_update_item", { id, itemId, changes }),
|
||||||
|
deleteItem: (id, itemId) => invoke<Note>("notes_delete_item", { id, itemId }),
|
||||||
|
uploadAttachment: () => Promise.reject<Note>(new Error(NEEDS_SERVER)),
|
||||||
|
deleteAttachment: (id, attId) => invoke<Note>("notes_delete_attachment", { id, attId }),
|
||||||
|
unfurl: () => Promise.reject<Note>(new Error(NEEDS_SERVER)),
|
||||||
|
deletePreview: (id, previewId) => invoke<Note>("notes_delete_preview", { id, previewId }),
|
||||||
|
import: () => Promise.reject<ImportResult>(new Error(NEEDS_SERVER)),
|
||||||
|
reorder: (orderedIds) => invoke<void>("notes_reorder", { orderedIds }),
|
||||||
|
trash: (id) => invoke<Note>("notes_trash", { id }),
|
||||||
|
restore: (id) => invoke<Note>("notes_restore", { id }),
|
||||||
|
deleteForever: (id) => invoke<void>("notes_delete_forever", { id }),
|
||||||
|
revisions: (id) => invoke<NoteRevision[]>("notes_revisions", { id }),
|
||||||
|
restoreRevision: (id, revId) => invoke<Note>("notes_restore_revision", { id, revId }),
|
||||||
|
reminders: () => invoke<Note[]>("notes_reminders"),
|
||||||
|
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
||||||
|
search: (q) => invoke<Note[]>("notes_search", { q }),
|
||||||
|
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
|
||||||
|
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
|
||||||
|
},
|
||||||
|
|
||||||
|
savedFilters: {
|
||||||
|
list: () => invoke<SavedFilter[]>("saved_filters_list"),
|
||||||
|
create: (name, params) => invoke<SavedFilter>("saved_filters_create", { name, params }),
|
||||||
|
remove: (id) => invoke<void>("saved_filters_remove", { id }),
|
||||||
|
rename: (id, name) => invoke<SavedFilter>("saved_filters_rename", { id, name }),
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ export function isDesktop(): boolean {
|
|||||||
return typeof window !== "undefined" && !!window.__TAURI__;
|
return typeof window !== "undefined" && !!window.__TAURI__;
|
||||||
}
|
}
|
||||||
|
|
||||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
export function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
const tauri = window.__TAURI__;
|
const tauri = window.__TAURI__;
|
||||||
if (!tauri) return Promise.reject(new Error("Not running in the desktop app."));
|
if (!tauri) return Promise.reject(new Error("Not running in the desktop app."));
|
||||||
return tauri.core.invoke<T>(cmd, args);
|
return tauri.core.invoke<T>(cmd, args);
|
||||||
|
|||||||
Reference in New Issue
Block a user