0d9feb0556342a7563dcdce7235dd7513fec200f
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b1d1c47261 |
Start every worker thread through the executors module
Thirty-nine spawn sites in dr-ui, and one in the Android entry point, called std::thread::spawn or a Builder of their own, and most of the threads they started were <unnamed> in a panic message or a profiler. Each now calls executors::spawn with its executor and a role, so the thread is named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open — and knows which executor it is on. The three that already set a name (automation, import, prefetch) keep their name as the role. Behaviour is unchanged: each job still gets a thread of its own when it starts, and spawn panics where std::thread::spawn did. The module's documentation now says how a job is assigned: by what it spends its time on, so a sweep that fetches bytes and then decodes them is Decode, and a sidecar write that touches the catalog is Network. Left as they were: the segmentation and refine workers in masks_ui.rs, which another change is reworking, and test-only threads. |
||
|
|
7be1efff32 |
Name the executors and fail a block_on on the UI thread
architecture.md §7.1 stated five executors and their thread counts, and no code named them. dr_ui::executors now does: the Executor enum with each one's thread name and the count §7.1 gives with its reason, and spawn, which starts a thread named <executor>:<role> and marks it with the executor it belongs to. The counts are the stated budget, not yet a bound: a job still gets a thread of its own when it starts. run marks its own thread as the UI executor before it builds the window. net_runtime::build now returns a NetRuntime whose block_on asserts, in debug and test builds, that the caller is not that thread; everything else derefs to the tokio runtime. The login, folder-list and remote-folder workers built the same runtime by hand and now take it from net_runtime, so their block_on is guarded too. Tests: a block_on on a thread marked as the UI executor panics naming the UI thread; the same call on a worker returns; a spawned thread carries its name and executor. |
||
|
|
9b580c3720 |
Satisfy rustfmt and clippy on the album and folder picker changes
rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused. |
||
|
|
2eb06b1064 |
Make a folder from the server browser
The in-app browser that chooses a library folder on the server could only open folders that already existed, so a library, or an export destination, that was not on the server yet had to be made in the Nextcloud web page first. It now has "New folder": a name, then MKCOL, then the parent listed again and the new folder walked into — a folder somebody has just named is the one they mean to choose. The listing is the server's rather than the name inserted locally: the server may have normalised or refused it. A name with a slash, "..", or nothing at all is refused before any request, because a folder typed with a slash in it is a path the user did not mean. remote_folders holds the two WebDAV round trips (list, make) off the UI thread, with the answer delivered through a Slint timer, so the album sheet can use the same browser. |
||
|
|
6683c14b40 |
Choose folders in the platform's dialogue, not by typing a path
Every folder the desktop asked for was a text field: the library folder at launch, an import's source and second copy, a preset folder brought over from Lightroom. A typed path is how a destination silently becomes a new folder nobody meant — one wrong letter three levels down and the write succeeds somewhere the photographer will never look — and a field cannot make the folder that is not there yet. They now open the platform's own dialogue through rfd: the XDG desktop portal on Linux, the common item dialogue on Windows. The portal rather than GTK because it reaches the user's files from inside the Flatpak and needs no GTK in a Slint application, and it draws whichever desktop's chooser is running, "New folder" included. It is awaited on Slint's event loop (spawn_local), so the window keeps drawing while it is open, and parented to the window so it opens over it. PathRow shows what is chosen, read-only, beside the button. Android has no filesystem dialogue — only SAF, which returns document trees, not paths — so there the same rows stay typed fields (Pickers.local-paths). The launch screen keeps the folder used last on screen with "Open folder" beside it, so reopening is one press. Presets get two buttons, a folder and a single .xmp file, because no platform dialogue picks "a file or a folder" in one go. |
||
|
|
352e59498b |
Open the bundled manual from Help and from Settings
The packages now carry the manual, but nothing in the application opened it: the help sheet listed gestures and stopped there. The help sheet gains a Manual button beside Done, and Settings a Manual row under About beside the version. Both go through dr_ui::manual, which finds the installed page through dr_plat::system_data_dirs (the package's share directory on Linux, the executable's directory on Windows), and a development build also in the checkout it was compiled from. A copy with no manual says so on the status line rather than doing nothing. On the desktop the page goes to the system browser. A section is a URL fragment, and xdg-open's generic mode and Windows' FileProtocolHandler both turn a file: URL into a path and drop the fragment, so a section is opened through a one-line redirect page written to the data directory: the opener gets a plain path, which every opener keeps, and the browser follows the redirect to index.html#section itself. The launcher behind the sign-in's open_in_browser is split out so both share it; the https check stays with the sign-in. Android has no path to give a browser: an asset is not a file, a copy in private storage is unreadable to other apps, a file: URI across apps is refused, and a content: URI leaves the browser resolving every picture against the provider. So ManualActivity, a WebView reading file:///android_asset/manual/index.html straight out of the APK, shows it, started by class name with the section as an extra. JavaScript is off, links off the page go to the browser, and the theme is day-night so the page's own light and dark follow the system. A test checks that the manifest, the Java class and dr_ui agree on the name and the extra. |
||
|
|
5569a066ff |
Upgrade accounts saved as http:// to https on launch
Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 45m7s
Build and test / Layer separation (push) Successful in 41s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Traceability / Requirement traces (push) Successful in 40s
Build and test / Android (aarch64) (push) Successful in 28m49s
Build and test / Windows (x86_64, cross) (push) Successful in 17m9s
Build and test / Publish the release (push) Skipped
Before the previous commit, browser sign-in could store an account as http://, and the client now refuses to send to one. Left alone, such a library would fail to open with a configuration error, so its stored endpoint is rewritten before anything reads it. The endpoint is half of two keys, and both are handled: - The keyring entry is filed under it. Rewriting only the record would strand the app password under the old key and sign the user out, so AccountStore::move_endpoint copies the secret across first, rewrites the record in place (the last record is the one resumed), and deletes the old entry only once nothing refers to it. - namespace() is built from it and names the catalog directory. For an http to https rewrite it does not change, because the namespace strips either scheme. A move that would change it is refused, not performed, so no later rewrite can abandon a catalog either. The rewrite is a new BackendProvider::upgrade_endpoint hook, which does nothing by default, and not a second call to normalise_endpoint. The folder connector's normalise_endpoint canonicalises the path and needs it to exist, so running it on every launch would fail a library on an unplugged disk, or rename one whose path now resolves differently. Only Nextcloud implements the hook. If the move fails (for example, a locked keyring), it is logged, the account is left as it was, and the move is tried again on the next launch. Closes #65. |
||
|
|
ea31791388 |
Keep the typed server after browser sign-in, and open only https
Login Flow v2 saved the account under the `server` field of the poll response, not under the address the person typed. That field is the server's idea of its own URL. Behind a TLS-terminating proxy without `overwriteprotocol` (a common setup) it says http://, and the account then sent its app password in the clear on every request after that. The typed address, already upgraded to https by normalise_endpoint, has just carried the whole flow, so it is the one kept. The flow's other two URLs come from the server as well, and are now upgraded from http to https, and refused if they use any other scheme: - The login URL is handed to the OS to open. On Windows that is `rundll32 url.dll,FileProtocolHandler`, which runs a file: or UNC path rather than showing a web page, so a hostile server could launch a program when the user starts signing in. open_in_browser also refuses anything that is not https, as the last check before a process starts. - The poll endpoint is where the app password comes back from. The host is not checked. A server reached by its LAN address can answer with its public name, and refusing that would break a working setup without protecting anything: the account is stored under the typed address whatever the server says. Part of #65. |
||
|
|
38819da222 |
Replace the six view booleans with View and Page enums
app.slint carried show-launch, show-library, show-identity, show-settings,
show-import and show-merge as separate booleans, so the root component chose
what to draw with five- and six-term conjunctions and nothing stopped two of
them being true at once. Replaced with two enums: View { develop, library,
identity, launch } for which top-level screen is showing, and Page { none,
settings, import, merge } for which page, if any, is drawn over it.
Two values rather than one, because the two questions are genuinely
different. Settings, Import and Merge are reachable from more than one View
and are drawn outermost without touching it — closing one has to return to
whichever View was already current, and today that works because the
underlying property is left alone while the page sits over it. A single
View with five or more variants would need a second field remembering what
to return to; Page needs nothing to remember, since View was never
overwritten in the first place. Identity, by contrast, genuinely replaces
the window the way Launch and Library do (see the existing "like the launch
screen" comment on its `if`), so it is a View variant, not a Page.
Every `if` chain in app.slint that used to compare four, five or six
booleans now compares active-view and active-page to at most one variant
each. library-visible collapsed from a six-term conjunction to
`active-page == Page.none && active-view == View.library`.
The Rust side follows: every set_show_*/get_show_* call in library_ui.rs,
identity_ui.rs, settings_ui.rs, merge_ui.rs, import_ui.rs, launch_ui.rs and
lib.rs now reads or writes active-view or active-page instead, including
lib.rs's startup match (View.launch vs View.develop, since a Startup that
skips the launch screen used to leave both old booleans false and fall
through the chain to develop) and identity_ui's close handler, which now
writes View.library or View.develop in one call where it used to write
show-library then show-identity separately.
back_one_step needed one deliberate adjustment beyond the mechanical
rename. Identity was never represented in NavState: back had nothing to do
when Identity was opened from the library (show-library stayed true,
unread by IdentityScreen's own condition) and could only reach ToLibrary
when opened from develop, which likewise wrote a property IdentityScreen
never read — so escaping out of Identity was invisible in both cases before
this change. With a single active-view, falling into the general case
would instead overwrite the value IdentityScreen's `if` does read and close
it as an unintended side effect. back_one_step now swallows the gesture
while View.identity is current, reproducing the same "nothing visible
happens" outcome for both origins without threading identity_ui's private
came-from-library state through lib.rs for one screen.
Verified with tools/manual/drive.py against a private Xvfb and the debug
build: launch screen to library, Settings opened and closed, Identity
opened and closed (including Escape doing nothing while it is open),
develop opened from a cell and closed both by the back button and by
Escape. Screenshots under verify/.
|
||
|
|
beb7a5eac0 |
Split launch_ui::wire into one function per section
The 225-line wire() registered the launch screen's callbacks in nine comment-delimited sections. Lift them into fn's, merging a few adjacent ones that were only a handful of lines each: wire_sign_in covers both the browser flow and the app-password fallback (the same button in two forms), wire_choose_folder_and_open covers opening the folder browser and the final "Open library" press, since both are short and sit back to back. wire_use_folder, wire_sign_out, wire_formats, wire_folder_picker_navigation and wire_copy_url stay as they were sectioned. wire() calls each in the original order and keeps the closing render() call, which every section relies on having run once at startup. |
||
|
|
0fa9003e54 |
Let the top level be chosen as the library root
Confirming "/" in the folder picker set an empty root, which the launch model read as no root at all: "Open library" stayed disabled after the question had plainly been answered, and a folder library — whose folder is the whole library — could never be opened without first descending into a subfolder of it. The empty string was carrying two meanings. Record the choice as its own fact on the account (`root_chosen`, defaulted so existing configuration loads unchanged), treat a folder endpoint as chosen by definition, and let the launch screen say so: a folder is shown as a LIBRARY rather than an ACCOUNT, the second question becomes an optional "scan only a subfolder", and the library header names the folder instead of calling it "· whole account". |
||
|
|
b396096787 |
Open the sign-in URL on Windows
Login Flow v2 cannot complete without a browser, and the launcher had a branch for xdg-open, one for Android's Intent, and an honest Unsupported error for everything else — which on Windows stranded the flow on "approve the sign-in in your browser". rundll32 url.dll,FileProtocolHandler is ShellExecute on the URL and needs no crate; chosen over cmd /C start, whose quoting of & in a query string is a known trap. Not verified: Wine has no browser to open. |
||
|
|
64462e9fd3 |
Test the folder sign-in instead of trusting it
The whole of a folder library's sign-in lived inside a Slint callback, which cannot run without a display server — so the one path that decides whether a mistyped folder becomes a *stored* account had no test at all. That failure is quiet and lasting: an account for a directory that is not there skips the launch screen on the next start and reads as a library that has lost its photographs. `open_folder_library` is that logic, lifted out whole. Four tests: it stores an account with no credential and reaches the keyring for nothing, a typo is refused before anything is written, the messages read as instructions because they go straight to the screen's error line, and a file is not a library. |
||
|
|
f12aece07e |
Make storage pluggable, and prove it with a folder backend
`RemoteBackend` existed from the first release and bought nothing it was
designed for. Seven files in `dr-ui` constructed a `NextcloudBackend`
directly, an account *was* a server URL beside a DAV user id, the local
cache directory was named after a hostname, and the launch screen knew
that signing in meant a browser handshake. The trait was real; the seam
was documentation.
A trait over operations is only a quarter of it. Pluggable storage needs
four things, and this adds the other three:
- **Capabilities** — already there, and the reason the engine can drive
two backends at the speed each actually runs at.
- **Configuration** — `dr_sync::Account`: where a library lives, in
whatever form its connector addresses, with no server in it. Loads
every existing config unchanged (`backend` defaults to `nextcloud`,
`endpoint` is stored under its historical `server` key), and
`Account::namespace()` reproduces the old catalog directory byte for
byte, because changing it would abandon a catalog, its thumbnail
shards, and the sidecars holding unsynced offline work.
- **Registration** — `BackendProvider` and `BackendRegistry`.
`ui/dr-ui/src/remote.rs` is now the only file above `dr-sync` that
names a connector.
`Connection` (an account plus an optional `Secret`) replaces the
credentials-and-user-id pair that was threaded through fifteen
signatures in an order that could be swapped. `Secret`'s inner string is
reachable only through `expose()` and its `Debug` prints `Secret(***)`,
so the indirect leak — a `{:?}` on anything holding one — no longer
compiles into a leak.
Nextcloud is unchanged and keeps every peculiarity: propagating ETags,
chunked upload v2, `oc:fileid`, the `oc:permissions` probe on a refused
PUT, the 423 retry classification, Login Flow v2. Those are what the
capability model exists to serve, not something to hide.
`dr-sync-folder` is the second connector: a local disk, a network mount,
an external drive, or a folder a Nextcloud client already syncs. No
account, no credential — the route that works where no secrets daemon
does. It declares `LocalEtags` rather than claiming propagation a POSIX
directory cannot provide, which costs nothing because 50k `stat` calls
are not 50k PROPFINDs. Identity is a path hash, not an inode: an inode
survives a rename but differs between devices and is reused after a
delete, so two machines would disagree about which photograph a
thumbnail belonged to. Re-deriving a thumbnail is a cost; showing the
wrong one is a bug.
docs/storage.md is the contract — the traits, the four steps to add a
backend, and what each connector declares. ARCH §8.0 and §8.4a, and
FR-NC-13, say why.
|
||
|
|
242374fd0f |
Let the interface hold a backend without knowing whose it is
`dr-sync` defines `RemoteBackend` and a capability model the engine adapts to, so a second backend can be added without touching the code that uses one. That boundary was documentation. Seven files in `dr-ui` constructed a `NextcloudBackend` directly, ten functions took one by concrete type, and exactly two call sites in the tree — both inside `dr-sync` itself — ever held the trait object. A WebDAV or local-folder backend would have had a well-written trait to implement and nowhere to go afterwards. The change is smaller than the finding suggests, because the trait was already right. Every method the UI has ever called on a backend — `get`, `put`, `list`, `delete`, `create_dir`, `move_to` — was already on it, so nothing had to be added and no behaviour moved. Ten signatures widened to `&dyn RemoteBackend`, sixteen constructions became `remote::connect`, and `remote.rs` is now the only file in the interface that names a connector. `connect` returns `Result<Box<dyn RemoteBackend>, RemoteError>`. The error type is `dr-sync`'s rather than the connector's, which is why every call site kept its shape — the `match`, the `let Ok(..) else`, and `.map_err(ScanFailure::local)?` all still read as they did. One wrinkle worth recording: `&Box<dyn Trait>` does not reach `&dyn Trait` on its own. The compiler reaches for unsizing, which wants `Box<dyn RemoteBackend>: RemoteBackend`, and reports a confusing missing impl rather than suggesting a deref. Twelve call sites therefore say `&*backend`, and two say `let backend: &dyn RemoteBackend = &*backend` where a borrow is shared across lanes. What this does *not* do is abstract credentials. `AppCredentials` is an app password from Login Flow v2 — a Nextcloud protocol, not a general notion of authenticating to a remote — and seven files still name it. An OAuth token, a bucket key pair and an app password have no useful common shape, so deciding what an account is across backends before a second one exists would be a confident guess. code-health.md CH-2 now records that as the remaining half, and it should wait for the backend that forces it. Verified: fmt clean, clippy clean at -D warnings, 2041 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
03326242a1 |
Make the CI checks say what they mean, and format the workspace
The Android job's "Verify minimum API level" step has never verified the minimum API level. It took the first `*.so` anywhere under the target directory, which is a host proc-macro from debug/deps — an x86-64 object built by the runner's gcc, whose .comment section cannot mention Android and so can never contradict the expected value. It now reads the artifact under the target triple, compares against MIN_API parsed from the Dockerfile rather than a second copy of the number, and fails on a mismatch. Both sides are checked non-empty first: two failed parses would otherwise compare equal and pass, which is the same silent success in a new costume. The Android image installs one SDK package per layer and keeps the output. sdkmanager is a JVM program that aborts when it cannot get memory, and the single `> /dev/null` step reported that as a bare "exit code 134" while a retry re-downloaded everything that had already succeeded. tools/ci-local.sh runs all four jobs — desktop, android, layering, traceability — against the host toolchain, which is pinned to the same 1.92.0 CI installs. Its matrix check compares regeneration against the working tree rather than against HEAD: CI starts from a clean checkout, so git's answer is the right one there and reports every local run stale here. The rest is rustfmt across the workspace, and the clippy findings that surfaced once it did: manual_contains in dr-thumbs and collections_ui, a map iterated as pairs for its keys, an index loop over a slice, and two runtime assertions on a constant now made at compile time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4d78041d1d | Many imorovments | ||
|
|
fa12afed18 |
Keep originals on this device, by pin and by use
Fills in `image_cache`, which the previous commit's "On this device" filter read but nothing wrote. Also carries in-flight work that shared these files: the Android TLS root store, the settings page, and a regenerated traceability report. # Two populations, deliberately separate An original is kept here for one of two reasons, and conflating them produces the exact failure the feature exists to prevent. **Pinned** originals were asked for. Pinning a collection before a trip is a promise, so pinned rows are never evicted and never counted against the budget — a cap that could silently delete a pinned trip would make pinning worthless, because it could not be relied on without checking. **Passively cached** originals are a side effect of working: develop already downloads the whole file, so keeping it costs no bandwidth and saves the entire transfer next time. This population is what the budget bounds, evicted least-recently-used, because it otherwise grows until a day of culling fills a disk. Sharing one budget would let a large pin starve the passive cache, or let browsing evict a pin. They are separate. # What was built `dr_catalog::cache` owns the bookkeeping — held tier, size, last use, pinned — and writes the bytes; deciding to download stays with the caller, which is what keeps a crate with no network out of the network's business. Files are written to a temporary and renamed, so a dropped connection cannot leave a truncated file recorded as a complete original. They are named by image id, not filename: `Photos/IMG_0001.CR2` and `Trips/IMG_0001.CR2` are different photographs, and a flat cache keyed on the name would serve one for the other. `spawn_full_fetch` became read-through. A hit is a disk read; a miss stores what it downloads and enforces the budget. A cache that cannot be opened is a miss, not a failure to open the photograph. Pinning writes intent — `tier_desired` — without downloading, so the button responds immediately, and `spawn_pin_fetch` fills it in sequentially afterwards. Sequential because these are tens of megabytes each: the lanes that make the thumbnail sweep fast buy little against one connection's bandwidth and cost a great deal of memory. A pin interrupted by a lost connection resumes from where it stopped. Schema v5 adds `pinned` and `path`. `pinned` is a column rather than something inferred from `pinned_by_rule`, which is ON DELETE SET NULL and so cannot answer for an image whose rule was deleted. A v4 catalog migrates in place; existing rows default to unpinned, the safe direction. The budget and "keep opened originals" come from the settings page rather than a constant, and are applied at startup rather than only on change — a cache capped at 2 GB last session would otherwise spend this one filling to the default. Turning off keeping leaves what is already cached readable: those bytes are paid for, and refusing them would re-download images sitting right there, including pinned ones. Also removes a doubled `#[test]` introduced in the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cd75e5a4c6 |
Run the library from local data when the server is unreachable
Also carries in-flight work that shared these files: the zoom structure-key fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the timeline scrub marker correction, the 423-Locked retry in the metadata sweep, and the thumbnail size-class migration. # Offline mode (FR-CAT-9) The app previously assumed the server was reachable and treated its absence as a series of unrelated per-operation failures. A launch without a connection produced an empty grid, even with a complete catalog on disk and every thumbnail already in the shards. Reachability is now inferred from traffic the app was already making, rather than probed for. `RemoteError::indicates_offline` draws the line that makes this possible: a dead connection is offline, a 403 or a 500 is not — the server answered, so blanking the library over one forbidden file would be a worse error than the one being reported. `Reachability` turns those outcomes into a state, so a library browsing happily never issues a probe at all. Going offline takes one failure, because the user is already experiencing it. Coming back requires evidence — a completed scan or a fetched thumbnail — with a capped exponential backoff behind the manual retry, so twelve sweep lanes failing together do not schedule twelve immediate probes. What keeps working: the catalog opens even when the scan that normally provides it failed, so the grid fills from the last successful scan. Thumbnails come from the shards. Rating, flagging and collecting are catalog writes that never touched the network. What stops is opening an original that was never stored locally, and it now says so in those words instead of reporting "network error: connection refused" over a photograph. Work that is pure network is refused rather than left to fail slowly: the metadata sweep, derived sync, and sidecar writes. The sweep would otherwise spend a timeout per image across the whole library while the progress bar implied something was happening. Deferring sidecars is a real gap rather than a hidden one — a rating made offline reaches its sidecar only when that image is judged again while connected — and it is recorded as such at the call site. # The "On this device" filter A chip beside the rating filters, narrowing the grid to images whose original is held locally. It composes with the rating terms rather than replacing them, so "five-star frames I can actually edit on this train" is one filter. The predicate is SQL, like the rating terms and for the same reason: the count in the header has to agree with the cells drawn. It reads `image_cache.tier_actual`, which nothing writes yet — the next commit fills it. Until then the chip honestly reports zero. `Tier` gains an explicit on-disk encoding. The variants are ordered by generosity and the derived `Ord` invites reordering them, which would silently reinterpret every cached row; the round-trip test is what holds the two in agreement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8b7c1e7f10 |
Open the sign-in URL through an ACTION_VIEW Intent on Android
The previous commit made the missing launcher honest; this gives Android a real one, so Login Flow v2 can complete on device. Builds `new Intent(ACTION_VIEW, Uri.parse(url))` and hands it to `startActivity` over JNI. The JavaVM and Activity come from ndk_context, which android-activity's glue populates at startup — the same handle Slint's backend uses, so there is no second VM to reconcile. The login worker is a plain std::thread and therefore unknown to the JVM, where any JNI call would abort the process. jni 0.22 scopes attachment to a closure rather than returning a guard, so the whole Intent is built and dispatched inside `attach_current_thread` and the thread detaches on the way out. Names use `jni_str!` and signatures `jni_sig!`, both compile-time: a typo is a build error rather than a NoSuchMethodError on the device. A pending Java exception is checked and cleared before returning, since leaving one pending makes the next JNI call fail somewhere unrelated; in practice it means ActivityNotFoundException, i.e. no browser installed. jni is pinned to 0.22 to match Slint's Android backend. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0876977133 |
Report a missing browser launcher instead of faking success
`open_in_browser` gated its xdg-open path on `target_os = "linux"`, which is false on Android — that is its own target_os. Android therefore took the fallback arm, which discarded the URL and returned `Ok(())`. Login Flow v2 cannot complete without a browser: the user approves the sign-in there and `auth::poll` waits for that approval. Claiming success meant the UI showed "Approve the sign-in in your browser" with no browser open, the poll waited for an approval that could never arrive, and the worker eventually dropped its channel — surfacing as "sign-in failed unexpectedly", which pointed at the network rather than at the real cause. The server had in fact been contacted successfully. Gate on `unix && !android && !macos` so the arm matches what xdg-open actually implies, and return Unsupported from the fallback. The caller treats it as terminal rather than swallowing it with `let _ =`. Android gets a real Intent-based launcher in the next commit; until then the failure is at least honest about what happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8ad5c86ff9 |
Add the library, collections, and trash views; theme from style.yaml
The UI gains the views the catalog work was building toward: a windowed
library grid with ratings and flags, the collection tree with drag-to-add,
and trash with restore. derived_sync pushes thumbnail shards and the catalog
snapshot to the server's derived folder.
Tokens now have one source of truth. build.rs reads style.yaml and generates
theme.slint into OUT_DIR, which answers every existing
`import { Theme } from "theme.slint"` unchanged, because Slint resolves
imports against the importing file's directory first and the include paths
after. Generating into OUT_DIR rather than beside the hand-written Slint is
the point: a generated file sitting in ui/ looks exactly like the files
around it that are meant to be edited, and an edit to it would survive until
the next touch of style.yaml — a bug that hides for weeks. build.rs fails
loudly if a stale ui/theme.slint exists, which would otherwise shadow the
generated one silently and make every palette change vanish with no error.
The palette moves to near-neutral dark with achromatic signalling, so the
accent means "modified" or "active" rather than "heading". Shared components
land in widgets.slint: a token that binds several values into one concept is
a component, not a row in a YAML file.
Adds an optional live-style feature that makes the tokens in-out so they can
be written at startup — a feature rather than the default because it stops
the properties being constant-folded.
serde_norway is the YAML crate: serde_yaml and serde_yml are both deprecated,
and its mappings preserve insertion order, which is what lets the generated
Slint keep the token ordering the author chose.
Assisted-by: LLM
|
||
|
|
5365123d92 |
Fix the folder picker stuck on "Loading…"
The picker set loading=true and nothing ever cleared it, because browser_loaded() was never called — my earlier edit to replace the stub silently failed to apply after cargo fmt reindented the code it matched against. The stale stub was still writing folder names into the status line, which is the list visible under the selector. Verified by grep before and after: browser_loaded had zero call sites, now two. Two related defects fixed while in there. Both timers polled their channel with `if let Ok(..)`, so a worker thread that died without sending left the screen on "Loading…" or "Connecting…" forever. Both now treat Disconnected as terminal. The first attempt at that fix introduced a bug of its own: a separate probe call consumed a pending message and discarded it, so a successful login would have vanished. The login poller now drains in one loop with disconnection as a match arm, and only reports failure when the flow had not already completed. Lesson worth recording: verify a replacement applied rather than trusting the edit succeeded. A silently-skipped replace looks identical to a successful one until the feature is used. 43 tests in dr-ui. |
||
|
|
2ca1716a29 |
Make "Choose folder" an actual folder picker
It previously fetched the folder list and threw it away into a status line — a button that looked like it worked and did not. Now it opens a browsable picker: click a folder to descend, ".." to go back, "Use this folder" to select, "Cancel" to leave the root unchanged. Descends one level per click because that is what the backend supports: Depth: infinity is frequently disabled server-side and prohibitively expensive where it is not (ARCH §8.4). The chosen root persists immediately on confirm, so it survives a crash before the library is opened. Confirming at the account root is allowed — a user may legitimately keep everything at the top level — and cancelling leaves any previous selection untouched, which a test asserts. Verified against nextcloud.tourolle.paris at both depths: 30 folders at the root, 21 year-folders inside PhotosRaw. 19 launch tests, 38 in dr-ui. |
||
|
|
f630a3ff81 |
Wire the launch screen into the app
The app now opens on the login screen when there is nothing else to show
— no local paths and no configured library — and goes straight to the
images otherwise. Making someone click past a login they already
completed is pure friction.
launch.slint imported by app.slint, replacing the window rather
than overlaying it: there is no library to look at
until an account is configured
launch_ui.rs the Slint wiring, kept out of lib.rs so the launch
flow can change without touching the develop window
Login runs on a worker thread and posts results back through a channel,
since Slint's event loop is single-threaded and a 20-minute browser wait
cannot block it. The system browser is opened via xdg-open, never an
embedded webview (FR-NC-1).
Sign-out deletes the local credential even if server-side revocation
fails: a network error must not leave a usable secret on the machine.
Format tick-boxes persist on each toggle, so a selection survives a
crash before the library is opened.
Two things deliberately incomplete rather than faked:
- "Choose folder" lists the account's folders and reports them, but
there is no picker widget yet, so selection still happens via the
connect example.
- "Open library" logs the request. Opening a remote library needs the
scan-and-cache path, which belongs with the catalog work in flight.
Earlier I broke the other in-flight dr-ui work by calling
slint_build::compile twice, which replaces the generated module. The
correct wiring is an import inside app.slint, which is what this does.
30 dr-ui tests passing; both launch paths verified by running the app.
|