* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
* New translations from Crowdin
The delete dialog called deleteProject from its click handler, so the
metadata read, recursive delete and sync data write all blocked the UI
thread. Run them on the IO dispatcher.
---------
Co-authored-by: Sean Li <22557931+sean0407@users.noreply.github.com>
* Keep unsaved scene buffers when the project scope closes
Scope close deleted every temp buffer, so an Android activity destroyed in the background lost unsaved edits. Only clean buffers are cleared now, Discard discards explicitly, export reads unsaved buffers, and the Android back handler observes dirty state.
* Discard the buffer of an archived or deleted scene without reloading
* Save the buffer of an archived or deleted scene without throwing
* Drop a scene's unsaved buffer and temp file when it is deleted
* Gate sync behind the unsaved-work dialogs and make Discard really discard
Sync flushed every dirty scene buffer in prepareForSync, so an automatic sync on
close committed scenes the user had just chosen to discard, and manual syncs
silently saved everything. startProjectSync now builds the same confirmation set
as requestClose (scenes, notes, encyclopedia, timeline) and ends in Sync instead
of Complete, so the existing dialogs run before the sync modal opens.
The Discard buttons previously only cleared the confirmation; they now revert the
scene buffers to disk and drop in-progress entity edits (or pop a create screen)
through a new Router.discardUnsaved hook.
* Close the remaining ungated sync paths and harden discard
Route the Home screen's Sync action through startProjectSync so it hits the
gate, ignore startProjectSync while a close request is pending, and stop the
scene synchronizer from persisting dirty buffers in prepareForSync and
finalizeSync: upload content and hashes already come from disk, and every
interactive sync is now confirmed up front, so the flush only ever committed
buffers nobody was asked about (crash-recovery buffers during the startup
account sync, for one).
ProjectRoot.discardUnsaved is now a suspend call that reverts scene buffers off
the main thread, and the entity components check and discard across their
whole stack rather than only the active destination.
* Refresh open views when their entity changes underneath them
Note, timeline event, encyclopedia entry and scene metadata views loaded
once and went stale after sync. They now follow their repository flows,
keeping an in-progress edit and only moving its baseline.
* Address review findings on open-view refresh
- An untouched open edit follows the synced change; only a dirty edit keeps its draft
- Entry reloads are serialized, survive I/O errors, and only run for the open entry
- A note deleted elsewhere closes its view unless a dirty edit is open
- Synced scene metadata is no longer marked dirty or written back by the panel
- Sync keeps the local draft name; reference use cases name their update source
* Catch specific exceptions in the entry refresh
* Offer in-app updates on the GitHub desktop builds
AppUpdater in :common with a no-op for every store build, and a Nucleus
backed NucleusAppUpdater in :desktop that checks shortly after launch and
every six hours while the new automaticUpdateChecks setting allows it. One
action, update(), downloads, verifies and hands the installer over; MSI,
EXE, DEB, RPM and AppImage installs relaunch on the new version, macOS
installs are told and sent to the release page until the signed-zip work
lands. A notice strip above the project list, a status row on the About
screen, and a settings toggle, each with previews and render tests.
* Keep an offered update on screen while it is re-checked
A check only shows Checking when nothing better is known, so the six-hourly
re-check no longer blinks the banner away. The updater's shared fields are
volatile, the banner and the About row share one state-to-text mapping,
the download test hands the installer over through a CompletableDeferred,
and the desktop platform settings component gets a test for the new toggle.
* Lay the update notice on the page and allow an environment override
The banner now renders inside the project list under the masthead, inset
to the page gutter, instead of as a strip above the whole content pane
butting against the nav rail. HAMMER_UPDATE_BASE_URL stands in for the
system property so an installed build can be pointed at a local server,
which is how the update from a 3.10.99 test build to the published 3.11.0
MSI was exercised end to end. The download test cleans up its staging
directory, as the real installer script does.
* Give the Android project selection preview fake the update members
* Fix backup restore nesting the project inside itself
Backup archives are rooted at the project's directory name, so unzipping
into the project directory left it without a project.toml at its root and
the project vanished. Restore now extracts to a staging directory and
swaps the unwrapped project into place, leaving the existing project
untouched if extraction fails.
The Backup Manager also lists backups of projects that no longer exist,
and reports a failed restore.
* Harden backup restore swap and missing-project listing
Move the existing project aside instead of deleting it so a failed swap
puts it back, serialize restores that share the staging directory, and
list a missing project once when it has both legacy and current backup
names.
* Stamp builds with their package format and publish update metadata
-Pformat=<token> is resolved in buildSrc, baked into BuildMetadata, and
printed in the startup banner next to the channel; every packaging step
passes its own. The MSI UpgradeCode is pinned to the value every installed
copy already carries. writeUpdateMetadata renders latest*.yml from the
attached installers and the release workflow uploads them to the draft
before it publishes. HammerReleaseProvider points the Nucleus updater at
releases/latest/download so partial-release tags cannot break downloads.
* Hash installers where they are built instead of re-downloading them
Each platform job now writes a <installer>.sha512 sidecar beside what it
attaches and hands the sidecars to the final job as a workflow artifact.
That job assembles latest*.yml from them and attaches the yml with the same
release action every other job uses, so nothing is downloaded back from the
draft and nothing depends on looking a draft release up by tag.
Three new options in the Export Story dialog, from user feedback on Word, EPUB and PDF exports.
- Number chapter titles: off by default for every format. On prefixes each title with its position in the headings and the Contents page. Word, RTF, PDF and Markdown used to number always; EPUB never did and now can.
- Font: Georgia, Times New Roman, Garamond, Arial, Verdana or Courier New for Word, RTF and EPUB. PDF keeps its built-in face.
- Keep blank lines between paragraphs: on by default. Off drops a lone blank line so the indent is the only separator; a run of two or more, and the boundary between scenes, still leaves one break.
* Derive the Mac App Store build mode from the distribution channel
The App Store build was detected two ways: Gradle read -PmacOsAppStoreRelease
for packaging and passed -Dhammer.app.store=true so IS_APP_STORE could gate
runtime behaviour. The channel enum already knows the same fact from
-Pchannel=mac-app-store, which build-appstore.sh passed alongside.
isAppStoreRelease now resolves from the channel token, the JVM property and
IS_APP_STORE are gone, and the three runtime readers (sandbox startup, the
bookmark-backed file access binding, the in-app changelog gate) branch on
DistributionChannel.current like every other per-vehicle rule.
* Share the channel resolver across build scripts and name the App Store check
Project.distributionChannel() in buildSrc owns the -Pchannel and F-Droid
detection, so :base and :desktop cannot disagree on the channel.
DistributionChannel.isMacAppStore replaces three spelled-out comparisons,
and supportsInAppChangelog is a pure function of the channel with a test
pinning the table. Docs corrected: section 1 is done, GitHubProvider only
uses the REST API for pre-release channels, and the App Store build also
needs -PbuildNumber.
Dark theme with a pure black background and base surface. Applies to project color themes and Android dynamic color too.
Offer the OLED theme on Android and iOS only
Read the live story as prose, one chapter at a time, from the story
editor details pane. Opens from the scene list footer, the scene editor
top bar, or the editor menu.
- Scenes in a chapter are stitched together with an ornament between
them; a toggle shows scene headings instead.
- While the reader is open the scene list works as a table of contents,
and its highlight follows the scene being read.
- Reader font size is its own setting. Text is selectable.
- Chapter and scene position survive rotation and process death.
Chapter collection is now shared with export, and the outline's chapter
pieces move into the design system. MarkdownView gains opt-in
importDuringComposition and isSelectable. The footer's outline button
uses a new short "Outline" string.
* Update ComposeTextEditor to 3.0.2
Styles move to TextEditorState.richTextStyles, headings use the block API so
they export as headings, and shortcuts match on layoutKey. Markdown pins
ParagraphSeparator.NEWLINE to keep the 2.8 on-disk format.
* Make the editor text size a render property
Rich text styles are a fixed set with em-relative headings, so the body size
lives on the editor text style and changing it rewrites no document. The
size steps through a ladder, shows its value in the format bar menu, and
answers Ctrl+Plus, Minus and 0.
* Recover from an undecryptable auth token keyset instead of crashing
Exclude the encrypted token prefs from backups, and attach the R8 mappings
to GitHub releases.
* Reset the Keystore master key along with the token prefs
Only count runs of non-whitespace containing a letter or digit, so
horizontal rules, heading, bullet and blockquote markers are not words.
Move countWords to :base and use it on the server too.
Add a project-scoped user spelling dictionary (#939)
Writers can whitelist words per project: "Add to dictionary" on a flagged word in
the scene editor and focus mode, plus an add/remove word list in Project Settings
under Spell checking.
Words live in ProjectData.dictionaryWords so they sync with the project. A conflict
confined to the word list merges both sides by union with no resolver; other
conflicting fields still go through the existing resolver with the dictionary
unioned. The union is verbatim, so a word a newer build stored under laxer rules
is never deleted server-side. Sync writes also re-apply any project-data edit made
after the phase snapshot, so an edit landing mid-sync is no longer clobbered.
ProjectDictionaryService now feeds user words to the checker alongside encyclopedia
words, independent of the encyclopedia toggle. HdHairlineTagField and the new
HdHairlineWordListField share an extracted HdHairlineChipInput.
Encyclopedia entry titles and scene draft names were still on the old
restricted set while project and scene names had moved to the shared
ProjectNameValidator. Both now use that validator, so punctuation like
`. , ! ? : ( ) & - "` works, and draft names accept non-Latin letters.
Both filename formats move to the `~` delimiter, since `-` is now a legal
name character:
encyclopedia type~id~name.toml, images type~id~image.ext
drafts sceneId~draftId~name~timestamp.md
Names round-trip through encodeForFilename/decodeFromFilename, so
OS-forbidden characters become lookalikes on disk. Legacy patterns are
kept for reads, and path resolution falls back to the legacy filename,
because unlike scenes these two rebuild the filename from the def rather
than scanning the tree.
Migration2_3 renames existing files and PROJECT_DATA_VERSION goes to 3.
It canonicalises names through the encoder, otherwise a name ending in a
space (which the old rules allowed) would migrate to a file that no
rebuilt path could ever match. Draft names are now trimmed at the save
and sync boundaries the way entry names already were.
A synced name containing a path separator is no longer rejected outright;
it is encoded to a lookalike and stays one path segment, matching how
scene names already behave. Containment rests on the isWithin guard. The
reserved `~` delimiter is still rejected.
A standalone watch client that dictates notes into subscribed projects
and story ideas, then syncs them on its own session.
- New :wear module: capture activity, tile and complication, project
subscriptions, sync log, and a WorkManager periodic plus
after-capture sync behind a single SyncCoordinator
- Pairing over the Wearable Data Layer: the phone confirms the request
and mints a session for the watch's install via the new
/api/account/pair_install endpoint. Play Services builds only; the
F-Droid build ships without it
- Manual sign-in on the watch, with an Android 17 local network
permission check before contacting a LAN server
- Plaintext sync is opt-in: typing an http:// URL selects it, with a
warning in server setup. HTTPS stays the default, and legacy
settings always restore as HTTPS
- Account sync extracted into SyncAccountUseCase, shared by the phone
project list and the watch
- temporaryProjectTask ref-counts concurrent users so one task no
longer closes a scope another is still using
- Wear version codes are offset from phone codes so both can ship in
one Play listing
- Crash handler and FileLogger moved into common for reuse
* Say why a login failed instead of answering a bare 401
A failed login gave nobody anything to work with. StatusPages matched status 401
and re-responded with a bare status for API calls, discarding the HttpResponseError
the route had just written, and the client's own 401 branch discarded whatever
body did survive in favour of a generic string. Two layers erasing the same
message, so a self-hoster saw "401 Unauthorized" in the log, an empty body on the
wire, and a generic message in the app.
StatusPages now leaves API responses alone, since the routes answer 401 with
their own body, and the client parses the body for every status and only falls
back to a generic message when the server sent nothing usable.
On top of that:
- HttpResponseError carries an optional errorCode from a shared ApiErrorCode
vocabulary. It is optional and the shared serializer ignores unknown keys, so
it is compatible with servers and clients on either side of this change.
- Account creation failures use accurate statuses rather than a blanket 409:
400 for a policy or email violation, 409 for a real conflict, 403 for the
whitelist. Login answers 403 when the whitelist was the problem, since that is
not a credential failure.
- The login path logs which failure occurred. The response still cannot
distinguish an unknown account from a wrong password, because that would let
anyone enumerate users, but the operator's log now can.
The docs gain the password policy (8 to 64 characters, no complexity rule,
nothing stripped or truncated) and the log lines to look for, which is what the
reporter of #835 asked for.
* Allow the round-trip test accounts now that Allowed Users is always on
Only the first account on a server is exempt, so every later address the test
creates has to be on the list or account creation answers 403.
* Point the login-failure note at the Allowed Users section
The section was renamed, so the anchor was dead.
* Cover the account error statuses and the client's failure body
AccountErrorCodeTest walks the create and refresh failures a client branches on:
existing email, email pending deletion, malformed email, short password, unknown
refresh token. ApiFailureBodyTest covers the other end, where a server message
has to survive instead of being replaced by the generic one.
RecordingStrRes moves to its own file so both API tests can use it.
* Mark the password error codes as non-secrets for semgrep
* Add a Copy Diagnostics action to the About screen
Puts the startup banner (version, channel, OS/JVM) and the last 200 lines
of the current log on the clipboard, so a bug report can carry its own
diagnostics instead of asking the reporter to find, zip, and attach a log.
The blob always includes the banner: a missing log costs the reporter the
log, not the whole report.
The buttons move into a FlowRow so three of them wrap on a narrow window.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Include the latest crash dump in Copy Diagnostics
* Only report the XDG session in the desktop banner on Linux
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Switch the desktop Ktor engine to OkHttp
`java.net.http.HttpClient`'s constructor opens an NIO `Selector`, whose
Windows wakeup pipe is an AF_UNIX socket. The MSIX sandbox rejects the
connect with EINVAL, so every Microsoft Store build hard-crashes on the
first network call, before any request is sent (JDK-8312215, open since
Java 17 with no fix in sight). OkHttp uses blocking socket IO and is
unaffected.
Ships unconditionally rather than gated on the distribution channel:
OkHttp works everywhere, and a second engine would be a second code path
to test forever. Android already uses it.
Includes the unchecked-IO mapping from `fix/unchecked-io-network-errors`,
so a client that cannot be built at all surfaces as a network error
rather than taking down the app.
* Add an Export Logs button to the desktop About screen
Opening the log directory hands the shell a path that does not exist under
an MSIX container: the app's writes to %LOCALAPPDATA% are redirected into
the package's LocalCache, but reads fall through, so the app sees the
directory and Explorer does not. Snap confinement will do the same on
Linux. Exporting a zip to a location the user picks works on every
distribution vehicle, which makes it the one support instruction that
never needs a per-channel caveat.
Also walk up to the nearest existing ancestor before handing a directory
to the shell, so the open button degrades instead of erroring.
* Bake the distribution channel into the build
Per-vehicle rules (self-update, store payment policy, "get the app" links,
sandbox-aware paths) were tracked by hand. `-Pchannel=<token>` now resolves
to a `DistributionChannel` constant, defaulting to DEV, and an unknown
token fails the build rather than quietly shipping a store binary as DEV.
Every release pipeline passes its own channel; the existing F-Droid build
flags map to FDROID without their call sites changing.
Flat enum rather than capability flags: every channel-conditional branch is
a greppable `when` on the type. Flags can emerge later from actual
duplication.
The startup banner and all three crash dumps now carry the channel. That
line pays for itself immediately: this investigation started from a crash
log that did not say which build produced it.
* Un-redirect container paths before handing them to the shell
MSIX filesystem redirection is asymmetric: the app's writes under
%LOCALAPPDATA% land inside the package container, but reads fall through,
so File.exists() is true from inside the container and the app is
satisfied. Explorer runs outside it and correctly reports nothing at the
literal path, which is the "location is not available" dialog the
open-logs button produced on the Store build.
Rewrite the path into the container for the Microsoft Store channel, both
where it is shown and where it is handed to the shell. The package family
name is hardcoded: Windows derives its hash suffix from the publisher ID,
so it is not in AppxManifest.xml and cannot be computed from it, and it
only changes if the Store identity does.
`Windows.Storage.ApplicationData.Current.LocalCacheFolder` is the native
answer but means a WinRT dependency on a KMP desktop target for one path
lookup. Worth revisiting only if more packaged-app APIs are needed.
* Cover the About log section with a render test, document the channel
* Suppress TooGenericExceptionCaught on the unchecked IO catch
* Map unchecked IO failures to a real network error message
The JDK builds its `java.net.http.HttpClient` lazily on the first request,
and `HttpClientImpl`'s constructor wraps a failed `Selector.open()` in an
`UncheckedIOException`. On Windows that selector is a loopback socket pair,
so a machine with a firewall, VPN filter driver, or exhausted ephemeral port
range fails with `java.io.IOException: Unable to establish loopback
connection` before a request is ever sent.
`UncheckedIOException` is a `RuntimeException`, so it slipped past every
catch in `Api.makeRequest` and surfaced in the sync log as a raw Java class
name. Route it through the same mapping, with a dedicated message: nothing
reached the network, so this must not read as a server or connectivity
problem.
* Only log "Sync complete!" when the sync actually succeeded
`handleSyncFailure` calls `onComplete`, which logged the success line
unconditionally, so a failed sync ended with an error immediately followed
by "Sync complete!". Thread the outcome through `onComplete` and gate the
log on it; the progress reset still runs either way.
* Suppress TooGenericExceptionCaught on the unchecked IO catch
`SYNCING-PROTOCOL.md` makes adding a field to a synced model a client-only
change: the server stores content as an opaque blob, and a peer that predates
the field is expected to drop it on decode. That only holds if the decode
ignores unknown keys.
Every `ContentNegotiation` install used a bare `json()`, which is Ktor's
`DefaultJson`: `encodeDefaults` and `isLenient`, but no `ignoreUnknownKeys`. So
instead of dropping the field, an older peer failed the whole sync. A 3.6.0
client hitting project data written by 3.9 got:
Sync failed: Illegal input: Unexpected JSON token at offset 72:
Encountered an unknown key 'language' at path: $.data
`createJsonSerializer` already sets `ignoreUnknownKeys`, but it is meant for
human-facing files: `prettyPrint` would put tabs and newlines in every request
body, and `coerceInputValues` would silently coerce bad values on the wire.
Split the two: files keep that one, machine-facing content gets
`createNetworkJsonSerializer`, resolved through `NetworkJsonQualifier`.
Applies to the three `ContentNegotiation` installs and the four classes that
decode wire content with an injected `Json`: `ServerProjectApi` (entity
payloads), `ProjectDataApi` and `ServerIdeasApi` (conflict bodies), and
`GithubVersionCheckDataSource`. The file-facing injections are unchanged.
This cannot reach already-shipped builds; 3.6 through 3.9.x keep failing
against projects a newer client has touched.
* Stop credential fields from auto-capitalizing what is typed
HdHairlineField defaults to KeyboardCapitalization.Sentences, and neither the
server setup dialog nor the reauthentication dialog overrode it on their email or
password fields. An IME that honours CAP_SENTENCES on a password variation
capitalizes the first character as it is entered, so the account is created with
a password the user cannot reproduce in a browser, where nothing capitalizes
anything. The failure looks exactly like a rejected password.
HdPasswordField and HdEmailField pin the keyboard options that credential entry
needs (no auto-capitalization, no autocorrect) so a screen cannot forget them.
HdPasswordField also absorbs the show/hide toggle that both dialogs had inlined
identically, and carries the 8 to 64 character rule as a field hint, formatted
from PasswordValidator so it cannot drift from what the server enforces.
* Add a design-system preview for the credential fields
Covers the password field masked with its length hint, revealed with an error,
and the email field beside them.
- Conflict resolution: onEncyclopediaEntryConflict now carries aliases and
excludeFromDictionary into the local entity; "keep local" was stripping
both, and with the flag in the entity hash that meant a conflict that
could never converge (the alias drop predates this feature)
- ViewEntryComponent: one saveEntry(transform) helper replaces six
hand-threaded updateEntry call sites and loads content from disk when
the async state load hasn't landed, so a save can no longer revert
aliases or the exclusion flag to defaults
- SpellCheckRepository: SupervisorJob + guarded checker calls so a
platform failure can't kill the settings collector; appliedCandidates
recorded only after a successful apply so failures retry
- ProjectDictionaryService: initialize() instead of init side effects,
cancel-before-clear plus an isActive check to keep a racing rebuild
from re-adding a closed project's words, spellCheckSettings.enabled
added to the rebuild gate, injectDefaultDispatcher(), and
loadEntriesImperative() now returns the defs
- temporaryProjectTask scopes skip the dictionary service, ending the
checker churn during bulk sync and import
- HdHairlineToggleRow gains an enabled param (semantic disable, no
ripple) replacing three hand-rolled Box+alpha idioms
ReferenceIndexConfig.default() now enables all five EntryTypes, so Thing,
Event, and Idea entries get linked to the scenes that mention them. The
metadata panel already bucketed refs into Characters/Locations/Other, so
the new types land in Other with their own glyphs.
Add-reference search takes a type set and applies it before the result
cap, so one tab can no longer starve another out of the slice. The
appearances chart filters to PERSON to stay true to its label; the
separate connections total still spans all types.