Commit graph

2960 commits

Author SHA1 Message Date
Adam Brown
fef4fddc76 Pin the GitHub version-check endpoint to HTTPS
Keep cleartext permitted for user-configured self-hosted servers, but deny it for api.github.com so the first-party version check can't be downgraded to HTTP.
2026-06-24 16:34:52 -07:00
Adam Brown
565045cfbe Fix line wrapped string 2026-06-24 16:22:37 -07:00
Adam Brown
3e9ad32109 Unexport AddNoteActivity and filter obscured touches (F-8)
AddNoteActivity was exported with no permission, so any installed app could pop the Add Note dialog over the user pre-locked to a chosen project; a tapjacking overlay tricking a Save tap would write a note and push it to the sync server. It is only ever launched in-process (ProjectSelectActivity) or via the home-screen widgets' PendingIntents, neither of which needs export. Set android:exported=false in both manifests and filter touches delivered while the window is obscured.
2026-06-24 16:09:47 -07:00
Adam Brown
810ae23c0d Encrypt auth tokens at rest per platform (F-4)
Replace the plaintext FileAuthTokenStore binding with platform-specific
encrypted stores behind the same AuthTokenStore interface, wired via a new
expect/actual authTokenStoreModule.

Android: EncryptedSharedPrefsAuthTokenStore backed by EncryptedSharedPreferences
with a Keystore-backed AES256_GCM master key (androidx.security:security-crypto).

Desktop: EncryptedFileAuthTokenStore writes the token-map JSON as AES/GCM/NoPadding
to the config directory. The key is derived (PBKDF2WithHmacSHA256) from the OS user
name and home dir plus a static salt, with no key file on disk, so a copied token
file is useless on another machine or user. A random 12-byte IV is prepended per
write and owner-only POSIX perms are applied best-effort. Decryption failure is
treated as no tokens rather than crashing. This guards against casual disk
scraping and off-machine copies, not same-user local malware that can re-derive
the key.

iOS: still uses the plaintext file store pending a Keychain-backed implementation
(TODO marker in the iOS binding).

Migration: a legacy plaintext auth_tokens.json from an intermediate build is
imported into the encrypted store and deleted on first access; existing encrypted
tokens win on key collision so a stale plaintext entry cannot clobber a fresh
session.
2026-06-24 16:07:40 -07:00
Adam Brown
4572c33a88 Relocate sync auth tokens out of server.json (F-5)
Auth bearer/refresh tokens were stored in cleartext inside the per-workspace
server.json, which on F-Droid builds can be relocated to public external
storage, exposing the secrets.

Move only the secret token fields into a new app-private, account-keyed
AuthTokenStore. server.json stays per-workspace under projectsDir with its
non-secret fields { ssl, url, email, userId } so switching the project
directory still auto-loads that workspace's configured server/account.

- AuthTokenStore interface + FileAuthTokenStore: a single JSON file under
  getConfigDirectory() holding accountKey ("url|userId") -> AuthTokens. The
  store is keyed by account so two workspaces on the same account share the
  login; tokens are per-machine and do not travel with the workspace folder.
  The interface lets an encrypted backing be substituted later.
- PersistedServerSettings: tokenless DTO written to server.json. The in-memory
  ServerSettings shape is unchanged, so token readers (Http auth/refresh,
  AccountUseCase, AccountReauthUseCase) keep reading .bearerToken/.refreshToken.
- store writes the tokenless DTO and puts tokens in the store; load composes
  tokens back in; remove deletes server.json and clears the account's tokens.
- Migration: loading a legacy server.json with inline tokens moves them into
  the store and rewrites the file tokenless. A token-store write failure is
  logged and the in-memory session is preserved (never logs the user out).

Reuses the existing readJsonOrNull/writeJson filesystem helpers, which also
tolerate corrupt/missing files. Writes route through the guarded
ContainedFileSystem; the config dir is an allowed root.
2026-06-24 16:05:34 -07:00
Adam Brown
29d9f7c731 Validate downloaded entity id and type against the request (F-12/F-13)
downloadEntry asks the server for a specific entity id but took the
returned entity's identity and type entirely from the response, then
keyed storeEntity and recordSyncedHash off the server-chosen values.

F-12 (id forgery): a hostile server asked for entity N could answer with
id M and attacker content. The client would overwrite/forge local entity
M and record the forged hash as the conflict baseline, concealing the
tampering and re-propagating it to the user's other devices.

F-13 (type confusion): the subtype was chosen purely from the server's
entity-type header, so the server could steer the same id into the wrong
repository.

Add two checks right after reading the server entity, before dispatch:
- reject any response whose id differs from the requested id, and
- reject a type mismatch when the client already owns the id
  (findEntityType non-null). When the client does not yet own the id, a
  legitimate new-entity download has no local type to compare against, so
  it is allowed through.

A rejected entity returns CResult.failure, which the fullEntityTransfer
loop already tolerates without aborting the sync or recording a hash, so
one bad entity skips cleanly. The synced hash is now recorded under the
requested id, which provably equals the server's id once the guard passes.
2026-06-24 16:03:26 -07:00
Adam Brown
26aa7c91dd Resolve encyclopedia images by extension, limited to Coil-supported formats
Image read, delete, reId, and sync hardcoded the "jpg" extension, so a non-jpg entry image was never found: it orphaned on delete, failed to re-sync, and could not round-trip. Images are now located by their actual on-disk extension (findEntryImagePath), and setEntryImage preserves the source file's extension instead of always writing .jpg.

The accepted extensions are restricted to the raster formats Coil renders on every target (Android BitmapFactory + desktop/iOS Skia): jpg, jpeg, png, webp. heic/heif are dropped because they decode on Android but not desktop/iOS, which would store an image that cannot be displayed. This set also backs the F-1 sync image-extension allowlist, so narrowing it tightens what a malicious server may write.
2026-06-24 16:01:16 -07:00
Adam Brown
1ae79eac26 Guard datasource writes with a contained FileSystem chokepoint
Replaces the raw platform FileSystem singleton with a ForwardingFileSystem
decorator, ContainedFileSystem, that fail-closes every mutation (sink,
appendingSink, atomicMove source+target, delete, createDirectory,
createSymlink, openReadWrite) to the app's managed storage: the cache,
config, and current projects directories. A write outside all roots throws.
Reads, listings, and metadata are deliberately not guarded.

Allowed roots are supplied as a lambda re-evaluated per check because the
projects directory is user-relocatable at runtime. The projects-dir lookup
is cycle-safe: it prefers the built GlobalSettingsStore, falls back to the
settings datasource, then to the default dir, so config-root writes during
bootstrap (before the store exists) are not blocked. Directory creation uses
a relaxed rule that also permits a root's ancestors, so a clean first run can
scaffold the roots while a sibling escape stays blocked.

Consumers that legitimately write outside managed storage get a raw,
unguarded FileSystem via the RAW_FILESYSTEM qualifier:
- ExportStoryUseCase (desktop/iOS export to a user-chosen path)
- DesktopExternalFileIo (desktop docx/pdf export)
- AndroidPlatformSettingsComponent (storage relocation moves projects into
  the new managed directory before it is registered as a root)
2026-06-24 15:44:50 -07:00
Adam Brown
fd1b2affda Encode project name in cache-dir path builders (F-15/F-16)
A server-synced project name can contain `/` and `..` (the validator
permits them; `toLocalSafeName` leaves an already-valid name unchanged),
and two cache-path builders used `projectDef.name` raw as a path segment:
statistics and the reference index. A crafted name could place the cache
file outside the per-project cache directory (the same traversal class as
F-1), and on Windows the raw `/` could break path construction outright.

Both builders now encode the name with `ProjectsRepository.encodeForFilename`,
matching how the on-disk project directory already encodes it, and each
datasource gains an `isWithin(cacheRoot)` backstop before every write,
createDirectories, and delete.

This orphans any old raw-named cache directories on existing installs.
That is harmless: statistics and the reference index regenerate on demand.
2026-06-24 15:11:31 -07:00
Adam Brown
fc736a0ed7 Narrow synced-image allowlist to web-renderable formats
Drop bmp/heic/heif from ALLOWED_IMAGE_EXTENSIONS; keep jpg/jpeg/png/gif/webp.
2026-06-24 15:00:02 -07:00
Adam Brown
45c52500c2 Block sync path traversal via draft name and image extension (F-1)
A malicious or compromised sync server could supply crafted entity
fields that were used verbatim as filename components when writing
downloaded entities to disk, with no validation on the sync path
(unlike the local-create paths). Embedded `/` or `..` escaped the
intended directory and, since file contents are attacker-controlled,
gave a write-anywhere primitive on the unsandboxed Desktop target.

Two sinks were affected:
- SceneDraftEntity.name flows into the draft filename in
  SceneDraftsDatasource.insertSyncDraft.
- EncyclopediaEntryEntity.Image.fileExtension flows into the entry
  image filename in EncyclopediaDatasource.writeEntryImage.

Defense in depth, two layers:

Layer 1 (synchronizer boundary, graceful per-entity skip):
- ClientSceneDraftSynchronizer.storeEntity rejects any draft whose
  name fails the existing validDraftName check, logs a warning, and
  returns false so only that entity is skipped (no synced hash is
  recorded, so sync state is not poisoned and the run is not aborted).
- ClientEncyclopediaSynchronizer.handleImage validates the image
  file extension against a known-image allowlist (ALLOWED_IMAGE_
  EXTENSIONS); a disallowed extension skips just the image write while
  the entry itself is still stored.

Layer 2 (containment backstop): a new Path.isWithin(root) helper
(okio normalized()) is asserted right before the write in both
datasource sinks, hard-failing any path that escapes its intended
directory. With Layer 1 in place this should never fire for the known
sinks; it fail-closes any future sink that forgets validation.

Tests (TDD, confirmed failing before the fix): traversal draft names
and image extensions are blocked with no file written outside the
target directory; isWithin rejects `..`, absolute, and sibling-escape
paths; happy-path drafts and jpg/png images still round-trip.
2026-06-24 15:00:02 -07:00
Adam Brown
668adfc940 Animate list items only while reordering
animateItem() was applied to every non-dragged item unconditionally, so a
fast fling to the top of the list animated item placement during plain
scrolling. Gate it on an active drag so only the displaced items animate.
2026-06-24 14:45:36 -07:00
Adam Brown
ac8222bde5 Move the right item after the list order changes
rememberDragDropListState captures its callbacks once via keyless remember,
so the external confirmReorder kept invoking the first composition's closure.
That closure indexed into a stale event list, so every reorder after the
first moved the wrong item. Route the callback through rememberUpdatedState
so the remembered state always calls the latest closure.
2026-06-24 14:45:36 -07:00
Adam Brown
678f352597 Fix timeline reorder snapping back after the first move
DragDropList tracked the last external list in a plain `var` initialized
from `remember { items }`, so the value was frozen at first composition
and reassignment never persisted. That left the reset guard permanently
true after the first committed reorder, clobbering the in-progress drag
preview on every recompose and snapping items back to their committed
order. Make the tracker state-backed so the guard fires only on a real
external change.
2026-06-24 14:45:36 -07:00
Adam Brown
c5ee60feee Encode dynamic project-name path segments to stop URL segment injection
Every project-scoped client API built its request path by raw string
interpolation of projectName, e.g. "/api/project/$userId/$projectName/begin_sync".
That string reached the shared url() builder whose only path handling was
pathSegments = path.split("/"). Because the split ran on the already-interpolated
string, a projectName containing "/" was split into extra discrete path segments
and a ".." survived as a literal traversal dot-segment, so the outbound request
could target a different endpoint than the {userId}/{projectName}/{action}
template intended (e.g. a malicious sync server returning a project named
"p/../../../api/account/test_auth").

The shared ProjectNameValidator permits "/", "\" and "." (they are encoded to
disk-safe lookalikes only when used as a directory name), so a server-supplied
project name persists verbatim and then injects into every subsequent
project-scoped request under the same host with the bearer token attached.

Fix: each dynamic value is now percent-encoded into a single opaque path
segment via String.encodeUrlPathSegment() before interpolation, and the sink
sets encodedPath directly. Embedded "/" becomes %2F so it cannot create extra
segments, and an all-dots segment is encoded to %2E so a ".." name cannot act as
a traversal segment. The validator is intentionally left unchanged: tightening
it to reject "/" or "." would break syncing for already-valid existing project
names, so encoding is the backward-compatible fix and the on-disk
encodeForFilename behavior is untouched.

Adds a MockEngine test asserting a malicious projectName collapses to a single
encoded segment in the outbound URL across ProjectDataApi, ServerProjectApi and
WritingActivityApi.
2026-06-24 14:45:27 -07:00
Adam Brown
5b377eaf83 Stop ProjectRootActivity from opening caller-supplied project paths
ProjectRootActivity was exported and resolveProjectDef deserialized a caller-supplied ProjectDef whose free-form path was used verbatim for all project file I/O, letting any installed app root the editor at an attacker-controlled directory (confused deputy, CWE-926/CWE-22). Resolve the target project by name against on-disk projects only, drop the serialized EXTRA_PROJECT path extra, and mark the activity exported=false; it is only launched in-process via explicit intents and PendingIntents from widgets and shortcuts.
2026-06-24 14:14:52 -07:00
Adam Brown
5397bd7bc4 Guard timeline loading against malformed TOML files
loadTimeline caught only FileNotFoundException, so a present-but-malformed timeline.toml crashed the load. Extend the catch to SerializationException, IllegalArgumentException, and IllegalStateException, returning an empty timeline as with the missing/blank-file cases.
2026-06-24 13:36:39 -07:00
Adam Brown
ede8fb488a Guard note loading against malformed TOML files
A malformed note file, or one with a non-integer id, threw uncaught from tomlkt (IllegalStateException/IllegalArgumentException beyond SerializationException) and aborted the entire notes load. loadNotes now skips unparseable files via readTomlOrNull and loads the rest.
2026-06-24 13:31:32 -07:00
Adam Brown
8fcc067446
Add a Google Analytics provider (#643)
Adds GA4 as a second web-analytics provider alongside Umami, selected via
analytics.type = "google" with an [analytics.google] measurementId block.

GA4 can fall back to tracking-pixel loads, so the AnalyticsProvider
interface gains imgSrcHosts() (empty for Umami) and the CSP builder folds
it into img-src. Download-click events flow through the existing neutral
data-track-* / hammerTrack bridge unchanged.
2026-06-23 20:48:56 -07:00
Adam Brown
86c26bfc88
Update SERVER-SECRET-STORAGE.md for clarity on upgrades
Clarified the handling of the 'server.secret' file after upgrade, emphasizing its automatic reading and the need for explicit mode setting.
2026-06-23 16:44:06 -07:00
Adam Brown
7a897ba423
New Crowdin updates (#639)
* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]

* New translations messages_en.properties (French)

[ci skip]

* New translations messages_en.properties (Spanish)

[ci skip]

* New translations messages_en.properties (German)

[ci skip]

* New translations messages_en.properties (Italian)

[ci skip]

* New translations messages_en.properties (Ukrainian)

[ci skip]

* New translations messages_en.properties (Chinese Simplified)

[ci skip]

* New translations messages_en.properties (Portuguese, Brazilian)

[ci skip]
2026-06-23 16:36:20 -07:00
renovate[bot]
4acb0073f6
Update actions/cache action to v6 (#638)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 16:36:06 -07:00
renovate[bot]
9ad37ee315
Update logback monorepo (#637)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 16:35:51 -07:00
Adam Brown
a42aae7a25
Add provider-agnostic download-link click tracking (#642)
Track download-button clicks on the home page through the analytics
provider abstraction so a future provider (e.g. Google Analytics) works
without touching templates.

Templates declare neutral data-track-* attributes; a shared binder
(analytics.js) forwards clicks to window.hammerTrack, which each provider
defines via the new AnalyticsProvider.eventBridge(). All tracking markup
is gated on analyticsEnabled, so nothing renders when analytics is off.
2026-06-23 15:31:41 -07:00
Adam Brown
e5408f3ac0
Make admin API Traffic graph a 30-day daily chart (#641)
The API Traffic graph on the monitor dashboard was the visual odd one
out: a filled-area chart over a 24h hourly window, while the Active
Users and Story Readers graphs are plain daily lines over 30 days.

Switch its data source to the 30-day daily time series (the same source
the Performance/Errors pages use for their 30d ranges) and drop the
region fill / hidden dots so it matches the neighboring charts.
2026-06-23 15:14:53 -07:00
Adam Brown
3b23dc6cf0 Prepared for release: v3.4.2 2026-06-23 10:10:05 -07:00
Adam Brown
8e55ec4ab0 Add a token-maintenance job to purge dead auth tokens
Now that refresh tokens expire, expired auth_token rows accumulated
forever since nothing deleted them. Add a daily maintenance job that
purges auth_token rows whose refresh window has fully elapsed (so a
still-refreshable row is never deleted), sharing AccountsRepository's
REFRESH_TOKEN_WINDOW so the cutoff can't drift from the refresh policy.

The job follows the existing job pattern (DI singleton + configure* at
boot, stopped on ApplicationStopped) and also runs the previously
unscheduled password-reset token cleanup.
2026-06-23 10:05:36 -07:00
Adam Brown
91dec02192 Compare Patreon webhook HMAC in constant time
verifyWebhookSignature compared the expected and supplied signatures with
String.equals, which short-circuits at the first differing character. On
an unauthenticated endpoint that leaks, via response timing, how long a
prefix of the expected signature a caller guessed correctly — enough to
recover a valid signature byte by byte and forge a webhook.

Use MessageDigest.isEqual, which examines the whole input regardless of
where the first difference is. Behaviour is otherwise unchanged
(case-insensitive, length-mismatch rejects), per PatreonApiClientTest.
2026-06-23 10:05:36 -07:00
Adam Brown
e691715541 Authorize web admin routes against the DB, not the session cookie
AdminOnlyPlugin gated access on the session cookie's isAdmin flag, baked
in at login. A user demoted from admin kept admin access in the web UI
until their 7-day session expired. Re-read is_admin from the database on
each admin request (the REST admin path already did this), so a demotion
takes effect immediately.

The cookie's isAdmin is still used for cosmetic UI (showing the admin
link); it no longer grants authorization.
2026-06-23 10:05:36 -07:00
Adam Brown
260a657737 Expire refresh tokens 6 months after the access token
refreshToken validated only that the refresh hash matched, never any
expiry, so a refresh token worked forever — a leaked one could mint new
sessions indefinitely.

Reject the refresh once now is past expires + a 6-month window. Because
expires slides forward to now + 30d on every token issue, the refresh
deadline slides with it: an actively-refreshed session never lapses,
while an idle one must re-login ~6 months after its access token expired.
The access token lifetime is unchanged.
2026-06-23 10:05:36 -07:00
Adam Brown
dee4e7220d Close the createAccount and refresh-token enumeration oracles
Two more account-existence oracles remained alongside the login one:

- refresh_token returned 500 for an unknown userId (getAccount threw
  AccountNotFound) but 401 for a known userId with a bad token, so the
  status code revealed whether the account existed. Look the account up
  without throwing and fall through to the normal 401 failure.

- createAccount only ran Argon2 when creating a new account, so the
  existing-account path returned faster — a timing oracle. Hash on that
  path too, and bring the route under the same rate limit as login.
2026-06-23 10:05:36 -07:00
Adam Brown
29cc901a98 Make login responses uniform to prevent account enumeration
Login returned distinct "Account not found" vs "Incorrect Password"
messages, and only ran Argon2 when the account existed — so both the
response body and the response timing revealed whether an email was
registered.

Return one generic "Invalid email or password" for both cases, and
verify the supplied password against a decoy Argon2 hash on the
unknown-account path so the work (and timing) matches a real wrong
password.
2026-06-23 10:05:36 -07:00
Adam Brown
25525df648 Derive emailed link hosts from config only, never the Host header
publicBaseUrl() fell back to the request Host header when publicUrl was
unset (the documented single-host default). An attacker could send
POST /forgot-password with a forged Host, and the server would email the
victim a real reset token inside a link pointing at the attacker's host —
account takeover. The review-invite and review-submitted emails shared
the same sink, giving an email-spoofing/phishing primitive.

publicBaseUrl() now resolves only from the configured publicUrl and
returns null when unset; out-of-band emails are skipped rather than sent
with a poisoned host (password reset still returns success to stay
enumeration-safe). A separate requestBaseUrl() keeps the request-derived
URL for in-session author-facing copy links, where the host is trusted.
2026-06-23 10:05:36 -07:00
Adam Brown
76f8b79df0 Scope project-access deletion to the owning project (fix IDOR)
deleteAccessById verified the caller owned the URL project but then
deleted by the global project_access.id alone, with no project scope.
Because the id is a sequential BIGSERIAL, any authenticated user could
enumerate ids and delete other tenants' publish/share records.

Scope the DELETE by project_id (RETURNING id to detect a hit), return
whether a row was deleted, and 404 the route when the access row is not
in the caller's project.
2026-06-23 10:05:36 -07:00
Wavesonics
0285ed029d
Let admins bypass the web whitelist
An admin shouldn't be able to lock themselves out of their own server by
enabling the whitelist without listing their own email. Authorize any
account flagged is_admin in the DB regardless of whitelist membership.
2026-06-23 08:18:06 -07:00
Wavesonics
951d12219e
Fix login/dashboard redirect loop for unauthorized sessions
The login page redirected to /dashboard on session presence alone, while
the dashboard auth gate also requires the whitelist check. A logged-in but
non-whitelisted user looped between the two. Share one sessionIsAuthorized
predicate across both, clear an unauthorized cookie on the login page, and
reject non-whitelisted logins with a message instead of setting a session.
2026-06-23 08:15:25 -07:00
renovate[bot]
66df2e64c3
Update nick-fields/retry action to v4 (#635)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-23 01:29:34 -07:00
Adam Brown
ed00d06e1f
Emit keyring JSON compactly for single-line env vars (#636)
Drop prettyPrint from the keyring serializer so generated keyrings
fit on one line, suitable for dropping into an environment variable.
2026-06-23 01:28:55 -07:00
Adam Brown
77f1eb1884 Prepared for release: v3.4.1 2026-06-23 00:45:34 -07:00
Adam Brown
a0ba72a75e Add "Server only" publish scope to prepareForRelease
Introduce a SERVER platform (tag token `server`) and a third release
scope alongside All / Targeted. A server-only release produces a
`vX.Y.Z+server` tag, which matches none of the per-store publish jobs in
publish-release.yml, so no client app store upload runs while the server
distribution still builds and deploys out of band.

isPlatformReleaseTag now recognizes `+server`, so backout/revert clean it
up like any other release tag.
2026-06-23 00:38:08 -07:00
Adam Brown
7a33e93aff Enforce web whitelist in session auth validator
The Ktor session `validate` lambda authenticates on any non-null
return. The validator returned a Boolean, so a non-whitelisted user
returned `false` — non-null — and was authenticated with a Boolean
principal. The whitelist gate was a no-op for the web UI: any valid
session reached whitelist-protected pages.

Return the UserSession when access is allowed and null when denied so
the challenge fires. Treat an unknown userId (getAccount throws
AccountNotFound) as denied rather than a 500.

ReviewCommitTest logged in a non-whitelisted author and was passing
only because of this bug; whitelist the author in its seed.
2026-06-23 00:32:19 -07:00
Adam Brown
5dd8400fa0 Rename file to match pattern 2026-06-23 00:09:49 -07:00
Adam Brown
6a34bf50c6 Encrypt-then-MAC web session tokens 2026-06-23 00:08:32 -07:00
Adam Brown
65067460d1 Update key migration docs with findings from the first key migration 2026-06-22 23:36:47 -07:00
Adam Brown
080c403619 Prepared for release: v3.4.0 2026-06-22 22:02:40 -07:00
renovate[bot]
72f47d6df7
Update actions/checkout action to v7 (#634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 14:15:41 -07:00
renovate[bot]
fe1f8162f9
Update aboutlibraries to v15 (#633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 14:15:24 -07:00
renovate[bot]
6b3f2b631b
Update plugin ee.schimke.composeai.preview to v0.16.1 (#608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 11:45:30 -07:00
renovate[bot]
68ffcc1eb1
Update kotlinx.collections.immutable to v0.5.0 (#630)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 11:43:14 -07:00
Adam Brown
47e5f9bf95
Fix flaky editor instrumented tests by re-injecting until the edit lands (#632)
The markdown/scene editor consumes hardware key events and reports edits
through an async flow. sendStringSync could fire before the field had focus
(or before the editor's editOperations collector started), silently dropping
the keystrokes so the observed change never happened and waitUntil timed out.
A longer timeout can't recover dropped input.

typeIntoEditor now re-focuses and re-injects until a caller-supplied
propagated() lambda confirms the change, or a deadline throws a clear error.
NotesWorkflowTest and SceneEditorWorkflowTest pass their existing change
signal and drop their redundant waitUntil blocks.
2026-06-22 11:43:00 -07:00