diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml index 0123a7c71c..a148f11ad1 100644 --- a/.github/workflows/main-docker.yml +++ b/.github/workflows/main-docker.yml @@ -2,6 +2,7 @@ on: push: branches: - "main" + - "standalone" - "feature/update**" - "feature/server_esm**" paths-ignore: diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 0000000000..1fc19b0649 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,57 @@ +name: Mobile + +on: + push: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build_android: + name: Build Android APK + runs-on: ubuntu-latest + steps: + - name: Checkout the repository + uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "pnpm" + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Update build info + run: pnpm run chore:update-build-info + + - name: Build client-standalone (webDir for Capacitor) + run: pnpm --filter @triliumnext/mobile build + + - name: Sync Capacitor Android project + run: pnpm --filter @triliumnext/mobile exec cap sync android + + - name: Assemble debug APK + working-directory: apps/mobile/android + run: ./gradlew assembleDebug --no-daemon + + - name: Upload APK + uses: actions/upload-artifact@v7 + with: + name: trilium-mobile-debug-apk + path: apps/mobile/android/app/build/outputs/apk/debug/*.apk + retention-days: 14 diff --git a/apps/client-standalone/src/lightweight/sql_provider.ts b/apps/client-standalone/src/lightweight/sql_provider.ts index 7a3c8ff1ec..576784b5ab 100644 --- a/apps/client-standalone/src/lightweight/sql_provider.ts +++ b/apps/client-standalone/src/lightweight/sql_provider.ts @@ -301,9 +301,14 @@ export default class BrowserSqlProvider implements DatabaseProvider { * Must be called after `initWasm()` and before `loadFromSahPool()`. * This is async because it acquires OPFS file handles. * + * Unlike the legacy OPFS VFS, SAHPool does **not** require SharedArrayBuffer + * or COOP/COEP headers — it only needs OPFS itself (a Worker context with + * `navigator.storage.getDirectory`). This makes it usable in Capacitor's + * Android WebView, which doesn't support cross-origin isolation. + * * @param options.directory - OPFS directory for the pool (default: auto-derived from VFS name) * @param options.initialCapacity - Minimum number of file slots (default: 6) - * @throws Error if the environment doesn't support SAHPool (no OPFS, no Worker, no COOP/COEP) + * @throws Error if the environment doesn't support OPFS (no Worker, or no OPFS API) */ async installSahPool(options: { directory?: string; initialCapacity?: number } = {}): Promise { this.ensureSqlite3(); @@ -508,11 +513,11 @@ export default class BrowserSqlProvider implements DatabaseProvider { loadFromFile(_path: string, _isReadOnly: boolean): void { // Browser environment doesn't have direct file system access. - // Use OPFS for persistent storage. + // Use SAHPool or OPFS for persistent storage. throw new Error( "loadFromFile is not supported in browser environment. " + "Use loadFromMemory() for temporary databases, loadFromBuffer() to load from data, " + - "or loadFromOpfs() for persistent storage." + "loadFromSahPool() (preferred) or loadFromOpfs() for persistent storage." ); } @@ -728,7 +733,10 @@ export default class BrowserSqlProvider implements DatabaseProvider { private ensureDb(): void { this.ensureSqlite3(); if (!this.db) { - throw new Error("Database not opened. Call loadFromMemory(), loadFromBuffer(), or loadFromOpfs() first."); + throw new Error( + "Database not opened. Call loadFromMemory(), loadFromBuffer(), " + + "loadFromSahPool(), or loadFromOpfs() first." + ); } } } diff --git a/apps/client-standalone/src/local-server-worker.ts b/apps/client-standalone/src/local-server-worker.ts index 9245d6276d..16fd81e9a9 100644 --- a/apps/client-standalone/src/local-server-worker.ts +++ b/apps/client-standalone/src/local-server-worker.ts @@ -342,13 +342,16 @@ async function initialize(): Promise { console.log("[Worker] SAHPool available, loading persistent database (WAL mode)..."); sqlProvider!.loadFromSahPool(dbName); } else if (sqlProvider!.isOpfsAvailable()) { - // Fall back to legacy OPFS VFS (no WAL, slower writes) - console.warn("[Worker] Using legacy OPFS VFS (no WAL mode). Consider enabling COOP/COEP headers for SAHPool."); + // Fall back to legacy OPFS VFS (no WAL, slower writes). + // This only kicks in if SAHPool installation failed for some + // reason but SharedArrayBuffer + legacy OPFS are both available. + console.warn("[Worker] SAHPool unavailable; using legacy OPFS VFS (no WAL mode)."); sqlProvider!.loadFromOpfs(dbName); } else { - // Fall back to in-memory database (non-persistent) + // Fall back to in-memory database (non-persistent). + // SAHPool only needs a Worker + OPFS API, so reaching this + // branch means the environment lacks OPFS entirely. console.warn("[Worker] OPFS not available, using in-memory database (data will not persist)"); - console.warn("[Worker] To enable persistence, ensure COOP/COEP headers are set by the server"); sqlProvider!.loadFromMemory(); } diff --git a/apps/client/src/layouts/mobile_layout.tsx b/apps/client/src/layouts/mobile_layout.tsx index 5412e6e708..edb244b291 100644 --- a/apps/client/src/layouts/mobile_layout.tsx +++ b/apps/client/src/layouts/mobile_layout.tsx @@ -24,6 +24,7 @@ import NoteTreeWidget from "../widgets/note_tree.js"; import NoteWrapperWidget from "../widgets/note_wrapper.js"; import NoteDetail from "../widgets/NoteDetail.jsx"; import QuickSearchWidget from "../widgets/quick_search.js"; +import { isMobileApp } from "../services/utils"; import ScrollPadding from "../widgets/scroll_padding"; import SearchResult from "../widgets/search_result.jsx"; import MobileEditorToolbar from "../widgets/type_widgets/text/mobile_editor_toolbar.jsx"; @@ -65,7 +66,8 @@ export default class MobileLayout { .child() .child() .child() - .optChild(glob.isStandalone, ) + .optChild(isMobileApp(), ) + .optChild(glob.isStandalone && !isMobileApp(), ) .child() ) .child( diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 8360950c87..485b517e47 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -149,6 +149,14 @@ export function isPWA() { ); } +/** + * Returns `true` when running inside the native Capacitor mobile app wrapper. + * PWAs and regular browsers return `false`. + */ +export function isMobileApp() { + return !!window.Capacitor?.isNativePlatform?.(); +} + export function isMac() { return navigator.platform.indexOf("Mac") > -1; } diff --git a/apps/client/src/setup.css b/apps/client/src/setup.css index 13debde7fe..1e7bf9bab1 100644 --- a/apps/client/src/setup.css +++ b/apps/client/src/setup.css @@ -52,6 +52,10 @@ body.setup { justify-content: center; gap: 1rem; + body.desktop & { + gap: 0.75rem; + } + .setup-option-card { padding: 1.5em; cursor: pointer; @@ -78,8 +82,12 @@ body.setup { } h3 { - font-size: 1.5em; + font-size: 1.15em; font-weight: normal; + + body.desktop & { + font-size: 1.5em; + } } p:last-of-type { @@ -94,15 +102,23 @@ body.setup { display: flex; flex-direction: column; height: 100%; - padding: 2em; + padding-top: calc(2em + env(safe-area-inset-top)); + padding-bottom: calc(2em + env(safe-area-inset-bottom)); + padding-left: calc(2em + env(safe-area-inset-left)); + padding-right: calc(2em + env(safe-area-inset-right)); overflow: auto; >.back-button { position: absolute; - top: 2em; - inset-inline-start: 2em; + top: calc(1em + env(safe-area-inset-top)); + inset-inline-start: 1em; color: var(--muted-text-color); + body.desktop & { + inset-inline-start: 2em; + top: 2em; + } + .tn-icon { margin-inline-end: 0.4em; } @@ -116,8 +132,11 @@ body.setup { flex: 1; display: flex; flex-direction: column; - padding-top: 1em; - min-height: 0; + padding-top: 2em; + + body.desktop & { + padding-top: 1em; + } } &.contentless { @@ -126,13 +145,20 @@ body.setup { } >footer { + background: var(--main-background-color); + position: sticky; + bottom: -2rem; + left: 0; + right: 0; display: flex; justify-content: flex-end; gap: 0.5rem; border-top: 1px solid var(--main-border-color); padding-top: 1rem; - margin-inline: -2em; - padding-inline: 2em; + padding-bottom: 2rem; + margin-inline: -2rem; + margin-bottom: -2rem; + padding-inline: 2rem; } >.page-error { @@ -158,9 +184,13 @@ body.setup { display: flex; flex-direction: column; gap: 1rem; - width: 80%; + width: 100%; margin-inline: auto; + body.desktop & { + width: 80%; + } + .form-group { margin-bottom: 0; } @@ -187,6 +217,11 @@ body.setup { justify-content: center; margin-top: 1.5em; margin-bottom: 1.5rem; + padding-block: 2em; + + body.desktop & { + padding-block: 1em; + } .tn-icon { font-size: 3em; @@ -223,13 +258,27 @@ body.setup { text-align: center; color: var(--muted-text-color); opacity: 0.6; - margin-block: 1rem; } .illustration-logo { - width: 96px; - height: 96px; + --size: 128px; + width: var(--size); + height: var(--size); margin: auto; + + body.desktop & { + --size: 96px; + } + } + + .illustration-icon, + .illustration-logo { + margin-top: 3rem; + margin-bottom: 1rem; + + body.desktop & { + margin-top: 1rem; + } } h1 { @@ -328,21 +377,30 @@ body[dir=rtl] .slide-in-backward { } .page.select-language { - .dropdownWrapper { - padding-bottom: 2em; - width: 80%; - margin: auto; + main { + min-height: 0; } - .dropdownWrapper, - .dropdown, - .dropdown-menu { + .tn-card { + width: 100%; + margin: 0 auto 2em; + height: 100%; + box-sizing: border-box; + min-height: 0; + overflow: hidden; + + body.desktop & { + width: 80%; + } + } + + .tn-card-body { height: 100%; } - .dropdown-menu { - box-sizing: border-box; + .tn-card-section { overflow: auto; + padding: 0.5em 0; } } diff --git a/apps/client/src/setup.tsx b/apps/client/src/setup.tsx index 62c34908c9..cdefabc1a5 100644 --- a/apps/client/src/setup.tsx +++ b/apps/client/src/setup.tsx @@ -15,7 +15,7 @@ import Admonition from "./widgets/react/Admonition"; import Button from "./widgets/react/Button"; import { Card, CardFrame, CardSection } from "./widgets/react/Card"; import FormGroup from "./widgets/react/FormGroup"; -import FormList, { FormListItem } from "./widgets/react/FormList"; +import { FormListItem } from "./widgets/react/FormList"; import FormTextBox from "./widgets/react/FormTextBox"; import Icon from "./widgets/react/Icon"; @@ -24,7 +24,7 @@ async function main() { const bodyWrapper = document.createElement("div"); bodyWrapper.classList.add("setup-outer-wrapper"); - document.body.classList.add("setup"); + document.body.classList.add("setup", window.glob.device || "desktop"); if (isElectron()) { document.body.classList.add("electron", `platform-${window.process.platform}`, "background-effects"); } @@ -101,16 +101,24 @@ function SelectLanguage({ setState }: { setState: (state: State) => void }) { illustration={} footer={