diff --git a/.env.example b/.env.example index 400b54d..f32aa8f 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# ThoughtSync production settings. Copy to `.env` and edit: +# Inkwell production settings. Copy to `.env` and edit: # # cp .env.example .env # @@ -9,56 +9,60 @@ # --- required --------------------------------------------------------------- -# Generate one and keep it: changing it later means also changing it inside the -# database, or Postgres will reject the app's connection. +# Generate one and keep it: changing it later means changing it inside the database +# too. Letters and digits only — it goes into a URL, where `@ / : #` break it. # # openssl rand -base64 24 | tr -d '/+=' | head -c 32 -# -# Stick to letters and digits. This value goes into a connection URL, so a `@`, -# `/`, `:` or `#` in it will be misparsed as URL structure rather than password. POSTGRES_PASSWORD= # --- optional --------------------------------------------------------------- -# Which build to run. -# -# latest tracks the `main` branch — the production line (default) -# dev tracks the `dev` branch — newer, less settled -# pins one exact build; every push publishes one, and this is -# the rollback lever when an upgrade misbehaves -# -# NOTE: `main` can sit well behind `dev`. If a feature you expect is missing, -# check which branch it actually landed on before assuming a bug. -#THOUGHTSYNC_TAG=latest +# Which build: `latest` (the `main` branch, default), `dev` (newer, less settled), +# or a commit sha on `main` to pin one exact build — the rollback lever. +#INKWELL_TAG=latest # The host port the app is published on. -#THOUGHTSYNC_PORT=5000 +#INKWELL_PORT=5000 -# Which interface to bind. The default (all interfaces) is what lets desktop -# clients on your network reach the server. Behind a reverse proxy, set this to -# 127.0.0.1 so only the proxy can talk to it. -#THOUGHTSYNC_BIND=0.0.0.0 +# Which interface to bind. Behind a reverse proxy, set 127.0.0.1 so only the proxy +# can reach the app. +#INKWELL_BIND=0.0.0.0 -# NOTE: how many proxies sit in front of this app is a SETTING, not an env var — -# Settings → Security → "Trusted proxy hops" in the admin UI. It defaults to 1 (one -# reverse proxy terminating HTTPS) and belongs there because it is something you may -# need to change while the server is running, alongside the sign-in limits. +# How many proxies sit in front is a setting, not an env var: Settings → Security → +# "Trusted proxy hops" (default 1). -# How much the app says. Credential events (sign-ins, failures, throttles, new -# accounts, device tokens issued) are logged at INFO and read with -# `docker compose logs app`. -#THOUGHTSYNC_LOG_LEVEL=INFO +# How much the app logs. Credential events (sign-ins, failures, throttles, device +# tokens issued) log at INFO; read them with `docker compose logs app`. +#INKWELL_LOG_LEVEL=INFO # Database identity. Changing these AFTER the first start does not rename anything # that already exists — the volume keeps whatever the first run created. +#POSTGRES_USER=inkwell +#POSTGRES_DB=inkwell + +# --- Upgrading from ThoughtSync --------------------------------------------- +# +# Inkwell was called ThoughtSync, and a deployment installed under that name has +# its data in volumes and a database named for it. Point at them instead of +# renaming anything. Leaving these unset on such a deployment starts Inkwell +# against EMPTY volumes; the old data is untouched, but you would not see it. +# +# Use the full names `docker volume ls` prints — compose prefixes them with the +# project name, so they usually look like `thoughtsync_thoughtsync-db`: +#INKWELL_DB_VOLUME=thoughtsync_thoughtsync-db +#INKWELL_DATA_VOLUME=thoughtsync_thoughtsync-data +# +# And the database identity the first start created, which a volume keeps: #POSTGRES_USER=thoughtsync #POSTGRES_DB=thoughtsync +# +# Rename any THOUGHTSYNC_* lines already in your .env to INKWELL_*; the old names +# aren't read. # --- a note on HTTPS -------------------------------------------------------- # -# The app marks its session cookie Secure automatically when a request arrives over -# HTTPS, directly or via a proxy setting X-Forwarded-Proto — no setting needed. +# The session cookie is marked Secure automatically when a request arrives over +# HTTPS, directly or via a proxy setting X-Forwarded-Proto. # -# Worth knowing if you use the desktop app: typing a bare hostname there defaults to -# https://, deliberately, so a device token never crosses the wire in cleartext by -# accident. Serving over plain HTTP means typing the `http://` yourself. +# The desktop and Android apps read a bare hostname as https://, so a server on +# plain HTTP has to be typed with its `http://`. diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index eb64b38..e055e56 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -1,11 +1,7 @@ name: Android -# The native Kotlin/Compose client over the shared Rust core (M12). -# -# Replaces the Tauri-mobile lane deleted in step 2. What changed is what this -# builds, not that Android has a lane: the UI is Compose, and the store and sync -# engine are `thoughtsync-core` cross-compiled by cargo-ndk and loaded through -# uniffi. +# The native Kotlin/Compose client over the shared Rust core. The store and sync +# engine are `inkwell-core` cross-compiled by cargo-ndk and loaded through uniffi. # # CI can only prove this BUILDS. A Linux runner cannot execute an APK, so anything # about feel, touch or on-device correctness is an operator pass on an emulator or @@ -15,12 +11,12 @@ name: Android # unsigned debug one when it is not. That distinction is not cosmetic: two builds # signed with different keys cannot replace one another, and bridging that gap # means uninstalling first — which deletes the app's database and every local note -# with it (Scribe issue 2803). +# with it. on: push: # NO `paths:` FILTER — the `decide` job below reads the real file set instead. - # See desktop.yml for why, and 85ead4d for what the duplication cost. + # See desktop.yml for why. branches: [dev, main] workflow_dispatch: @@ -32,17 +28,17 @@ env: # Silences the JDK 22+ "restricted method in java.lang.System has been called" # warning that Gradle 9.1's bundled native-platform jar trips at launch. This # targets the LAUNCHER JVM, which is why org.gradle.jvmargs in - # gradle.properties is not enough on its own (Minstrel hit the same thing). + # gradle.properties is not enough on its own. JAVA_TOOL_OPTIONS: "--enable-native-access=ALL-UNNAMED" jobs: # Does the APK need rebuilding, or is the channel already serving this source? - # See the equivalent job in desktop.yml — same reasoning, same replacement of a - # hand-kept `paths:` filter with the one file set in `packaging/version.sh`. + # See the equivalent job in desktop.yml — same reasoning, the same one file set in + # `packaging/version.sh`. # - # The guard runs here so it covers the skip path too (§6.3). + # The guard runs here so it covers the skip path too. # - # NOTE THE COUPLING WITH ci.yml: when this lane builds, its last step dispatches + # NOTE THE COUPLING WITH ci.yml: when this lane builds, its server-image job dispatches # ci.yml so the image bakes in the APK just published. When it SKIPS, no dispatch # happens — and that is correct, because ci.yml's `gate` stands down only when the # push touched Android's files, which is the same condition that makes this build. @@ -72,22 +68,44 @@ jobs: sh packaging/guard-forward.sh android "$channel" echo "build=$(sh packaging/should-build.sh android "$channel")" >> $GITHUB_OUTPUT - build: - name: Kotlin + Rust (APK) + # The core ships inside the APK (through android/ffi), so its checks have to gate + # the APK HERE, in this workflow's graph. desktop.yml runs them too, but a red run + # there cannot stop this lane publishing. + # + # On ci-tauri, not ci-rust-android: these are host-target tests, and on Linux the + # core's native-tls is OpenSSL, whose headers only ci-tauri carries. The Android + # image has OpenSSL vendored for the Android targets alone, so `openssl-sys` fails + # there. Same Rust pin, same Cargo.lock. + rust: + name: Core and FFI clippy and tests needs: [decide] if: needs.decide.outputs.build == 'true' - # runs-on is only a scheduling label (Label Model B). flutter-ci is the + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 + steps: + - uses: actions/checkout@v6 + + - name: Clippy + run: cargo clippy --locked -p inkwell-core -p inkwell-ffi --all-targets -- -D warnings + + - name: Test + run: cargo test --locked -p inkwell-core -p inkwell-ffi + + build: + name: Kotlin + Rust (APK) + needs: [decide, rust] + if: needs.decide.outputs.build == 'true' + # runs-on is only a scheduling label. flutter-ci is the # proven-working label that can pull our container images. runs-on: flutter-ci container: - # The image repurposed from ci-tauri-android in M12 step 3: Rust + the four + # Rust + the four # Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt. image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97 permissions: contents: write - # For the dispatch at the end: this lane starts the server image build. - actions: write defaults: run: @@ -97,7 +115,7 @@ jobs: - uses: actions/checkout@v4 with: # Derives a version, so it needs the whole history — see the note in - # desktop.yml. Depth-1 is silently wrong here, not loudly broken (§6.1). + # desktop.yml. Depth-1 is silently wrong here, not loudly broken. fetch-depth: 0 - name: Cache Gradle and Cargo @@ -121,7 +139,7 @@ jobs: env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | - # TWO CLOCKS, ON PURPOSE (note 3127 §2). The NAME answers "is this the + # TWO CLOCKS, ON PURPOSE. The NAME answers "is this the # same code?", so it comes from the COMMIT and a dev build and the main # build of one commit read identically. The CODE answers "may this be # installed over that?" and must be monotonic BY CONSTRUCTION, because @@ -134,16 +152,14 @@ jobs: echo "code=$code" >> $GITHUB_OUTPUT if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then - printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/thoughtsync-release.jks + printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/inkwell-release.jks echo "variant=Release" >> $GITHUB_OUTPUT echo "label=release" >> $GITHUB_OUTPUT - # DEBUG profile, in a release APK, deliberately — see the note above - # the cargoNdk task. The release profile strips the symbols uniffi - # reads its metadata out of, so `generateUniffiBindings` fails - # outright (run 4077). Unpicking that is worth doing and is not worth - # blocking signed builds on. - echo "profile=debug" >> $GITHUB_OUTPUT - echo "keystore=/tmp/thoughtsync-release.jks" >> $GITHUB_OUTPUT + # An optimised .so for the APK people install. The cargoNdk task keeps + # the symbols uniffi reads its metadata from, which the release + # profile would strip. + echo "profile=release" >> $GITHUB_OUTPUT + echo "keystore=/tmp/inkwell-release.jks" >> $GITHUB_OUTPUT echo "apk=android/app/build/outputs/apk/release/app-release.apk" >> $GITHUB_OUTPUT echo "Signed release build — $version (versionCode $code)" else @@ -167,10 +183,10 @@ jobs: # from the built .so. Run as its own step so a Rust failure is legible as a # Rust failure instead of arriving inside a Gradle stack trace. - name: Build the native library and bindings - run: ./gradlew generateUniffiBindings -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} + run: ./gradlew generateUniffiBindings -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} # The image's PINNED CLIs, not Gradle plugins. ci-rust-android carries both - # (M12 step 3) precisely so this lane needs no second image, and going + # precisely so this lane needs no second image, and going # through Gradle plugins would mean a second version of each tool resolved # at build time and kept in lockstep with the image's by hand. # @@ -185,15 +201,15 @@ jobs: - name: Unit tests # Host-JVM tests only. Anything touching the core needs an Android # runtime to load the .so, so those are instrumented tests and belong on - # an emulator, not here — the Rust side is covered by the workspace - # tests in the desktop lane. + # an emulator, not here — the Rust side is the `rust` job, which this + # one needs. # # DEBUG regardless of what is being packaged: AGP creates unit-test tasks # only for `testBuildType`, which is debug, so `testReleaseUnitTest` does - # not exist (run 4082). It costs one extra Kotlin compile and buys the + # not exist. It costs one extra Kotlin compile and buys the # type-check on the debug variant, which is the one an emulator build # would use. - run: ./gradlew testDebugUnitTest -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} + run: ./gradlew testDebugUnitTest -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} - name: Assemble the APK env: @@ -203,20 +219,39 @@ jobs: ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} run: | ./gradlew assemble${{ steps.build.outputs.variant }} \ - -PTHOUGHTSYNC_CARGO_PROFILE=${{ steps.build.outputs.profile }} \ - -PTHOUGHTSYNC_VERSION_NAME=${{ steps.build.outputs.name }} \ - -PTHOUGHTSYNC_VERSION_CODE=${{ steps.build.outputs.code }} + -PINKWELL_CARGO_PROFILE=${{ steps.build.outputs.profile }} \ + -PINKWELL_VERSION_NAME=${{ steps.build.outputs.name }} \ + -PINKWELL_VERSION_CODE=${{ steps.build.outputs.code }} # Prints the certificate the APK was actually signed with, so the operator # can compare it against the fingerprint recorded when the key was # generated. Signing with the WRONG key produces a perfectly valid APK that # simply refuses to install over the app already on the phone — a failure # that otherwise only shows up on the device, after the run is green. - - name: Show the signing certificate + # + # And FAILS unless there is exactly one signer and it is the release key. + # Printing alone is not a check: a build signed with any other key would go on + # to be staged and published, and every phone would refuse it. The steps that + # publish come after this one in the same job, so a failure here stops them. + - name: Check the signing certificate if: steps.build.outputs.keystore != '' + env: + # The release certificate's SHA-256, as apksigner prints it for a signed + # APK (CN=Bryan Van Deusen, O=fabledsword). Every + # installed copy carries this certificate. It only changes if the key + # does, and then every install has to be replaced by hand anyway. + RELEASE_CERT_SHA256: 408a5835ea1627f329d9fba4f00712b096faf2d30624ec6f3a8b2b5f8bb15caa run: | apksigner="$(ls /opt/android-sdk/build-tools/*/apksigner | head -1)" - "$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk" + certs="$("$apksigner" verify --print-certs "app/build/outputs/apk/release/app-release.apk")" + printf '%s\n' "$certs" + signers="$(printf '%s\n' "$certs" | grep -c '^Signer #[0-9]* certificate SHA-256 digest:' || true)" + digest="$(printf '%s\n' "$certs" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')" + if [ "$signers" != 1 ] || [ "$digest" != "$RELEASE_CERT_SHA256" ]; then + echo "::error::The APK is not signed by the release key alone ($signers signer(s), first $digest; expected $RELEASE_CERT_SHA256)." + exit 1 + fi + echo "Signed by the release key." # Staged with a STABLE name plus the sidecar the server reads its version # out of — an APK keeps that in a binary manifest Python cannot parse, and @@ -226,10 +261,10 @@ jobs: if: steps.build.outputs.keystore != '' run: | mkdir -p dist - cp "app/build/outputs/apk/release/app-release.apk" dist/thoughtsync.apk - size="$(wc -c < dist/thoughtsync.apk | tr -d ' ')" - sha="$(sha256sum dist/thoughtsync.apk | cut -d' ' -f1)" - cat > dist/thoughtsync-android.json < dist/inkwell-android.json <` holding a client that + # isn't from that commit. The previous `:dev` stays where it is; fix forward. # - # Not `if: success()` and not skipped on tags either: every ref that builds an - # image needs the call, or nothing builds one at all. + # Not skipped on tags: every ref that builds an image needs the call, since + # ci.yml stands down on these pushes and nothing else builds one. - name: Build the server image now the client is published - if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') - working-directory: . env: GITHUB_TOKEN: ${{ github.token }} run: | diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index a842cb7..e3825bc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,22 +3,21 @@ # Push to dev: typecheck + lint + test + build :dev # Push to main: typecheck + lint + test + build :latest + : # -# THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane: -# nothing pins one — verified by looking for a consumer, not for whether one is -# imaginable — and the git release tag is a different object in a different system -# (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date. +# THAT IS THE COMPLETE TAG SET. No version-shaped image tag in any lane: nothing +# pins one, and the git release tag is a different object in a different system. +# The image is addressed by CHANNEL or by COMMIT; the release by date. # # A `v*` tag builds nothing at all. The merge to main already published everything, -# so a tag rebuilding that same source would re-push : with different bytes, -# which rule 145 forbids even when they match. +# and a tag rebuilding that same source would re-push :, which a published tag +# never allows. # -# main is the production line, so a merge moves :latest to its tip (family rule 46) -# — no version release required. : is the immutable rollback unit, and it is -# on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled, -# accumulating forever, for a channel whose entire contract is that it moves. +# main is the production line, so a merge moves :latest to its tip — no version +# release required. : is the immutable rollback unit, and it is on main ONLY: a +# sha tag per dev push is a rollback target nobody pulls, accumulating forever, for +# a channel whose entire contract is that it moves. # # Required secret (repo -> Settings -> Secrets -> Actions): -# REGISTRY_TOKEN -- Forgejo PAT with write:packages scope +# REGISTRY_TOKEN -- forge PAT with write:packages scope # The registry username is derived from github.repository_owner (public — it's in # the image path), so no REGISTRY_USER secret is needed. name: CI & Build @@ -28,13 +27,12 @@ on: # NO `paths:` FILTER, and unlike the client lanes this one does not skip either — # the image ALWAYS builds. Two reasons: # - # * Rule 145 promises that every push to `main` publishes a `:`, so any - # production commit is addressable. A path filter quietly broke that promise - # for a docs-only merge: no trigger, no image, no sha tag for that commit. + # * Every push to `main` publishes a `:`, so any production commit is + # addressable. A path filter would break that for a docs-only merge: no + # trigger, no image, no sha tag for that commit. # * It is the artifact most exposed to base-image staleness (`python:3.12-slim` # is a floating tag and this can face the internet), and building every push - # picks those updates up. That is why note 3127 §4's base tension does not - # bite here — the one artifact it would apply to never skips. + # picks those updates up. # # Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes # for the clients, which is why THEY skip and this does not. @@ -54,7 +52,7 @@ permissions: env: REGISTRY: git.fabledsword.com - IMAGE: git.fabledsword.com/bvandeusen/thoughtsync + IMAGE: git.fabledsword.com/bvandeusen/inkwell jobs: # Should this push build an image now, or is the Android lane about to publish a @@ -62,17 +60,17 @@ jobs: # # A push touching the Android app runs BOTH workflows at once. Building here # would bake in the PREVIOUS client and then, when the new one landed, there - # would be no second build — `:` is the immutable rollback unit (rule 46) + # would be no second build — `:` is the immutable rollback unit # and rebuilding it with different content would make it neither. # # So on such a push this workflow stands down, and the Android lane dispatches it # when it is finished. Exactly one image per commit, containing the client from # that commit. # - # The path list below MUST match android.yml's trigger. Two places holding one - # decision is the recurring failure in this repo (issues 2181-2183); it is here - # because a workflow cannot read another's filters, and it is a `git diff` rather - # than a config so at least it is inspectable in the log. + # The path list below MUST match android's file set in packaging/version.sh. Two + # places holding one decision drift apart; it is here because this gate needs the + # answer before the Android lane has decided, and it is a `git diff` rather than a + # config so at least it is inspectable in the log. gate: name: Build now, or wait for Android? if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' @@ -125,11 +123,10 @@ jobs: echo "Changed in this push:" echo "$changed" | sed 's/^/ /' - # MUST match android's file set in packaging/version.sh. `packaging/` was - # missing here after step 4 added it there — so a packaging-only push had - # the Android lane rebuild and dispatch while this gate ALSO let the image - # build, producing two images for one commit and, on main, a second push of - # the same : with different bytes. Rule 145's exact prohibition. + # MUST match android's file set in packaging/version.sh. A path missing here + # lets a push touching only it rebuild and dispatch from the Android lane + # while this gate ALSO lets the image build: two images for one commit and, + # on main, a second push of the same : with different bytes. if echo "$changed" | grep -qE '^(android/|core/|packaging/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then echo "" echo "This push also changes the Android client. Standing down: the" @@ -143,8 +140,12 @@ jobs: echo "build=true" >> $GITHUB_OUTPUT fi + # The web's whole verification: the type check, then its unit tests. Both in the + # job `build` already needs, so a red web test blocks the image like any other + # lane. Among other things they guard the web copy of the note grammar, which is + # mirrored in Rust and Python. typecheck: - name: TypeScript typecheck + name: Web typecheck and unit tests if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: @@ -156,8 +157,14 @@ jobs: run: npm ci working-directory: frontend + # tsconfig.test.json, not the default: the default leaves the unit tests out + # so the Docker build (which has only frontend/) can type-check the app. - name: Type check - run: npx vue-tsc --noEmit + run: npx vue-tsc --noEmit -p tsconfig.test.json + working-directory: frontend + + - name: Unit tests + run: npm test working-directory: frontend lint: @@ -193,17 +200,16 @@ jobs: # and runs in the job below. run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration" - # Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran - # for the first time when the operator's container started — 26 revisions, none of - # them ever executed by CI — and the schema the migrations build had never been - # checked against the models that read it. + # Real-Postgres lane: `alembic upgrade head` runs here before any deployment runs + # it, and the schema the migrations build is checked against the models that read + # it. # # Gates the build, along with every other lane — see the `build` job's `needs`. # - # Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner + # Job key stays separator-free ("integration") with no `name:`. act_runner # derives the service-container name from the truncated job display name, and the # discovery step below filters `docker ps` by it. Service hostnames are not routable - # on this runner (rule 79), so the step resolves the container's bridge IP. + # on this runner, so the step resolves the container's bridge IP. integration: if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci @@ -215,11 +221,11 @@ jobs: # Postgres it will actually meet. image: postgres:16-alpine env: - POSTGRES_USER: thoughtsync + POSTGRES_USER: inkwell POSTGRES_PASSWORD: ci_integration - POSTGRES_DB: thoughtsync_test + POSTGRES_DB: inkwell_test options: >- - --health-cmd "pg_isready -U thoughtsync" + --health-cmd "pg_isready -U inkwell" --health-interval 10s --health-timeout 5s --health-retries 10 @@ -239,12 +245,22 @@ jobs: set -eux echo "=== container landscape (diagnostic for the name filter) ===" docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' - PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1) - test -n "$PG" + # Only THIS job's services. The runner's docker daemon is shared, so another + # repo's job can be running beside this one, and a job-name filter matches its + # containers too, and would take another repo's Postgres. act names every + # container of a task GITEA-ACTIONS-TASK--...: read + # from our own container (its id is in the /etc/hostname bind mount) and + # require exactly one match. + SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true) + SELF=${SELF:-$(hostname)} + TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-') + test -n "$TASK" + PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q) + test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1 PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") test -n "$PG_IP" - export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test" - # Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) — + export INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test" + # Wait for Postgres to accept connections. `run:` is busybox sh — # no bash /dev/tcp — so use the Python that is always present here. /opt/venv/bin/python - "$PG_IP" <<'PY' import socket, sys, time @@ -257,7 +273,7 @@ jobs: else: sys.exit("postgres did not become reachable") PY - # Real migrations build the schema, never metadata.create_all (rule 82) — + # Real migrations build the schema, never metadata.create_all — # testing a schema no deployment has ever seen would prove nothing. This # step IS the migration test: a broken revision fails the job here. /opt/venv/bin/alembic upgrade head @@ -265,15 +281,9 @@ jobs: build: name: Build & push image - # Every lane gates the build. This once stopped at lint + typecheck, on the - # reasoning that DB-backed testing happened manually against the dev image - # rather than on every push — true until 6f21db8 added the integration lane, - # and false since. - # - # What that gap cost: run 4293 failed `test` and published :dev and : - # anyway, so the deployed server ran a build whose test lane was red. An image - # tag is the rollback substrate (family rule 46); one that can be published - # from a failing run is not a substrate you can roll back TO. + # Every lane gates the build. An image tag is the rollback substrate, and one + # that can be published from a failing run is not a substrate you can roll back + # TO. needs: [gate, typecheck, lint, test, integration] if: needs.gate.outputs.build == 'true' runs-on: python-ci @@ -286,22 +296,20 @@ jobs: - uses: actions/checkout@v6 with: # Derives a version — see the note in desktop.yml. Depth-1 sees one commit - # and produces a too-low value silently, with the lane green (§6.1). + # and produces a too-low value silently, with the lane green. fetch-depth: 0 - name: Generate image tags and version id: tags - # run: steps execute under busybox sh (family rule 81), so use POSIX `case`, + # run: steps execute under busybox sh, so use POSIX `case`, # NOT bash `[[ ]]`. run: | # The image's version is DERIVED from its own shipped files — including the # Android client it bakes in, which is why an APK-only change re-versions - # it. One value and no ordering key: nothing compares a server image, so - # §2 says do not invent one just because the other artifacts have one. + # it. One value and no ordering key: nothing compares a server image. # - # This was a short sha on main and the literal "dev" elsewhere, which could - # not answer "how old is this instance?" — the question that actually gets - # asked of a self-hosted app running in several places. + # Date-shaped, because "how old is this instance?" is the question that + # actually gets asked of a self-hosted app running in several places. BUILD_VERSION="$(sh packaging/version.sh display server)" case "${{ github.ref }}" in refs/heads/dev) @@ -323,53 +331,36 @@ jobs: docker system prune -af || true docker builder prune --keep-storage 5g -f || true - # Bake the Android client in, on EVERY image build, so :dev, :latest and - # : all carry one and a `docker compose pull` delivers a new client - # along with the new server. + # Bake EVERY client in, on every image build, so a self-hoster gets a working + # app for their machine from the server holding their notes, with no trip to a + # release page on the forge. # - # Always the rolling `dev` release — the newest build there is. A versioned - # image therefore carries the newest client rather than one pinned to that - # version; the two negotiate a sync protocol version before linking, so - # "newest" is safe in a way "matching" would not buy anything over. + # ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is + # the price of the product being complete, and the AppImage is not + # optional within it: it is the ONLY bundle that can replace itself in place, + # so a server without one cannot serve in-app updates to anyone. # - # Fetched by the JOB, not by the Dockerfile: the release is private, and a - # token used inside a build lands in the context or a layer. + # Fetched by the JOB, not by the Dockerfile, so the token it may use never lands + # in the build context or a layer. # - # NEVER fails the build. An image with no Android client advertises none and - # hides the download — a supported state, and the only one available before - # the first Android build has ever published. - - name: Fetch the Android client to bake in + # NEVER fails the build — see the script. A platform with nothing published + # means the server advertises nothing for it and the UI hides that download, + # which is a supported state and the only one available before that platform's + # first build has ever published. + - name: Fetch the clients to bake in env: GITHUB_TOKEN: ${{ github.token }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | - mkdir -p client - # THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev - # client; :latest serves the stable one. This read `download/dev` - # unconditionally until M314 step 3, on every branch — so every stable - # server shipped a dev-channel APK to anyone who downloaded the client - # from it. Not a versioning gap; a plain defect, fixed here because this - # is the step that gave `stable` an APK to point at. + # THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients; + # :latest serves stable ones. The channel is chosen here rather than inside + # the script because the caller is what knows which image it is building. case "${{ github.ref_name }}" in main) channel=stable ;; *) channel=dev ;; esac - echo "Baking in the $channel client." - base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel" - ok=1 - for f in thoughtsync.apk thoughtsync-android.json; do - curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0 - done - if [ "$ok" = 1 ]; then - echo "Baking in:" - cat client/thoughtsync-android.json - ls -l client/thoughtsync.apk - else - # Both or neither. Half a pair is worse than none: the server would - # read a sidecar describing an APK that isn't there, or an APK it - # cannot state a version for. - echo "::warning::No Android client on the dev release — this image ships without one." - rm -f client/thoughtsync.apk client/thoughtsync-android.json - fi + sh packaging/fetch-clients.sh "$channel" client - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index 39748ef..581f1f4 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -4,22 +4,20 @@ # It DOES run on frontend changes. tauri's generate_context! embeds the built # frontend in the binary, so a frontend commit that never triggers this ships to # the web and silently never reaches the desktop app — and desktop, web and Android -# are peer surfaces held to one quality bar, not a primary and its fallbacks. The -# filter was once narrowed to the adapter/bridge directories against a "~20-40 min" -# build; measured runs are 4-5 minutes, so the cost that justified the narrowing -# isn't there. +# are peer surfaces held to one quality bar, not a primary and its fallbacks. A +# build takes 4-5 minutes, so there is no cost worth narrowing that for. # # Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli); -# runs-on is just a registered scheduling label (Label Model B), not a per-purpose +# runs-on is just a registered scheduling label, not a per-purpose # runner. The frontend is built here because tauri's generate_context! embeds it. name: Desktop (Tauri) on: push: - # NO `paths:` FILTER. It was a second, independent statement of this artifact's - # file set, hand-kept beside the one in `packaging/version.sh`, and it drifted - # from it within a day (85ead4d). The `decide` job below reads the real set and - # skips in seconds when nothing moved — one definition, one reader (§3). + # NO `paths:` FILTER. It would be a second, hand-kept statement of this artifact's + # file set beside the one in `packaging/version.sh`, and the two drift. The + # `decide` job below reads the real set and skips in seconds when nothing moved — + # one definition, one reader. # # The cost is that this workflow starts on every push rather than on a matching # one. That is a ~15s container for a decision, against a lane that cannot @@ -40,13 +38,9 @@ permissions: jobs: # Does anything need building at all? # - # ONE reader of ONE definition — the file sets in `packaging/version.sh` — replacing - # the `paths:` filters that used to state the same fact a second time. They drifted - # from it within a day: `packaging/` was added to the sets and not to the filters, - # so the commit fixing a derivation bug never ran on the two lanes it fixed - # (85ead4d). Note 3127 §3 warns about exactly that duplication. + # ONE reader of ONE definition: the file sets in `packaging/version.sh`. # - # THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one (§6.3). + # THE GUARD RUNS HERE, so it runs on every path INCLUDING the skip one. # Skipping because "the channel already serves this version" is indistinguishable # from "we derived a stale value that happens to match" unless something checks. decide: @@ -60,7 +54,7 @@ jobs: steps: - uses: actions/checkout@v6 with: - # Derives a version — depth-1 is silently wrong (§6.1). + # Derives a version — depth-1 is silently wrong. fetch-depth: 0 - name: Decide @@ -75,9 +69,77 @@ jobs: sh packaging/guard-forward.sh desktop "$channel" echo "build=$(sh packaging/should-build.sh desktop "$channel")" >> $GITHUB_OUTPUT + # The workspace checks, in their OWN job, so that BOTH publishing jobs can need + # them: nothing publishes on red. + # + # As steps inside `build` they would gate the Linux publish and nothing else, and + # the Windows installer would publish beside a failed clippy. An edge from each + # publisher to this job is the gate; a verdict inside a sibling job is only a + # report. + # + # Both publishers need it directly rather than through each other, so a Windows + # failure still never blocks the Linux bundles, and neither waits on the other's + # bundling. No `always()` or `continue-on-error` anywhere on this path: a skipped + # or failed verify must leave both publishers skipped. + verify: + name: Web tests, clippy, Rust tests and rustfmt + needs: [decide] + timeout-minutes: 20 + if: needs.decide.outputs.build == 'true' + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-tauri:1.97 + steps: + # No version is derived here, so the default shallow checkout is enough. + - uses: actions/checkout@v6 + + # tauri's generate_context! embeds the built frontend at compile time, so the + # frontend must exist before clippy or the tests can compile the desktop crate. + - name: Build the shared frontend + run: npm ci && npm run build + working-directory: frontend + + # The web's unit tests, HERE as well as in ci.yml. The installers embed this + # frontend, and ci.yml's verdict is invisible to this workflow — run there only, + # a red web test would still let both installers publish. + - name: Web unit tests + run: npm test + working-directory: frontend + + # --locked on the FIRST cargo invocation is the lockfile gate: it fails the + # run if Cargo.toml and the committed Cargo.lock disagree, instead of silently + # re-resolving. Both publishing jobs need this job, so neither + # bundles a commit whose lockfile drifted. + # + # Run from the REPO ROOT with --workspace, not from desktop/src-tauri. Inside the + # desktop crate, cargo scopes to the desktop PACKAGE: the core's tests would not + # run and nothing would lint the Android uniffi shim, while both still COMPILE + # as dependencies — so the run stays green and the gap stays invisible. + - name: Clippy + run: cargo clippy --locked --workspace --all-targets -- -D warnings + + - name: Test + run: cargo test --locked --workspace + + # Deliberately AFTER clippy + test, not before. + # + # It's the cheapest check, so fail-fast ordering would normally put it first — + # but there is no Rust toolchain on the workstation (the desktop lane is + # verified entirely here), so a formatting nit failing first SKIPS clippy and + # the tests, and one CI cycle teaches nothing but whitespace. Running it here + # means every push reports its real problems too. Still before either bundle + # build, so a fmt failure doesn't burn one. + - name: Rust format check + run: cargo fmt --all --check + build: name: Tauri desktop (Linux) - needs: [decide] + needs: [decide, verify] + # A deadline, not a budget: warm builds take ~4 minutes. Without one a hung step + # runs to the runner's own 30-minute cap, as a rust-lld link whose threads have + # all deadlocked will. Kept under that cap so this one is the limit + # that fires, whatever the runner is configured with. + timeout-minutes: 20 if: needs.decide.outputs.build == 'true' runs-on: python-ci container: @@ -92,57 +154,24 @@ jobs: with: # DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # commit and `git log -- ` produces a too-LOW value, silently, with - # the lane green — note 3127 §6.1, and the direction you cannot recover + # the lane green — the direction you cannot recover # from. `packaging/version.sh` fails loudly on an empty result rather than # emitting something plausible, which is what turns this into a red lane # if it is ever dropped. fetch-depth: 0 # tauri's generate_context! embeds the built frontend at compile time, so the - # frontend must exist before any cargo compile (clippy/test/build), not just - # at bundle time. + # frontend must exist before cargo compiles anything, not just at bundle time. - name: Build the shared frontend run: npm ci && npm run build working-directory: frontend - # --locked on the FIRST cargo invocation of the job is the lockfile gate: it - # fails the run if Cargo.toml and the committed Cargo.lock disagree, instead - # of silently re-resolving. Everything after it in this job then compiles the - # exact versions recorded in the lockfile, so the flag isn't repeated on the - # bundle build (issue 2102). - # - # Run from the REPO ROOT with --workspace, not from desktop/src-tauri. - # - # These three steps used to run inside the desktop crate, which was right when - # it was the only Rust in the repo. After the core was extracted (M12 step 1) - # it silently stopped being right: cargo scoped to the desktop PACKAGE, so the - # core's 89 tests stopped running and nothing lints the Android uniffi shim at - # all. Both crates are dependencies of the desktop, so they still COMPILED — - # which is exactly why the gap was invisible, and why a green run kept meaning - # less than it looked like it meant. - - name: Clippy - run: cargo clippy --locked --workspace --all-targets -- -D warnings - - - name: Test - run: cargo test --locked --workspace - - # Deliberately AFTER clippy + test, not before. - # - # It's the cheapest check, so fail-fast ordering would normally put it first — - # but there is no Rust toolchain on the workstation (the desktop lane is - # verified entirely here), so a formatting nit failing first SKIPS clippy and - # the tests, and one CI cycle teaches nothing but whitespace. Running it here - # means every push reports its real problems too. Still before the ~20-40 min - # bundle build, so a fmt failure doesn't burn that. - - name: Rust format check - run: cargo fmt --all --check - # Frontend already built above; skip the beforeBuildCommand rebuild. # - # createUpdaterArtifacts is applied only when a signing key exists (M10.9): - # tauri FAILS the build if it's asked to produce updater artifacts with no key, - # so making it conditional is what lets the pipeline stay green before the - # operator has added the secret. With the key present, each bundle gets a + # createUpdaterArtifacts is applied only when a signing key exists: tauri + # FAILS the build if it's asked to produce updater artifacts with no key, so + # making it conditional is what lets the pipeline stay green without the + # secret. With the key present, each bundle gets a # `.sig` beside it — the file the updater actually verifies against. - name: Tauri build (deb + AppImage) env: @@ -159,9 +188,17 @@ jobs: # The ORDERING KEY, not the display version: this string is what Tauri's # updater parses as semver, and what it stamps into bundle FILENAMES that # `write-manifest.sh` then selects on. The human-readable version is a - # separate value and arrives with the UI that shows it (#3181). + # separate value, exported just below. version="$(sh ../../packaging/version.sh key desktop)" echo "Building desktop ordering key $version" + # The DISPLAY version, baked into the binary by `option_env!`. + # A different value for a different audience: this is the one a person + # quotes in a bug report, the key above is the one only a comparator + # sees. Exported rather than passed as a flag because the macro that + # reads it is in Rust source, not in Tauri's config. + INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)" + export INKWELL_DISPLAY_VERSION + echo "Baking display version $INKWELL_DISPLAY_VERSION" cargo tauri build \ --config '{"build":{"beforeBuildCommand":""}}' \ --config "{\"version\":\"$version\"}" \ @@ -170,7 +207,7 @@ jobs: # Tauri's AppImage bundles the build host's graphics/display libs # (libEGL/libGL/libdrm/libgbm/libwayland-*), which clash with end-user GPU - # drivers and abort to a black window (EGL_BAD_PARAMETER, issue 2021). + # drivers and abort to a black window (EGL_BAD_PARAMETER). # Strip that host-coupled stack so the app uses the running system's # graphics libs; webkit/gtk stay bundled. Runs from the repo root (the # script resolves its own paths), overwriting the AppImage in place. @@ -204,9 +241,9 @@ jobs: # control file and cross-checks it against what the ELF actually needs # (dpkg-shlibdeps). # - # We deliberately do NOT set bundle.linux.deb.depends: run 2872 showed - # tauri already infers exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so - # declaring them again only produced a control file listing each twice. + # We deliberately do NOT set bundle.linux.deb.depends: tauri already infers + # exactly libwebkit2gtk-4.1-0 + libgtk-3-0, so declaring them again only + # produces a control file listing each twice. # This step is the guard instead — if tauri's inference ever stops # covering what the binary links, the build fails here. - name: Verify the .deb @@ -222,18 +259,16 @@ jobs: run: bash desktop/packaging/arch/package-prebuilt.sh # Make the built .deb + .AppImage downloadable from the run (for hand-testing). - # Mirrored action, never actions/upload-artifact: @v4+ throws - # GHESNotSupportedError on the hostname before it connects, and @v3 uploads - # something Gitea stores but will never serve back (it returns artifacts only - # through the v4 API, which filters on content_encoding='application/zip'). - # Pinned by SHA — the mirror auto-syncs, so a moved upstream tag would - # silently change what runs. See Scribe issues 2255 / 2270. - # No continue-on-error: a swallowed upload failure is exactly how 110 - # unreachable artifacts accumulated here unnoticed. Fail loudly instead. + # Stock action: it works on this forge because gitea/runner 3.x edits + # upload-artifact's client-side GHES refusal out of the action bundle. Never + # @v3 — it uploads something Gitea stores but will never serve back (it returns + # artifacts only through the v4 API, which filters on + # content_encoding='application/zip'). No continue-on-error: a swallowed upload + # failure lets unreachable artifacts pile up unnoticed. Fail loudly instead. - name: Upload bundles - uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 + uses: actions/upload-artifact@v7 with: - name: thoughtsync-linux + name: inkwell-linux path: | target/release/bundle/appimage/*.AppImage target/release/bundle/deb/*.deb @@ -244,14 +279,11 @@ jobs: # The rolling channel for this branch: `dev` from dev, `stable` from main. Both # are releases whose tag never moves, so the updater has a permanent URL to - # read — Forgejo has no /releases/latest/download/ route, so "newest" + # read — the forge has no /releases/latest/download/ route, so "newest" # cannot be named in a URL. # - # MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until - # M314 step 3 this job built on main and published nothing, so the stable - # channel moved only when somebody cut a tag — that section's diagnostic - # failing outright: main publishing was not sufficient for a user to receive - # the build. + # MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main + # reaches stable users with no tag cut. # # Gated on the signing key INSIDE the script rather than with an `if:`, because # the secrets context isn't reliably available to step conditions. Publishing @@ -267,13 +299,16 @@ jobs: echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish." exit 0 fi - # POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81). + # POSIX `case`, not bash `[[ ]]` — these run under busybox sh. # `prerelease` is true for dev so it does not read as a supported build, # and false for stable, which is the real thing. case "$GITHUB_REF_NAME" in - main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; - *) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; + main) channel=stable; RELEASE_PRERELEASE=false ;; + *) channel=dev; RELEASE_PRERELEASE=true ;; esac + # The channel's release TAG, not its name: `dev` publishes on `dev-rolling` + # (packaging/channel-tag.sh). A tag named `dev` would shadow the branch. + RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")" export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh @@ -293,7 +328,8 @@ jobs: # built, not that it runs. A real-machine check stays mandatory before trusting it. windows: name: Windows installer (cross-compiled) - needs: [decide] + needs: [decide, verify] + timeout-minutes: 20 if: needs.decide.outputs.build == 'true' runs-on: python-ci container: @@ -303,7 +339,7 @@ jobs: with: # DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # commit and `git log -- ` produces a too-LOW value, silently, with - # the lane green — note 3127 §6.1, and the direction you cannot recover + # the lane green — the direction you cannot recover # from. `packaging/version.sh` fails loudly on an empty result rather than # emitting something plausible, which is what turns this into a red lane # if it is ever dropped. @@ -324,8 +360,8 @@ jobs: run: cargo tauri icon app-icon.png working-directory: desktop/src-tauri - # This lane's lockfile gate (the Linux job gets it from `cargo clippy - # --locked`). It has to be its own step here because the build is this job's + # This lane's own lockfile check (`verify` has already run `cargo clippy + # --locked` for the workspace). It has to be its own step here because the build is this job's # only crate-graph command, and discovering the drift 30 minutes into a # cross-compile is the expensive way to learn it. Fetching for the Windows # target also pre-warms exactly the crates the build will want. @@ -344,9 +380,17 @@ jobs: # The ORDERING KEY, not the display version: this string is what Tauri's # updater parses as semver, and what it stamps into bundle FILENAMES that # `write-manifest.sh` then selects on. The human-readable version is a - # separate value and arrives with the UI that shows it (#3181). + # separate value, exported just below. version="$(sh ../../packaging/version.sh key desktop)" echo "Building desktop ordering key $version" + # The DISPLAY version, baked into the binary by `option_env!`. + # A different value for a different audience: this is the one a person + # quotes in a bug report, the key above is the one only a comparator + # sees. Exported rather than passed as a flag because the macro that + # reads it is in Rust source, not in Tauri's config. + INKWELL_DISPLAY_VERSION="$(sh ../../packaging/version.sh display desktop)" + export INKWELL_DISPLAY_VERSION + echo "Baking display version $INKWELL_DISPLAY_VERSION" updater='{}' if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then updater='{"bundle":{"createUpdaterArtifacts":true}}' @@ -360,26 +404,21 @@ jobs: --config "$updater" working-directory: desktop/src-tauri - # Mirrored action, never actions/upload-artifact — see the Linux job's - # Upload bundles step for the full reasoning. Pinned by SHA because the - # mirror auto-syncs. + # Stock action — see the Linux job's Upload bundles step for why. - name: Upload installer - uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245 + uses: actions/upload-artifact@v7 with: - name: thoughtsync-windows + name: inkwell-windows path: target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe if-no-files-found: error # The rolling channel for this branch: `dev` from dev, `stable` from main. Both # are releases whose tag never moves, so the updater has a permanent URL to - # read — Forgejo has no /releases/latest/download/ route, so "newest" + # read — the forge has no /releases/latest/download/ route, so "newest" # cannot be named in a URL. # - # MAIN PUBLISHING HERE is what makes a `v*` tag optional (note 3127 §0). Until - # M314 step 3 this job built on main and published nothing, so the stable - # channel moved only when somebody cut a tag — that section's diagnostic - # failing outright: main publishing was not sufficient for a user to receive - # the build. + # MAIN PUBLISHING HERE is what makes a `v*` tag optional: a merge to main + # reaches stable users with no tag cut. # # Gated on the signing key INSIDE the script rather than with an `if:`, because # the secrets context isn't reliably available to step conditions. Publishing @@ -395,13 +434,16 @@ jobs: echo "No TAURI_SIGNING_PRIVATE_KEY — skipping the channel publish." exit 0 fi - # POSIX `case`, not bash `[[ ]]` — these run under busybox sh (rule 81). + # POSIX `case`, not bash `[[ ]]` — these run under busybox sh. # `prerelease` is true for dev so it does not read as a supported build, # and false for stable, which is the real thing. case "$GITHUB_REF_NAME" in - main) RELEASE_TAG=stable; RELEASE_PRERELEASE=false ;; - *) RELEASE_TAG=dev; RELEASE_PRERELEASE=true ;; + main) channel=stable; RELEASE_PRERELEASE=false ;; + *) channel=dev; RELEASE_PRERELEASE=true ;; esac + # The channel's release TAG, not its name: `dev` publishes on `dev-rolling` + # (packaging/channel-tag.sh). A tag named `dev` would shadow the branch. + RELEASE_TAG="$(sh packaging/channel-tag.sh "$channel")" export RELEASE_TAG RELEASE_PRERELEASE echo "Publishing to the $RELEASE_TAG channel." bash desktop/packaging/publish-release.sh @@ -417,6 +459,7 @@ jobs: manifest: name: Update manifest needs: [build, windows] + timeout-minutes: 5 if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: @@ -426,7 +469,7 @@ jobs: with: # DERIVES A VERSION -> needs the whole history. A depth-1 clone sees one # commit and `git log -- ` produces a too-LOW value, silently, with - # the lane green — note 3127 §6.1, and the direction you cannot recover + # the lane green — the direction you cannot recover # from. `packaging/version.sh` fails loudly on an empty result rather than # emitting something plausible, which is what turns this into a red lane # if it is ever dropped. @@ -447,17 +490,27 @@ jobs: # match the binary it points at is an updater that never settles. It must # be `key`: this value is matched against bundle filenames. version="$(sh packaging/version.sh key desktop)" + # The version a PERSON reads, published beside the manifest as + # `inkwell-desktop.json`. The image build reads it to describe the + # bundles it bakes in (packaging/fetch-clients.sh) without re-deriving + # anything from its own checkout — which would be a different commit + # whenever the desktop did not rebuild. + display="$(sh packaging/version.sh display desktop)" # Both channels are rolling: the manifest lands on the same release that # holds the bundles, and the previous build's bundles are dropped once it # points at this one. Nothing can reach them, and they are ~100 MB a push. # - # No tag arm any more. A `v*` tag does not reach this workflow at all — it + # No tag arm: a `v*` tag does not reach this workflow at all — it # triggers release.yml, which writes a changelog and builds nothing. case "${GITHUB_REF_NAME}" in - main) export RELEASE_TAG=stable + main) RELEASE_TAG="$(sh packaging/channel-tag.sh stable)" export RELEASE_NOTES="Stable build from ${GITHUB_SHA}" ;; - *) export RELEASE_TAG=dev + *) RELEASE_TAG="$(sh packaging/channel-tag.sh dev)" export RELEASE_NOTES="Development build from ${GITHUB_SHA}" ;; esac + # Assigned bare above so a failing channel-tag.sh fails this step; + # `export X="$(...)"` would swallow its exit status. + export RELEASE_TAG export PRUNE_OLD_ASSETS=true - APP_VERSION="$version" bash desktop/packaging/write-manifest.sh + APP_VERSION="$version" DISPLAY_VERSION="$display" \ + bash desktop/packaging/write-manifest.sh diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index a647444..e4adb4d 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -1,26 +1,26 @@ name: Release -# A RELEASE BUILDS NOTHING. That is the whole point of this lane (M314 step 7). +# A RELEASE BUILDS NOTHING. That is the whole point of this lane. # # The merge to `main` already published everything a user can receive: the server # image as `:latest` + `:`, the desktop bundles and the APK to the `stable` # channel, and the updater manifest that advertises them. A tag rebuilding that same # source would produce identical artifacts under identical names, and would re-push -# `:` with different bytes — which rule 145 forbids even when they match. +# `:`, which a published tag never allows. # # So the tag is a BOOKMARK, and this lane gives it the only job it has left: saying -# what was in it. Note 3127 §5 — there are two halves to "what am I running", and +# what was in it. There are two halves to "what am I running", and # the version answers only the first: # # which build is this? the footer, /api/config, the APK's versionName # what changed since the one ← this # I was running last month? # -# Cutting the tag is the operator's act (rule 2). This only responds to one. +# This lane never cuts a tag; it only responds to one. # # THE TAG IS NOT AN IMAGE TAG and never becomes one. `ci.yml` does not trigger on # tags at all. The image is addressed by channel or by commit; the release by date. -# Same string as the artifact version (rule 148, `vYYYY.MM.DD.HHMM`), different +# Same string as the artifact version (`vYYYY.MM.DD.HHMM`), different # system. on: @@ -62,7 +62,7 @@ jobs: # Through publish-release.sh for its create-or-PATCH-on-409 path: a # release that is only ever POSTed keeps whatever body its first run - # wrote (#2182), so re-tagging or re-running must rewrite it. No bundles + # wrote, so re-tagging or re-running must rewrite it. No bundles # exist in this workspace, so its asset globs match nothing and it # uploads none — which is the intended behaviour, not a side effect. RELEASE_TAG="$GITHUB_REF_NAME" bash desktop/packaging/publish-release.sh diff --git a/.gitignore b/.gitignore index a3d7f3a..e839729 100644 --- a/.gitignore +++ b/.gitignore @@ -209,5 +209,5 @@ android/local.properties # The Android client CI bakes into the server image. Fetched fresh on every image # build, so it is never worth 55 MiB of git history. client/.keep IS tracked, so # the Dockerfile's COPY always has a directory to copy. -client/thoughtsync.apk -client/thoughtsync-android.json +client/inkwell.apk +client/inkwell-android.json diff --git a/Cargo.lock b/Cargo.lock index da578b5..b15349f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,30 @@ dependencies = [ "winnow 1.0.4", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-compat" version = "0.2.5" @@ -157,6 +181,113 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atk" version = "0.18.2" @@ -261,6 +392,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -513,6 +657,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -925,6 +1078,33 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "env_filter" version = "0.1.4" @@ -962,6 +1142,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1138,6 +1338,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1286,6 +1499,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1405,6 +1628,24 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.20", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1521,6 +1762,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1826,6 +2073,58 @@ dependencies = [ "cfb", ] +[[package]] +name = "inkwell-core" +version = "0.1.0" +dependencies = [ + "chrono", + "flate2", + "log", + "openssl-sys", + "reqwest 0.12.28", + "rusqlite", + "serde", + "serde_json", + "sha2", + "uuid", + "zip", +] + +[[package]] +name = "inkwell-desktop" +version = "0.2.0" +dependencies = [ + "inkwell-core", + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-global-shortcut", + "tauri-plugin-log", + "tauri-plugin-notification", + "tauri-plugin-updater", +] + +[[package]] +name = "inkwell-ffi" +version = "0.1.0" +dependencies = [ + "inkwell-core", + "log", + "serde_json", + "thiserror 2.0.20", + "tokio", + "uniffi", +] + +[[package]] +name = "inkwell-uniffi-bindgen" +version = "0.1.0" +dependencies = [ + "uniffi", +] + [[package]] name = "ipnet" version = "2.12.1" @@ -2128,6 +2427,20 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2271,6 +2584,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe430c879247371c47fdedd328ad4e301e6b30b533c5fd5db1c06d75253d0e46" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2590,6 +2917,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "osakit" version = "0.3.1" @@ -2629,6 +2966,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2717,6 +3060,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2768,6 +3122,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2798,6 +3166,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2896,6 +3273,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3522,6 +3928,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3784,7 +4200,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -3866,7 +4282,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -3947,6 +4363,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", +] + [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -3968,6 +4399,25 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "time", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4023,7 +4473,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -4048,7 +4498,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -4101,6 +4551,17 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.20", + "windows 0.62.2", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4172,54 +4633,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "thoughtsync-core" -version = "0.1.0" -dependencies = [ - "chrono", - "log", - "openssl-sys", - "reqwest 0.12.28", - "rusqlite", - "serde", - "serde_json", - "sha2", - "uuid", -] - -[[package]] -name = "thoughtsync-desktop" -version = "0.2.0" -dependencies = [ - "log", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-log", - "tauri-plugin-updater", - "thoughtsync-core", -] - -[[package]] -name = "thoughtsync-ffi" -version = "0.1.0" -dependencies = [ - "log", - "serde_json", - "thiserror 2.0.20", - "thoughtsync-core", - "tokio", - "uniffi", -] - -[[package]] -name = "thoughtsync-uniffi-bindgen" -version = "0.1.0" -dependencies = [ - "uniffi", -] - [[package]] name = "time" version = "0.3.55" @@ -4496,9 +4909,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4548,6 +4973,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -4995,7 +5431,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -5019,7 +5455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.20", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -5084,11 +5520,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5100,6 +5548,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5134,7 +5591,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5181,6 +5649,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5319,6 +5797,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -5550,7 +6037,7 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5577,6 +6064,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -5587,6 +6091,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" @@ -5610,6 +6120,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -5698,6 +6278,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", ] @@ -5707,3 +6288,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.3", + "winnow 1.0.4", +] diff --git a/Cargo.toml b/Cargo.toml index 01c7023..ec11774 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # Rust workspace. The framework-free client core, and the two shims that wrap it: # the Tauri desktop app and the uniffi bindings the native Android client loads. # Neither shim owns the core — that is the reason it is a crate at all rather than a -# module inside the desktop app (Scribe note 2730). +# module inside the desktop app. [workspace] resolver = "2" members = ["core", "desktop/src-tauri", "android/ffi", "android/bindgen"] diff --git a/Dockerfile b/Dockerfile index 2ffe9ce..223a32c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,22 +20,33 @@ RUN --mount=type=cache,target=/root/.cache/pip \ # Bake the built SPA into the package's static dir (served by app.py). PYTHONPATH # points at /app/src so the runtime imports this source tree (with static/ present), # not the pip-installed copy. -COPY --from=build-frontend /build/dist/ src/thoughtsync/static/ +COPY --from=build-frontend /build/dist/ src/inkwell/static/ COPY alembic.ini . COPY alembic/ alembic/ -# The Android client this server hands out. CI fetches the newest published build -# into ./client immediately before this runs (ci.yml), so every image tag — :dev, -# :latest and : alike — ships a client, and a `docker compose pull` -# delivers a new one with no file copying by hand. +# The clients this server hands out — the APK and all four desktop bundles. CI +# fetches the newest published build of each into ./client immediately before this +# runs (packaging/fetch-clients.sh), so both image tags ship a full set and a +# `docker compose pull` delivers new ones with no file copying by hand. # -# Fetched by the JOB rather than here on purpose: the release is private, and a +# ~104 MB of this image is that set, almost all of it the AppImage. +# +# Fetched by the JOB rather than here on purpose: the releases are private, and a # token used inside a build ends up in the build context or a layer. # +# LAST of the COPYs, deliberately: this directory changes on every build, so +# putting it above the `pip install` layer would invalidate that layer every time. +# # The directory is tracked (client/.keep) so this COPY cannot fail on a tree where -# that step never ran. An image with no APK is a supported state — the server -# advertises nothing and the web UI hides the download (client_dist.py). -COPY client/ src/thoughtsync/client/ +# that step never ran. An image with no clients — or with some and not others — is +# a supported state: the server advertises what it has and the web UI hides the +# rest (client_dist.py). +COPY client/ src/inkwell/client/ + +# The desktop installer, served at /install.sh with this server's own address +# written into it (installer.py). The SAME script the forge serves raw, copied rather +# than moved so the forge's raw URL keeps working. +COPY desktop/packaging/install.sh src/inkwell/install.sh ENV PYTHONPATH=/app/src @@ -44,6 +55,17 @@ ENV APP_VERSION=$BUILD_VERSION EXPOSE 5000 # Wait for the database, run migrations, then serve. The DB wait keeps a briefly -# slow/unready database from crash-looping the container. Family convention -# (rule 82): schema is built by real migrations, never metadata.create_all. -CMD ["sh", "-c", "python -m thoughtsync.dbwait && alembic upgrade head && hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] +# slow or unready database from crash-looping the container. The schema is built by +# real migrations, never metadata.create_all. +# +# Timeouts, as hypercorn 0.18 implements them: +# - `--keep-alive 120` is the idle timeout. It is also the header timeout: +# hypercorn marks a connection busy only once a whole request has arrived, so a +# client dribbling headers is still "idle" and is cut off at this. 120 stays +# above Traefik's 90s idle timeout for backend connections, so the proxy never +# reuses one this server has just closed (a 502). +# - No `--read-timeout`, on purpose. It bounds every socket read, including the +# whole time a download is streaming and the client sends nothing, so it would +# cut off an APK fetched slowly over mobile data. Bodies are capped instead +# (MAX_CONTENT_LENGTH in app.py). +CMD ["sh", "-c", "python -m inkwell.dbwait && alembic upgrade head && hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 120"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 60b5bcb..d1ea45a 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,21 @@ -# ThoughtSync +

+ Inkwell logo +

-Self-hosted personal thought-capture web app in the **FabledSword** family — a -Google-Keep-style **masonry post-it board** for capturing disparate thoughts in -under a second, designed to grow into a lightweight second brain (labels, search, -`[[wiki-links]]`, graph). +

Inkwell

-- **Stack:** Quart (async Python) + Vue 3 + PostgreSQL, Docker, Fabled-Git CI. -- **Auth:** native email + password (signed-cookie sessions). Multi-user with the - family owner + direct-share + group-share ACL. -- **Web-first.** An Android companion is a later, conditional milestone. +

Capture a thought in under a second, and find it again later.

-## Layout +Inkwell is a self-hosted notes app built around a Keep-style board of cards. Type +and it's saved; tag with `#words`, tick checklists, set reminders, attach files, +and search or browse by date when you need something back. It runs in the browser, +as a desktop app for Linux and Windows that works offline, and as an Android app, +all syncing with your own server. -``` -src/thoughtsync/ Quart app (app factory, auth, models, ACL, config, db) -alembic/ async migrations (schema built via `alembic upgrade head`) -tests/ DB-free unit tests (pytest) -frontend/ Vue 3 + Vite + TypeScript + Tailwind SPA -Dockerfile 2-stage build (Vue -> python:3.12-slim runtime) -docker-compose.yml local app + Postgres stack -``` +## Quick start -## Development - -Backend (needs a Postgres reachable at `THOUGHTSYNC_DATABASE_URL`): - -```sh -pip install -e ".[dev]" -alembic upgrade head -hypercorn 'thoughtsync.app:create_app()' --bind 0.0.0.0:5000 -``` - -Frontend (proxies `/api` to `:5000`): - -```sh -cd frontend -npm install -npm run dev # http://localhost:5173 -``` - -Or run it in Docker. **Hot-reload dev stack** (edit code, changes reload live) — -Postgres + backend + Vite dev server, app at http://localhost:5173: - -```sh -docker compose -f docker-compose.dev.yml up -``` - -Or the **full production image** (SPA baked in) at http://localhost:5000: - -```sh -docker compose up --build -``` - -## Deploy (self-host) - -A complete two-container stack (app + Postgres) you can copy and run. Save it as -`docker-compose.yml`, change the two `CHANGE_ME` passwords (they must match), then -`docker compose up -d`: +Save this as `docker-compose.yml`, replace both `CHANGE_ME`s with the same password, +and run `docker compose up -d`: ```yaml services: @@ -64,65 +23,142 @@ services: image: postgres:16-alpine restart: unless-stopped environment: - POSTGRES_USER: thoughtsync - POSTGRES_PASSWORD: CHANGE_ME # change this - POSTGRES_DB: thoughtsync + POSTGRES_USER: inkwell + POSTGRES_PASSWORD: CHANGE_ME + POSTGRES_DB: inkwell volumes: - - thoughtsync-db:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U thoughtsync"] - interval: 5s - timeout: 5s - retries: 10 + - inkwell-db:/var/lib/postgresql/data app: - image: git.fabledsword.com/bvandeusen/thoughtsync:latest # :dev for the current dev build + image: git.fabledsword.com/bvandeusen/inkwell:latest restart: unless-stopped depends_on: - db: - condition: service_healthy + - db environment: - THOUGHTSYNC_DATABASE_URL: postgresql+asyncpg://thoughtsync:CHANGE_ME@db:5432/thoughtsync + INKWELL_DATABASE_URL: postgresql+asyncpg://inkwell:CHANGE_ME@db:5432/inkwell volumes: - - thoughtsync-data:/var/thoughtsync # uploaded images; omit if you don't use attachments + - inkwell-data:/var/inkwell ports: - "5000:5000" volumes: - thoughtsync-db: - thoughtsync-data: + inkwell-db: + inkwell-data: ``` -Then open `http://:5000` and register — **the first account becomes the admin**. +Open `http://:5000` and register. **The first account becomes the admin.** -- **Only `THOUGHTSYNC_DATABASE_URL` is required.** `THOUGHTSYNC_SECRET_KEY` is optional; if - unset, a signing key is generated and persisted in the database (sessions survive restarts). -- Uploaded images live under the `thoughtsync-data` volume at `/var/thoughtsync`. -- The app waits for the database and runs migrations (`alembic upgrade head`) automatically on start. -- **Image tags:** `:latest` (stable, built from `main`) · `:dev` (latest `dev` - build) · `:` on `main` only (immutable, the rollback unit). There are - no version-shaped tags: nothing pins one, and the build reports its own version - at `/api/config` and `/health`. -- **Putting it on the public internet:** there are four things to do first — close - registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing the app - port, and back up the attachment volume as well as the database. See - [docs/public-hosting.md](docs/public-hosting.md), which also lists what the app - hardens on its own and what it deliberately doesn't. -- **Install as an app (PWA):** ThoughtSync is installable ("Add to Home Screen" / the - browser's install button) for an app-like window. Browsers only offer install over a - **secure context**, so put the app behind a reverse proxy terminating **HTTPS** (or reach - it via `localhost`) — plain `http://:5000` won't show the install prompt. +The repository's own [docker-compose.yml](docker-compose.yml) is the same stack read +from a `.env` file; copy [.env.example](.env.example) to `.env` to use it. -## Milestones +## Configuration -- **M0 — Foundation & Identity** ✅: Quart+Vue+Postgres skeleton, native auth, - sharing-ACL spine, Fabled-Git CI. -- **M1 — Capture Core** ✅: note model + masonry board (quick-add, colors, pin, - edit-in-place, archive, trash+restore). -- **M1.5 — Roles & DB-backed Settings** ✅: first user is admin, admin Settings UI, - `DATABASE_URL` is the only required env. -- M2 — Organize: labels/tags, search, checklists, attachments. -- M3 — Second-brain seeds: `[[wiki-links]]`, backlinks, graph view, reminders. -- M4 — Launch hardening: responsive/PWA, settings UI, polish. +| Variable | Required | What it is | +|---|---|---| +| `INKWELL_DATABASE_URL` | yes | The Postgres connection URL. | +| `INKWELL_SECRET_KEY` | no | Signs sessions. Unset, one is generated and kept in the database. | +| `INKWELL_LOG_LEVEL` | no | `INFO` by default. Sign-ins and other credential events log at INFO. | -Work happens on `dev`; `main` is protected (PR-only). +Everything else — site name, sign-ups, email, attachment limits, trash retention, +link previews, backups, proxy hops — is set by the admin in **Settings**. + +## Before you put it on the internet + +Close registration, terminate TLS and forward `X-Forwarded-Proto`, stop publishing +the app port directly, and back up the attachment volume as well as the database. +[docs/public-hosting.md](docs/public-hosting.md) walks through each one, and lists +what the app already hardens on its own. + +The browser only offers to install Inkwell as an app (PWA) over HTTPS or `localhost`. + +## Updating and backups + +- **Update:** `docker compose pull && docker compose up -d`. Migrations run on start. +- **Roll back:** pin `image: …/inkwell:`. Every build on `main` publishes one. +- **Back up:** turn on scheduled backups in **Settings → Backups** (a folder, an + S3-compatible bucket, or Fabled Archives). See [docs/backups.md](docs/backups.md). + Otherwise back up both volumes: `inkwell-db` and `inkwell-data`. + +## Features + +**Capturing** +- A masonry board of cards. Start typing anywhere on it to write a new note. +- Notes save as you type; there is no save button. +- Checklists inside a note, ticked straight from the card. +- Images and files attached to a note, shown on its card; click an image to see it + full size or copy it back out. +- Paste a link and it gets a preview card. +- On the desktop, a global hotkey opens a small capture window over whatever you're doing. +- On Android, share text or images into Inkwell from any app. + +**Organising** +- `#tags` written in the text become tags; a line of tags on its own is tidied away. +- Each tag gets a colour, and tags can be renamed, recoloured, merged and deleted. +- Pin, archive, drag to reorder. +- Trash with undo, emptied after a retention period the admin sets. + +**Finding** +- Full-text search, combined with filters for tags, attachments and notes shared with you. +- A timeline that groups notes by when you wrote them. +- A command palette that jumps to any note by name. +- Version history: every editing session keeps the note as it was before. + +**Remembering** +- Reminders, one-off or repeating, with Done and snooze from the card or the + notification. +- The desktop and Android apps notify you when the app isn't in front. + +**Sharing and accounts** +- Share a note with a person or a group, to view or to edit. +- Invites, groups, per-account storage limits and an activity log for the admin. +- Password reset by email, or by a link the admin makes. +- Export everything as a zip, and import an Inkwell export or a Google Keep Takeout. + +**Every device** +- Web app, installable as a PWA. +- Desktop app for Linux (`.deb`, Arch package, AppImage) and Windows. It keeps its + own local store, works with no server at all, and syncs once you link one. Install + it from your server's Account page, or with `curl -fsSL https:///install.sh | sh`. +- Android app, downloaded from your server and updated by it. + +## Troubleshooting + +| What you see | Why | Fix | +|---|---|---| +| The app container restarts with a database error at startup | Postgres isn't up yet, or the two passwords differ | Wait a minute; check both `CHANGE_ME`s match | +| No "Install app" option in the browser | The page isn't on HTTPS | Serve it over HTTPS, or open it on `localhost` | +| The desktop or Android app can't reach a plain-HTTP server | A bare hostname is read as `https://` | Type the address with `http://` | +| A deployment upgraded from ThoughtSync shows no notes | It's pointed at new, empty volumes | Set `INKWELL_DB_VOLUME` and `INKWELL_DATA_VOLUME`; see [.env.example](.env.example) | + +## Development + +Backend (needs Postgres at `INKWELL_DATABASE_URL`): + +```sh +pip install -e ".[dev]" +alembic upgrade head +hypercorn 'inkwell.app:create_app()' --bind 0.0.0.0:5000 +``` + +Frontend, proxying `/api` to `:5000`: `cd frontend && npm install && npm run dev` +(http://localhost:5173). Or run everything with hot reload: +`docker compose -f docker-compose.dev.yml up`. + +| Path | What's there | +|---|---| +| `src/inkwell/` | The Quart server | +| `alembic/` | Database migrations | +| `frontend/` | The Vue web app, also the desktop app's UI | +| `core/` | The Rust store and sync engine the desktop and Android apps share | +| `desktop/` | The Tauri desktop app and its packaging | +| `android/` | The Kotlin/Compose Android app | + +How devices sync is in [docs/sync.md](docs/sync.md); how the Android app reaches a +server is in [docs/android-distribution.md](docs/android-distribution.md). Work +happens on `dev`; `main` is the stable line. + +## Licence + +Inkwell is free software under the [GNU Affero General Public License v3.0 only](LICENSE) +(`AGPL-3.0-only`). If you run a modified version for other people over a network, the +licence requires you to offer them its source. diff --git a/alembic.ini b/alembic.ini index 1741e86..4ad4574 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,12 +1,12 @@ -# Alembic single-database async configuration for ThoughtSync. +# Alembic single-database async configuration for Inkwell. [alembic] script_location = %(here)s/alembic prepend_sys_path = . src path_separator = os -# Local-dev default; overridden at runtime by THOUGHTSYNC_DATABASE_URL (see env.py). -sqlalchemy.url = postgresql+asyncpg://thoughtsync:thoughtsync@localhost:5432/thoughtsync +# Local-dev default; overridden at runtime by INKWELL_DATABASE_URL (see env.py). +sqlalchemy.url = postgresql+asyncpg://inkwell:inkwell@localhost:5432/inkwell [loggers] diff --git a/alembic/env.py b/alembic/env.py index bcfe7d2..79a3539 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -8,8 +8,8 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context -from thoughtsync.models import Base -import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata +from inkwell.models import Base +import inkwell.models.all # noqa: F401 — registers every model on Base.metadata config = context.config @@ -18,7 +18,7 @@ if config.config_file_name is not None: config.set_main_option( "sqlalchemy.url", - os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")), + os.environ.get("INKWELL_DATABASE_URL", config.get_main_option("sqlalchemy.url")), ) target_metadata = Base.metadata diff --git a/alembic/versions/0001_foundation.py b/alembic/versions/0001_foundation.py index 9f6083a..cf93ab2 100644 --- a/alembic/versions/0001_foundation.py +++ b/alembic/versions/0001_foundation.py @@ -4,8 +4,8 @@ Revision ID: 0001 Revises: Create Date: 2026-07-19 -The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the -shares table is polymorphic so notes reuse it without a schema retrofit. +Identity and the sharing ACL. The shares table is polymorphic, so any shareable +entity uses it. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0002_notes.py b/alembic/versions/0002_notes.py index e246e1b..19ef164 100644 --- a/alembic/versions/0002_notes.py +++ b/alembic/versions/0002_notes.py @@ -4,7 +4,7 @@ Revision ID: 0002 Revises: 0001 Create Date: 2026-07-19 -The M1 capture core: the notes table the masonry board renders. +The notes table the board renders. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0003_roles_settings.py b/alembic/versions/0003_roles_settings.py index 50a2d12..5271e9b 100644 --- a/alembic/versions/0003_roles_settings.py +++ b/alembic/versions/0003_roles_settings.py @@ -5,7 +5,7 @@ Revises: 0002 Create Date: 2026-07-19 Adds the admin role bit and the DB-backed settings table. No rows are seeded — -defaults live in the code registry (thoughtsync.settings), so an empty table +defaults live in the code registry (inkwell.settings), so an empty table means every setting is at its default. """ from alembic import op diff --git a/alembic/versions/0012_note_display_title.py b/alembic/versions/0012_note_display_title.py index 507f93c..82b91f1 100644 --- a/alembic/versions/0012_note_display_title.py +++ b/alembic/versions/0012_note_display_title.py @@ -16,10 +16,8 @@ depends_on = None def upgrade() -> None: # The note's display NAME: explicit title if set, else the first body line. op.add_column("notes", sa.Column("display_title", sa.Text(), nullable=False, server_default="")) - # Best-effort backfill (kept intentionally simple + unambiguous — CI has no - # Postgres lane, so this only runs on a real deploy). The app recomputes the - # precise value via derive_display_title on the next save (which additionally - # skips leading blank lines). Capped at 200 chars. + # Best-effort backfill, capped at 200 chars. The app recomputes the exact value + # (derive_display_title, which also skips leading blank lines) on the next save. op.execute( r""" UPDATE notes SET display_title = LEFT(btrim( diff --git a/alembic/versions/0015_sync_revision.py b/alembic/versions/0015_sync_revision.py index c69e8d8..6610636 100644 --- a/alembic/versions/0015_sync_revision.py +++ b/alembic/versions/0015_sync_revision.py @@ -1,4 +1,4 @@ -"""sync revision + tombstone infrastructure (M8 sync hub, step 1) +"""sync revision + tombstone infrastructure Revision ID: 0015 Revises: 0014 @@ -11,9 +11,6 @@ forget to bump it. Child-row changes (items / attachments / label membership) bu their parent note's revision, since a note syncs as a whole. `purged_at` is the hard-delete tombstone (distinct from `deleted_at` = recoverable trash) so an offline client learns a row is gone instead of resurrecting it. - -No API behavior changes here — this is pure infrastructure. Triggers/behavior are -operator-verified on deploy (no Postgres CI lane). """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0016_device_tokens.py b/alembic/versions/0016_device_tokens.py index b6ff96d..1d85dc2 100644 --- a/alembic/versions/0016_device_tokens.py +++ b/alembic/versions/0016_device_tokens.py @@ -1,4 +1,4 @@ -"""device_tokens (M8 sync hub, step 2) +"""device_tokens Revision ID: 0016 Revises: 0015 diff --git a/alembic/versions/0017_label_updated_at.py b/alembic/versions/0017_label_updated_at.py index 75d241b..43b6584 100644 --- a/alembic/versions/0017_label_updated_at.py +++ b/alembic/versions/0017_label_updated_at.py @@ -1,4 +1,4 @@ -"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog) +"""labels.updated_at, the last-write-wins field for the label catalog Revision ID: 0017 Revises: 0016 diff --git a/alembic/versions/0018_attachment_sha256.py b/alembic/versions/0018_attachment_sha256.py index 56cbfce..55f5345 100644 --- a/alembic/versions/0018_attachment_sha256.py +++ b/alembic/versions/0018_attachment_sha256.py @@ -1,4 +1,4 @@ -"""note_attachments.sha256 (M8 sync hub, step 5 — attachment blob sync) +"""note_attachments.sha256, for attachment blob sync Revision ID: 0018 Revises: 0017 diff --git a/alembic/versions/0019_attachment_filename.py b/alembic/versions/0019_attachment_filename.py index debb41e..b2db251 100644 --- a/alembic/versions/0019_attachment_filename.py +++ b/alembic/versions/0019_attachment_filename.py @@ -1,4 +1,4 @@ -"""note_attachments.filename (any-file attachments — M6 1900) +"""note_attachments.filename, for attachments of any file type Revision ID: 0019 Revises: 0018 diff --git a/alembic/versions/0020_note_link_previews.py b/alembic/versions/0020_note_link_previews.py index d31abb0..0f55ba4 100644 --- a/alembic/versions/0020_note_link_previews.py +++ b/alembic/versions/0020_note_link_previews.py @@ -1,4 +1,4 @@ -"""note_link_previews (URL unfurl — M6 1901) +"""note_link_previews, cached URL unfurls Revision ID: 0020 Revises: 0019 diff --git a/alembic/versions/0021_saved_filters.py b/alembic/versions/0021_saved_filters.py index 2a30606..9373910 100644 --- a/alembic/versions/0021_saved_filters.py +++ b/alembic/versions/0021_saved_filters.py @@ -1,4 +1,4 @@ -"""saved_filters (richer search + saved views — M6 1902) +"""saved_filters, saved search views Revision ID: 0021 Revises: 0020 diff --git a/alembic/versions/0022_note_recurrence.py b/alembic/versions/0022_note_recurrence.py index 1027d32..18b2f12 100644 --- a/alembic/versions/0022_note_recurrence.py +++ b/alembic/versions/0022_note_recurrence.py @@ -1,4 +1,4 @@ -"""notes.recurrence (recurring reminders — M6 1908) +"""notes.recurrence, for recurring reminders Revision ID: 0022 Revises: 0021 diff --git a/alembic/versions/0023_note_link_target_id.py b/alembic/versions/0023_note_link_target_id.py index 4a21a9d..9d913ba 100644 --- a/alembic/versions/0023_note_link_target_id.py +++ b/alembic/versions/0023_note_link_target_id.py @@ -1,14 +1,11 @@ -"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1) +"""note_links.target_id — resolve [[links]] to a note, not to a string Revision ID: 0023 Revises: 0022 Create Date: 2026-08-22 -A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename -the note and every inbound link stops matching. The old answer was to rewrite the -`[[Old Name]]` text inside every note that linked to it — workable while an explicit -title existed to hold still, untenable once a note's name is just its first body -line (M13). +A wiki-link stored only as normalized text makes a note's name the edge, so renaming +the note breaks every inbound link. Resolving to an id survives a rename. `target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note that doesn't exist yet is a supported way to create one. diff --git a/alembic/versions/0024_drop_note_links.py b/alembic/versions/0024_drop_note_links.py index ab831ff..d3f806e 100644 --- a/alembic/versions/0024_drop_note_links.py +++ b/alembic/versions/0024_drop_note_links.py @@ -1,22 +1,14 @@ -"""drop note_links — [[wiki-links]] are removed (note 2897) +"""drop note_links — [[wiki-links]] are removed Revision ID: 0024 Revises: 0023 Create Date: 2026-08-22 -ThoughtSync is an intermediary surface for capture and recall; a linking system is -organization, which is not what it is for. Backlinks, the graph and the name index -went with it. +0023 stays in the chain: removing an applied revision would strand a database's +alembic_version pointer. -0023 (which added `note_links.target_id`) is deliberately left in the chain rather -than deleted. It shipped in an image and may already be applied, and removing an -applied revision would strand a database's alembic_version pointer. So the column is -dropped here along with the table it lived on, and the history stays honest about the -fact that it existed for a day. - -No down-migration data concern: note_links was always DERIVED from note bodies. The -`[[text]]` is still sitting in every body it was written in; nothing a person typed is -lost by this. +note_links was derived from note bodies, so nothing typed is lost; the `[[text]]` is +still in every body. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0025_drop_note_kind.py b/alembic/versions/0025_drop_note_kind.py index 08fd2d0..362e975 100644 --- a/alembic/versions/0025_drop_note_kind.py +++ b/alembic/versions/0025_drop_note_kind.py @@ -1,19 +1,11 @@ -"""drop notes.kind — a checklist is something a note HAS (M13 step 2) +"""drop notes.kind — a checklist is something a note has Revision ID: 0025 Revises: 0024 Create Date: 2026-08-22 -`kind` was never a type: a plain TEXT column with no enum and no CHECK, compared -against a hardcoded ("text", "list") tuple in six places. `note_items` was always an -ordinary child table keyed by note_id, serialization always emitted `items` whatever -the kind, and the Android editor already toggled between the two losslessly. The -storage has modelled "a body plus optional checkable items" the whole time; only the -gates forbade it. - -Nothing is lost. Items were already rows in their own table, and a note that was -`kind = 'list'` keeps every one of them — it just stops being a different sort of -thing from the note next to it. +A note is a body plus optional checkable items. Items are rows in their own table, +so a `kind = 'list'` note keeps every one. """ from alembic import op import sqlalchemy as sa @@ -29,7 +21,6 @@ def upgrade() -> None: def downgrade() -> None: - # server_default so existing rows get a value; every note comes back as 'text', - # which is right — a restored note with items would previously have hidden its - # body, and there is no record of which ones were once lists. + # server_default so existing rows get a value. Every note comes back as 'text': + # nothing records which were lists. op.add_column("notes", sa.Column("kind", sa.Text(), nullable=False, server_default="text")) diff --git a/alembic/versions/0026_drop_note_title.py b/alembic/versions/0026_drop_note_title.py index e00bdc5..66d92d6 100644 --- a/alembic/versions/0026_drop_note_title.py +++ b/alembic/versions/0026_drop_note_title.py @@ -4,33 +4,14 @@ Revision ID: 0026 Revises: 0025 Create Date: 2026-08-22 -M13 step 3. A note is a body plus optional checkable items; its NAME is the first -non-empty line of that body, falling back to its first checklist item. There is no -separate field to type into, and `display_title` (already persisted, already what -search results and export filenames read) carries the name. +A note's name is its first non-empty body line, carried by `display_title`. -## The search vector has to be rebuilt, not just left alone +`notes.search_vector` is a stored generated column over `title` (0005, weight A), and +Postgres won't drop a column a generated one depends on. So it is recreated over +`display_title`, keeping the name ranked above the body. That recomputes every row +and rebuilds the GIN index, which takes a while on a large table. -`notes.search_vector` is a STORED GENERATED column whose expression names `title` -(migration 0005, weight A) — Postgres will refuse to drop a column another generated -column depends on, and even if it didn't, the weighting would be wrong. So it is -dropped and recreated over `display_title` instead, which keeps the original -intent: the note's NAME ranks above the rest of its body. - -Rebuilding a stored generated column re-computes every row, and the GIN index is -rebuilt with it. On a personal instance that is milliseconds; it is worth knowing -before running this against something large. - -## What happens to existing titles - -Nothing preserves them, deliberately: `display_title` was already derived from the -title when one was set, so every note keeps the NAME it had. What is lost is the -distinction between "this note has an explicit title" and "this note's first line is -its name" — which is the distinction being removed. - -Imports are the exception and are handled in code, not here: a Keep note's title, or -one in an export taken before this, is folded in as the note's first body line rather -than dropped (see `_create_imported_note`). +`display_title` was already derived from the title, so every note keeps its name. """ from alembic import op import sqlalchemy as sa diff --git a/alembic/versions/0027_checklist_items_into_body.py b/alembic/versions/0027_checklist_items_into_body.py index bae2368..f4d630f 100644 --- a/alembic/versions/0027_checklist_items_into_body.py +++ b/alembic/versions/0027_checklist_items_into_body.py @@ -4,64 +4,31 @@ Revision ID: 0027 Revises: 0026 Create Date: 2026-08-24 -M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items` -goes. The reason is positional, not cosmetic: a row had a position in a table and no -position in the text, so a separate list could only ever render AFTER the prose. With -the items in the body, a list can sit between two paragraphs — which is the thing that -could not be built before and no amount of restyling would have delivered. +A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit +between two paragraphs, and `note_items` goes. ## This migration rewrites note bodies -Every note that has items gets its body appended to. The rules below are strict -because rewriting somebody's text deserves it — not, as an earlier draft of this -docstring claimed, because this instance holds imported Google Keep notes. It does -not; note 2916's headline is that nothing here is anyone's work but the operator's -test data. What 2916 actually says about imports is conditional — text arriving from -another app WOULD be real, and any import path has to treat it that way — and the -importer this migration shares a format with is one nobody here has run. + * Rows are read before the table is dropped, in this one transaction. + * The existing body is only appended to. + * The layout (a blank line between prose and the list, nothing between items) is + what the export writes and what `derive::append_item` produces, so the clients + migrate their own stores independently and still agree with the server. -Careful was still the right call. It cost little, and the same care is what the rule -demands the day someone does import something: +The fold is inlined rather than imported from `notes/checklist.py`: a migration must +keep producing what it produced the day it ran. - * Rows are read BEFORE the table is dropped, in this one transaction. - * The existing body is never rewritten, only appended to. - * The layout — a blank line between prose and the list, nothing between consecutive - items — is byte-for-byte what `_note_markdown` has always exported and what - `derive::append_item` produces on every client. All three landing on the same text - is what lets the clients migrate their own SQLite stores independently and still - agree with the server, with no sync required to reconcile them. +## `updated_at` is left alone -## The fold is inlined on purpose +Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the +newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire, +so every migrated note is pulled once and clients converge. -`notes/checklist.py` has this same function and this migration deliberately does not -import it. A migration has to keep producing what it produced the day it ran; if the -app's spacing rule ever changes, this file must not change with it. +## The downgrade is not a true inverse -## `updated_at` is left alone, and that is load-bearing - -Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters -more than the first. Every client folds the same rows the same way, so the new body is -news to nobody. And a client holding an UNPUSHED body edit still has the newer -`updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead -of the migration silently winning. - -The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every -migrated note becomes pullable once. That is wanted: it is what makes a client whose -local fold somehow differed converge on the server's text. - -## The downgrade is not a true inverse, and says so - -It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost — -every item is still there as text, which is where this migration put it — but the old -code would show those notes as prose with no checklist. A faithful inverse is not -possible: once the items are lines, nothing distinguishes a line this migration wrote -from one somebody typed, and a downgrade that guessed would eat hand-written task -lists. The real rollback is a database restore. - -Recreating the table is not decoration, though. Migration 0015's downgrade runs -`DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS` -covers the trigger, not the table — against a missing table that statement errors. So -this is what keeps the migration chain runnable all the way back down. +It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line +this wrote from one somebody typed. The real rollback is a database restore. The +table must exist because 0015's downgrade drops a trigger on it. """ import re @@ -100,9 +67,8 @@ def upgrade(): for note_id, items in grouped.items(): body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar() - # An item whose note is already gone has nothing to fold into. The foreign key - # should make this impossible; skipping costs nothing and failing here would - # leave the database half-migrated. + # An item whose note is gone has nothing to fold into. The foreign key should + # prevent it; skipping avoids a half-migrated database. if body is None: continue for text, checked in items: @@ -113,9 +79,8 @@ def upgrade(): def downgrade(): - # Column-for-column as migration 0006 created it, index name included: 0015's - # downgrade names both the table and its trigger, so a near-enough copy is not - # good enough. + # Exactly as 0006 created it, index name included: 0015's downgrade names both + # the table and its trigger. op.create_table( "note_items", sa.Column("id", UUID(as_uuid=True), primary_key=True), diff --git a/alembic/versions/0028_lift_standalone_tags.py b/alembic/versions/0028_lift_standalone_tags.py index fc9663c..63c6a06 100644 --- a/alembic/versions/0028_lift_standalone_tags.py +++ b/alembic/versions/0028_lift_standalone_tags.py @@ -4,73 +4,27 @@ Revision ID: 0028 Revises: 0027 Create Date: 2026-08-26 -M311. A `#tag` was being shown twice — once as the text you typed and once as a chip — -and with the chip moved to the top of the card the text is redundant. This removes it, -but only from notes where the tag was standing on its own. +A tag standing on its own line shows as a chip, so its text is removed, by the same +rule as `notes/tags.py:split_body_tags`: -## This migration rewrites note bodies + * A line containing nothing but tags and whitespace is removed. + * Every other line is left exactly as written, so `remember to call #mom` stays. + * A line inside a ``` fence is never touched. + * A note that is nothing but tags keeps its text, rather than becoming a blank card. -The rule is deliberately narrow, and the same one `notes/tags.py:split_body_tags` -applies from here on: +The label rows flip to `via_tag = false` in the same transaction: the text backing +them is gone, and `_lift_and_reconcile_tags` would otherwise detach them on the next +save. - * A line containing nothing but tags and whitespace is REMOVED. - * Every other line is left exactly as written. +The transform and `_display_title` are inlined: a migration must keep producing what +it produced the day it ran. The title is recomputed only where the body moved. -So `#todo` on its own line goes, and `remember to call #mom tomorrow` does not. The -looser reading — also stripping a trailing tag off a prose line — was rejected because -the text does not say which kind it is: `buy milk #grocery` is filing, `remember to -call #mom` is the sentence's object, and lifting the second leaves "remember to call". -Rewriting somebody's words to save a duplicate chip is a bad trade, and a migration is -the worst possible place to make it. +Raw SQL, so `onupdate` never fires and a client's unpushed edit still wins +last-write-wins. The `sync_revision` trigger fires, so clients pull the server's text. -Two guards, both of which cost a note nothing: - - * A line inside a ``` fence is never touched. A `#tag` there is a shell comment in a - snippet somebody pasted, and deleting it would eat a line of their example. - * A note that is NOTHING but tags keeps its text. Lifting would leave a blank card, - which is worse than the duplication this fixes. - -## The label rows have to graduate in the same transaction - -A `via_tag` row means "this label is backed by text still in the body". Once the text -is gone that is false, and leaving it true is not cosmetic: `_lift_and_reconcile_tags` -detaches any `via_tag` row it cannot find a `#tag` for, so the note would lose the tag -on its very next save. The flip to `via_tag = false` is what makes the label the record -instead — and what makes the chip's × appear in both editors, which is now the only way -to remove a tag whose text no longer exists. - -## The transform is inlined, like 0027's - -`split_body_tags` is deliberately NOT imported. A migration has to keep producing what -it produced the day it ran; if the app's rule is ever loosened, this file must not -loosen with it and start eating prose it previously left alone. - -`_display_title` is inlined for the same reason, and is only recomputed for a note whose -body actually moved — a note named after a `#todo` line needs a new name, and reading it -from the app would couple this migration to a rule that has already changed once (M13). - -## `updated_at` is left alone, and that is load-bearing - -Raw SQL, so SQLAlchemy's `onupdate` never fires. A client holding an UNPUSHED body edit -keeps the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps -its edit instead of the migration silently winning. - -The `sync_revision` trigger (migration 0015) does fire, so every rewritten note becomes -pullable once and clients converge on the server's text. That is wanted here: unlike -0027, the clients do NOT yet apply this rule locally, so the server's copy is the only -correct one until they do. - -## The downgrade is not a true inverse, and says so - -It cannot be. Nothing distinguishes a `#todo` line this migration deleted from one that -was never there, and putting one back would be guessing at where in the note it went. - -Nothing is lost, though, which is why that is acceptable: the tag still exists as a -label on the note, and the chip still shows it. What a downgrade cannot restore is the -DUPLICATE — which is the thing this migration set out to remove. Rolling the rows back -to `via_tag = true` would be actively harmful: the text that flag claims to be backed by -is gone, so the next save would detach the label and lose the tag for real. So the -downgrade leaves both alone. The real rollback is a database restore. +The downgrade does nothing. Restoring the lines would be guessing, and flipping the +rows back to `via_tag = true` would detach every label on the next save. The real +rollback is a database restore. """ import re @@ -152,17 +106,11 @@ def upgrade(): sa.text("UPDATE notes SET body = :body, display_title = :title WHERE id = :id"), {"body": lifted, "title": _display_title(lifted), "id": note_id}, ) - # Even when the body did not move, a tag can be standalone only in the sense - # that its line was already removed by an earlier pass — so the flip is driven - # by the tag list, not by whether the text changed. + # The flip is driven by the tag list, not by whether the text changed: an + # earlier pass may already have removed the line. if standalone: bind.execute(flip, {"nid": note_id, "names": [n.lower() for n in standalone]}) def downgrade(): - """Deliberately empty — see the module docstring. - - Restoring the deleted lines would be guessing, and flipping the rows back to - `via_tag = true` would be worse than doing nothing: the text that flag claims backs - them is gone, so the next save would detach the label and lose the tag for real. - """ + """Deliberately empty; see the module docstring.""" diff --git a/alembic/versions/0029_drop_note_color.py b/alembic/versions/0029_drop_note_color.py index bf0e2e3..f0183bf 100644 --- a/alembic/versions/0029_drop_note_color.py +++ b/alembic/versions/0029_drop_note_color.py @@ -4,50 +4,18 @@ Revision ID: 0029 Revises: 0028 Create Date: 2026-08-28 -M315 step 3. A note's colour was set by a picker and read by three card renderers. -Steps 1 and 2 stopped every one of those reads: the card is one neutral per theme and -the only coloured thing on a board is a tag. This drops the column that nothing has -been reading since, and the picker goes with it. +Cards are one neutral surface per theme; the only colour on a board is a tag's. +`labels.color` is untouched. A colour a note was given is dropped with the column. -`labels.color` is untouched. That is the colour that survived, and the one the whole -milestone was about keeping. +`saved_filters.params` may carry a `"color"` facet, so stored views are swept too. In +Python rather than `params::jsonb - 'color'`, because Postgres has no try-cast and one +malformed blob would abort the migration; a row that doesn't parse is left as it is. -## What is lost, and why that is the change rather than a cost of it - -Any colour a note was explicitly given. There is nowhere to preserve it TO — the field -it would be preserved in is the one being dropped — and nothing renders it, so a -preserved value would be a column kept warm for a feature that was deliberately -removed. A note that had a colour now takes its identity from its tags, which is what -the operator asked for: "strip color from the cards ... and keep the color for tags -just on the tag." - -The palette itself is not lost. `NOTE_COLORS` moved from `models/note.py` to -`colors.py` in the same change — labels still name a colour, and leaving the vocabulary -defined on the model that lost one would be an invitation to put the column back. - -## The saved-filter sweep is not optional - -`saved_filters.params` is opaque JSON mirroring the `GET /api/notes` facet query, and -a stored view could carry `"color": "teal"`. With the facet gone that key would sit -there forever, and `clean_params` only guards what is written FROM here on. A view that -silently filters on a field the app no longer has is worse than one that visibly lost a -criterion, so the stored rows are swept too. - -Done in Python rather than as `params::jsonb - 'color'`, deliberately. Postgres has no -try-cast: one malformed blob would abort the whole migration, and these rows are -somebody's saved views. `json.loads` in a try/except lets a corrupt row keep whatever it -holds and lets every other row be fixed. - -## Search is not affected - -`notes.search_vector` is a stored generated column over `display_title` and `body` -(rebuilt in 0026). It never named `color`, so unlike the title drop there is nothing -here to tear down and recreate. +The search vector never named `color`, so it needs no rebuild. ## Downgrade -Restores the column, empty, at its old default. The values are not recoverable — see -above. It is the schema that comes back, not the data. +Restores the column, empty, at its old default. The values are not recoverable. """ import json @@ -71,9 +39,8 @@ def upgrade() -> None: try: parsed = json.loads(params) except (ValueError, TypeError): - # A blob that does not parse cannot be edited safely. Leaving it is - # correct: it was already unreadable by the app, and this migration is not - # the place to decide what it should have said. + # A blob that doesn't parse can't be edited safely; the app couldn't read + # it either. continue if not isinstance(parsed, dict) or "color" not in parsed: continue diff --git a/alembic/versions/0030_site_name_inkwell.py b/alembic/versions/0030_site_name_inkwell.py new file mode 100644 index 0000000..258b2c0 --- /dev/null +++ b/alembic/versions/0030_site_name_inkwell.py @@ -0,0 +1,45 @@ +"""a stored site_name of the old default follows the rename to Inkwell + +Revision ID: 0030 +Revises: 0029 +Create Date: 2026-10-06 + +The app was renamed from ThoughtSync to Inkwell. The Settings page saves every key, +so most servers have a stored `site_name` of the old default without anyone choosing +it. The row is rewritten only when it holds exactly that; a typed name is left alone. + +The match is on the stored JSON text (`settings.value` is `json.dumps(value)`), so +`'"ThoughtSync"'` is the whole of what it looks for. + +## Downgrade + +Puts the old default back on a row holding exactly the new one. A server where the +admin typed "Inkwell" themselves between the two cannot be told apart from one this +migration rewrote; on downgrade both read "ThoughtSync", which is what the old code +would have shown for either. +""" +from alembic import op +import sqlalchemy as sa + +revision = "0030" +down_revision = "0029" +branch_labels = None +depends_on = None + +_OLD = '"ThoughtSync"' +_NEW = '"Inkwell"' + + +def _swap(old: str, new: str) -> None: + op.get_bind().execute( + sa.text("UPDATE settings SET value = :new WHERE key = 'site_name' AND value = :old"), + {"old": old, "new": new}, + ) + + +def upgrade() -> None: + _swap(_OLD, _NEW) + + +def downgrade() -> None: + _swap(_NEW, _OLD) diff --git a/alembic/versions/0031_previews_bump_note_revision.py b/alembic/versions/0031_previews_bump_note_revision.py new file mode 100644 index 0000000..74d1b4b --- /dev/null +++ b/alembic/versions/0031_previews_bump_note_revision.py @@ -0,0 +1,31 @@ +"""a link preview's insert, update or delete bumps its note's sync revision + +Revision ID: 0031 +Revises: 0030 +Create Date: 2026-10-07 + +A note syncs as a whole, so every child table bumps its note's `sync_revision`. With +this, a preview fetched after a save, or dismissed on the web, reaches every device. +The trigger is the function 0015 installs on the other child tables. + +## Downgrade + +Drops the trigger. Previews go back to not propagating; nothing is lost. +""" +from alembic import op + +revision = "0031" +down_revision = "0030" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + "CREATE TRIGGER trg_note_link_previews_bump_note AFTER INSERT OR UPDATE OR DELETE " + "ON note_link_previews FOR EACH ROW EXECUTE PROCEDURE ts_bump_parent_note_revision()" + ) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS trg_note_link_previews_bump_note ON note_link_previews") diff --git a/alembic/versions/0032_invites.py b/alembic/versions/0032_invites.py new file mode 100644 index 0000000..cdf0789 --- /dev/null +++ b/alembic/versions/0032_invites.py @@ -0,0 +1,41 @@ +"""invites: single-use, expiring registration links an admin issues + +Revision ID: 0032 +Revises: 0031 +Create Date: 2026-10-07 + +An invite lets one person register while registration stays closed. Only the token's +SHA-256 hash is stored. + +## Downgrade + +Drops the table. Accounts created through invites are untouched; the record of who +invited them goes with it. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import CITEXT, UUID + +revision = "0032" +down_revision = "0031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "invites", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("token_hash", sa.Text(), nullable=False, unique=True), + sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("email", CITEXT(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("invites") diff --git a/alembic/versions/0033_password_resets.py b/alembic/versions/0033_password_resets.py new file mode 100644 index 0000000..b6ea8e4 --- /dev/null +++ b/alembic/versions/0033_password_resets.py @@ -0,0 +1,49 @@ +"""password resets: an admin-issued, one-hour link; sessions an account can outlive + +Revision ID: 0033 +Revises: 0032 +Create Date: 2026-10-07 + +An admin makes a reset link for the account and hands it over. Only the token's +SHA-256 hash is stored. + +`users.session_epoch` is what lets a reset sign the account out everywhere. Sessions +are signed cookies held by the browser, so the server can't delete them; each one +carries the epoch it was signed in under, and a reset moves the account's epoch on. +It starts at 0, the value a cookie without one is read as, so the upgrade signs +nobody out. + +## Downgrade + +Drops the table and the column. Outstanding reset links stop working; sessions keep +working, since nothing checks an epoch. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0033" +down_revision = "0032" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0")) + op.create_table( + "password_resets", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("token_hash", sa.Text(), nullable=False, unique=True), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_password_resets_user_id", table_name="password_resets") + op.drop_table("password_resets") + op.drop_column("users", "session_epoch") diff --git a/alembic/versions/0034_share_constraints.py b/alembic/versions/0034_share_constraints.py new file mode 100644 index 0000000..5861e96 --- /dev/null +++ b/alembic/versions/0034_share_constraints.py @@ -0,0 +1,49 @@ +"""shares: one grant per note and person, and only the permissions the app knows + +Revision ID: 0034 +Revises: 0033 +Create Date: 2026-10-07 + +For the Share dialog: +- A unique index on (resource_type, resource_id, shared_with_user_id) where a user is + the target, so sharing a note with someone twice updates the one grant rather than + stacking two (the same for a group). +- A check that `permission` is `view` or `edit`. +- An index on `shared_with_user_id` for "Shared with me". + +## Downgrade + +Drops the indexes and the check. The rows stay. +""" +from alembic import op + +revision = "0034" +down_revision = "0033" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_index( + "uq_shares_resource_user", + "shares", + ["resource_type", "resource_id", "shared_with_user_id"], + unique=True, + postgresql_where="shared_with_user_id IS NOT NULL", + ) + op.create_index( + "uq_shares_resource_group", + "shares", + ["resource_type", "resource_id", "shared_with_group_id"], + unique=True, + postgresql_where="shared_with_group_id IS NOT NULL", + ) + op.create_index("ix_shares_user", "shares", ["shared_with_user_id"]) + op.create_check_constraint("ck_shares_permission", "shares", "permission IN ('view', 'edit')") + + +def downgrade() -> None: + op.drop_constraint("ck_shares_permission", "shares", type_="check") + op.drop_index("ix_shares_user", table_name="shares") + op.drop_index("uq_shares_resource_group", table_name="shares") + op.drop_index("uq_shares_resource_user", table_name="shares") diff --git a/alembic/versions/0035_share_revocations.py b/alembic/versions/0035_share_revocations.py new file mode 100644 index 0000000..4953e77 --- /dev/null +++ b/alembic/versions/0035_share_revocations.py @@ -0,0 +1,50 @@ +"""share_revocations: telling a recipient's devices a shared note has left them + +Revision ID: 0035 +Revises: 0034 +Create Date: 2026-10-07 + +The change feed carries every note a person can see, including notes shared with +them. When a share ends, the note stops being visible, so it simply stops +appearing in the feed. A device that already holds a copy would keep it forever. A +revocation is that missing signal: one row per (note, person) who lost the note, +stamped from the same `sync_revision_seq` the notes and labels draw from, so it sits +on the one cursor every client already pages by. + +Sharing the note with that person again deletes the row, so a device that never +heard of the revocation never gets told to delete a note it is meant to have. + +## Downgrade + +Drops the table. Devices that missed a revocation keep their copy. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0035" +down_revision = "0034" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "share_revocations", + sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "sync_revision", + sa.BigInteger(), + nullable=False, + server_default=sa.text("nextval('sync_revision_seq')"), + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.PrimaryKeyConstraint("note_id", "user_id"), + ) + op.create_index("ix_share_revocations_user_revision", "share_revocations", ["user_id", "sync_revision"]) + + +def downgrade() -> None: + op.drop_index("ix_share_revocations_user_revision", table_name="share_revocations") + op.drop_table("share_revocations") diff --git a/alembic/versions/0036_note_user_state.py b/alembic/versions/0036_note_user_state.py new file mode 100644 index 0000000..19eabd6 --- /dev/null +++ b/alembic/versions/0036_note_user_state.py @@ -0,0 +1,53 @@ +"""note_user_state: a recipient's own pin, archive and place for a shared note + +Revision ID: 0036 +Revises: 0035 +Create Date: 2026-10-07 + +Pin, archive and board order are personal. The owner's are columns on `notes`; this +table holds everyone else's, one row per (note, person it is shared with) who has +pinned, archived or moved it. A recipient with no row sees the note unpinned, +unarchived, in the owner's order. + +`sync_revision` is stamped by the same `ts_set_sync_revision()` trigger the notes +and labels use (0015), so a recipient's change reaches their own devices on the one +cursor they already page by, and never moves the note on anyone else's. + +## Downgrade + +Drops the table. Recipients lose their own pins and archives; the notes are +untouched. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0036" +down_revision = "0035" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "note_user_state", + sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("position", sa.Integer(), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("sync_revision", sa.BigInteger(), nullable=True), + sa.PrimaryKeyConstraint("note_id", "user_id"), + ) + op.create_index("ix_note_user_state_user_revision", "note_user_state", ["user_id", "sync_revision"]) + op.execute( + "CREATE TRIGGER trg_note_user_state_sync_revision BEFORE INSERT OR UPDATE ON note_user_state " + "FOR EACH ROW EXECUTE PROCEDURE ts_set_sync_revision()" + ) + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS trg_note_user_state_sync_revision ON note_user_state") + op.drop_index("ix_note_user_state_user_revision", table_name="note_user_state") + op.drop_table("note_user_state") diff --git a/alembic/versions/0037_drop_unused_user_fields.py b/alembic/versions/0037_drop_unused_user_fields.py new file mode 100644 index 0000000..28edd9f --- /dev/null +++ b/alembic/versions/0037_drop_unused_user_fields.py @@ -0,0 +1,34 @@ +"""users: drop email_verified and avatar_path, which nothing used + +Revision ID: 0037 +Revises: 0036 +Create Date: 2026-10-07 + +Nothing set `email_verified` or read `avatar_path`, and a field whose value means +nothing would be trusted by a later feature. + +## Downgrade + +Adds both columns back empty: every account unverified, no avatar. That is what +they held before. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0037" +down_revision = "0036" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_column("users", "email_verified") + op.drop_column("users", "avatar_path") + + +def downgrade() -> None: + op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True)) + op.add_column( + "users", + sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()), + ) diff --git a/alembic/versions/0038_drop_saved_filters.py b/alembic/versions/0038_drop_saved_filters.py new file mode 100644 index 0000000..7400250 --- /dev/null +++ b/alembic/versions/0038_drop_saved_filters.py @@ -0,0 +1,39 @@ +"""saved_filters: drop the table, saved views are gone + +Revision ID: 0038 +Revises: 0037 +Create Date: 2026-10-08 + +Saved views are removed; tags in the drawer give one-click recall. + +## Downgrade + +Recreates the table empty, as 0021 made it. The views that were stored are not +recoverable. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0038" +down_revision = "0037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index("ix_saved_filters_owner", table_name="saved_filters") + op.drop_table("saved_filters") + + +def downgrade() -> None: + op.create_table( + "saved_filters", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("params", sa.Text(), nullable=False, server_default="{}"), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_saved_filters_owner", "saved_filters", ["owner_id", "position"]) diff --git a/alembic/versions/0039_audit_events.py b/alembic/versions/0039_audit_events.py new file mode 100644 index 0000000..33c2a5d --- /dev/null +++ b/alembic/versions/0039_audit_events.py @@ -0,0 +1,40 @@ +"""audit_events: the audit log + +Revision ID: 0039 +Revises: 0038 +Create Date: 2026-10-08 + +Keeps credential events in the database for `audit_retention_days`, for admins to +read in Settings → Activity; the app log isn't queryable and rotates away. + +## Downgrade + +Drops the table and every event in it. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import UUID + +revision = "0039" +down_revision = "0038" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "audit_events", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("event", sa.Text(), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("email", sa.Text(), nullable=True), + sa.Column("address", sa.Text(), nullable=True), + sa.Column("detail", sa.Text(), nullable=True), + ) + op.create_index("ix_audit_events_at", "audit_events", ["at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_events_at", table_name="audit_events") + op.drop_table("audit_events") diff --git a/alembic/versions/0040_label_live_names.py b/alembic/versions/0040_label_live_names.py new file mode 100644 index 0000000..9665edd --- /dev/null +++ b/alembic/versions/0040_label_live_names.py @@ -0,0 +1,39 @@ +"""labels: a tag's name is unique among LIVE tags only + +Revision ID: 0040 +Revises: 0039 +Create Date: 2026-10-08 + +Deleting a tag leaves a tombstone row (`purged_at` set) so the change feed tells +linked devices. A tombstone must not hold its name, so `(owner_id, name)` is unique +only where `purged_at IS NULL`. + +## Downgrade + +Restores the plain unique constraint. Tombstones are deleted first, since one may +share its name with a live tag; devices that have not pulled since keep the tag. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0040" +down_revision = "0039" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_constraint("uq_labels_owner_name", "labels", type_="unique") + op.create_index( + "uq_labels_owner_name_live", + "labels", + ["owner_id", "name"], + unique=True, + postgresql_where=sa.text("purged_at IS NULL"), + ) + + +def downgrade() -> None: + op.drop_index("uq_labels_owner_name_live", table_name="labels") + op.execute("DELETE FROM labels WHERE purged_at IS NOT NULL") + op.create_unique_constraint("uq_labels_owner_name", "labels", ["owner_id", "name"]) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4d86859..0e74116 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -10,17 +10,16 @@ plugins { val workspaceRoot: Directory = layout.projectDirectory.dir("../..") // The ABIs a release APK carries. arm64 is essentially every real device; armv7 -// covers older 32-bit hardware; the two x86 targets are what emulators run on, and -// dropping them would make the app untestable on a desktop emulator (the reason -// x86_64 was added to the old Tauri lane in task 1864). +// covers older 32-bit hardware; the two x86 targets are what emulators run, so the +// app stays testable on a desktop emulator. val androidAbis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") /** - * Cross-compile `thoughtsync-ffi` for each Android ABI and drop the resulting + * Cross-compile `inkwell-ffi` for each Android ABI and drop the resulting * `.so` into jniLibs, where AGP packages it. * - * `ExecOperations` injected rather than `project.exec`: the latter was REMOVED in - * Gradle 9, and reaching for `project` at execution time is also what breaks the + * `ExecOperations` injected rather than `project.exec`: Gradle 9 has no + * `project.exec`, and reaching for `project` at execution time breaks the * configuration cache this build has enabled. */ abstract class CargoNdkBuild : DefaultTask() { @@ -49,15 +48,31 @@ abstract class CargoNdkBuild : DefaultTask() { args += "-t" args += abi } - args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "thoughtsync-ffi") + args += listOf("-o", jniLibsDir.get().asFile.absolutePath, "build", "-p", "inkwell-ffi") // --locked so an Android build cannot silently re-resolve the workspace // lockfile the desktop lanes are gated on. args += "--locked" - if (cargoProfile.get() == "release") args += "--release" + val release = cargoProfile.get() == "release" + if (release) args += "--release" execOps.exec { commandLine(listOf("cargo") + args) workingDir = workspaceDir.get().asFile + if (release) { + // The workspace's release profile with two settings put back, for this + // build only. Environment overrides rather than a profile of our own, + // because cargo-ndk copies its `-o` output from the `release` + // directory; the desktop's binaries are untouched either way. + // + // Keep the symbol table: `strip = true` removes the symbols uniffi's + // `--library` mode reads the interface out of, and bindgen then fails + // with "No UniFFI metadata found". A release build has no debug info to + // strip, so this keeps symbols and nothing else. + environment("CARGO_PROFILE_RELEASE_STRIP", "debuginfo") + // Unwind, so a panic in the core reaches Kotlin as an exception the + // board can show rather than ending the app. See the ffi crate's docs. + environment("CARGO_PROFILE_RELEASE_PANIC", "unwind") + } } } } @@ -94,7 +109,7 @@ abstract class UniffiBindgen : DefaultTask() { "run", "--locked", "-p", - "thoughtsync-uniffi-bindgen", + "inkwell-uniffi-bindgen", "--", "generate", "--library", @@ -124,24 +139,16 @@ val rustInputs = /** * Which Cargo profile the `.so` is built with. * - * A property rather than a debug/release task PAIR, deliberately. This runner has - * no working Gradle or Cargo cache (`reserveCache failed` on every run), so a cold - * cross-compile of four ABIs costs about four minutes — and a lane that both - * type-checks and packages would pay that twice if the two used different - * profiles. `android.yml` picks one profile and uses it for every Gradle call in - * the run. + * A property rather than a debug/release task pair: this runner has no working + * Gradle or Cargo cache, so a cold cross-compile of four ABIs costs about four + * minutes, and a lane that type-checks and packages with different profiles would + * pay it twice. `android.yml` picks one profile for every Gradle call in the run. * - * CI currently passes `debug` even for a release APK, which is not where this - * should end up: an unoptimised store and sync engine is a real difference on a - * phone, not a theoretical one. The blocker is that the workspace's release - * profile sets `strip = true`, which removes the symbols uniffi reads its - * interface metadata from — `generateUniffiBindings` then fails with "No UniFFI - * metadata found" (run 4077). Fixing it means either an Android-specific profile - * that keeps symbols or generating the bindings from a separate unstripped - * build, and neither is worth holding signed APKs up for. Scribe #2810. + * CI passes `release` for a signed APK, so a phone runs an optimised store and sync + * engine; `CargoNdkBuild` keeps the symbols bindgen needs. */ val rustProfile = - (project.findProperty("THOUGHTSYNC_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() } + (project.findProperty("INKWELL_CARGO_PROFILE") as String?)?.takeIf { it.isNotBlank() } ?: "debug" val jniLibsOut = layout.buildDirectory.dir("rustJniLibs") @@ -149,7 +156,7 @@ val bindingsOut = layout.buildDirectory.dir("generated/uniffi") val cargoNdk = tasks.register("cargoNdk") { - description = "Cross-compile thoughtsync-ffi for the Android ABIs." + description = "Cross-compile inkwell-ffi for the Android ABIs." rustSources.from(rustInputs) abis.set(androidAbis) cargoProfile.set(rustProfile) @@ -163,27 +170,26 @@ val generateBindings = dependsOn(cargoNdk) // arm64 is arbitrary — every ABI carries the same uniffi metadata, and // reading one is cheaper than reading four. - libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libthoughtsync_ffi.so") }) + libraryFile.set(jniLibsOut.map { it.file("arm64-v8a/libinkwell_ffi.so") }) workspaceDir.set(workspaceRoot) outputDir.set(bindingsOut) } android { - namespace = "com.fabledsword.thoughtsync" + namespace = "com.fabledsword.inkwell" compileSdk = 36 defaultConfig { - applicationId = "com.fabledsword.thoughtsync" - // 26 (Android 8, 2017) matches Minstrel and clears the NDK's floor with - // room to spare. + applicationId = "com.fabledsword.inkwell" + // 26 (Android 8, 2017) clears the NDK's floor with room to spare. minSdk = 26 targetSdk = 36 // Injected by CI from the git tag + commit count for a release; "dev" // locally so the About screen reads honestly rather than claiming 1.0. val nameOverride = - (project.findProperty("THOUGHTSYNC_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } + (project.findProperty("INKWELL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } val codeOverride = - (project.findProperty("THOUGHTSYNC_VERSION_CODE") as String?)?.toIntOrNull() + (project.findProperty("INKWELL_VERSION_CODE") as String?)?.toIntOrNull() versionCode = codeOverride ?: 1 versionName = nameOverride ?: "dev" @@ -199,11 +205,10 @@ android { } // The signing key reaches this build only through the environment: CI decodes - // it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. - // It is never in the repo and never in this file. Generated by the operator - // and never seen by an agent session, because an Android signing key cannot be - // rotated without the original — v3 lineage needs it — so a leaked or lost one - // means every install has to be removed and replaced by hand. + // it from a secret into a file and points ANDROID_KEYSTORE_FILE at that path. It + // is never in the repo. An Android signing key cannot be rotated without the + // original (v3 lineage needs it), so a leaked or lost one means every install + // has to be removed and replaced by hand. val keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")?.takeIf { it.isNotBlank() } val keystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")?.takeIf { it.isNotBlank() } @@ -212,10 +217,11 @@ android { create("release") { storeFile = File(keystoreFile) storePassword = keystorePassword - // Hardcoded, and NOT a secret: the alias is fixed for the life of - // this app and is written into the certificate every install - // already carries. Hiding it would buy nothing and stop this file - // describing its own signing setup. + // Hardcoded, and NOT a secret: the alias is fixed for the life of this + // app and written into the certificate every install carries. It says + // `thoughtsync`, the app's earlier name, because it names the key + // inside the existing keystore; renaming it would stop the key being + // found. keyAlias = "thoughtsync" // PKCS12 cannot hold a key password distinct from the store // password — keytool refuses to set one — so this is the same @@ -288,5 +294,9 @@ dependencies { implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) + // A link preview's image (LinkPreviewRow.kt). + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) + testImplementation(libs.junit) } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 1721620..c739600 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,4 +4,4 @@ # is the worst possible time to learn it. -keep class com.sun.jna.** { *; } -keepclassmembers class * extends com.sun.jna.** { public *; } --keep class com.fabledsword.thoughtsync.core.** { *; } +-keep class com.fabledsword.inkwell.core.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5c53b18..03b9859 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,9 +25,8 @@ FOREGROUND_SERVICE used only for expedited work; nothing here asks for it, and it arrives with the library - Verified against the built APK's merged manifest, not assumed. Noted here - because all four appear in the app's permission list and nothing else in - this file would explain where they came from. + Listed because all four appear in the app's permission list and nothing + else in this file would explain where they came from. --> @@ -90,24 +87,78 @@ + + + android:theme="@style/Theme.Inkwell"> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt b/android/app/src/main/java/com/fabledsword/inkwell/AppUpdate.kt similarity index 77% rename from android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt rename to android/app/src/main/java/com/fabledsword/inkwell/AppUpdate.kt index 10f9855..f6db1a6 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/AppUpdate.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/AppUpdate.kt @@ -1,8 +1,9 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.content.Context import android.content.Intent import android.content.IntentSender +import android.content.pm.ApplicationInfo import android.content.pm.PackageInstaller import android.net.ConnectivityManager import android.net.NetworkCapabilities @@ -17,24 +18,20 @@ import java.io.File * * ## A PackageInstaller session, not an install intent * - * The obvious route — `ACTION_VIEW` on the APK with - * `application/vnd.android.package-archive` — is the one on-device install - * heuristics are tuned against, and it is what produces the "bypassing Android - * security" warning the operator saw on Minstrel (Scribe note 2437). It also never - * tells the OS that this app is the legitimate updater of its own package, and it - * returns nothing: a failed install is indistinguishable from a person dismissing - * the dialog. + * The obvious route, `ACTION_VIEW` on the APK with + * `application/vnd.android.package-archive`, is the one on-device install + * heuristics are tuned against, and it produces a "bypassing Android security" + * warning. It also never tells the OS that this app is the legitimate updater of its + * own package, and it returns nothing: a failed install is indistinguishable from a + * person dismissing the dialog. * * A session says who is doing what. On Android 12+ it can also declare that no user - * action is required, which — paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION` — - * removes the confirmation dialog entirely on the UPDATE path. Not on a first - * install: the OS will not let an app quietly put a NEW package on a device, which - * is right. + * action is required, which, paired with `UPDATE_PACKAGES_WITHOUT_USER_ACTION`, + * removes the confirmation dialog on the UPDATE path. Not on a first install: the + * OS will not let an app quietly put a NEW package on a device. * - * Two things from that same research that are NOT done here, deliberately: - * `setRequestUpdateOwnership` was chased and turned out to be a red herring, and - * `REQUEST_INSTALL_PACKAGES` is not the differentiator either — Mihon declares it - * too. The mechanism was the whole difference. + * Neither `setRequestUpdateOwnership` nor `REQUEST_INSTALL_PACKAGES` is what avoids + * the warning; the session is. * * ## The outcome comes back * @@ -42,7 +39,7 @@ import java.io.File * [UpdateReceiver], which is why a failure can be shown rather than guessed at. */ object AppUpdate { - private const val TAG = "ThoughtSyncUpdate" + private const val TAG = LogTag.UPDATE /** This build's versionCode — what the server's is compared against. */ fun installedVersionCode(context: Context): Long = @@ -50,6 +47,17 @@ object AppUpdate { context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode }.getOrDefault(0L) + /** + * Whether this build can update itself at all. + * + * A debuggable build is signed with a debug key (CI's unsigned path, or a local + * build), and Android refuses the release-signed APK over it with + * INSTALL_FAILED_UPDATE_INCOMPATIBLE. Offering that update would be a download + * that can only fail, so a debug build says so instead. + */ + fun selfUpdates(context: Context): Boolean = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0 + /** * Whether this app may install packages at all. * @@ -112,10 +120,9 @@ object AppUpdate { val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Only honoured on an UPDATE of an app signed with the same key — - // exactly our case, and the reason the signing work had to land - // first. Android ignores it for anything else rather than failing, - // so there is no need to guard on which it is. + // Only honoured on an UPDATE of an app signed with the same key, which + // is this case. Android ignores it for anything else rather than + // failing, so there is no need to guard on which it is. params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) } @@ -137,11 +144,8 @@ object AppUpdate { } /** - * Stream the APK into the session. - * - * Its own function only because the two nested `use` blocks read badly inline — - * and detekt agreed, which is fair: a stream inside a session inside a try is - * three things to hold at once. + * Stream the APK into the session. Its own function so a stream inside a session + * inside a try isn't three nested things to hold at once. */ private fun writeApk( session: PackageInstaller.Session, @@ -173,5 +177,5 @@ object AppUpdate { .intentSender } - private const val WRITE_NAME = "thoughtsync-update" + private const val WRITE_NAME = "inkwell-update" } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/BuildIdentity.kt b/android/app/src/main/java/com/fabledsword/inkwell/BuildIdentity.kt new file mode 100644 index 0000000..702793b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/BuildIdentity.kt @@ -0,0 +1,20 @@ +package com.fabledsword.inkwell + +import android.content.Context + +/** + * The `versionName` of the INSTALLED package, or null when it cannot be read. + * + * From the package manager rather than from `BuildConfig`: this reports what is + * actually on the phone, which is the question both callers are asking — a bug + * report reading the foot of Sync, and a server log reading the client header. It + * also needs no `buildFeatures.buildConfig`, which this module does not enable. + * + * Returns null rather than a fallback string, because the two callers want + * different ones: the UI wants a localized "unknown" from string resources, the + * client header wants the literal the core recognizes. With no version tags, the + * artifact's self-report is the only answer to "which build is this?", so a missing + * name must read as missing, never as a plausible default. + */ +fun Context.installedVersionName(): String? = + runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ThoughtSyncApplication.kt b/android/app/src/main/java/com/fabledsword/inkwell/InkwellApplication.kt similarity index 69% rename from android/app/src/main/java/com/fabledsword/thoughtsync/ThoughtSyncApplication.kt rename to android/app/src/main/java/com/fabledsword/inkwell/InkwellApplication.kt index 1303297..57144ac 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ThoughtSyncApplication.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/InkwellApplication.kt @@ -1,8 +1,9 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.app.Application import android.util.Log -import com.fabledsword.thoughtsync.core.ThoughtSync +import com.fabledsword.inkwell.core.Inkwell +import com.fabledsword.inkwell.core.setClientAgent /** * Opens the shared Rust core once, for the process lifetime. @@ -15,14 +16,14 @@ import com.fabledsword.thoughtsync.core.ThoughtSync * removed on uninstall, and never on external media. The core does not guess at * platform paths; Android is the only thing that knows where this is. */ -class ThoughtSyncApplication : Application() { +class InkwellApplication : Application() { /** * Null only if the store could not be opened — a corrupt or unwritable * database. The UI reports that honestly rather than crashing on first * touch, because a user whose notes won't open needs a message, not a * stack trace. */ - var core: ThoughtSync? = null + var core: Inkwell? = null private set var openFailure: String? = null @@ -30,8 +31,14 @@ class ThoughtSyncApplication : Application() { override fun onCreate() { super.onCreate() + + // Introduce this app to any server it links to, before anything can sync. The + // core can't name its host app, since the same crate is compiled into the + // desktop app. "unknown" rather than a guess when the package manager won't say. + setClientAgent("inkwell-android", installedVersionName() ?: "unknown") + try { - val handle = ThoughtSync(filesDir.absolutePath) + val handle = Inkwell(filesDir.absolutePath, KeystoreTokenSeal()) core = handle Log.i(TAG, "local store ready — ${handle.summary()}") } catch (e: Exception) { @@ -44,6 +51,6 @@ class ThoughtSyncApplication : Application() { } private companion object { - const val TAG = "ThoughtSync" + const val TAG = LogTag.APP } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/KeystoreTokenSeal.kt b/android/app/src/main/java/com/fabledsword/inkwell/KeystoreTokenSeal.kt new file mode 100644 index 0000000..92894e6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/KeystoreTokenSeal.kt @@ -0,0 +1,96 @@ +package com.fabledsword.inkwell + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import com.fabledsword.inkwell.core.TokenSeal +import java.security.KeyStore +import java.util.Base64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Keeps the device token sealed under a key in the Android Keystore. The core + * stores what this returns, never the token itself. + * + * The key never leaves this phone, so a copy of the app's files yields a token + * nothing can open. That includes Android's backup, which stays on: an unlinked + * phone's notes exist only here, and a backup is their one other copy. Restored + * onto another phone, the notes come back and the app asks to sign in again. + * + * Both methods answer null rather than throw, because an exception crossing into + * the core would be a panic there. The core reads null from [sealToken] as "store + * it as is for now" and from [openToken] as "sign in again". + */ +class KeystoreTokenSeal : TokenSeal { + override fun sealToken(token: String): String? = + runCatching { SealedBox.seal(key(), token) } + .onFailure { Log.w(TAG, "couldn't seal the device token", it) } + .getOrNull() + + override fun openToken(sealed: String): String? = + runCatching { SealedBox.open(key(), sealed) } + .onFailure { Log.w(TAG, "the device token won't open on this phone", it) } + .getOrNull() + + private fun key(): SecretKey { + val store = KeyStore.getInstance(KEYSTORE).apply { load(null) } + (store.getKey(ALIAS, null) as? SecretKey)?.let { return it } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + generator.init( + KeyGenParameterSpec + .Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_BITS) + .build(), + ) + return generator.generateKey() + } + + private companion object { + const val TAG = LogTag.APP + const val KEYSTORE = "AndroidKeyStore" + const val ALIAS = "inkwell-device-token" + const val KEY_BITS = 256 + } +} + +/** + * AES-GCM with a fresh IV per seal, the IV stored in front of the ciphertext. Split + * from the Keystore so it can be tested on the JVM with an ordinary key. + */ +internal object SealedBox { + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val TAG_BITS = 128 + private const val IV_BYTES = 12 + + // Binds a sealed value to what it is, so it can't be passed off as another secret. + private val AAD = "inkwell-device-token-v1".toByteArray(Charsets.UTF_8) + + fun seal( + key: SecretKey, + plaintext: String, + ): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + // The provider picks a random IV; the Keystore refuses a caller-chosen one. + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(AAD) + val sealed = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return Base64.getEncoder().encodeToString(sealed) + } + + fun open( + key: SecretKey, + sealed: String, + ): String { + val bytes = Base64.getDecoder().decode(sealed) + require(bytes.size > IV_BYTES) { "sealed value too short" } + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, bytes, 0, IV_BYTES)) + cipher.updateAAD(AAD) + return String(cipher.doFinal(bytes, IV_BYTES, bytes.size - IV_BYTES), Charsets.UTF_8) + } +} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/LogTag.kt b/android/app/src/main/java/com/fabledsword/inkwell/LogTag.kt new file mode 100644 index 0000000..9eef97c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/LogTag.kt @@ -0,0 +1,12 @@ +package com.fabledsword.inkwell + +/** + * The tags the app logs under, one per concern, so `adb logcat -s` on one of them + * finds every line about it whichever file wrote it. + */ +internal object LogTag { + const val APP = "Inkwell" + const val UPDATE = "InkwellUpdate" + const val REMINDERS = "InkwellReminders" + const val WORKER = "InkwellWorker" +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt similarity index 63% rename from android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt rename to android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt index a3a2596..20a7fa4 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/MainActivity.kt @@ -1,7 +1,8 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.Manifest import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity @@ -11,6 +12,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -21,21 +23,29 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.core.content.IntentCompat import androidx.lifecycle.viewmodel.compose.viewModel -import com.fabledsword.thoughtsync.core.ThoughtSync -import com.fabledsword.thoughtsync.ui.BoardScreen -import com.fabledsword.thoughtsync.ui.BoardSync -import com.fabledsword.thoughtsync.ui.BoardUpdate -import com.fabledsword.thoughtsync.ui.BoardViewModel -import com.fabledsword.thoughtsync.ui.ForegroundTransitions -import com.fabledsword.thoughtsync.ui.NoteEditorScreen -import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen -import com.fabledsword.thoughtsync.ui.SyncScreen -import com.fabledsword.thoughtsync.ui.SyncState -import com.fabledsword.thoughtsync.ui.SyncViewModel -import com.fabledsword.thoughtsync.ui.ThoughtSyncTheme -import com.fabledsword.thoughtsync.ui.UpdateViewModel -import com.fabledsword.thoughtsync.ui.olderThan +import com.fabledsword.inkwell.core.Inkwell +import com.fabledsword.inkwell.ui.AttachmentFiles +import com.fabledsword.inkwell.ui.BoardScreen +import com.fabledsword.inkwell.ui.BoardSync +import com.fabledsword.inkwell.ui.BoardUpdate +import com.fabledsword.inkwell.ui.BoardViewModel +import com.fabledsword.inkwell.ui.ForegroundTransitions +import com.fabledsword.inkwell.ui.InkwellTheme +import com.fabledsword.inkwell.ui.LocalAttachmentFiles +import com.fabledsword.inkwell.ui.NoteEditorScreen +import com.fabledsword.inkwell.ui.OnEachForeground +import com.fabledsword.inkwell.ui.ShareSheet +import com.fabledsword.inkwell.ui.ShareViewModel +import com.fabledsword.inkwell.ui.StoreUnavailableScreen +import com.fabledsword.inkwell.ui.SyncScreen +import com.fabledsword.inkwell.ui.SyncState +import com.fabledsword.inkwell.ui.SyncViewModel +import com.fabledsword.inkwell.ui.TagsScreen +import com.fabledsword.inkwell.ui.TagsViewModel +import com.fabledsword.inkwell.ui.UpdateViewModel +import com.fabledsword.inkwell.ui.olderThan import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -51,15 +61,27 @@ class MainActivity : ComponentActivity() { */ private val requestedNote = mutableStateOf(null) + /** + * Text or files shared into the app from elsewhere, waiting to become a note. + * + * Same shape and same reason as [requestedNote]: a share that arrives while + * the app is already running lands in [onNewIntent], long after the + * composition was built, so a piece of state it is already reading is the only + * way in. The activity is `singleTop` in the manifest precisely so that this + * path exists for an intent another app built. + */ + private val shared = mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - val app = application as ThoughtSyncApplication + val app = application as InkwellApplication requestedNote.value = takeRequestedNote(intent) + shared.value = takeShared(intent) setContent { - ThoughtSyncTheme { + InkwellTheme { val core = app.core if (core == null) { // The store never opened. There is no board to show and no @@ -67,7 +89,12 @@ class MainActivity : ComponentActivity() { // than render an empty board that looks like data loss. StoreUnavailableScreen(reason = app.openFailure) } else { - App(core, requestedNote) + // Application context inside, so holding it for the + // composition's life cannot leak this activity. + val files = remember(core) { AttachmentFiles(this, core) } + CompositionLocalProvider(LocalAttachmentFiles provides files) { + App(core, files, requestedNote, shared) + } } } } @@ -77,6 +104,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) requestedNote.value = takeRequestedNote(intent) + shared.value = takeShared(intent) } /** @@ -92,10 +120,83 @@ class MainActivity : ComponentActivity() { intent.removeExtra(Reminders.EXTRA_NOTE_ID) return id } + + /** + * Read what a share or a text selection brought in, and CONSUME it. + * + * Consumed for the same reason [takeRequestedNote] is: the activity keeps the + * intent it was launched with, so without removing the extras a rotation would + * replay the share and mint the same note again, with nothing on screen to + * explain where the duplicates were coming from. + */ + private fun takeShared(intent: Intent?): SharedIn? { + val incoming = + when (intent?.action) { + Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE -> + SharedIn(intent.takeSendText(), intent.takeStreams()) + Intent.ACTION_PROCESS_TEXT -> SharedIn(intent.takeProcessText(), emptyList()) + else -> null + } + return incoming?.takeIf { !it.text.isNullOrBlank() || it.files.isNotEmpty() } + } +} + +/** + * What a share brought: words, files, or both — a photo shared from the gallery + * often comes with a caption. + * + * The files are content URIs the sending app granted this one read access to. The + * grant lasts while this activity does, which is longer than reading them takes. + */ +data class SharedIn( + val text: String?, + val files: List, +) + +/** The file or files a SEND or SEND_MULTIPLE carried, consumed like the text. */ +private fun Intent.takeStreams(): List { + val streams = + if (action == Intent.ACTION_SEND_MULTIPLE) { + IntentCompat.getParcelableArrayListExtra(this, Intent.EXTRA_STREAM, Uri::class.java).orEmpty() + } else { + listOfNotNull(IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)) + } + removeExtra(Intent.EXTRA_STREAM) + return streams +} + +/** + * The shared text, with a subject line above it when the sender gave one. + * + * Sharing a page from a browser sends EXTRA_SUBJECT as the page title and + * EXTRA_TEXT as the URL. Keeping both makes the note read as its title, because + * the core names a note by its first line — so this is not decoration, it is what + * turns a board of identical-looking links into a board you can scan. + * + * `distinct` because plenty of apps put the same string in both, and a note that + * says the URL twice is worse than one that says it once. + */ +private fun Intent.takeSendText(): String? { + val body = getStringExtra(Intent.EXTRA_TEXT) + val subject = getStringExtra(Intent.EXTRA_SUBJECT) + removeExtra(Intent.EXTRA_TEXT) + removeExtra(Intent.EXTRA_SUBJECT) + return listOfNotNull(subject, body) + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .joinToString("\n") +} + +/** The selection from another app's text field, via the selection toolbar. */ +private fun Intent.takeProcessText(): String? { + val text = getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString() + removeExtra(Intent.EXTRA_PROCESS_TEXT) + return text } /** Which screen is up. Exactly one at a time. */ -private enum class Screen { BOARD, EDITOR, SYNC } +private enum class Screen { BOARD, EDITOR, SYNC, TAGS } /** * The whole app, once the store is open. @@ -104,21 +205,23 @@ private enum class Screen { BOARD, EDITOR, SYNC } * both cover the display completely, so keeping the board's two-column grid * measuring and recomposing underneath one would be pure waste. * - * Still no navigation library. Three destinations, each entered from exactly one + * No navigation library. Four destinations, each entered from exactly one * place and left by back — a nav graph would be ceremony around an enum, and the * state that actually matters (which note is open, whether this device is linked) * already lives in view models. */ @Composable private fun App( - core: ThoughtSync, + core: Inkwell, + files: AttachmentFiles, requestedNote: MutableState, + shared: MutableState, ) { val context = LocalContext.current val board: BoardViewModel = viewModel( factory = - BoardViewModel.factory(core) { + BoardViewModel.factory(core, readFile = files::read) { // Any store write can have moved the next reminder. Called on // the IO dispatcher by the view model, which is where it has to // be — this reads every note carrying a reminder. @@ -135,6 +238,15 @@ private fun App( } } + // Cleared the same way and for the same reason: without it every later + // recomposition would capture the share again as a new note. + LaunchedEffect(shared.value) { + shared.value?.let { + board.captureShared(it.text, it.files) + shared.value = null + } + } + ReminderAlarms(core) // A pull can rewrite every note the board is holding, so a sync that changed // anything tells it to reload. Wired here, at the one place that owns both. @@ -145,10 +257,21 @@ private fun App( // the display, and nothing in the store cares. Saveable so a rotation does not // close it. // - // The capture sheet used to keep its own flag here too. It is gone: the + button - // opens the editor on an unsaved draft, so writing a note and editing one are the - // same surface with the same toolbar. + // There is no separate capture sheet: the + button opens the editor on an unsaved + // draft, so writing a note and editing one are the same surface. var showingSync by rememberSaveable { mutableStateOf(false) } + var showingTags by rememberSaveable { mutableStateOf(false) } + + // Tag writes reach the board two ways at once: the drawer lists tags, and the + // board may be LOOKING at one that a delete or a merge just removed. Both are + // `refreshLabels`, which also leaves a lens whose tag stopped existing. + val tags: TagsViewModel = + viewModel(factory = TagsViewModel.factory(core, onStoreChanged = board::refreshLabels)) + + // A share landing changes the note's `shared` flag, which the board's card chip + // reads, so the board reloads after one. + val share: ShareViewModel = + viewModel(factory = ShareViewModel.factory(core, onStoreChanged = board::refresh)) val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) val settings = remember(context) { SyncSettings(context) } @@ -161,6 +284,9 @@ private fun App( val screen = when { showingSync -> Screen.SYNC + // Above the editor: tags are reached only from the board's drawer, so + // there is never an open note underneath one to go back to. + showingTags -> Screen.TAGS editing != null -> Screen.EDITOR else -> Screen.BOARD } @@ -188,6 +314,18 @@ private fun App( onInstallOutcome = update::consumeInstallOutcome, ) + Screen.TAGS -> + TagsScreen( + state = tags.state, + onClose = { showingTags = false }, + onCreate = tags::create, + onRename = tags::rename, + onColour = tags::setColour, + onDelete = tags::remove, + onMerge = tags::merge, + onDismissError = tags::dismissError, + ) + Screen.EDITOR -> NoteEditorScreen( // Non-null by construction: `screen` is EDITOR only when it is. @@ -196,6 +334,7 @@ private fun App( labels = board.state.labels, saving = board.state.saving, error = board.state.error, + onShare = { share.open(editing.id) }, // The one seam between the editor and the store. Exhaustive at the // other end, so a new action cannot be added without being handled. onAction = { board.onEditorAction(editing, it) }, @@ -218,7 +357,10 @@ private fun App( onDismissError = sync::dismissSyncError, ), onOpenSync = { showingSync = true }, + onManageTags = { showingTags = true }, onSearch = board::search, + onFilter = board::filter, + onReorder = board::reorder, onCompose = board::compose, onToggleItem = board::toggleItem, // The SAME seam the editor uses. `onEditorAction` is already the @@ -244,9 +386,20 @@ private fun App( } } + // Over whichever screen opened it; a ModalBottomSheet handles its own back. + if (share.state.noteId != null) { + ShareSheet( + state = share.state, + onShare = share::share, + onUnshare = share::unshare, + onDismiss = share::close, + ) + } + // The sync screen has no back handler of its own, so one lives here. The // editor keeps its own, because it has to save the open note before leaving. BackHandler(enabled = showingSync) { showingSync = false } + BackHandler(enabled = showingTags) { showingTags = false } } /** @@ -263,7 +416,7 @@ private fun App( * notifications this app would send — is spending it on nothing. */ @Composable -private fun ReminderAlarms(core: ThoughtSync) { +private fun ReminderAlarms(core: Inkwell) { val context = LocalContext.current val scope = rememberCoroutineScope() // Off the main thread: this reads every note that has a reminder, and a phone @@ -277,7 +430,7 @@ private fun ReminderAlarms(core: ThoughtSync) { refresh() } - ForegroundTransitions(onForeground = { refresh() }, onBackground = {}) + ForegroundTransitions(onForeground = { refresh() }) LaunchedEffect(Unit) { // TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below @@ -292,33 +445,21 @@ private fun ReminderAlarms(core: ThoughtSync) { } /** - * Looking for an app update without being asked. - * - * Until this existed, `check()` had exactly one caller: a button on the sync screen. - * So a new build was found only by someone who went looking for one, and the operator - * had to remember to go looking — which is the same as not being told. + * Looking for an app update without being asked, so a new build is found without + * anyone going to look for one. * * On coming forward rather than on a timer: it is the moment the person is present, * and the view model rate-limits so flicking between two apps is not a re-check. - * Unlinked devices are skipped entirely — updates come from a linked server, and - * there is nothing to ask. + * Unlinked devices are skipped, since updates come from a linked server. */ @Composable private fun AutomaticUpdate( linked: Boolean, onCheck: () -> Unit, ) { - var wanted by remember { mutableStateOf(false) } - ForegroundTransitions(onForeground = { wanted = true }, onBackground = {}) - - LaunchedEffect(wanted, linked) { - if (!wanted || !linked) return@LaunchedEffect - // Consumed here, so this fires once per trip to the foreground however many - // times the effect restarts. There is no suspension point before the call, so - // the block completes before the recomposition that would cancel it. - wanted = false - onCheck() - } + // Waits for a link rather than dropping the trip: linking a device while the app is + // in front checks as soon as it is linked. + OnEachForeground(ready = linked, act = onCheck) } /** @@ -355,28 +496,18 @@ private fun AutomaticSync( if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context) } - var wanted by remember { mutableStateOf(false) } - ForegroundTransitions( - onForeground = { wanted = true }, + // Ready once `loading` is done, so the decision waits for the stored link to be + // READ. At first composition `linked` is still false because nothing has looked in + // the database yet, and acting on that would skip the sync on every cold start. + OnEachForeground( + ready = !state.loading, onBackground = { // Unsent work follows the person out of the app. Without this, a note // written on a phone that then goes into a pocket for the night does // not reach the desktop until the app is opened again by hand. if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context) }, - ) - - // Keyed on `loading` so the decision waits for the stored link to be READ. At - // first composition `linked` is still false because nothing has looked in the - // database yet, and acting on that would skip the sync on every cold start. - LaunchedEffect(wanted, state.loading) { - if (!wanted || state.loading) return@LaunchedEffect - // Consumed here, so this fires exactly once per trip to the foreground - // however many times the effect restarts. Writing a key from inside the - // effect does restart it — but there is no suspension point between here - // and the call below, so the block runs to completion before the - // recomposition that would cancel it can be scheduled. - wanted = false + ) { val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES) if (enabled && state.linked && worthIt) onSync() } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt b/android/app/src/main/java/com/fabledsword/inkwell/ReminderNotification.kt similarity index 97% rename from android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt rename to android/app/src/main/java/com/fabledsword/inkwell/ReminderNotification.kt index e904209..eafd0d3 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderNotification.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ReminderNotification.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.app.NotificationChannel import android.app.NotificationManager @@ -8,7 +8,7 @@ import android.content.Intent import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.fabledsword.thoughtsync.core.Note +import com.fabledsword.inkwell.core.Note /** * What a due reminder looks like in the shade. @@ -117,5 +117,5 @@ internal object ReminderNotification { ) private const val CHANNEL = "reminders" - private const val TAG = "ThoughtSyncReminders" + private const val TAG = LogTag.REMINDERS } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt b/android/app/src/main/java/com/fabledsword/inkwell/ReminderReceiver.kt similarity index 86% rename from android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt rename to android/app/src/main/java/com/fabledsword/inkwell/ReminderReceiver.kt index 4a3d151..3cf09ab 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ReminderReceiver.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ReminderReceiver.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.content.BroadcastReceiver import android.content.Context @@ -36,7 +36,7 @@ class ReminderReceiver : BroadcastReceiver() { context: Context, intent: Intent, ) { - val core = (context.applicationContext as? ThoughtSyncApplication)?.core ?: return + val core = (context.applicationContext as? InkwellApplication)?.core ?: return val action = intent.action val noteId = intent.getStringExtra(Reminders.EXTRA_NOTE_ID) val app = context.applicationContext @@ -64,9 +64,9 @@ class ReminderReceiver : BroadcastReceiver() { } companion object { - const val ACTION_DUE = "com.fabledsword.thoughtsync.REMINDER_DUE" - const val ACTION_DONE = "com.fabledsword.thoughtsync.REMINDER_DONE" - const val ACTION_SNOOZE = "com.fabledsword.thoughtsync.REMINDER_SNOOZE" - private const val TAG = "ThoughtSyncReminders" + const val ACTION_DUE = "com.fabledsword.inkwell.REMINDER_DUE" + const val ACTION_DONE = "com.fabledsword.inkwell.REMINDER_DONE" + const val ACTION_SNOOZE = "com.fabledsword.inkwell.REMINDER_SNOOZE" + private const val TAG = LogTag.REMINDERS } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt b/android/app/src/main/java/com/fabledsword/inkwell/Reminders.kt similarity index 92% rename from android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt rename to android/app/src/main/java/com/fabledsword/inkwell/Reminders.kt index 34686c8..7c02202 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/Reminders.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/Reminders.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.app.AlarmManager import android.app.PendingIntent @@ -7,9 +7,10 @@ import android.content.Intent import android.os.Build import android.util.Log import androidx.core.app.NotificationManagerCompat -import com.fabledsword.thoughtsync.core.Note -import com.fabledsword.thoughtsync.core.ThoughtSync -import java.time.OffsetDateTime +import com.fabledsword.inkwell.core.Inkwell +import com.fabledsword.inkwell.core.Note +import com.fabledsword.inkwell.ui.SNOOZE_HOUR +import com.fabledsword.inkwell.ui.epochMillis /** * Getting a reminder in front of someone at the time they asked for. @@ -25,7 +26,7 @@ import java.time.OffsetDateTime * ## AlarmManager, not WorkManager * * The background sync runs on WorkManager and is right to: nobody minds whether it - * happens at 3:05 or 3:19. A reminder minded very much. WorkManager's periodic + * happens at 3:05 or 3:19. A reminder does. WorkManager's periodic * floor is fifteen minutes and it batches work into maintenance windows, so * "remind me at 09:00" would routinely arrive at 09:14 — which is not a reminder, * it is a rebuke. @@ -51,8 +52,7 @@ object Reminders { */ private const val MISSED_WINDOW_MS = 24L * 60 * 60 * 1000 - private const val SNOOZE_MINUTES = 60L - private const val TAG = "ThoughtSyncReminders" + private const val TAG = LogTag.REMINDERS /** * Announce what is due, then schedule the next one. @@ -63,7 +63,7 @@ object Reminders { */ fun refresh( context: Context, - core: ThoughtSync, + core: Inkwell, ) { ReminderNotification.ensureChannel(context) val notes = @@ -111,7 +111,7 @@ object Reminders { */ fun promptToNotifyDue( context: Context, - core: ThoughtSync, + core: Inkwell, ): Boolean = !Announced(context).askedToNotify && !NotificationManagerCompat.from(context).areNotificationsEnabled() && @@ -123,7 +123,7 @@ object Reminders { /** Clear the reminder, as the notification's Done action. */ fun complete( context: Context, - core: ThoughtSync, + core: Inkwell, noteId: String, ) { runCatching { core.completeReminder(noteId) } @@ -134,10 +134,10 @@ object Reminders { /** Push the reminder an hour out, as the notification's Snooze action. */ fun snooze( context: Context, - core: ThoughtSync, + core: Inkwell, noteId: String, ) { - runCatching { core.snoozeReminder(noteId, SNOOZE_MINUTES) } + runCatching { core.snoozeReminder(noteId, SNOOZE_HOUR) } .onFailure { Log.w(TAG, "could not snooze reminder", it) } ReminderNotification.dismiss(context, noteId) } @@ -194,10 +194,7 @@ object Reminders { // ────────────────────────────── bookkeeping ────────────────────────────── /** Epoch millis of a note's reminder, or null if it has none we can read. */ - private fun at(note: Note): Long? = - note.remindAt?.let { - runCatching { OffsetDateTime.parse(it).toInstant().toEpochMilli() }.getOrNull() - } + private fun at(note: Note): Long? = note.remindAt?.let(::epochMillis) /** * Identity of one OCCURRENCE, not of the note. @@ -237,7 +234,7 @@ private class Announced( fun markAsked() = prefs.edit().putBoolean(KEY_ASKED, true).apply() private companion object { - const val FILE = "thoughtsync-reminders" + const val FILE = "inkwell-reminders" const val KEY_SEEN = "announced" const val KEY_PRIMED = "primed" const val KEY_ASKED = "asked_to_notify" diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncSchedule.kt b/android/app/src/main/java/com/fabledsword/inkwell/SyncSchedule.kt similarity index 84% rename from android/app/src/main/java/com/fabledsword/thoughtsync/SyncSchedule.kt rename to android/app/src/main/java/com/fabledsword/inkwell/SyncSchedule.kt index 1bc5456..973f460 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncSchedule.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/SyncSchedule.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.content.Context import androidx.work.BackoffPolicy @@ -9,6 +9,7 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager +import androidx.work.WorkRequest import java.util.concurrent.TimeUnit /** @@ -38,8 +39,7 @@ object SyncSchedule { fun enable(context: Context) { val request = PeriodicWorkRequestBuilder(PERIOD_MINUTES, TimeUnit.MINUTES) - .setConstraints(networkRequired()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .online() .build() // UPDATE, not KEEP: this is called on every launch, and KEEP would ignore @@ -68,8 +68,7 @@ object SyncSchedule { fun pushSoon(context: Context) { val request = OneTimeWorkRequestBuilder() - .setConstraints(networkRequired()) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) + .online() .build() // REPLACE rather than KEEP: if an earlier attempt is sitting in a long @@ -79,14 +78,17 @@ object SyncSchedule { .enqueueUniqueWork(PUSH, ExistingWorkPolicy.REPLACE, request) } - private fun networkRequired(): Constraints = - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() + /** What both requests share: a network to run on, and the same backoff. */ + private fun > B.online(): B = + setConstraints( + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ).setBackoffCriteria(BackoffPolicy.EXPONENTIAL, BACKOFF_SECONDS, TimeUnit.SECONDS) - private const val PERIODIC = "thoughtsync-periodic-sync" - private const val PUSH = "thoughtsync-push-pending" + private const val PERIODIC = "inkwell-periodic-sync" + private const val PUSH = "inkwell-push-pending" /** WorkManager's own minimum for periodic work. Asking for less gets this. */ private const val PERIOD_MINUTES = 15L diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncSettings.kt b/android/app/src/main/java/com/fabledsword/inkwell/SyncSettings.kt similarity index 95% rename from android/app/src/main/java/com/fabledsword/thoughtsync/SyncSettings.kt rename to android/app/src/main/java/com/fabledsword/inkwell/SyncSettings.kt index bc7abf6..1ffc051 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncSettings.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/SyncSettings.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.content.Context @@ -38,7 +38,7 @@ class SyncSettings( } private companion object { - const val FILE = "thoughtsync-sync" + const val FILE = "inkwell-sync" const val KEY_AUTOMATIC = "automatic" } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt b/android/app/src/main/java/com/fabledsword/inkwell/SyncWorker.kt similarity index 92% rename from android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt rename to android/app/src/main/java/com/fabledsword/inkwell/SyncWorker.kt index e788f0e..18091f7 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/SyncWorker.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/SyncWorker.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.content.Context import android.util.Log @@ -8,7 +8,7 @@ import androidx.work.WorkerParameters /** * One sync cycle, run by the system rather than by a person. * - * WorkManager may start the process to run this, which means [ThoughtSyncApplication.onCreate] + * WorkManager may start the process to run this, which means [InkwellApplication.onCreate] * has already opened the store by the time [doWork] is called — the same handle * the UI uses, so there is never a second SQLite connection racing the first. * @@ -30,7 +30,7 @@ class SyncWorker( params: WorkerParameters, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { - val core = (applicationContext as? ThoughtSyncApplication)?.core + val core = (applicationContext as? InkwellApplication)?.core // Both of these are "nothing to do", not "something went wrong", so both // report success and let the run retire quietly: @@ -67,6 +67,6 @@ class SyncWorker( } private companion object { - const val TAG = "ThoughtSyncWorker" + const val TAG = LogTag.WORKER } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/TagViews.kt b/android/app/src/main/java/com/fabledsword/inkwell/TagViews.kt new file mode 100644 index 0000000..11a978d --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/TagViews.kt @@ -0,0 +1,31 @@ +package com.fabledsword.inkwell + +import android.content.Context + +/** + * When each tag's lens was last opened on this phone, for the drawer's recent tags. + * Opening a tag counts as using it, alongside the notes that carry it. + * + * Kept on the device rather than synced: it is a convenience about where this + * person's thumb goes on this screen, and losing it costs one tap. + */ +class TagViews( + context: Context, +) { + // applicationContext, as in SyncSettings: this must not hold an Activity. + private val prefs = + context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) + + fun all(): Map = prefs.all.mapNotNull { (id, at) -> (at as? Long)?.let { id to it } }.toMap() + + /** Records a view now and returns every view, this one included. */ + fun record(tagId: String): Map { + val now = System.currentTimeMillis() + prefs.edit().putLong(tagId, now).apply() + return all() + (tagId to now) + } + + private companion object { + const val FILE = "inkwell-tag-views" + } +} diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateOutcome.kt b/android/app/src/main/java/com/fabledsword/inkwell/UpdateOutcome.kt similarity index 97% rename from android/app/src/main/java/com/fabledsword/thoughtsync/UpdateOutcome.kt rename to android/app/src/main/java/com/fabledsword/inkwell/UpdateOutcome.kt index b027884..cc2ea17 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateOutcome.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/UpdateOutcome.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateReceiver.kt b/android/app/src/main/java/com/fabledsword/inkwell/UpdateReceiver.kt similarity index 91% rename from android/app/src/main/java/com/fabledsword/thoughtsync/UpdateReceiver.kt rename to android/app/src/main/java/com/fabledsword/inkwell/UpdateReceiver.kt index 63e2c1c..677a680 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/UpdateReceiver.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/UpdateReceiver.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync +package com.fabledsword.inkwell import android.content.BroadcastReceiver import android.content.Context @@ -12,8 +12,7 @@ import android.util.Log * Without this the app would `commit` and learn nothing — a failed install would * look exactly like a person deciding not to go ahead, and the update card would * sit there claiming an update is available with no explanation of why nothing - * happened. That was the specific complaint recorded against Minstrel's first - * attempt (Scribe #2438). + * happened. * * The result is written to [UpdateOutcome] rather than notified: the app is on * screen when this fires — someone just tapped Update — so the place to say it is @@ -71,7 +70,7 @@ class UpdateReceiver : BroadcastReceiver() { } companion object { - const val ACTION_INSTALLED = "com.fabledsword.thoughtsync.UPDATE_INSTALLED" - private const val TAG = "ThoughtSyncUpdate" + const val ACTION_INSTALLED = "com.fabledsword.inkwell.UPDATE_INSTALLED" + private const val TAG = LogTag.UPDATE } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt new file mode 100644 index 0000000..f17857b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt @@ -0,0 +1,297 @@ +package com.fabledsword.inkwell.ui + +import android.content.ClipData +import android.content.ClipDescription +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import android.net.Uri +import android.provider.OpenableColumns +import android.util.LruCache +import androidx.annotation.StringRes +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.content.FileProvider +import com.fabledsword.inkwell.R +import com.fabledsword.inkwell.core.Attachment +import com.fabledsword.inkwell.core.Inkwell +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * A file picked or shared into the app, read and ready to attach — or the reason + * it could not be. + */ +sealed interface PickedFile { + // A plain class: a data class would compare `bytes` by identity, and nothing + // here compares two picked files anyway. + class Ready( + val name: String, + val mime: String, + val bytes: ByteArray, + ) : PickedFile + + data class Refused( + val reason: String, + ) : PickedFile +} + +/** + * What [AttachmentFiles] made to hand a file to another app — an intent to start, a + * clip for the clipboard — or why there isn't one. + */ +sealed interface Handout { + data class Ready( + val value: T, + ) : Handout + + data class Failed( + val message: String, + ) : Handout +} + +/** + * Everything the app does with attachment FILES, as opposed to attachment rows: + * reading a picked file, decoding an image for display, and handing a file to + * another app to open. + * + * Holds the application context, never an Activity, so the board's view model can + * keep a reference to [read] without leaking a screen. + */ +class AttachmentFiles( + context: Context, + private val core: Inkwell, +) { + private val app = context.applicationContext + + /** + * Decoded images, keyed by hash and size. Sized in bytes against an eighth of + * the heap, which is the platform's own guidance for an in-memory image cache. + */ + private val images = + object : LruCache(cacheBytes()) { + override fun sizeOf( + key: String, + value: ImageBitmap, + ): Int = value.width * value.height * BYTES_PER_PIXEL + } + + /** + * Read a picked or shared file, all of it, with its name and type. + * + * Blocking; call it off the main thread. Refuses anything over + * [MAX_ATTACH_MB] — before reading it when the sender says how big it is — so + * a long video shared by mistake is a message rather than an out-of-memory + * crash. + */ + fun read(uri: Uri): PickedFile { + val (label, declared) = describe(uri) + val overDeclared = (declared ?: 0) > MAX_ATTACH_BYTES + val bytes = if (overDeclared) null else readCapped(uri) + return when { + overDeclared -> tooLarge(label) + bytes == null -> PickedFile.Refused(app.getString(R.string.attach_unreadable, label)) + bytes.size > MAX_ATTACH_BYTES -> tooLarge(label) + else -> PickedFile.Ready(label, app.contentResolver.getType(uri) ?: GENERIC_MIME, bytes) + } + } + + /** + * The image, decoded no larger than it will be drawn, or null when this device + * doesn't hold the file yet or it isn't an image Android can read. + * + * Blocking; call it off the main thread. + */ + fun image( + attachment: Attachment, + maxPx: Int, + ): ImageBitmap? { + val sha = attachment.sha256 ?: return null + val key = "$sha@$maxPx" + return images.get(key) + ?: core.blobPath(sha)?.let { decode(it, maxPx) }?.also { images.put(key, it) } + } + + /** + * An intent that hands the file to whatever app opens its type, or the reason + * there can't be one. + * + * Blocking; call it off the main thread, then start the intent from a screen. + */ + fun opener(attachment: Attachment): Handout = + handOut(attachment, R.string.attach_open_failed) { uri -> + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, attachment.mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + /** + * A clip that puts the file itself on the clipboard, so it pastes into another + * app as the picture rather than as a path. + * + * The clip carries the attachment's own type: built from the URI alone, its type + * would be guessed from the file name's extension, and a pasted image saved + * without one would paste as an unknown file. The clipboard grants whichever app + * pastes it read access to the one URI. + * + * Blocking; call it off the main thread. + */ + fun clip(attachment: Attachment): Handout = + handOut(attachment, R.string.attach_copy_failed) { uri -> + val label = attachment.filename.orEmpty() + ClipData(ClipDescription(label, arrayOf(attachment.mime)), ClipData.Item(uri)) + } + + /** + * The file as a URI another app may read, made into whatever [make] builds from + * it. + * + * Copied out of the blob store first, under its real name: the store names files + * by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a + * spreadsheet. The copy lives in the cache, which the system reclaims, and only + * that directory is shared (`res/xml/file_paths.xml`). + */ + private fun handOut( + attachment: Attachment, + @StringRes failed: Int, + make: (Uri) -> T, + ): Handout { + val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File) + if (source == null) return Handout.Failed(app.getString(R.string.attach_not_here)) + val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}") + val copy = File(dir, safeName(attachment.filename)) + return try { + dir.mkdirs() + if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true) + Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy))) + } catch (e: IOException) { + Handout.Failed(app.getString(failed, e.message ?: attachment.filename.orEmpty())) + } + } + + /** The name the sender gives the file and the size it declares, when it does. */ + private fun describe(uri: Uri): Pair { + val row = + try { + app.contentResolver.query(uri, COLUMNS, null, null, null)?.use { firstRow(it) } + } catch (expected: SecurityException) { + // No grant to read it at all; the read that follows says so. + null + } + val name = row?.first?.takeIf { it.isNotBlank() } ?: uri.lastPathSegment ?: FALLBACK_NAME + return name to row?.second + } + + /** + * The file's bytes, at most one past the cap — enough to know it is over without + * reading the rest — or null when it can't be read. + */ + private fun readCapped(uri: Uri): ByteArray? = + try { + app.contentResolver.openInputStream(uri)?.use { it.readUpTo(MAX_ATTACH_BYTES + 1) } + } catch (expected: IOException) { + null + } catch (expected: SecurityException) { + null + } + + private fun tooLarge(label: String) = + PickedFile.Refused(app.getString(R.string.attach_too_large, label, MAX_ATTACH_MB)) + + private companion object { + const val BYTES_PER_PIXEL = 4 + const val CACHE_FRACTION = 8 + const val OPEN_DIR = "open" + const val GENERIC_MIME = "application/octet-stream" + const val FALLBACK_NAME = "file" + val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + + /** How far each EXIF orientation is turned from upright. */ + val ROTATIONS = + mapOf( + ExifInterface.ORIENTATION_ROTATE_90 to 90f, + ExifInterface.ORIENTATION_ROTATE_180 to 180f, + ExifInterface.ORIENTATION_ROTATE_270 to 270f, + ) + + /** DISPLAY_NAME and SIZE from the first row of a [COLUMNS] query; either may be absent. */ + fun firstRow(cursor: Cursor): Pair? = + if (cursor.moveToFirst()) { + cursor.getString(0) to (if (cursor.isNull(1)) null else cursor.getLong(1)) + } else { + null + } + + fun cacheBytes(): Int = + (Runtime.getRuntime().maxMemory() / CACHE_FRACTION) + .coerceAtMost(Int.MAX_VALUE.toLong()) + .toInt() + + fun decode( + path: String, + maxPx: Int, + ): ImageBitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val options = + BitmapFactory.Options().apply { + inSampleSize = sampleSize(bounds.outWidth, bounds.outHeight, maxPx) + } + return BitmapFactory.decodeFile(path, options)?.let { upright(it, path).asImageBitmap() } + } + + /** Camera photos are stored sideways with a tag saying so; BitmapFactory ignores it. */ + fun upright( + bitmap: Bitmap, + path: String, + ): Bitmap { + val orientation = + try { + ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, 0) + } catch (expected: IOException) { + // No readable EXIF: draw it as stored. + 0 + } + val degrees = ROTATIONS[orientation] ?: return bitmap + val turn = Matrix().apply { postRotate(degrees) } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, turn, true) + } + } +} + +/** + * The app's [AttachmentFiles], for the card and the editor. Null in previews and + * anywhere it was never provided, where attachments draw as plain file rows. + */ +val LocalAttachmentFiles = staticCompositionLocalOf { null } + +/** + * The largest file the phone will attach. Read whole into memory and copied once + * into the core, so the cap is about the phone's heap. The server has its own + * limit (25 MB by default, `max_attachment_mb`); a file over that one is kept here + * and its upload reported as refused. + */ +const val MAX_ATTACH_MB = 50 +private const val MAX_ATTACH_BYTES = MAX_ATTACH_MB * 1024 * 1024 + +/** `InputStream.readNBytes(int)` is API 33; this app supports 26. */ +private fun InputStream.readUpTo(limit: Int): ByteArray { + val out = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (total < limit) { + val read = read(buffer, 0, minOf(buffer.size, limit - total)) + if (read < 0) break + out.write(buffer, 0, read) + total += read + } + return out.toByteArray() +} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentRules.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentRules.kt new file mode 100644 index 0000000..4db9da1 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentRules.kt @@ -0,0 +1,56 @@ +package com.fabledsword.inkwell.ui + +import java.util.Locale +import kotlin.math.max +import kotlin.math.roundToInt + +// The attachment decisions that need no Android: which files draw as pictures, how +// far to scale a decode, what to name a copy, how to print a size. Kept apart from +// AttachmentFiles so the JVM unit tests can load them without a device. + +/** + * Whether a type is drawn as a picture rather than offered as a file. SVG is + * excluded on every surface: it is a document that can carry script. + * The same rule as `rendersInline` in the web's `notes/attachments.ts`. + */ +fun rendersInline(mime: String): Boolean { + val type = mime.lowercase().substringBefore(';').trim() + return type.startsWith("image/") && type != "image/svg+xml" +} + +/** + * The power-of-two step BitmapFactory decodes at, so the result's longer side is + * still at least [maxPx]: never upscaled on screen, never decoded at full camera + * resolution for a thumbnail. + */ +fun sampleSize( + width: Int, + height: Int, + maxPx: Int, +): Int { + val longest = max(width, height) + var sample = 1 + while (longest / (sample * 2) >= maxPx) sample *= 2 + return sample +} + +/** A name safe to create in the cache: no path separators, never empty. */ +fun safeName(filename: String?): String { + val base = + filename + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.trim() + .orEmpty() + return base.takeIf { it.isNotEmpty() && it != "." && it != ".." } ?: "file" +} + +/** "12 KB", "3.4 MB" — the same rounding as `fmtSize` in the web's NoteEditor.vue. */ +fun sizeLabel(bytes: Long): String = + when { + bytes < KIB -> "$bytes B" + bytes < KIB * KIB -> "${(bytes / KIB).roundToInt()} KB" + else -> "%.1f MB".format(Locale.ROOT, bytes / (KIB * KIB)) + } + +private const val KIB = 1024.0 diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt new file mode 100644 index 0000000..5f4a9a0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt @@ -0,0 +1,387 @@ +package com.fabledsword.inkwell.ui + +import android.content.ActivityNotFoundException +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.fabledsword.inkwell.R +import com.fabledsword.inkwell.core.Attachment +import com.fabledsword.inkwell.core.LinkPreview +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private val ATTACHMENT_RADIUS = 8.dp + +/** + * Decoded no larger than this on the card. A board column on a phone is around + * 180dp wide, which is about 540px at the common densities, so this is sharp on + * every screen the app runs on without decoding a 12-megapixel photo for it. + */ +private const val CARD_IMAGE_PX = 640 + +/** The editor draws images at the sheet's full width. */ +private const val EDITOR_IMAGE_PX = 1440 + +/** + * The narrowest an image may draw, as width over height. A tall screenshot drawn + * at its own ratio would push the rest of the card off the board, so it is cropped + * to 3:4 instead; anything wider than that draws whole. + */ +private const val MIN_ASPECT = 0.75f + +/** How many file names the card lists before it says "and N more". */ +private const val CARD_FILE_ROWS = 2 + +/** + * A note's attachments on its board card: the first image as a picture, then the + * other files by name. + * + * One picture, not a gallery. The card is a glance at the note, and a strip of + * thumbnails would make an image note the tallest thing on the board whatever its + * words say. The editor shows them all. + */ +@Composable +fun CardAttachments(attachments: List) { + val (images, files) = attachments.partition { rendersInline(it.mime) } + images.firstOrNull()?.let { first -> + Spacer(Modifier.height(8.dp)) + AttachmentImage(attachment = first, maxPx = CARD_IMAGE_PX) { + FileRow(attachment = first) + } + } + val rest = images.drop(1) + files + rest.take(CARD_FILE_ROWS).forEach { file -> + Spacer(Modifier.height(4.dp)) + FileRow(attachment = file) + } + if (rest.size > CARD_FILE_ROWS) { + Text( + text = stringResource(R.string.attach_more, rest.size - CARD_FILE_ROWS), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } +} + +/** + * Every attachment, in the editor: images at full width, files as rows, each one + * opening with the system and each removable. + * + * A file the server refused says so under it, in its own words — the upload is + * not retried, so without this a file that never reaches the other devices would + * look exactly like one that did. + */ +@Composable +fun EditorAttachments( + attachments: List, + readOnly: Boolean, + onAction: (EditorAction) -> Unit, +) { + val open = rememberOpener() + val copy = rememberCopier() + Column(modifier = Modifier.padding(top = 12.dp)) { + attachments.forEach { attachment -> + val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) } + Box(modifier = Modifier.padding(vertical = 4.dp)) { + if (rendersInline(attachment.mime)) { + AttachmentImage( + attachment = attachment, + maxPx = EDITOR_IMAGE_PX, + onClick = { open(attachment) }, + onLongClick = { copy(attachment) }, + ) { + FileRow(attachment = attachment, onClick = { open(attachment) }) + } + } else { + FileRow(attachment = attachment, onClick = { open(attachment) }) + } + if (!readOnly) { + IconButton( + onClick = remove, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(4.dp) + .size(32.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + ) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.attach_remove), + ) + } + } + } + attachment.uploadError?.let { reason -> + Text( + text = stringResource(R.string.attach_refused, reason), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +/** + * The note's link previews in the editor, each dismissable. + * + * Dismissing is the only control: the server made the preview and will not make + * it again, so removing one is a decision about this note rather than a refresh. + */ +@Composable +fun EditorPreviews( + previews: List, + readOnly: Boolean, + onAction: (EditorAction) -> Unit, +) { + Column(modifier = Modifier.padding(top = 12.dp)) { + previews.forEach { preview -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 2.dp), + ) { + LinkPreviewCard(preview = preview, compact = true, modifier = Modifier.weight(1f)) + if (!readOnly) { + IconButton(onClick = { onAction(EditorAction.RemovePreview(preview.id)) }) { + Icon( + Icons.Filled.Close, + contentDescription = stringResource(R.string.preview_remove), + ) + } + } + } + } + } +} + +/** Loading, drawn, or not drawable (not downloaded yet, or not an image Android reads). */ +private sealed interface ImageLoad { + data object Loading : ImageLoad + + data class Ready( + val bitmap: ImageBitmap, + ) : ImageLoad + + data object Missing : ImageLoad +} + +/** + * An attachment drawn as a picture, decoded off the main thread. + * + * In the editor a tap opens it full size in the phone's viewer and a long press + * copies it, the same gesture that copies an image in a browser. + * + * [fallback] is what draws when it can't be: a file this device hasn't downloaded + * yet, or bytes that don't decode. It is a file row, so the note still shows that + * something is attached instead of a gap. + */ +@Composable +private fun AttachmentImage( + attachment: Attachment, + maxPx: Int, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + fallback: @Composable () -> Unit, +) { + val files = LocalAttachmentFiles.current + val load by produceState(ImageLoad.Loading, attachment.sha256, maxPx, files) { + val bitmap = files?.let { withContext(Dispatchers.IO) { it.image(attachment, maxPx) } } + value = bitmap?.let { ImageLoad.Ready(it) } ?: ImageLoad.Missing + } + val shape = RoundedCornerShape(ATTACHMENT_RADIUS) + when (val state = load) { + ImageLoad.Loading -> + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(4f / 3f) + .clip(shape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + is ImageLoad.Ready -> { + val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT) + val tap = + onClick?.let { click -> + Modifier.combinedClickable( + onClickLabel = stringResource(R.string.attach_view), + onLongClickLabel = onLongClick?.let { stringResource(R.string.attach_copy) }, + onLongClick = onLongClick, + onClick = click, + ) + } ?: Modifier + Image( + bitmap = state.bitmap, + contentDescription = attachment.filename, + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(ratio) + .clip(shape) + .then(tap), + ) + } + ImageLoad.Missing -> fallback() + } +} + +/** A file by name and size, behind a paperclip. Tappable in the editor, not on the card. */ +@Composable +private fun FileRow( + attachment: Attachment, + onClick: (() -> Unit)? = null, +) { + val shape = RoundedCornerShape(ATTACHMENT_RADIUS) + val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clip(shape) + .border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape) + .then(tap) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Icon( + painter = painterResource(R.drawable.ic_attach), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = attachment.filename?.takeIf { it.isNotBlank() } ?: stringResource(R.string.attach_unnamed), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + attachment.size?.let { bytes -> + Text( + text = sizeLabel(bytes), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Clear of the editor's remove button, which sits over this corner. + modifier = Modifier.padding(start = 8.dp, end = if (onClick != null) 32.dp else 0.dp), + ) + } + } +} + +/** + * Open an attachment with whatever app handles its type, or say why not. + * + * The copy out of the blob store runs on IO; the activity starts on the main + * thread, from the screen's own context, so the viewer opens over this app rather + * than in a task of its own. + */ +@Composable +private fun rememberOpener(): (Attachment) -> Unit { + val files = LocalAttachmentFiles.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + return { attachment -> + if (files != null) { + scope.launch { + when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) { + is Handout.Ready -> start(context, opener.value) + is Handout.Failed -> toast(context, opener.message) + } + } + } + } +} + +/** + * Put an image on the clipboard, or say why not. + * + * Android 13 and later show their own confirmation when anything is copied, so the + * toast is only for the versions that don't — two notices for one copy reads as two + * copies. + */ +@Composable +private fun rememberCopier(): (Attachment) -> Unit { + val files = LocalAttachmentFiles.current + val context = LocalContext.current + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + return { attachment -> + if (files != null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + scope.launch { + when (val clip = withContext(Dispatchers.IO) { files.clip(attachment) }) { + is Handout.Ready -> { + context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip.value) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + toast(context, context.getString(R.string.attach_copied)) + } + } + is Handout.Failed -> toast(context, clip.message) + } + } + } + } +} + +private fun start( + context: Context, + intent: Intent, +) { + try { + context.startActivity(intent) + } catch (expected: ActivityNotFoundException) { + toast(context, context.getString(R.string.attach_no_app)) + } +} + +private fun toast( + context: Context, + message: String, +) = Toast.makeText(context, message, Toast.LENGTH_SHORT).show() diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt similarity index 86% rename from android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt rename to android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt index f1935c9..0a84038 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BlockBody.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BlockBody.kt @@ -1,4 +1,4 @@ -package com.fabledsword.thoughtsync.ui +package com.fabledsword.inkwell.ui import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement @@ -20,7 +20,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -28,18 +31,19 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp -import com.fabledsword.thoughtsync.R +import com.fabledsword.inkwell.R /** * The note's body, as fields and checkboxes rather than as markup. * * The point of the whole shape: a box you can tick while looking at the note, rather - * than `- [ ] ` to read and edit around. What the note IS never changed. + * than `- [ ] ` to read and edit around. The note's stored markup is the same. */ @Composable fun BlockBody( @@ -89,8 +93,8 @@ fun BlockBody( val remaining = blocks.withoutIndex(index) onChange(remaining) // The row above — or, for the FIRST row, whichever one takes - // its place. `index - 1` alone is -1 there, which left the - // keyboard up with nothing focused. + // its place. `index - 1` alone is -1 there, which would leave + // the keyboard up with nothing focused. onFocus(remaining.getOrNull((index - 1).coerceAtLeast(0))?.id) }, ) @@ -114,7 +118,7 @@ fun BlockBody( } /** - * A run of prose: one ordinary multi-line field, exactly as the editor always had. + * A run of prose: one ordinary multi-line field. * * Leaving it is when a `- [ ] ` typed by hand becomes a real checklist item — see * [promotingTasks] for why blur is the only safe moment to do that. @@ -161,8 +165,7 @@ private fun TaskBlock( onDelete: () -> Unit, ) { // Material sizes every interactive component to a 48dp touch target, and on a - // checklist that IS the row height — which is why six items filled a phone screen - // even after the field's own padding came off. + // checklist that IS the row height. CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides ROW_TOUCH) { Row(verticalAlignment = Alignment.CenterVertically) { Checkbox( @@ -203,25 +206,23 @@ private fun TaskBlock( * Material's floor is 48dp and this is deliberately under it. That floor is sized for * a control somebody has to find; a checklist box sits in a predictable column with an * identical box directly above and below, and the cost of a near miss is ticking the - * neighbouring item — visible, and undone by tapping again. Trading twelve of those - * dp for a list that fits on a screen is what was asked for, twice. + * neighbouring item — visible, and undone by tapping again. Twelve of those dp buy a + * list that fits on a screen. */ private val ROW_TOUCH = 36.dp /** * The field a block is typed into. * - * `BasicTextField`, not the Material one [PlainTextField] wraps, and the reason is - * density. Material's TextField puts 16dp above and below its text — padding that - * makes a FORM field comfortable to hit, and that on a checklist IS the row height. It - * made six items twice as tall as the six items, which is what the operator saw. + * `BasicTextField`, not the Material one [PlainTextField] wraps, for density. + * Material's TextField puts 16dp above and below its text: padding that makes a FORM + * field comfortable to hit, and that on a checklist IS the row height. * - * Nothing is lost by dropping down a layer. `PlainTextField` exists to strip a - * container and an indicator; `BasicTextField` never had either, so there is no box - * here to drift back into existence. What it does not supply and this must: + * `BasicTextField` has no container or indicator to strip. What it does not supply + * and this must: * - * - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK — the same - * default that made the editor's toolbar invisible in dark mode. Set, not inherited. + * - the text COLOUR. It defaults to `Color.Unspecified`, which draws BLACK and is + * invisible in dark mode. Set, not inherited. * - the cursor brush, which would otherwise be black for the same reason. * - the placeholder, which is a plain Text behind the field rather than a slot. * @@ -241,6 +242,15 @@ private fun BlockField( keyboardActions: KeyboardActions = KeyboardActions.Default, ) { val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface) + // Kept for the arrow keys, which ask which line the caret is on. Read only when + // one is pressed, never while composing. + var layout by remember { mutableStateOf(null) } + val line = { offset: Int -> layout?.getLineForOffset(offset) ?: 0 } + val arrows = + Modifier.arrowKeysMoveCaret( + atTop = { line(value.selection.end) == 0 }, + atBottom = { line(value.selection.end) == (layout?.lineCount ?: 1) - 1 }, + ) Box(modifier = modifier) { if (hint != null && value.text.isEmpty()) { Text( @@ -252,12 +262,13 @@ private fun BlockField( BasicTextField( value = value, onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().then(arrows), enabled = enabled, singleLine = singleLine, textStyle = style, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, + onTextLayout = { layout = it }, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), ) } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt new file mode 100644 index 0000000..c1211a5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardDrag.kt @@ -0,0 +1,190 @@ +package com.fabledsword.inkwell.ui + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import com.fabledsword.inkwell.core.Note + +// Reordering the board by dragging a card, as on the web: hold a card, then move it. +// The hold is the same long press that opens the card's menu, so the menu appears +// first and closes as the card starts to move. Lifting without moving leaves the +// menu open. Moving BEFORE the hold completes is a scroll. +// +// Watched on the Initial pass, ahead of the grid's scrolling and the card's own +// click handling. Nothing is consumed until the hold has completed and the finger +// has moved, so taps, long presses and scrolls all reach the grid and the card +// untouched. After that point every change is consumed, so the grid cannot scroll +// under a card that is being carried. + +/** A card being carried. Every offset is in root coordinates. */ +internal data class CardDrag( + val id: String, + /** Where the finger went down. */ + val from: Offset, + /** Where the finger is now. */ + val at: Offset, + /** Where the card's corner was when the drag began. */ + val startTopLeft: Offset, +) { + /** Where the card's corner should be drawn, so it stays under the finger. */ + val topLeft: Offset get() = startTopLeft + (at - from) +} + +/** + * Where each card on screen was last laid out, in root coordinates. + * + * A plain map rather than snapshot state: only the gesture reads it, so a write on + * every layout pass must not invalidate any composition. + */ +internal class CardBounds { + private val bounds = HashMap() + + fun record( + id: String, + rect: Rect, + ) { + bounds[id] = rect + } + + fun forget(id: String) { + bounds.remove(id) + } + + operator fun get(id: String): Rect? = bounds[id] + + /** The card under [point], other than [except]. */ + fun idAt( + point: Offset, + except: String? = null, + ): String? = bounds.entries.firstOrNull { (id, rect) -> id != except && rect.contains(point) }?.key +} + +/** What the board does as a card is carried. Every offset is in root coordinates. */ +internal class CardCarrier( + val onStart: (id: String, at: Offset) -> Unit, + val onMove: (at: Offset) -> Unit, + /** Runs however the gesture finishes, cancelled included, so no card is left held. */ + val onEnd: () -> Unit, +) + +/** + * Carry a card: hold, then move. + * + * [toRoot] maps a position in this element to root coordinates, which is where + * [bounds] keeps the cards. + */ +internal fun Modifier.dragToReorder( + enabled: Boolean, + bounds: CardBounds, + toRoot: (Offset) -> Offset, + carrier: CardCarrier, +): Modifier = + if (enabled) { + pointerInput(bounds) { awaitEachGesture { carry(bounds, toRoot, carrier) } } + } else { + this + } + +private suspend fun AwaitPointerEventScope.carry( + bounds: CardBounds, + toRoot: (Offset) -> Offset, + carrier: CardCarrier, +) { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val id = bounds.idAt(toRoot(down.position)) + if (id == null || !holdsStill(down) || !movesAfterHold(down)) return + carrier.onStart(id, toRoot(down.position)) + try { + followUntilUp(down.id) { carrier.onMove(toRoot(it)) } + } finally { + carrier.onEnd() + } +} + +/** True when the finger stayed down and inside the touch slop for a long press. */ +private suspend fun AwaitPointerEventScope.holdsStill(down: PointerInputChange): Boolean { + val endedEarly = + withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { + var ended = false + while (!ended) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } + ended = change == null || !change.pressed || change.movedPast(down, viewConfiguration.touchSlop) + } + } + return endedEarly == null +} + +/** After the hold: true once the finger moves past the slop, false if it lifts first. */ +private suspend fun AwaitPointerEventScope.movesAfterHold(down: PointerInputChange): Boolean { + var moved: Boolean? = null + while (moved == null) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == down.id } + if (change == null || !change.pressed) { + moved = false + } else if (change.movedPast(down, viewConfiguration.touchSlop)) { + // Consumed so the grid's scrolling, which has not claimed this gesture + // yet, never does. + change.consume() + moved = true + } + } + return moved == true +} + +/** Report every position until the finger lifts, consuming each change. */ +private suspend fun AwaitPointerEventScope.followUntilUp( + pointer: PointerId, + onMove: (Offset) -> Unit, +) { + var pressed = true + while (pressed) { + val change = awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.id == pointer } + pressed = change?.pressed == true + change?.consume() + if (change != null && pressed) onMove(change.position) + } +} + +private fun PointerInputChange.movedPast( + down: PointerInputChange, + slop: Float, +): Boolean = (position - down.position).getDistance() > slop + +/** The grid's own layout, so a position in it can be taken to root coordinates. */ +internal class GridOrigin { + var coords: LayoutCoordinates? = null +} + +/** + * [order] with the carried card moved to the place of the card under the finger, the + * way the web's drop splices it. Unchanged when there is no card there, or when it is + * on the other side of the pinned line: the store sorts pinned notes first, so a + * card dropped across the line would only snap back. + */ +internal fun moved( + order: List?, + drag: CardDrag, + bounds: CardBounds, + notes: List, +): List? { + val target = bounds.idAt(drag.at, except = drag.id) + val pinned = notes.associate { it.id to it.pinned } + val from = order?.indexOf(drag.id) ?: -1 + val to = if (target != null && pinned[target] == pinned[drag.id]) order?.indexOf(target) ?: -1 else -1 + return if (order == null || from < 0 || to < 0) { + order + } else { + order.toMutableList().apply { + removeAt(from) + add(to, drag.id) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt new file mode 100644 index 0000000..6ab37f0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt @@ -0,0 +1,168 @@ +package com.fabledsword.inkwell.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.fabledsword.inkwell.R +import com.fabledsword.inkwell.core.Label +import com.fabledsword.inkwell.core.NoteFacets + +/** + * The board's filters: the same three the web's Filters panel offers (FilterBar.vue). + * Tags a note must ALL carry, has an attachment, and shared with me. Reminders and + * dates have their own lenses, here as on the web. + * + * Only the main board filters, as on the web. Opening Archive, Trash or a tag from + * the drawer starts that view unfiltered. A search narrows whichever view is open, + * and on the main board it combines with these. + */ +data class BoardFilters( + val labelIds: Set = emptySet(), + val hasAttachment: Boolean = false, + val sharedWithMe: Boolean = false, +) { + /** How many are on, counted the way the web's badge counts them: each tag is one. */ + val count: Int + get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0) + + /** + * The core's facets for these and the search box's [text], or null when there is + * nothing to narrow by. The core ANDs them, the same as the server's query. + */ + fun facets(text: String = ""): NoteFacets? { + val q = text.trim().ifEmpty { null } + return if (count == 0 && q == null) { + null + } else { + NoteFacets( + q = q, + label = labelIds.toList().ifEmpty { null }, + hasAttachment = if (hasAttachment) true else null, + createdAfter = null, + createdBefore = null, + shared = if (sharedWithMe) SHARED_WITH_ME else null, + ) + } + } + + fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id) +} + +/** The facet value the core reads as "only notes someone else shared with me". */ +private const val SHARED_WITH_ME = "with_me" + +/** + * The row under the search bar: a Filters chip that opens the sheet, showing how many + * are on, and a Clear beside it once any are. + */ +@Composable +internal fun FilterRow( + filters: BoardFilters, + onOpen: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = GUTTER), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = filters.count > 0, + onClick = onOpen, + label = { + Text( + if (filters.count > 0) { + stringResource(R.string.filters_count, filters.count) + } else { + stringResource(R.string.filters) + }, + ) + }, + ) + if (filters.count > 0) { + TextButton(onClick = onClear) { Text(stringResource(R.string.filters_clear)) } + } + } +} + +/** + * Pick the filters. Each tap applies at once, as on the web, so the board behind the + * sheet is already showing the answer when it is dismissed. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +internal fun FilterSheet( + filters: BoardFilters, + labels: List