Nimbin[12]?Web & Tools / Browser / commits / 5b51b36

Browser git · main

Minimal browser in C (mini and big variant)

browser c · first commit 2026-08-25 · last commit 2026-10-05 (4 days ago) · synced 3 days ago · upstream: github.com/nimbin2/Browser

C 95.8% Markdown 3.9%
git clone https://git.christianimmanuel.de/web-tools/Browser.gitwget https://git.christianimmanuel.de/web-tools/Browser/archive/Browser.tar.gz

Fix url spec download dir And styling

Christian Immanuel · 2026-08-28 09:58 · 5b51b36f1073fd3990cdb9d6a3abde33b08f9b69

 Makefile       |  15 +-
 README.md      |  76 +++++-
 bigbrowser     | Bin 169560 -> 0 bytes
 browser_core.c | 713 ++++++++++++++++++++++++++++++++++++++++++++++++++-------
 browser_core.h |  17 ++
 minibrowser    | Bin 127264 -> 0 bytes
 6 files changed, 728 insertions(+), 93 deletions(-)

diff --git a/Makefile b/Makefile
index 8215f37..becfbf4 100644
--- a/Makefile
+++ b/Makefile
@@ -2,6 +2,10 @@ CC      ?= gcc
 CFLAGS  ?= -O2 -Wall -Wextra
 PREFIX  ?= /usr/local
 
+VERSION  := 1.1.2
+SOURCES  := browser_core.c browser_core.h minibrowser.c bigbrowser.c Makefile
+BUILD_ID := $(shell cat $(SOURCES) 2>/dev/null | md5sum | cut -c1-7)
+
 CORE_PKGS = gtk4 webkitgtk-6.0 libsoup-3.0
 BIG_PKGS  = $(CORE_PKGS) gstreamer-1.0
 
@@ -10,15 +14,20 @@ CORE_LIBS   := $(shell pkg-config --libs   $(CORE_PKGS))
 BIG_CFLAGS  := $(shell pkg-config --cflags $(BIG_PKGS))
 BIG_LIBS    := $(shell pkg-config --libs   $(BIG_PKGS))
 
+VERFLAGS := -DBROWSER_VERSION='"$(VERSION)"' -DBROWSER_BUILD='"$(BUILD_ID)"'
+
 BINS = minibrowser bigbrowser
 
 all: $(BINS)
 
 minibrowser: minibrowser.c browser_core.c browser_core.h
-	$(CC) $(CFLAGS) -o $@ minibrowser.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS)
+	$(CC) $(CFLAGS) $(VERFLAGS) -o $@ minibrowser.c browser_core.c $(CORE_CFLAGS) $(CORE_LIBS)
 
 bigbrowser: bigbrowser.c browser_core.c browser_core.h
-	$(CC) $(CFLAGS) -o $@ bigbrowser.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS)
+	$(CC) $(CFLAGS) $(VERFLAGS) -o $@ bigbrowser.c browser_core.c $(BIG_CFLAGS) $(BIG_LIBS)
+
+version:
+	@echo "$(VERSION) (build $(BUILD_ID))"
 
 install: all
 	install -Dm755 minibrowser $(DESTDIR)$(PREFIX)/bin/minibrowser
@@ -27,4 +36,4 @@ install: all
 clean:
 	rm -f $(BINS)
 
-.PHONY: all install clean
+.PHONY: all install clean version
diff --git a/README.md b/README.md
index 711afa3..14452fb 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,16 @@ GTK 4.14 / WebKitGTK 2.52.3, and run headless under Xvfb.
 
 minibrowser adds nothing to the core. Every hook is optional.
 
+## Version
+
+```
+minibrowser --version     # minibrowser 1.1.2 (build 060a193)
+make version
+```
+
+The build id is an md5 of the sources, so two builds can be told apart
+without guessing.
+
 ## Build
 
 Debian and derivatives:
@@ -36,7 +46,8 @@ minibrowser [URL] [options]
 bigbrowser  [URL|PATH|diag] [options]
 ```
 
-With no address the window comes up blank with the history open. A local
+With no address the window comes up on `about:blank` with the history popup
+open, so there is something to pick from rather than an empty window. A local
 file needs three slashes — `file:///tmp/index.html` — though a bare path
 works too (`./index.html`, `/tmp/index.html`), and anything without a
 scheme is tried as https.
@@ -92,11 +103,30 @@ the matching history under it.
   `Down` asks for it — with nothing typed yet, that is the whole history.
 - `Ctrl+H` opens the same popup on the history.
 
+Every panel — address, history, find, download directory, key list — is the
+same width: `popup_width`, 550px by default, which is about fifty
+characters of the mono font — enough for most addresses without the panel
+taking over the window. It shrinks to fit a small window rather than
+clipping, and the history list takes up to about two thirds of the window's
+height.
+
+```
+popup_width = 900         # in swov's config, or the browser's
+```
+
 `Tab` and `Down` walk forward through the matches, `Shift+Tab` and `Up`
 back, the wheel scrolls, a click opens. Whatever is selected is written into
 the input, so `Enter` always loads exactly what you can read. `Esc`,
 `Ctrl+H` or a click outside closes it.
 
+Anything the popup can save shows a **Save** and a **Cancel** button with
+the keys named on them (`Save (Enter)`, `Cancel (Esc)`) — Enter is never the
+only way in. They are text, not boxes: Cancel sits at the left in `subtext`,
+Save at the right in the accent and bold, and either underlines and
+brightens on hover. When the directory popup asks about a clash the same two
+buttons become `Drop that rule` and `Keep both`, so both answers are on
+screen rather than implied.
+
 `Esc` closes whatever is on screen — popup, key list, download list or a
 message — and reaches the page only when nothing of ours is up.
 
@@ -112,19 +142,53 @@ this address only, everything on the site, or everything. The narrower rule
 wins, so one page can go somewhere its site does not.
 
 ```
-~/.local/share/wkview/<profile>/download-dirs.tsv    key <TAB> directory
+~/.local/share/wkview/download-dirs.tsv    key <TAB> directory [<TAB> overwrite]
 ```
 
-The key is a whole address or a bare host. A rule set on a page also covers
+The file belongs to you, not to a profile: every profile and both browsers
+read the same rules, since which directory a site's files go in has nothing
+to do with which cookie jar is in use. Rules written by an older build are
+carried over from the profile on first run.
+
+The key is a whole address or a bare host. The scopes are independent and the
+narrower one always wins: a rule on `a.de/b` keeps its own directory when
+`a.de` gets one. Setting a rule only ever writes the key for the scope you
+picked. A rule set on a page also covers
 the files that page hands out, which usually live on another address — a
 CDN, or just a different path — so a rule on a download page catches what
 it serves.
 
+Ticking **always replace existing files** turns the question off for
+whatever that rule covers: files take the name they ask for. It follows the
+same select, so it can apply to one page, a whole site, or everything —
+`always_overwrite` in a config file does the last one permanently.
+
+If something else already points at the directory you set, you are asked
+first: `Enter` drops the older rule, `Esc` lets both use it. Sharing a
+directory is usually meant, but not always.
+
+Setting the scope to **everything** lasts for that run only — put
+`download_dir` in a config file to make it permanent.
+
+Two browsers saving into one directory do not collide either: a name is
+claimed by creating the file, so the second one moves to the next number
+rather than landing on top of the first. Tested with two instances pulling
+the same file into the same directory at once.
+
+Rules are merged on save, not overwritten: a second browser on the same
+profile re-reads the file and keeps what the first one wrote, so two
+instances cannot trample each other's rules. The same goes for search
+keywords.
+
 A file whose name is already taken is downloaded beside the old one and the
 question is asked in the download panel itself — no second popup. `Enter`
 puts it in the old one's place, `Esc` keeps both, and the transfer only
 reads as done once the file is where it is going to stay. A directory that does not exist
-is created group writable (`0770`). The download panel shows the
+is created group writable (`0770`). The panel appears the moment the server answers, and reads `starting` until
+the first byte lands. It normally fades out under the pointer so the page
+can be read through it, but a download that has just appeared outranks that
+for a few seconds — otherwise one starting while the pointer happened to
+rest there would arrive invisibly. The panel shows the
 destination in small text on the right of its header, so it is clear where
 a file is going before it lands.
 
@@ -164,8 +228,8 @@ whose name collides with a keyword still works.
 Every overlay — the popup, toast, downloads — is drawn from
 swov's palette and geometry, so the two programs read as one set. Defaults
 match swov value for value: `tile` panels at `radius 14` with a `border 3`
-outline, `text` on top, `hint` for headers, `hl` (the orange) for the caret
-and the download bar, `urgent` for failures.
+outline, `text` on top, `hint` for headers, `hl` (the orange) for the caret,
+the download bar and, at a third of its weight, for selections, `urgent` for failures.
 
 Nothing is hardcoded. `ui_css_install()` builds the stylesheet from the
 theme, so changing `hl` in a config file moves every accent at once.
diff --git a/bigbrowser b/bigbrowser
deleted file mode 100755
index 8e496f9..0000000
Binary files a/bigbrowser and /dev/null differ
diff --git a/browser_core.c b/browser_core.c
index 36c2c2b..93b4b19 100644
--- a/browser_core.c
+++ b/browser_core.c
@@ -9,6 +9,7 @@
 #include "browser_core.h"
 
 #include <errno.h>
+#include <fcntl.h>
 #include <signal.h>
 #include <stdlib.h>
 #include <string.h>
@@ -28,6 +29,7 @@
 /* download overlay */
 #define DL_LINGER_SECONDS  4      /* keep a finished download on screen */
 #define DL_TICK_MS         100    /* progress / ETA refresh rate */
+#define DL_REVEAL_MS       3000   /* a new download outranks the hover fade */
 #define DL_ACTIVE_ROWS     4      /* max rows in the live overlay */
 #define DL_HISTORY_ROWS    8      /* max rows in the <mod>+D list */
 #define DL_KEEP            32     /* max downloads remembered */
@@ -127,6 +129,7 @@ static void       on_ready_to_show (WebKitWebView *view, gpointer u);
 static void       on_session_download_started (WebKitNetworkSession *session,
                                                WebKitDownload *download, gpointer u);
 static void       downloads_refresh (void);
+static void       downloads_reveal  (void);
 static void       downloads_tick_start (void);
 static void       dl_panel_rebuild  (Win *w);
 static void       css_apply_one     (WebKitUserContentManager *ucm);
@@ -333,7 +336,31 @@ profile_dirs (const char *profile, char **out_data_dir, char **out_cache_dir)
 
 typedef enum { DL_SCOPE_PAGE, DL_SCOPE_SITE, DL_SCOPE_ALL } DlScope;
 
-static GHashTable *g_dlrules;      /* host -> dir, both owned */
+static GHashTable *g_dlrules;      /* key -> dir, both owned */
+static GHashTable *g_dlgone;       /* keys this session deleted, as a set */
+static GHashTable *g_dlalways;     /* keys whose files may be replaced */
+static gboolean    g_always_overwrite;   /* ... and the same for everything */
+static gboolean    g_always_overwrite_set;  /* said so on the command line */
+
+#define DL_ALWAYS_TOKEN  "overwrite"
+#define DL_ALL_KEY       "*"         /* the line that carries the global flag */
+
+/*
+ * These rules belong to the person, not to a profile: which directory a
+ * site's files go in has nothing to do with which cookie jar is in use,
+ * and two browsers on different profiles should agree about it. So they
+ * live one level up from the profiles, in
+ *
+ *   ~/.local/share/wkview/download-dirs.tsv
+ *
+ * and every instance of either browser shares them.
+ */
+static char *
+dlrules_path (void)
+{
+    return g_build_filename (g_get_user_data_dir (), PROFILE_DIR_NAME,
+                             DLRULES_FILE, NULL);
+}
 
 static char *
 uri_host (const char *uri)
@@ -352,58 +379,206 @@ uri_host (const char *uri)
     return out;
 }
 
+/*
+ * Two browsers on one profile would otherwise trample each other: both
+ * read the file at startup, and whichever saved last would write its own
+ * idea of the world over the other's. So a save re-reads the file first
+ * and merges: what is on disk survives unless we changed that key or
+ * deleted it here. The merged table becomes ours, so the other instance's
+ * rules are picked up rather than lost.
+ */
+/* key <TAB> dir <TAB> overwrite, the last field optional. */
+static void
+dlrules_parse (const char *text, GHashTable *dirs, GHashTable *always,
+               gboolean *global_always, gboolean skip_gone)
+{
+    char **lines = g_strsplit (text, "\n", -1);
+
+    for (int i = 0; lines[i]; i++) {
+        if (!*lines[i])
+            continue;
+
+        char **f = g_strsplit (lines[i], "\t", 3);
+        gboolean ok = g_strv_length (f) >= 2 && *f[0];
+
+        if (ok && skip_gone && g_dlgone && g_hash_table_contains (g_dlgone, f[0]))
+            ok = FALSE;
+
+        if (ok) {
+            gboolean always_on = g_strv_length (f) >= 3 &&
+                                 !g_strcmp0 (g_strstrip (f[2]), DL_ALWAYS_TOKEN);
+
+            if (!g_strcmp0 (f[0], DL_ALL_KEY)) {
+                if (global_always)
+                    *global_always = always_on;
+            } else if (*f[1]) {
+                if (dirs)
+                    g_hash_table_insert (dirs, g_strdup (f[0]), g_strdup (f[1]));
+                if (always && always_on)
+                    g_hash_table_add (always, g_strdup (f[0]));
+            }
+        }
+        g_strfreev (f);
+    }
+
+    g_strfreev (lines);
+}
+
+static void
+dlrules_write (GString *s, const char *key, const char *dir)
+{
+    gboolean always = g_dlalways && g_hash_table_contains (g_dlalways, key);
+
+    g_string_append_printf (s, "%s\t%s%s%s\n", key, dir,
+                            always ? "\t" : "", always ? DL_ALWAYS_TOKEN : "");
+}
+
 static void
 dlrules_save (void)
 {
-    if (!g_dlrules || !g_data_dir)
+    if (!g_dlrules)
         return;
 
-    GString        *s = g_string_new (NULL);
-    GHashTableIter  it;
-    gpointer        k, v;
+    char       *path   = dlrules_path ();
+    GHashTable *merged = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+    char       *data   = NULL;
+
+    if (g_file_get_contents (path, &data, NULL, NULL)) {
+        /* the other instance's always-flags survive with its rules */
+        dlrules_parse (data, merged, g_dlalways, NULL, TRUE);
+        g_free (data);
+    }
+
+    GHashTableIter it;
+    gpointer       k, v;
 
     g_hash_table_iter_init (&it, g_dlrules);
     while (g_hash_table_iter_next (&it, &k, &v))
-        g_string_append_printf (s, "%s\t%s\n", (char *) k, (char *) v);
+        g_hash_table_insert (merged, g_strdup (k), g_strdup (v));   /* ours wins */
+
+    GString *s = g_string_new (NULL);
+    g_hash_table_iter_init (&it, merged);
+    while (g_hash_table_iter_next (&it, &k, &v))
+        dlrules_write (s, k, v);
+
+    if (g_always_overwrite)
+        g_string_append (s, DL_ALL_KEY "\t\t" DL_ALWAYS_TOKEN "\n");
 
-    char *path = g_build_filename (g_data_dir, DLRULES_FILE, NULL);
     if (!g_file_set_contents (path, s->str, -1, NULL))
         g_printerr ("download: cannot write %s\n", path);
 
+    g_hash_table_unref (g_dlrules);
+    g_dlrules = merged;
+
     g_free (path);
     g_string_free (s, TRUE);
 }
 
+/* Keys other than except_key that already point at this directory. */
+static GPtrArray *
+dl_dir_users (const char *dir, const char *except_key)
+{
+    GPtrArray      *out = g_ptr_array_new ();
+    GHashTableIter  it;
+    gpointer        k, v;
+
+    g_hash_table_iter_init (&it, g_dlrules);
+    while (g_hash_table_iter_next (&it, &k, &v))
+        if (!g_strcmp0 ((char *) v, dir) && g_strcmp0 ((char *) k, except_key))
+            g_ptr_array_add (out, k);
+
+    return out;
+}
+
 static void
-dlrules_setup (const char *data_dir)
+dl_dir_forget (const char *key)
 {
-    g_dlrules = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+    if (!key)
+        return;
+    if (!g_dlgone)
+        g_dlgone = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
 
-    if (!data_dir)
-        return;                        /* --private: rules stay in memory */
+    g_hash_table_add (g_dlgone, g_strdup (key));
+    g_hash_table_remove (g_dlrules, key);
+}
+
+/* "replace the existing link": every other rule aiming there is dropped. */
+static void
+dl_dir_clear_users (const char *dir, const char *except_key)
+{
+    GPtrArray *users = dl_dir_users (dir, except_key);
+    GPtrArray *keys  = g_ptr_array_new_with_free_func (g_free);
+
+    for (guint i = 0; i < users->len; i++)
+        g_ptr_array_add (keys, g_strdup (g_ptr_array_index (users, i)));
+
+    for (guint i = 0; i < keys->len; i++) {
+        LOG ("download: dropped rule %s\n", (char *) g_ptr_array_index (keys, i));
+        dl_dir_forget (g_ptr_array_index (keys, i));
+    }
+
+    g_ptr_array_free (users, TRUE);
+    g_ptr_array_free (keys, TRUE);
+}
 
-    char *path = g_build_filename (data_dir, DLRULES_FILE, NULL);
+/* profile_dir is only consulted to carry across rules written by an
+ * older build, which kept them per profile. */
+static void
+dlrules_setup (const char *profile_dir)
+{
+    g_dlrules  = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+    g_dlalways = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+
+    char *path = dlrules_path ();
     char *data = NULL;
 
     if (g_file_get_contents (path, &data, NULL, NULL)) {
-        char **lines = g_strsplit (data, "\n", -1);
-        for (int i = 0; lines[i]; i++) {
-            if (!*lines[i])
-                continue;
-            char **f = g_strsplit (lines[i], "\t", 2);
-            if (g_strv_length (f) == 2 && *f[0] && *f[1])
-                g_hash_table_insert (g_dlrules, g_strdup (f[0]), g_strdup (f[1]));
-            g_strfreev (f);
-        }
-        g_strfreev (lines);
+        dlrules_parse (data, g_dlrules, g_dlalways,
+                       g_always_overwrite_set ? NULL : &g_always_overwrite, FALSE);
         g_free (data);
-        LOG ("download: %u site rules from %s\n",
-             g_hash_table_size (g_dlrules), path);
+        LOG ("download: %u rules from %s\n", g_hash_table_size (g_dlrules), path);
+        g_free (path);
+        return;
+    }
+
+    if (profile_dir) {
+        char *old = g_build_filename (profile_dir, DLRULES_FILE, NULL);
+
+        if (g_file_get_contents (old, &data, NULL, NULL)) {
+            dlrules_parse (data, g_dlrules, g_dlalways, &g_always_overwrite, FALSE);
+            g_free (data);
+            LOG ("download: carried %u rules over from %s\n",
+                 g_hash_table_size (g_dlrules), old);
+            dlrules_save ();           /* now they live with the others */
+        }
+        g_free (old);
     }
 
     g_free (path);
 }
 
+/* Whether files reached through this key may be replaced without asking. */
+static gboolean
+dl_always_for_key (const char *key)
+{
+    return g_always_overwrite ||
+           (key && g_dlalways && g_hash_table_contains (g_dlalways, key));
+}
+
+static void
+dl_always_set (const char *key, gboolean on)
+{
+    if (!key) {                        /* the everything scope */
+        g_always_overwrite = on;
+        return;
+    }
+
+    if (on)
+        g_hash_table_add (g_dlalways, g_strdup (key));
+    else
+        g_hash_table_remove (g_dlalways, key);
+}
+
 /* The directory a download from this address should land in: the most
  * specific rule that matches, else the general one. */
 static const char *
@@ -489,8 +664,53 @@ dl_dir_for_download (WebKitDownload *download)
     return dl_dir_for_uri (file);      /* the file's host, then the general one */
 }
 
-/* scope is what the select in the <mod>+S popup decides. Setting a
- * broader scope clears the narrower rules that would have shadowed it. */
+/* The key that supplied the directory, so its always-flag can be found. */
+static char *
+dl_key_for_download (WebKitDownload *download)
+{
+    WebKitWebView    *view = webkit_download_get_web_view (download);
+    WebKitURIRequest *req  = webkit_download_get_request (download);
+
+    const char *page = view ? webkit_web_view_get_uri (view) : NULL;
+    const char *file = req  ? webkit_uri_request_get_uri (req) : NULL;
+
+    if (page && g_hash_table_contains (g_dlrules, page))
+        return g_strdup (page);
+    if (file && g_hash_table_contains (g_dlrules, file))
+        return g_strdup (file);
+
+    char *host = uri_host (page);
+    if (host && g_hash_table_contains (g_dlrules, host))
+        return host;
+    g_free (host);
+
+    host = uri_host (file);
+    if (host && g_hash_table_contains (g_dlrules, host))
+        return host;
+    g_free (host);
+
+    return NULL;                       /* the general directory answered */
+}
+
+static gboolean
+dl_always_for_download (WebKitDownload *download)
+{
+    char    *key = dl_key_for_download (download);
+    gboolean on  = dl_always_for_key (key);
+
+    g_free (key);
+    return on;
+}
+
+/*
+ * The select says which key to write, and nothing else is touched. An
+ * earlier version cleared the narrower rules when a broader one was set,
+ * on the theory that a stale page rule would shadow a new site rule -
+ * but shadowing is the whole point: a.de/b keeps its own directory when
+ * a.de gets one, which is what having three scopes is for. Clearing them
+ * also left a tombstone, so the page rule did not come back after a
+ * restart.
+ */
 static void
 dl_dir_set (const char *uri, const char *dir, DlScope scope)
 {
@@ -498,26 +718,22 @@ dl_dir_set (const char *uri, const char *dir, DlScope scope)
 
     if (scope == DL_SCOPE_PAGE && uri) {
         LOG ("download: %s -> %s\n", uri, dir);
+        if (g_dlgone) g_hash_table_remove (g_dlgone, uri);
         g_hash_table_insert (g_dlrules, g_strdup (uri), g_strdup (dir));
         dlrules_save ();
         g_free (host);
         return;
     }
 
-    if (uri)
-        g_hash_table_remove (g_dlrules, uri);
-
     if (scope == DL_SCOPE_SITE && host) {
         LOG ("download: %s -> %s\n", host, dir);
+        if (g_dlgone) g_hash_table_remove (g_dlgone, host);
         g_hash_table_insert (g_dlrules, host, g_strdup (dir));   /* takes host */
         dlrules_save ();
         return;
     }
 
-    if (host) {
-        g_hash_table_remove (g_dlrules, host);
-        g_free (host);
-    }
+    g_free (host);
     dlrules_save ();
 
     g_free (g_download_dir);
@@ -539,23 +755,52 @@ dl_dir_set (const char *uri, const char *dir, DlScope scope)
 
 static GHashTable *g_searches;     /* keyword -> url template */
 
+static void searches_setup (const char *data_dir);
+
 static void
 searches_save (void)
 {
     if (!g_searches || !g_data_dir)
         return;
 
+    /* Merged for the same reason as the download rules, and into a table
+     * of its own: reading straight into g_searches would let a stale line
+     * on disk overwrite the very change being saved. */
+    char       *path   = g_build_filename (g_data_dir, SEARCH_FILE, NULL);
+    GHashTable *merged = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
+    char       *data   = NULL;
+
+    if (g_file_get_contents (path, &data, NULL, NULL)) {
+        char **lines = g_strsplit (data, "\n", -1);
+        for (int i = 0; lines[i]; i++) {
+            if (!*lines[i])
+                continue;
+            char **f = g_strsplit (lines[i], "\t", 2);
+            if (g_strv_length (f) == 2 && *f[0] && *f[1])
+                g_hash_table_insert (merged, g_strdup (f[0]), g_strdup (f[1]));
+            g_strfreev (f);
+        }
+        g_strfreev (lines);
+        g_free (data);
+    }
+
     GString        *s = g_string_new (NULL);
     GHashTableIter  it;
     gpointer        k, v;
 
     g_hash_table_iter_init (&it, g_searches);
+    while (g_hash_table_iter_next (&it, &k, &v))
+        g_hash_table_insert (merged, g_strdup (k), g_strdup (v));   /* ours wins */
+
+    g_hash_table_iter_init (&it, merged);
     while (g_hash_table_iter_next (&it, &k, &v))
         g_string_append_printf (s, "%s\t%s\n", (char *) k, (char *) v);
 
-    char *path = g_build_filename (g_data_dir, SEARCH_FILE, NULL);
     g_file_set_contents (path, s->str, -1, NULL);
 
+    g_hash_table_unref (g_searches);
+    g_searches = merged;
+
     g_free (path);
     g_string_free (s, TRUE);
 }
@@ -673,6 +918,9 @@ typedef struct {
 
     /* text */
     int   label_px, title_px, hint_px, ws_px;
+
+    /* how wide a popup wants to be, before the window has its say */
+    int   popup_w;
     char *font;        /* "" -> whatever GTK is themed with */
     char *font_mono;   /* URLs and file names read better fixed width */
 } Theme;
@@ -723,6 +971,16 @@ css_rgba (Color c)
                             (int) (c.b * 255 + 0.5), c.a);
 }
 
+/* The same colour at a different weight: a selection wants the accent's
+ * hue without its full strength, which is what makes it read as a wash
+ * over the text rather than a block on top of it. */
+static char *
+css_rgba_at (Color c, double alpha)
+{
+    c.a *= alpha;
+    return css_rgba (c);
+}
+
 static void
 theme_defaults (void)
 {
@@ -752,6 +1010,7 @@ theme_defaults (void)
     t->win_gap  = 5.0;
     t->ui_scale = 1.0;
 
+    t->popup_w  = 550;         /* about 50 characters of the mono font */
     t->ws_px    = 26;
     t->label_px = 16;
     t->title_px = 13;
@@ -820,6 +1079,7 @@ cfg_set (const char *k, const char *v)
     }
 
     /* ---- text, shared with swov ---- */
+    if (key_is (k, "popup_w") || key_is (k, "popup_width")) { t->popup_w = atoi (v); return TRUE; }
     if (key_is (k, "ws_px") || key_is (k, "workspace_px")) { t->ws_px    = atoi (v); return TRUE; }
     if (key_is (k, "label_px") || key_is (k, "app_px"))    { t->label_px = atoi (v); return TRUE; }
     if (key_is (k, "title_px"))                            { t->title_px = atoi (v); return TRUE; }
@@ -841,6 +1101,11 @@ cfg_set (const char *k, const char *v)
     if (key_is (k, "quiet"))         { g_quiet = truthy (v); return TRUE; }
     if (key_is (k, "page_title"))    { g_want_page_title = truthy (v); return TRUE; }
     if (key_is (k, "middle_click_paste")) { g_no_middle_paste = !truthy (v); return TRUE; }
+    if (key_is (k, "always_overwrite")) {
+        g_always_overwrite     = truthy (v);
+        g_always_overwrite_set = TRUE;      /* the file must not undo this */
+        return TRUE;
+    }
     if (key_is (k, "mod"))           return parse_mod (v);
 
     /* search_s = https://example.com/?q={} */
@@ -1022,8 +1287,10 @@ usage (const char *argv0, gboolean to_stdout)
 "\n"
 "  A download shows a progress bar in the top right corner with the file\n"
 "  name, percentage and estimated time left. Move the pointer over it and\n"
-"  it fades out so you can read the page underneath; it disappears on its\n"
-"  own %d seconds after the transfer ends.\n"
+"  it fades out so you can read the page underneath, though one that has\n"
+"  just appeared stays visible anyway. It shows up as soon as the server\n"
+"  answers, reading \"starting\" until the first byte lands, and it\n"
+"  disappears on its own %d seconds after the transfer ends.\n"
 "\n",
         g_app->default_title, g_app->default_app_id, g_mod_name,
         DEFAULT_CLIP_CMD, DL_LINGER_SECONDS);
@@ -1036,8 +1303,10 @@ usage (const char *argv0, gboolean to_stdout)
 "  --download-dir DIR  download target (default: XDG download dir)\n"
 "                      single pages and whole sites can be sent elsewhere\n"
 "                      with <mod>+S; those rules live next to the profile\n"
-"                      in download-dirs.tsv, search keywords in\n"
-"                      searches.tsv. A directory that does not exist is\n"
+"                      in ~/.local/share/wkview/download-dirs.tsv, which\n"
+"                      belongs to you rather than to a profile: every\n"
+"                      profile and both browsers share it. Search keywords\n"
+"                      are per profile, in searches.tsv. A directory that does not exist is\n"
 "                      created group writable (0770)\n"
 "  --profile NAME      named profile, persists cookies (default: \"default\")\n"
 "  --clear-data        wipe the profile's data and cache before starting\n"
@@ -1064,17 +1333,20 @@ usage (const char *argv0, gboolean to_stdout)
 "  swov's file and drive both. Keys either program does not know are\n"
 "  ignored with a note on stderr.\n"
 "\n"
-"  look:    bg tile tile_sel tile_hover card card_hover text subtext dim\n"
+"  look:    popup_width (how wide the panels want to be, %d)\n"
+"           bg tile tile_sel tile_hover card card_hover text subtext dim\n"
 "           accent hl hltext hint urgent outline\n"
 "           radius border pad gap win_gap ui_scale\n"
 "           font font_mono label_px title_px hint_px\n"
 "  ours:    title app_id zoom mod clip_cmd download_dir profile private\n"
 "           no_media page_title middle_click_paste css user_agent quiet\n"
+"           always_overwrite\n"
 "           search_KEY (e.g. search_s = https://google.com/search?q={})\n"
 "\n"
 "misc:\n"
 "  -q, --quiet         silence the diagnostic output on stderr\n"
 "  -h, --help          this text\n"
+"  -V, --version       version and build id, to tell two builds apart\n"
 "\n"
 "key bindings (<mod> = %s):\n"
 "  <mod>+O  or <mod>+L type an address. Tab or Down brings up the matching\n"
@@ -1087,13 +1359,23 @@ usage (const char *argv0, gboolean to_stdout)
 "  <mod>+S             download directory. The select decides how far it\n"
 "                      reaches: this address only, everything on the site,\n"
 "                      or everything. The narrower rule wins, and a rule\n"
-"                      set on a page also covers the files it hands out\n"
+"                      set on a page also covers the files it hands out.\n"
+"                      If something else already points at that directory\n"
+"                      you are asked: Enter drops the older rule, Esc lets\n"
+"                      both use it. Tick \"always replace existing files\"\n"
+"                      and files covered by that rule take the name they\n"
+"                      ask for, with no question. \"everything\" lasts for\n"
+"                      this run only; put download_dir in a config file to\n"
+"                      keep it\n"
 "  <mod>+K             add a search keyword, as \"g URL\" with {} where the\n"
 "                      words go\n"
 "  F1  or  <mod>+/     the key list, on screen\n"
 "  <mod>+J             back: one page back, then on into the stored history\n"
 "  <mod>+Shift+J       forward, the same way round\n"
 "\n"
+"  Anything the popup can save shows a Save and a Cancel button with the\n"
+"  keys named on them, so Enter is never the only way in.\n"
+"\n"
 "  In the popup: Tab and Down walk forward through the matches, Shift+Tab\n"
 "  and Up back, the wheel scrolls, a click opens, Delete removes the entry\n"
 "  from the history. Whatever is selected is written into the input, so\n"
@@ -1112,7 +1394,7 @@ usage (const char *argv0, gboolean to_stdout)
 "  <mod>+minus         zoom out\n"
 "  <mod>+0             reset zoom to 100%%\n"
 "  <mod>+Y             copy the current URL, and show what was copied\n",
-        hblock, HIST_KEEP, cfg_shared, cfg_own,
+        hblock, HIST_KEEP, cfg_shared, cfg_own, g_theme.popup_w,
         g_mod_name, DL_HISTORY_SECONDS, URL_TOAST_SECONDS);
 
     if (g_app->usage_keys)
@@ -1213,6 +1495,8 @@ ui_css_install (void)
     char *c_hl      = css_rgba (t->hl);
     char *c_hint    = css_rgba (t->hint);
     char *c_hltext  = css_rgba (t->hltext);
+    char *c_hl_soft = css_rgba_at (t->hl, 0.34);   /* selections */
+    char *c_hl_dim  = css_rgba_at (t->hl, 0.78);   /* a button at rest */
     char *c_urgent  = css_rgba (t->urgent);
     char *c_outline = css_rgba (t->outline);
 
@@ -1285,6 +1569,20 @@ ui_css_install (void)
         "label.br-key-head { color: %s; %s font-size: %.0fpx; }"
         "checkbutton.br-check, checkbutton.br-check label {"
         "  color: %s; %s font-size: %.0fpx;"
+        "}"
+        "button.br-btn {"
+        "  background: none; background-color: transparent;"
+        "  background-image: none; box-shadow: none;"
+        "  border: none; border-radius: %.0fpx; outline: none;"
+        "  color: %s; %s font-size: %.0fpx;"
+        "  padding: %.0fpx %.0fpx; min-height: 0;"
+        "}"
+        "button.br-btn:hover {"
+        "  color: %s; text-decoration-line: underline;"
+        "}"
+        "button.br-btn-primary { color: %s; font-weight: bold; }"
+        "button.br-btn-primary:hover {"
+        "  color: %s; text-decoration-line: underline;"
         "}",
         c_hint, ui_font, t->hint_px * s,
         c_subtext, mono_font, t->title_px * s,
@@ -1296,7 +1594,12 @@ ui_css_install (void)
         c_hl, mono_font, t->title_px * s,
         c_text, ui_font, t->title_px * s,
         c_hint, ui_font, t->hint_px * s,
-        c_subtext, ui_font, t->title_px * s);
+        c_subtext, ui_font, t->title_px * s,
+        t->radius / 3.0, c_subtext, ui_font, t->title_px * s,
+        t->pad / 2.0, t->pad / 2.0,
+        c_text,
+        c_hl_dim,
+        c_hl);
 
     /* ---- entries: the URL bar and the picker search share a look ---- */
     /* The input reads as a field: its own well, a rule under it, and the
@@ -1316,6 +1619,8 @@ ui_css_install (void)
         "  padding: %.0fpx %.0fpx; margin: 0;"
         "}"
         "entry.br-omni-entry:focus-within { border-bottom-color: %s; }"
+        /* tile_sel vanished against the panel and solid hl shouted; the
+         * accent at a third of its weight reads as a wash over the text */
         "entry.br-omni-entry > text > selection {"
         "  background-color: %s; color: %s;"
         "}",
@@ -1324,7 +1629,7 @@ ui_css_install (void)
         t->radius / 3.0, t->radius / 3.0,
         t->pad / 2.0, t->pad / 2.0,
         c_hl,
-        c_sel, c_text);
+        c_hl_soft, c_text);
 
     /* ---- download rows: the bar picks up the accent ---- */
     g_string_append_printf (css,
@@ -1349,15 +1654,13 @@ ui_css_install (void)
         "  border-radius: %.0fpx; padding: %.0fpx %.0fpx;"
         "}"
         "list.br-pick-list > row:hover { background-color: %s; }"
-        /* the selection carries the accent, so it is obvious which row
-         * Enter would open */
+        /* the selected row carries the accent at the same weight, so the
+         * two kinds of selection look like one idea */
         "list.br-pick-list > row:selected { background-color: %s; }"
-        "list.br-pick-list > row:selected label.br-pick-main,"
-        "list.br-pick-list > row:selected label.br-pick-sub {"
-        "  color: %s;"
-        "}",
+        "list.br-pick-list > row:selected label.br-pick-main { color: %s; }"
+        "list.br-pick-list > row:selected label.br-pick-sub { color: %s; }",
         t->radius / 2.0, t->win_gap / 2.0, t->pad / 2.0,
-        c_hover, c_hl, c_hltext);
+        c_hover, c_hl_soft, c_text, c_subtext);
 
     GtkCssProvider *p = gtk_css_provider_new ();
 #if GTK_CHECK_VERSION (4, 12, 0)
@@ -1376,6 +1679,7 @@ ui_css_install (void)
     g_free (c_card);    g_free (c_text);    g_free (c_subtext);
     g_free (c_dim);     g_free (c_hl);      g_free (c_hint);
     g_free (c_urgent);  g_free (c_outline);  g_free (c_hltext);
+    g_free (c_hl_soft); g_free (c_hl_dim);
 }
 
 /* ----------------------------------------------------------- clipboard */
@@ -1489,6 +1793,7 @@ dl_new (WebKitDownload *download, const char *name)
     g_object_set_data (G_OBJECT (download), "dl", d);
     downloads_tick_start ();
     downloads_refresh ();       /* on screen now, not at the first tick */
+    downloads_reveal ();        /* ... and not hidden under the pointer */
     return d;
 }
 
@@ -1543,6 +1848,12 @@ dl_finish (WebKitDownload *download, DlState state)
                         d->want, g_strerror (errno));
         }
     }
+
+    if (state == DL_FAILED && d->dest) {
+        GStatBuf st;
+        if (g_stat (d->dest, &st) == 0 && st.st_size == 0)
+            g_remove (d->dest);        /* drop the empty name claim */
+    }
     if (state == DL_DONE && d->total == 0)
         d->total = d->received;
 
@@ -1550,13 +1861,17 @@ dl_finish (WebKitDownload *download, DlState state)
     downloads_refresh ();
 }
 
+/*
+ * Reserve the name by creating the file rather than by looking and
+ * hoping. Two browsers saving the same file into the same directory would
+ * otherwise both see the name free, both take it, and one would land on
+ * top of the other. O_EXCL makes the claim atomic, so the loser moves on
+ * to the next number. The empty file left behind is the one WebKit then
+ * writes into, which is why the caller has to allow overwriting it.
+ */
 static char *
 unique_download_path (const char *dir, const char *suggested)
 {
-    char *path = g_build_filename (dir, suggested, NULL);
-    if (!g_file_test (path, G_FILE_TEST_EXISTS))
-        return path;
-
     const char *dot = strrchr (suggested, '.');
     char       *stem;
     const char *ext = "";
@@ -1568,13 +1883,24 @@ unique_download_path (const char *dir, const char *suggested)
         stem = g_strdup (suggested);
     }
 
-    for (int i = 1; i < 10000; i++) {
-        char *name = g_strdup_printf ("%s (%d)%s", stem, i, ext);
+    char *path = NULL;
+
+    for (int i = 0; i < 10000; i++) {
+        char *name = i == 0 ? g_strdup (suggested)
+                            : g_strdup_printf ("%s (%d)%s", stem, i, ext);
         g_free (path);
         path = g_build_filename (dir, name, NULL);
         g_free (name);
-        if (!g_file_test (path, G_FILE_TEST_EXISTS))
+
+        int fd = g_open (path, O_CREAT | O_EXCL | O_WRONLY, 0644);
+        if (fd >= 0) {
+            close (fd);
             break;
+        }
+        if (errno != EEXIST) {
+            g_printerr ("download: cannot create %s: %s\n", path, g_strerror (errno));
+            break;
+        }
     }
 
     g_free (stem);
@@ -1610,9 +1936,18 @@ on_decide_destination (WebKitDownload *download,
 
     /* WebKitGTK 2.52.x wants an absolute filesystem path here. */
     char *wanted = g_build_filename (absdir, suggested_filename, NULL);
-    char *path   = unique_download_path (absdir, suggested_filename);
-    gboolean clash = g_strcmp0 (wanted, path) != 0;
 
+    /* "always replace" turns the question off for whatever this rule
+     * covers: the file simply takes the name it asked for. */
+    gboolean always = dl_always_for_download (download);
+    char *path = always ? g_strdup (wanted)
+                        : unique_download_path (absdir, suggested_filename);
+    gboolean clash = !always && g_strcmp0 (wanted, path) != 0;
+
+    /* Before the destination, not after: setting the destination is what
+     * opens the file, and it refuses an existing one unless told. The file
+     * exists because we just created it to claim the name. */
+    webkit_download_set_allow_overwrite (download, TRUE);
     webkit_download_set_destination (download, path);
 
     char *base = g_path_get_basename (path);
@@ -2327,12 +2662,29 @@ omni_reflect (Win *w, GtkListBoxRow *row)
     w->omni_setting = FALSE;
 }
 
+/* The list is worth as much of the window as it can decently have: a
+ * fixed cap looked cramped on anything but a small screen. */
+static void
+omni_size_list (Win *w)
+{
+    int h = gtk_widget_get_height (w->win);
+
+    if (h <= 0)
+        h = 800;                        /* not mapped yet, assume the default */
+
+    gtk_scrolled_window_set_min_content_height (GTK_SCROLLED_WINDOW (w->omniscroll),
+                                                MIN (320, (int) (h * 0.35)));
+    gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (w->omniscroll),
+                                                (int) (h * 0.68));
+}
+
 static void
 omni_list_show (Win *w)
 {
     if (w->omni_list)
         return;
 
+    omni_size_list (w);
     omni_rebuild (w);
     gtk_widget_set_visible (w->omniscroll, TRUE);
     w->omni_list = TRUE;
@@ -2387,6 +2739,7 @@ omni_setup_scope (Win *w, OmniMode mode, const char *uri)
 {
     if (mode != OMNI_DLDIR) {
         gtk_widget_set_visible (w->omniscope, FALSE);
+        gtk_widget_set_visible (w->omnialways, FALSE);
         return;
     }
 
@@ -2403,6 +2756,17 @@ omni_setup_scope (Win *w, OmniMode mode, const char *uri)
     gtk_drop_down_set_selected (GTK_DROP_DOWN (w->omniscope), dl_scope_for_uri (uri));
     gtk_widget_set_visible (w->omniscope, TRUE);
 
+    /* the same select decides how far this reaches */
+    DlScope now = dl_scope_for_uri (uri);
+    char   *key = now == DL_SCOPE_PAGE ? g_strdup (uri)
+                : now == DL_SCOPE_SITE ? uri_host (uri)
+                                       : NULL;
+
+    gtk_check_button_set_active (GTK_CHECK_BUTTON (w->omnialways),
+                                 dl_always_for_key (key));
+    gtk_widget_set_visible (w->omnialways, TRUE);
+    g_free (key);
+
     g_object_unref (list);
     g_free (site);
     g_free (page);
@@ -2437,6 +2801,14 @@ omni_show (Win *w, OmniMode mode)
     gtk_widget_set_visible (w->omniscroll, FALSE);
     gtk_widget_set_visible (w->omnihint, mode == OMNI_FIND);
     gtk_label_set_text (GTK_LABEL (w->omnihint), "");
+
+    /* Typing into a box and hoping is not an interface: the two things
+     * Enter and Esc would do are on screen, with their keys named. */
+    gboolean confirmable = mode == OMNI_DLDIR || mode == OMNI_SEARCH;
+
+    gtk_button_set_label (GTK_BUTTON (w->omnisave), "Save  (Enter)");
+    gtk_button_set_label (GTK_BUTTON (w->omnicancel), "Cancel  (Esc)");
+    gtk_widget_set_visible (w->omnibuttons, confirmable);
     omni_setup_scope (w, mode, uri);
     gtk_widget_set_visible (w->omni, TRUE);
 
@@ -2584,38 +2956,96 @@ omni_apply_search (Win *w)
 
 /* Enter in the download-directory popup. */
 static void
-omni_apply_dldir (Win *w)
+omni_finish_dldir (Win *w, gboolean replace_others)
 {
-    char       *dir   = g_strstrip (g_strdup (gtk_editable_get_text (GTK_EDITABLE (w->omnientry))));
     const char *uri   = webkit_web_view_get_uri (w->view);
-    DlScope     scope = (DlScope) gtk_drop_down_get_selected (GTK_DROP_DOWN (w->omniscope));
+    DlScope     scope = (DlScope) w->dl_pending_scope;
+    char       *dir   = w->dl_pending_dir;
+    char       *key   = scope == DL_SCOPE_PAGE ? g_strdup (uri)
+                      : scope == DL_SCOPE_SITE ? uri_host (uri)
+                                               : NULL;
 
-    if (!*dir) {
-        g_free (dir);
-        omni_hide (w);
-        return;
-    }
+    if (replace_others)
+        dl_dir_clear_users (dir, key);
 
     dl_dir_make (dir);
     dl_dir_set (uri, dir, scope);
-    omni_hide (w);
+    dl_always_set (key, gtk_check_button_get_active (GTK_CHECK_BUTTON (w->omnialways)));
+    dlrules_save ();
 
     char *msg = g_strdup_printf (scope == DL_SCOPE_PAGE ? "downloads for this page  %s"
                                : scope == DL_SCOPE_SITE ? "downloads for this site  %s"
                                                         : "downloads  %s", dir);
+
+    w->dl_conflict = FALSE;
+    g_clear_pointer (&w->dl_pending_dir, g_free);
+    omni_hide (w);
     toast_show (w, msg, URL_TOAST_SECONDS);
 
     g_free (msg);
-    g_free (dir);
+    g_free (key);
 }
 
 static void
-on_omni_activate (GtkEntry *entry, gpointer u)
+omni_apply_dldir (Win *w)
 {
-    (void) entry;
-    Win *w = u;
+    char       *dir   = g_strstrip (g_strdup (gtk_editable_get_text (GTK_EDITABLE (w->omnientry))));
+    const char *uri   = webkit_web_view_get_uri (w->view);
+    DlScope     scope = (DlScope) gtk_drop_down_get_selected (GTK_DROP_DOWN (w->omniscope));
 
-    if (w->omni_mode == OMNI_FIND)
+    if (!*dir) {
+        g_free (dir);
+        omni_hide (w);
+        return;
+    }
+
+    g_free (w->dl_pending_dir);
+    w->dl_pending_dir   = dir;          /* takes it */
+    w->dl_pending_scope = scope;
+
+    /* Somebody else is already pointing there. Both can, but say so
+     * first: sharing a directory is usually meant, and sometimes not. */
+    char      *key   = scope == DL_SCOPE_PAGE ? g_strdup (uri)
+                     : scope == DL_SCOPE_SITE ? uri_host (uri)
+                                              : NULL;
+    GPtrArray *users = dl_dir_users (dir, key);
+
+    if (users->len > 0) {
+        char *who = elide (g_ptr_array_index (users, 0), 40);
+        char *ask = users->len == 1
+                  ? g_strdup_printf ("%s already goes there.  Enter drops that rule,  Esc keeps both.", who)
+                  : g_strdup_printf ("%u rules already go there.  Enter drops them,  Esc keeps all.", users->len);
+
+        gtk_label_set_text (GTK_LABEL (w->omnihint), ask);
+        gtk_widget_set_halign (w->omnihint, GTK_ALIGN_START);
+        gtk_widget_set_visible (w->omnihint, TRUE);
+        gtk_widget_set_visible (w->omnientry, FALSE);
+        gtk_widget_set_visible (w->omniscope, FALSE);
+        gtk_widget_set_visible (w->omnialways, FALSE);
+        gtk_button_set_label (GTK_BUTTON (w->omnisave), "Drop that rule  (Enter)");
+        gtk_button_set_label (GTK_BUTTON (w->omnicancel), "Keep both  (Esc)");
+        gtk_widget_set_visible (w->omnibuttons, TRUE);
+        w->dl_conflict = TRUE;
+
+        g_free (ask);
+        g_free (who);
+        g_ptr_array_free (users, TRUE);
+        g_free (key);
+        return;                          /* the popup stays up and asks */
+    }
+
+    g_ptr_array_free (users, TRUE);
+    g_free (key);
+    omni_finish_dldir (w, FALSE);
+}
+
+/* Enter and the Save button are the same thing, so they cannot drift. */
+static void
+omni_confirm (Win *w)
+{
+    if (w->omni_mode == OMNI_DLDIR && w->dl_conflict)
+        omni_finish_dldir (w, TRUE);          /* drop the older rule */
+    else if (w->omni_mode == OMNI_FIND)
         find_step (w, +1);
     else if (w->omni_mode == OMNI_DLDIR)
         omni_apply_dldir (w);
@@ -2625,6 +3055,37 @@ on_omni_activate (GtkEntry *entry, gpointer u)
         omni_go (w);
 }
 
+/* Esc and the second button, likewise. */
+static void
+omni_dismiss (Win *w)
+{
+    if (w->omni_mode == OMNI_DLDIR && w->dl_conflict)
+        omni_finish_dldir (w, FALSE);         /* let both use it */
+    else
+        omni_hide (w);
+}
+
+static void
+on_omni_activate (GtkEntry *entry, gpointer u)
+{
+    (void) entry;
+    omni_confirm (u);
+}
+
+static void
+on_omni_save (GtkButton *b, gpointer u)
+{
+    (void) b;
+    omni_confirm (u);
+}
+
+static void
+on_omni_cancel (GtkButton *b, gpointer u)
+{
+    (void) b;
+    omni_dismiss (u);
+}
+
 static void
 on_omni_changed (GtkEditable *e, gpointer u)
 {
@@ -2736,6 +3197,13 @@ dl_row_new (Dl *d)
     if (d->state == DL_ACTIVE) {
         char *eta = NULL;
 
+        /* Before the first byte there is no percentage and no size worth
+         * showing, but the row still has to say something. */
+        if (d->received == 0) {
+            status = g_strdup ("starting");
+            goto have_status;
+        }
+
         /* Needs a size, a measured rate, and enough elapsed time that the
          * rate means something - otherwise show no estimate at all rather
          * than a wrong one. */
@@ -2754,6 +3222,8 @@ dl_row_new (Dl *d)
 
         g_free (got);
         g_free (eta);
+    have_status:
+        ;
     } else if (d->state == DL_ASK) {
         status = g_strdup ("exists:  Enter replaces,  Esc keeps both");
     } else if (d->state == DL_DONE) {
@@ -2838,6 +3308,24 @@ dl_answer_pending (gboolean overwrite)
     downloads_refresh ();
     return TRUE;
 }
+
+/*
+ * The overlay fades out under the pointer so the page can be read through
+ * it. A download that starts while the pointer happens to be parked there
+ * would then arrive invisibly, which reads as the panel being slow. So a
+ * new download un-fades it and outranks the hover for a few seconds.
+ */
+static void
+downloads_reveal (void)
+{
+    gint64 now = g_get_monotonic_time ();
+
+    for (guint i = 0; i < g_wins->len; i++) {
+        Win *w = g_ptr_array_index (g_wins, i);
+        w->dl_reveal_us = now;
+        gtk_widget_set_opacity (w->topright, 1.0);
+    }
+}
 static void
 dl_panel_rebuild (Win *w)
 {
@@ -2998,6 +3486,11 @@ overlay_hover_update (Win *w, double x, double y)
             want = 0.0;
     }
 
+    /* a download that just appeared wins over the fade */
+    if (want == 0.0 && w->dl_reveal_us &&
+        g_get_monotonic_time () - w->dl_reveal_us < (gint64) DL_REVEAL_MS * 1000)
+        want = 1.0;
+
     if (gtk_widget_get_opacity (w->topright) != want)
         gtk_widget_set_opacity (w->topright, want);
 }
@@ -3104,6 +3597,20 @@ on_key (GtkEventControllerKey *c, guint keyval, guint code,
      * filters and <mod>+A still selects the text in it. */
     /* Esc belongs to whatever is on screen; with nothing up it is the
      * page's own key, so it is passed on. */
+    /* the directory popup, mid-question: both answers apply the setting,
+     * they only differ on what happens to the rule already pointing there */
+    if (gtk_widget_get_visible (w->omni) && w->omni_mode == OMNI_DLDIR && w->dl_conflict) {
+        if (keyval == GDK_KEY_Escape) {
+            omni_finish_dldir (w, FALSE);      /* keep both */
+            return TRUE;
+        }
+        if (keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter) {
+            omni_finish_dldir (w, TRUE);       /* drop the other rule */
+            return TRUE;
+        }
+        return TRUE;
+    }
+
     if (keyval == GDK_KEY_Escape) {
         if (close_popups (w))
             return TRUE;
@@ -3437,14 +3944,14 @@ keys_panel_new (void)
     gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll),
                                     GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
     gtk_scrolled_window_set_child (GTK_SCROLLED_WINDOW (scroll), grid);
-    gtk_scrolled_window_set_propagate_natural_width (GTK_SCROLLED_WINDOW (scroll), TRUE);
+
     gtk_scrolled_window_set_propagate_natural_height (GTK_SCROLLED_WINDOW (scroll), TRUE);
     gtk_scrolled_window_set_max_content_height (GTK_SCROLLED_WINDOW (scroll), 460);
 
     GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.win_gap);
     gtk_widget_add_css_class (box, "br-keys");
-    gtk_widget_set_halign (box, GTK_ALIGN_CENTER);
-    gtk_widget_set_valign (box, GTK_ALIGN_CENTER);
+    gtk_widget_set_halign (box, GTK_ALIGN_FILL);
+    gtk_widget_set_valign (box, GTK_ALIGN_FILL);
     gtk_widget_set_visible (box, FALSE);
     gtk_box_append (GTK_BOX (box), scroll);
 
@@ -3494,6 +4001,7 @@ win_free (gpointer data)
     if (w->urltoast_id)
         g_source_remove (w->urltoast_id);
     g_free (w->omni_needle);
+    g_free (w->dl_pending_dir);
 
     g_free (w);
 }
@@ -3561,12 +4069,13 @@ on_overlay_position (GtkOverlay *ov, GtkWidget *child, GdkRectangle *alloc, gpoi
     if (W <= 0 || H <= 0)
         return FALSE;
 
-    if (child == w->omni)
-        want = w->omni_mode == OMNI_DLDIR ? 960 : 720;   /* paths are long */
-    else if (child == w->keys)
-        want = 640;
+    /* One width for every panel, whatever it holds: the address popup,
+     * the history, the directory popup and the key list are all the same
+     * object as far as the eye is concerned. */
+    if (child == w->omni || child == w->keys)
+        want = (int) (g_theme.popup_w * g_theme.ui_scale);
     else if (child == w->topright)
-        want = 360;
+        want = (int) (360 * g_theme.ui_scale);
     else
         return FALSE;                   /* not ours, let GTK align it */
 
@@ -3647,7 +4156,10 @@ window_new (WebKitWebView *view, gboolean primary)
      * sit flush against the window edge. */
     w->omni = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.win_gap);
     gtk_widget_add_css_class (w->omni, "br-omni");
-    gtk_widget_set_halign (w->omni, GTK_ALIGN_CENTER);
+    /* FILL, not CENTER: on_overlay_position hands this widget an exact
+     * rectangle, and a centered child would shrink to its content inside
+     * it instead of taking the width we worked out. */
+    gtk_widget_set_halign (w->omni, GTK_ALIGN_FILL);
     gtk_widget_set_valign (w->omni, GTK_ALIGN_START);
     gtk_widget_set_size_request (w->omni, -1, -1);
     gtk_widget_set_visible (w->omni, FALSE);
@@ -3677,6 +4189,35 @@ window_new (WebKitWebView *view, gboolean primary)
     gtk_widget_set_visible (w->omniscope, FALSE);
     gtk_box_append (GTK_BOX (w->omni), w->omniscope);
 
+    w->omnialways = gtk_check_button_new_with_label ("always replace existing files");
+    gtk_widget_add_css_class (w->omnialways, "br-check");
+    gtk_widget_set_visible (w->omnialways, FALSE);
+    gtk_box_append (GTK_BOX (w->omni), w->omnialways);
+
+    /* the confirm row: the same two actions Enter and Esc perform */
+    /* one at each end: they are opposite answers, not a pair of options */
+    w->omnibuttons = gtk_box_new (GTK_ORIENTATION_HORIZONTAL, (int) g_theme.win_gap);
+    gtk_widget_set_halign (w->omnibuttons, GTK_ALIGN_FILL);
+    gtk_widget_set_visible (w->omnibuttons, FALSE);
+
+    w->omnicancel = gtk_button_new_with_label ("Cancel  (Esc)");
+    gtk_widget_add_css_class (w->omnicancel, "flat");
+    gtk_widget_add_css_class (w->omnicancel, "br-btn");
+    gtk_widget_set_halign (w->omnicancel, GTK_ALIGN_START);
+    gtk_widget_set_hexpand (w->omnicancel, TRUE);
+    g_signal_connect (w->omnicancel, "clicked", G_CALLBACK (on_omni_cancel), w);
+    gtk_box_append (GTK_BOX (w->omnibuttons), w->omnicancel);
+
+    w->omnisave = gtk_button_new_with_label ("Save  (Enter)");
+    gtk_widget_add_css_class (w->omnisave, "flat");
+    gtk_widget_add_css_class (w->omnisave, "br-btn");
+    gtk_widget_set_halign (w->omnisave, GTK_ALIGN_END);
+    gtk_widget_add_css_class (w->omnisave, "br-btn-primary");
+    g_signal_connect (w->omnisave, "clicked", G_CALLBACK (on_omni_save), w);
+    gtk_box_append (GTK_BOX (w->omnibuttons), w->omnisave);
+
+    gtk_box_append (GTK_BOX (w->omni), w->omnibuttons);
+
     w->omnilist = gtk_list_box_new ();
     gtk_widget_add_css_class (w->omnilist, "br-pick-list");
     gtk_list_box_set_selection_mode (GTK_LIST_BOX (w->omnilist), GTK_SELECTION_SINGLE);
@@ -3715,7 +4256,7 @@ window_new (WebKitWebView *view, gboolean primary)
     /* Top right column: URL toast on top, downloads below. The whole
      * column is click-through, so it never swallows a click on the page. */
     w->topright = gtk_box_new (GTK_ORIENTATION_VERTICAL, (int) g_theme.gap);
-    gtk_widget_set_halign (w->topright, GTK_ALIGN_END);
+    gtk_widget_set_halign (w->topright, GTK_ALIGN_FILL);
     gtk_widget_set_valign (w->topright, GTK_ALIGN_START);
     gtk_widget_set_can_target (w->topright, FALSE);
 
@@ -3998,6 +4539,10 @@ browser_main (int argc, char **argv, const BrowserApp *app)
         if (!strcmp (a, "-h") || !strcmp (a, "--help")) {
             usage (argv[0], TRUE);
             return 0;
+        } else if (!strcmp (a, "-V") || !strcmp (a, "--version")) {
+            g_print ("%s %s (build %s)\n", app->default_app_id,
+                     BROWSER_VERSION, BROWSER_BUILD);
+            return 0;
         } else if (!strcmp (a, "-q") || !strcmp (a, "--quiet")) {
             g_quiet = TRUE;
         } else if (!strcmp (a, "--title")) {
@@ -4137,7 +4682,7 @@ browser_main (int argc, char **argv, const BrowserApp *app)
     object_set_string_if_exists (G_OBJECT (g_session), "downloads-directory", g_download_dir);
 
     history_setup (g_data_dir);
-    dlrules_setup (g_data_dir);
+    dlrules_setup (g_data_dir);      /* app wide; the profile is only migrated from */
     searches_setup (g_data_dir);
 
     setup_settings ();
diff --git a/browser_core.h b/browser_core.h
index cbe4a7a..002ecb6 100644
--- a/browser_core.h
+++ b/browser_core.h
@@ -40,6 +40,10 @@ typedef struct {
     GtkWidget     *omniscroll;
     GtkWidget     *omnihint;    /* match count while finding               */
     GtkWidget     *omniscope;   /* page / site / everything, in the dir popup */
+    GtkWidget     *omnialways;  /* "always replace existing files"          */
+    GtkWidget     *omnibuttons; /* the confirm row, so Enter is not the only way */
+    GtkWidget     *omnisave;
+    GtkWidget     *omnicancel;
     GtkWidget     *urltoast;    /* the address, top left, on a new page     */
     guint          urltoast_id;
     GtkWidget     *keys;        /* key reference, hidden by default         */
@@ -50,6 +54,7 @@ typedef struct {
     gboolean       dl_history;  /* <mod>+D list is showing                */
     guint          dl_history_id;
     gboolean       primary;
+    gint64         dl_reveal_us;  /* a download just appeared; do not fade   */
 
     /* stored-history walk, see history.c section in browser_core.c */
     gboolean       hist_walk;    /* past the end of the session list       */
@@ -65,6 +70,11 @@ typedef struct {
     gboolean       omni_setting; /* we are writing to the input ourselves  */
     char          *omni_needle;  /* what the user actually typed           */
 
+    /* the download-directory popup, while it is asking about a clash */
+    gboolean       dl_conflict;
+    char          *dl_pending_dir;
+    int            dl_pending_scope;
+
     gpointer       ext;          /* front-end state, see BrowserApp.win_* */
 } Win;
 
@@ -110,6 +120,13 @@ typedef struct {
     void     (*cleanup)       (void);
 } BrowserApp;
 
+#ifndef BROWSER_VERSION
+#define BROWSER_VERSION "0.0.0"
+#endif
+#ifndef BROWSER_BUILD
+#define BROWSER_BUILD   "unknown"
+#endif
+
 int browser_main (int argc, char **argv, const BrowserApp *app);
 
 /* -------------------------------------------------------- shared state */
diff --git a/minibrowser b/minibrowser
deleted file mode 100755
index 3cd5377..0000000
Binary files a/minibrowser and /dev/null differ