This is an automated email from the git hooks/post-receive script.

git pushed a commit to branch master
in repository enlightenment.

View the commit online.

commit 65f8936828e422731472fed7a9c92147c2f90fed
Author: [email protected] <[email protected]>
AuthorDate: Sun Jun 21 22:53:54 2026 -0600

    networkmanager: prompt for the VPN username on import
    
    NetworkManager stores a VPN username as a connection property
    (vpn.data["username"]), not a secret, and its SecretAgent only ever
    requests secrets -- so it never prompts for the username. Importing an
    .ovpn with a bare auth-user-pass therefore produced an unusable connection
    with no username, and at connect time the server rejected the (empty) auth.
    
    Capture the username right after import, while nothing is activating:
    
    - New e_networkmanager_vpn_username.{c,h}: a predicate deciding when a
      username is needed (by VPN type + connection-type), a single-field dialog,
      an async `nmcli connection modify <uuid> +vpn.data username=...` writer
      (+vpn.data so the rest of the dict is preserved), and a post-import probe.
    - The probe queries only vpn.data (a single line) by connection UUID, which
      avoids both the duplicate-name ambiguity and the multi-line ecore_exe
      data/exit race; the VPN service type comes from the import type.
    - e_networkmanager_import.{c,h}: capture nmcli stdout and report the new
      connection's UUID to the done callback.
    - e_mod_main.c: trigger the probe on a successful import.
    - test_vpn_username.c: standalone unit test for the predicate matrix.
    
    The username stays a property and is never sent as a secret (the openvpn
    plugin rejects that); the connect-time dialog remains password-only, like
    every other NetworkManager front-end.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---
 src/modules/networkmanager/e_mod_main.c            |  16 +-
 .../networkmanager/e_networkmanager_import.c       |  74 +++-
 .../networkmanager/e_networkmanager_import.h       |   7 +-
 .../networkmanager/e_networkmanager_vpn_username.c | 427 +++++++++++++++++++++
 .../networkmanager/e_networkmanager_vpn_username.h |  52 +++
 src/modules/networkmanager/meson.build             |   2 +
 src/modules/networkmanager/test_vpn_username.c     |  33 ++
 7 files changed, 595 insertions(+), 16 deletions(-)

diff --git a/src/modules/networkmanager/e_mod_main.c b/src/modules/networkmanager/e_mod_main.c
index 56bb02991..0ac6e8f5b 100644
--- a/src/modules/networkmanager/e_mod_main.c
+++ b/src/modules/networkmanager/e_mod_main.c
@@ -3,6 +3,7 @@
 #include "e_networkmanager.h"
 #include "e_networkmanager_vpn.h"
 #include "e_networkmanager_import.h"
+#include "e_networkmanager_vpn_username.h"
 
 E_Module *networkmanager_mod = NULL;
 E_NM_Config *networkmanager_config = NULL;
@@ -406,10 +407,16 @@ _enm_itc_bt_content_get(void *data, Evas_Object *obj, const char *part)
 /* ---- VPN item class callbacks --------------------------------------------- */
 
 static void
-_enm_vpn_import_done_cb(void *data EINA_UNUSED, Eina_Bool ok, const char *err)
+_enm_vpn_import_done_cb(void *data, Eina_Bool ok, const char *err,
+                        const char *conn_name)
 {
+   const char *type = data;  /* import VPN type ("openvpn", ...); may be NULL */
    E_Dialog *err_dlg;
-   if (ok) { INF("VPN import succeeded"); return; }
+   if (ok)
+     {
+        if (conn_name) enm_vpn_username_maybe_prompt(conn_name, type);
+        return;
+     }
 
    ERR("VPN import failed: %s", err ?: "(no output)");
 
@@ -452,11 +459,12 @@ _enm_vpn_fs_done_cb(void *data, Evas_Object *fs EINA_UNUSED, void *event)
      {
         _enm_vpn_import_done_cb(NULL, EINA_FALSE,
               _("Could not detect VPN type from file extension. "
-                "Use a .conf (WireGuard) or .ovpn (OpenVPN) file."));
+                "Use a .conf (WireGuard) or .ovpn (OpenVPN) file."),
+              NULL);
      }
    else
      {
-        enm_import_run(type, file, _enm_vpn_import_done_cb, NULL);
+        enm_import_run(type, file, _enm_vpn_import_done_cb, (void *)type);
      }
    e_object_del(E_OBJECT(dialog));
 }
diff --git a/src/modules/networkmanager/e_networkmanager_import.c b/src/modules/networkmanager/e_networkmanager_import.c
index 0b5d2487b..d3cb109d2 100644
--- a/src/modules/networkmanager/e_networkmanager_import.c
+++ b/src/modules/networkmanager/e_networkmanager_import.c
@@ -73,11 +73,13 @@ enm_import_detect_type(const char *file_path)
 
 typedef struct _Import_Ctx
 {
-   Enm_Import_Done_Cb  done_cb;
-   void               *data;
-   Ecore_Exe          *exe;
-   Eina_Strbuf        *stderr_buf;
+   Enm_Import_Done_Cb   done_cb;
+   void                *data;
+   Ecore_Exe           *exe;
+   Eina_Strbuf         *stderr_buf;
+   Eina_Strbuf         *stdout_buf;
    Ecore_Event_Handler *handler_err;
+   Ecore_Event_Handler *handler_out;
    Ecore_Event_Handler *handler_del;
 } Import_Ctx;
 
@@ -86,8 +88,10 @@ _import_ctx_free(Import_Ctx *ctx)
 {
    if (!ctx) return;
    if (ctx->handler_err) ecore_event_handler_del(ctx->handler_err);
+   if (ctx->handler_out) ecore_event_handler_del(ctx->handler_out);
    if (ctx->handler_del) ecore_event_handler_del(ctx->handler_del);
    if (ctx->stderr_buf)  eina_strbuf_free(ctx->stderr_buf);
+   if (ctx->stdout_buf)  eina_strbuf_free(ctx->stdout_buf);
    /* exe is already gone by the time _del fires; don't del it here */
    free(ctx);
 }
@@ -124,6 +128,35 @@ _on_stderr(void *data, int type EINA_UNUSED, void *event)
    return ECORE_CALLBACK_DONE;
 }
 
+static Eina_Bool
+_on_stdout(void *data, int type EINA_UNUSED, void *event)
+{
+   Import_Ctx *ctx = data;
+   Ecore_Exe_Event_Data *ev = event;
+
+   if (!ev || !ev->exe || ev->exe != ctx->exe)
+     return ECORE_CALLBACK_PASS_ON;
+   if (ev->data && ev->size > 0)
+     eina_strbuf_append_length(ctx->stdout_buf, ev->data, ev->size);
+   return ECORE_CALLBACK_DONE;
+}
+
+/* Parse `Connection 'NAME' (UUID) successfully added.` -> malloc'd UUID.
+ * The UUID (not the name) is returned because connection names are not
+ * unique, which makes later `nmcli ... <name>` operations ambiguous. */
+static char *
+_import_parse_conn_uuid(const char *out)
+{
+   const char *a, *b;
+   if (!out) return NULL;
+   a = strchr(out, '(');
+   if (!a) return NULL;
+   a++;
+   b = strchr(a, ')');
+   if (!b) return NULL;
+   return strndup(a, (size_t)(b - a));
+}
+
 static Eina_Bool
 _on_del(void *data, int type EINA_UNUSED, void *event)
 {
@@ -136,13 +169,18 @@ _on_del(void *data, int type EINA_UNUSED, void *event)
    /* Prevent double-free: clear handlers before freeing ctx */
    ecore_event_handler_del(ctx->handler_err);
    ctx->handler_err = NULL;
+   if (ctx->handler_out)
+     { ecore_event_handler_del(ctx->handler_out); ctx->handler_out = NULL; }
    ecore_event_handler_del(ctx->handler_del);
    ctx->handler_del = NULL;
 
    Eina_Bool ok = (ev->exited && ev->exit_code == 0);
    const char *stderr_text = eina_strbuf_string_get(ctx->stderr_buf);
+   char *conn_uuid = ok ? _import_parse_conn_uuid(
+                              eina_strbuf_string_get(ctx->stdout_buf)) : NULL;
 
-   ctx->done_cb(ctx->data, ok, stderr_text);
+   ctx->done_cb(ctx->data, ok, stderr_text, conn_uuid);
+   free(conn_uuid);
 
    _import_ctx_free(ctx);
    return ECORE_CALLBACK_DONE;
@@ -159,7 +197,7 @@ enm_import_run(const char *type, const char *file_path,
    const char *nmcli = enm_import_nmcli_path();
    if (!nmcli)
      {
-        done_cb(data, EINA_FALSE, "nmcli not found on PATH");
+        done_cb(data, EINA_FALSE, "nmcli not found on PATH", NULL);
         return;
      }
 
@@ -172,13 +210,15 @@ enm_import_run(const char *type, const char *file_path,
         free(esc_nmcli);
         free(esc_type);
         free(esc_filepath);
-        done_cb(data, EINA_FALSE, "out of memory");
+        done_cb(data, EINA_FALSE, "out of memory", NULL);
         return;
      }
 
    char cmd[4096];
+   /* LC_ALL=C so the "Connection 'NAME' ... added" banner we parse for the
+    * connection name is not localized. */
    snprintf(cmd, sizeof(cmd),
-            "'%s' connection import type '%s' file '%s'",
+            "LC_ALL=C '%s' connection import type '%s' file '%s'",
             esc_nmcli, esc_type, esc_filepath);
 
    free(esc_nmcli);
@@ -188,7 +228,7 @@ enm_import_run(const char *type, const char *file_path,
    Import_Ctx *ctx = calloc(1, sizeof(Import_Ctx));
    if (!ctx)
      {
-        done_cb(data, EINA_FALSE, "out of memory");
+        done_cb(data, EINA_FALSE, "out of memory", NULL);
         return;
      }
 
@@ -197,12 +237,22 @@ enm_import_run(const char *type, const char *file_path,
    ctx->stderr_buf = eina_strbuf_new();
    if (!ctx->stderr_buf)
      {
-        done_cb(data, EINA_FALSE, "out of memory");
+        done_cb(data, EINA_FALSE, "out of memory", NULL);
+        free(ctx);
+        return;
+     }
+   ctx->stdout_buf = eina_strbuf_new();
+   if (!ctx->stdout_buf)
+     {
+        done_cb(data, EINA_FALSE, "out of memory", NULL);
+        eina_strbuf_free(ctx->stderr_buf);
         free(ctx);
         return;
      }
 
    ctx->exe = ecore_exe_pipe_run(cmd,
+                                 ECORE_EXE_PIPE_READ |
+                                 ECORE_EXE_PIPE_READ_LINE_BUFFERED |
                                  ECORE_EXE_PIPE_ERROR |
                                  ECORE_EXE_PIPE_ERROR_LINE_BUFFERED |
                                  ECORE_EXE_NOT_LEADER |
@@ -210,11 +260,13 @@ enm_import_run(const char *type, const char *file_path,
                                  ctx);
    if (!ctx->exe)
      {
-        done_cb(data, EINA_FALSE, "failed to spawn nmcli");
+        done_cb(data, EINA_FALSE, "failed to spawn nmcli", NULL);
         _import_ctx_free(ctx);
         return;
      }
 
+   ctx->handler_out = ecore_event_handler_add(ECORE_EXE_EVENT_DATA,
+                                              _on_stdout, ctx);
    ctx->handler_err = ecore_event_handler_add(ECORE_EXE_EVENT_ERROR,
                                                _on_stderr, ctx);
    ctx->handler_del = ecore_event_handler_add(ECORE_EXE_EVENT_DEL,
diff --git a/src/modules/networkmanager/e_networkmanager_import.h b/src/modules/networkmanager/e_networkmanager_import.h
index d34087023..8bf9a11ba 100644
--- a/src/modules/networkmanager/e_networkmanager_import.h
+++ b/src/modules/networkmanager/e_networkmanager_import.h
@@ -28,12 +28,17 @@ const char *enm_import_detect_type(const char *file_path);
  * stderr_text points into an internal buffer that is freed immediately
  * after the callback returns; copy it if you need to retain.
  *
+ * conn_name is the imported connection's name parsed from nmcli stdout
+ * (NULL on failure or when it cannot be parsed); it too is freed right
+ * after the callback returns.
+ *
  * data must remain valid until done_cb fires.  enm_import_run does not
  * return a cancellation handle, so the caller is responsible for
  * ensuring it does not deallocate data while a call is in flight.
  */
 typedef void (*Enm_Import_Done_Cb)(void *data, Eina_Bool ok,
-                                   const char *stderr_text);
+                                   const char *stderr_text,
+                                   const char *conn_name);
 
 /* Run nmcli connection import asynchronously.  done_cb is invoked exactly
  * once. */
diff --git a/src/modules/networkmanager/e_networkmanager_vpn_username.c b/src/modules/networkmanager/e_networkmanager_vpn_username.c
new file mode 100644
index 000000000..5b1eb9557
--- /dev/null
+++ b/src/modules/networkmanager/e_networkmanager_vpn_username.c
@@ -0,0 +1,427 @@
+#ifdef ENM_VPN_USERNAME_TEST
+/* Standalone unit-test build: no EFL, no config.h. */
+typedef unsigned char Eina_Bool;
+# define EINA_TRUE  1
+# define EINA_FALSE 0
+# include <string.h>
+#else
+# ifdef HAVE_CONFIG_H
+#  include "config.h"
+# endif
+# include <string.h>
+# include <stdlib.h>
+# include "e.h"
+# include "e_networkmanager.h"
+# include "e_networkmanager_import.h"
+# include "e_networkmanager_vpn_username.h"
+#endif
+
+Eina_Bool
+enm_vpn_username_needed(const char *svc_short, const char *conn_type,
+                        const char *current_username)
+{
+   if (current_username && current_username[0]) return EINA_FALSE;
+   if (!svc_short) return EINA_FALSE;
+
+   if (!strcmp(svc_short, "openvpn"))
+     {
+        if (!conn_type) return EINA_FALSE;
+        return (!strcmp(conn_type, "password") ||
+                !strcmp(conn_type, "password-tls"))
+               ? EINA_TRUE : EINA_FALSE;
+     }
+
+   if (!strcmp(svc_short, "pptp")        || !strcmp(svc_short, "l2tp") ||
+       !strcmp(svc_short, "fortisslvpn") || !strcmp(svc_short, "vpnc") ||
+       !strcmp(svc_short, "openswan")    || !strcmp(svc_short, "libreswan") ||
+       !strcmp(svc_short, "strongswan"))
+     return EINA_TRUE;
+
+   return EINA_FALSE;
+}
+
+#ifndef ENM_VPN_USERNAME_TEST
+
+/* ---- shared shell-escape (same scheme as e_networkmanager_import.c) ------- */
+static char *
+_username_shell_escape(const char *s)
+{
+   Eina_Strbuf *b;
+   char *out;
+
+   if (!s) return strdup("");
+   b = eina_strbuf_new();
+   if (!b) return NULL;
+   for (const char *p = s; *p; p++)
+     {
+        if (*p == '\'') eina_strbuf_append(b, "'\\''");
+        else            eina_strbuf_append_char(b, *p);
+     }
+   out = strdup(eina_strbuf_string_get(b));
+   eina_strbuf_free(b);
+   return out;
+}
+
+typedef struct _Username_Set_Ctx
+{
+   Enm_Username_Done_Cb  cb;
+   void                 *data;
+   Ecore_Exe            *exe;
+   Ecore_Event_Handler  *handler_del;
+} Username_Set_Ctx;
+
+static Eina_Bool
+_username_set_on_del(void *data, int type EINA_UNUSED, void *event)
+{
+   Username_Set_Ctx *ctx = data;
+   Ecore_Exe_Event_Del *ev = event;
+
+   if (!ev || !ev->exe || ev->exe != ctx->exe)
+     return ECORE_CALLBACK_PASS_ON;
+
+   if (ctx->handler_del) ecore_event_handler_del(ctx->handler_del);
+   if (ctx->cb)
+     ctx->cb(ctx->data, (ev->exited && ev->exit_code == 0));
+   free(ctx);
+   return ECORE_CALLBACK_DONE;
+}
+
+void
+enm_vpn_username_set(const char *conn_name, const char *username,
+                     Enm_Username_Done_Cb cb, void *data)
+{
+   const char *nmcli;
+   char *esc_nmcli, *esc_name, *esc_user;
+   char cmd[4096];
+   Username_Set_Ctx *ctx;
+
+   if (!conn_name) { if (cb) cb(data, EINA_FALSE); return; }
+
+   nmcli = enm_import_nmcli_path();
+   if (!nmcli) { if (cb) cb(data, EINA_FALSE); return; }
+
+   esc_nmcli = _username_shell_escape(nmcli);
+   esc_name  = _username_shell_escape(conn_name);
+   esc_user  = _username_shell_escape(username);
+   if (!esc_nmcli || !esc_name || !esc_user)
+     {
+        free(esc_nmcli); free(esc_name); free(esc_user);
+        if (cb) cb(data, EINA_FALSE);
+        return;
+     }
+
+   /* '+vpn.data' updates the single username key; plain 'vpn.data' would
+    * REPLACE the whole dictionary, wiping connection-type/ca/cert/etc. */
+   snprintf(cmd, sizeof(cmd),
+            "'%s' connection modify '%s' +vpn.data 'username=%s'",
+            esc_nmcli, esc_name, esc_user);
+   free(esc_nmcli); free(esc_name); free(esc_user);
+
+   ctx = calloc(1, sizeof(*ctx));
+   if (!ctx) { if (cb) cb(data, EINA_FALSE); return; }
+   ctx->cb = cb;
+   ctx->data = ""
+   ctx->exe = ecore_exe_pipe_run(cmd,
+                                 ECORE_EXE_NOT_LEADER |
+                                 ECORE_EXE_TERM_WITH_PARENT, ctx);
+   if (!ctx->exe) { free(ctx); if (cb) cb(data, EINA_FALSE); return; }
+   ctx->handler_del = ecore_event_handler_add(ECORE_EXE_EVENT_DEL,
+                                              _username_set_on_del, ctx);
+}
+
+/* ---- shared username dialog ----------------------------------------------- */
+
+typedef struct _Username_Dialog
+{
+   E_Dialog                *dialog;
+   Evas_Object             *entry;
+   Enm_Username_Entered_Cb  cb;
+   void                    *data;
+   Eina_Bool                replied;
+} Username_Dialog;
+
+static void
+_username_dialog_finish(Username_Dialog *ud, const char *value)
+{
+   if (!ud->replied)
+     {
+        ud->replied = EINA_TRUE;
+        if (ud->cb) ud->cb(ud->data, value);
+     }
+   e_object_del(E_OBJECT(ud->dialog));
+}
+
+static void
+_username_dialog_ok_cb(void *data, E_Dialog *dialog EINA_UNUSED)
+{
+   Username_Dialog *ud = data;
+   char *user = elm_entry_markup_to_utf8(elm_entry_entry_get(ud->entry));
+   _username_dialog_finish(ud, user ?: "");
+   free(user);
+}
+
+static void
+_username_dialog_cancel_cb(void *data, E_Dialog *dialog EINA_UNUSED)
+{
+   _username_dialog_finish(data, NULL);
+}
+
+static void
+_username_entry_activated_cb(void *data, Evas_Object *o EINA_UNUSED,
+                             void *ev EINA_UNUSED)
+{
+   _username_dialog_ok_cb(data, NULL);
+}
+
+static void
+_username_dialog_key_down_cb(void *data, Evas *e EINA_UNUSED,
+                             Evas_Object *o EINA_UNUSED, void *event)
+{
+   Evas_Event_Key_Down *ev = event;
+   if (!strcmp(ev->key, "Escape")) _username_dialog_finish(data, NULL);
+}
+
+static void
+_username_dialog_del_cb(void *data)
+{
+   E_Dialog *dialog = data;
+   Username_Dialog *ud = e_object_data_get(E_OBJECT(dialog));
+   if (!ud) return;
+   if (!ud->replied)
+     {
+        ud->replied = EINA_TRUE;
+        if (ud->cb) ud->cb(ud->data, NULL);   /* WM close == cancel */
+     }
+   free(ud);
+}
+
+void
+enm_vpn_username_dialog(const char *conn_name, const char *type_label,
+                        const char *initial,
+                        Enm_Username_Entered_Cb cb, void *data)
+{
+   Username_Dialog *ud;
+   E_Dialog *dialog;
+   Evas_Object *frame, *table, *label, *entry, *spacer;
+   Evas_Coord minw = 280 * e_scale;
+   char header[160];
+
+   dialog = e_dialog_new(NULL, "E", "nm_vpn_username");
+   if (!dialog) { if (cb) cb(data, NULL); return; }
+
+   ud = E_NEW(Username_Dialog, 1);
+   if (!ud) { e_object_del(E_OBJECT(dialog)); if (cb) cb(data, NULL); return; }
+   ud->dialog = dialog;
+   ud->cb = cb;
+   ud->data = ""
+
+   e_dialog_resizable_set(dialog, 0);
+   e_dialog_title_set(dialog, _("VPN Username Required"));
+   e_dialog_border_icon_set(dialog, "dialog-password");
+   e_dialog_button_add(dialog, _("OK"), NULL, _username_dialog_ok_cb, ud);
+   e_dialog_button_add(dialog, _("Cancel"), NULL, _username_dialog_cancel_cb, ud);
+
+   snprintf(header, sizeof(header), "%s — %s",
+            conn_name ?: "VPN", type_label ?: _("VPN"));
+   frame = elm_frame_add(dialog->win);
+   elm_object_text_set(frame, header);
+   evas_object_size_hint_weight_set(frame, EVAS_HINT_EXPAND, 0);
+   evas_object_size_hint_align_set(frame, EVAS_HINT_FILL, EVAS_HINT_FILL);
+
+   table = elm_table_add(frame);
+   elm_table_padding_set(table, 8 * e_scale, 4 * e_scale);
+   elm_object_content_set(frame, table);
+   evas_object_show(table);
+
+   /* Transparent spacer forces the entry column to a usable width (~25 chars);
+    * a scrollable single-line entry otherwise collapses to one character in an
+    * elm_table cell. */
+   spacer = evas_object_rectangle_add(evas_object_evas_get(table));
+   evas_object_color_set(spacer, 0, 0, 0, 0);
+   evas_object_size_hint_min_set(spacer, minw, 1);
+   evas_object_size_hint_weight_set(spacer, EVAS_HINT_EXPAND, 0);
+   evas_object_size_hint_align_set(spacer, EVAS_HINT_FILL, 0.0);
+   elm_table_pack(table, spacer, 1, 0, 1, 1);
+   evas_object_show(spacer);
+
+   label = elm_label_add(table);
+   elm_object_text_set(label, _("Username"));
+   evas_object_size_hint_align_set(label, 1.0, 0.5);
+   elm_table_pack(table, label, 0, 1, 1, 1);
+   evas_object_show(label);
+
+   entry = elm_entry_add(table);
+   elm_entry_single_line_set(entry, EINA_TRUE);
+   elm_entry_scrollable_set(entry, EINA_TRUE);
+   if (initial && initial[0]) elm_object_text_set(entry, initial);
+   evas_object_size_hint_weight_set(entry, EVAS_HINT_EXPAND, 0);
+   evas_object_size_hint_align_set(entry, EVAS_HINT_FILL, 0.5);
+   evas_object_size_hint_min_set(entry, minw, 0);
+   evas_object_smart_callback_add(entry, "activated",
+                                  _username_entry_activated_cb, ud);
+   elm_table_pack(table, entry, 1, 1, 1, 1);
+   evas_object_show(entry);
+   ud->entry = entry;
+
+   evas_object_show(frame);
+   e_dialog_content_set(dialog, frame, minw, 0);
+   e_dialog_show(dialog);
+
+   evas_object_event_callback_add(dialog->bg_object, EVAS_CALLBACK_KEY_DOWN,
+                                  _username_dialog_key_down_cb, ud);
+   e_object_del_attach_func_set(E_OBJECT(dialog), _username_dialog_del_cb);
+   e_object_data_set(E_OBJECT(dialog), ud);
+   elm_object_focus_set(entry, EINA_TRUE);
+   elm_win_center(dialog->win, 1, 1);
+}
+
+/* ---- post-import orchestrator --------------------------------------------- */
+
+/* Parse a `key = value, key = value` list (nmcli -g vpn.data output) for one
+ * key.  Returns a malloc'd value or NULL.  Tolerant of surrounding spaces. */
+static char *
+_nmcli_kv_find(const char *blob, const char *key)
+{
+   size_t klen = strlen(key);
+   const char *p = blob;
+
+   while (p && *p)
+     {
+        while (*p == ' ' || *p == ',') p++;
+        if (!strncmp(p, key, klen))
+          {
+             const char *q = p + klen;
+             while (*q == ' ') q++;
+             if (*q == '=')
+               {
+                  const char *v;
+                  const char *end;
+                  q++;
+                  while (*q == ' ') q++;
+                  v = q;
+                  end = strchr(v, ',');
+                  if (!end) end = v + strlen(v);
+                  while (end > v && (end[-1] == ' ')) end--;
+                  return strndup(v, (size_t)(end - v));
+               }
+          }
+        p = strchr(p, ',');
+        if (p) p++;
+     }
+   return NULL;
+}
+
+typedef struct _Username_Probe_Ctx
+{
+   char                *conn_uuid;  /* unique id for nmcli ops */
+   char                *svc;        /* service short name, from import type */
+   Ecore_Exe           *exe;
+   Eina_Strbuf         *out;
+   Ecore_Event_Handler *h_data;
+   Ecore_Event_Handler *h_del;
+} Username_Probe_Ctx;
+
+static Eina_Bool
+_probe_on_data(void *data, int type EINA_UNUSED, void *event)
+{
+   Username_Probe_Ctx *ctx = data;
+   Ecore_Exe_Event_Data *ev = event;
+   if (!ev || ev->exe != ctx->exe) return ECORE_CALLBACK_PASS_ON;
+   if (ev->data && ev->size > 0)
+     eina_strbuf_append_length(ctx->out, ev->data, ev->size);
+   return ECORE_CALLBACK_DONE;
+}
+
+static void
+_probe_username_entered(void *data, const char *username)
+{
+   char *conn_name = data;
+   if (username) enm_vpn_username_set(conn_name, username, NULL, NULL);
+   free(conn_name);
+}
+
+static Eina_Bool
+_probe_on_del(void *data, int type EINA_UNUSED, void *event)
+{
+   Username_Probe_Ctx *ctx = data;
+   Ecore_Exe_Event_Del *ev = event;
+   char *conn_type = NULL, *username = NULL;
+   const char *blob;
+   Eina_Bool needed;
+
+   if (!ev || ev->exe != ctx->exe) return ECORE_CALLBACK_PASS_ON;
+
+   if (ctx->h_data) ecore_event_handler_del(ctx->h_data);
+   if (ctx->h_del)  ecore_event_handler_del(ctx->h_del);
+
+   /* Single line: the vpn.data list (queried alone to avoid a multi-line
+    * ecore_exe data/exit race).  The service short name comes from the import
+    * type, so we never need vpn.service-type here. */
+   blob = eina_strbuf_string_get(ctx->out);
+   conn_type = _nmcli_kv_find(blob, "connection-type");
+   username  = _nmcli_kv_find(blob, "username");
+
+   needed = enm_vpn_username_needed(ctx->svc, conn_type, username);
+
+   if (needed)
+     {
+        char *uuid_copy = strdup(ctx->conn_uuid);
+        if (uuid_copy)
+          enm_vpn_username_dialog(NULL,        /* generic "VPN" header */
+                                  ctx->svc,     /* type label */
+                                  username,
+                                  _probe_username_entered,
+                                  uuid_copy);
+     }
+
+   free(conn_type); free(username);
+   free(ctx->conn_uuid); free(ctx->svc);
+   eina_strbuf_free(ctx->out);
+   free(ctx);
+   return ECORE_CALLBACK_DONE;
+}
+
+void
+enm_vpn_username_maybe_prompt(const char *conn_uuid, const char *svc_short)
+{
+   const char *nmcli;
+   char *esc_nmcli, *esc_uuid, cmd[4096];
+   Username_Probe_Ctx *ctx;
+
+   if (!conn_uuid) return;
+   nmcli = enm_import_nmcli_path();
+   if (!nmcli) return;
+
+   esc_nmcli = _username_shell_escape(nmcli);
+   esc_uuid  = _username_shell_escape(conn_uuid);
+   if (!esc_nmcli || !esc_uuid) { free(esc_nmcli); free(esc_uuid); return; }
+
+   /* Query ONLY vpn.data, by UUID (unique).  vpn.data prints as a single
+    * comma-joined line, which avoids the multi-line ecore_exe data/exit race
+    * that drops trailing lines.  The service short name comes from the import
+    * type, so vpn.service-type is not needed. */
+   snprintf(cmd, sizeof(cmd),
+            "LC_ALL=C '%s' -g vpn.data connection show '%s'",
+            esc_nmcli, esc_uuid);
+   free(esc_nmcli); free(esc_uuid);
+
+   ctx = calloc(1, sizeof(*ctx));
+   if (!ctx) return;
+   ctx->conn_uuid = strdup(conn_uuid);
+   ctx->svc       = svc_short ? strdup(svc_short) : NULL;
+   ctx->out = eina_strbuf_new();
+   if (!ctx->conn_uuid || !ctx->out)
+     { free(ctx->conn_uuid); free(ctx->svc); if (ctx->out) eina_strbuf_free(ctx->out); free(ctx); return; }
+
+   ctx->exe = ecore_exe_pipe_run(cmd,
+                                 ECORE_EXE_PIPE_READ |
+                                 ECORE_EXE_PIPE_READ_LINE_BUFFERED |
+                                 ECORE_EXE_NOT_LEADER |
+                                 ECORE_EXE_TERM_WITH_PARENT, ctx);
+   if (!ctx->exe)
+     { free(ctx->conn_uuid); free(ctx->svc); eina_strbuf_free(ctx->out); free(ctx); return; }
+   ctx->h_data = ecore_event_handler_add(ECORE_EXE_EVENT_DATA, _probe_on_data, ctx);
+   ctx->h_del  = ecore_event_handler_add(ECORE_EXE_EVENT_DEL,  _probe_on_del,  ctx);
+}
+
+#endif
diff --git a/src/modules/networkmanager/e_networkmanager_vpn_username.h b/src/modules/networkmanager/e_networkmanager_vpn_username.h
new file mode 100644
index 000000000..0335d1b87
--- /dev/null
+++ b/src/modules/networkmanager/e_networkmanager_vpn_username.h
@@ -0,0 +1,52 @@
+#ifndef E_NETWORKMANAGER_VPN_USERNAME_H
+#define E_NETWORKMANAGER_VPN_USERNAME_H
+
+#include <Eina.h>
+
+/*
+ * VPN username capture.
+ *
+ * NetworkManager stores a VPN username as a connection *property*
+ * (vpn.data["username"]), not as a secret, and the SecretAgent D-Bus API
+ * (GetSecrets) only ever deals with secrets.  NM therefore never prompts for
+ * a missing username: importing an .ovpn with a bare `auth-user-pass` yields a
+ * connection with password-flags set but no username, which is unusable.
+ *
+ * This long-standing NetworkManager behaviour is reported in:
+ *   - https://bugzilla.redhat.com/show_bug.cgi?id=1535517
+ *   - https://bugzilla.redhat.com/show_bug.cgi?id=1548873
+ *   - https://bbs.archlinux.org/viewtopic.php?id=286378
+ *   - https://bbs.archlinux.org/viewtopic.php?id=225395
+ *   - https://github.com/pop-os/cosmic-settings/issues/1820
+ *
+ * All functions here are MAIN THREAD ONLY.
+ */
+
+/* True when the VPN type uses username auth and current_username is empty.
+ * svc_short is the short service name ("openvpn", "pptp", ...); conn_type is
+ * vpn.data["connection-type"] (may be NULL). */
+Eina_Bool enm_vpn_username_needed(const char *svc_short,
+                                  const char *conn_type,
+                                  const char *current_username);
+
+/* Called with the entered username (NULL on cancel). */
+typedef void (*Enm_Username_Entered_Cb)(void *data, const char *username);
+
+/* Called when the nmcli modify completes. */
+typedef void (*Enm_Username_Done_Cb)(void *data, Eina_Bool ok);
+
+/* Persist username to vpn.data["username"] via `nmcli connection modify`. */
+void enm_vpn_username_set(const char *conn_name, const char *username,
+                          Enm_Username_Done_Cb cb, void *data);
+
+/* Single-field username dialog.  Calls cb(username) on OK, cb(NULL) on cancel. */
+void enm_vpn_username_dialog(const char *conn_name, const char *type_label,
+                             const char *initial,
+                             Enm_Username_Entered_Cb cb, void *data);
+
+/* Post-import orchestrator: query the connection (by UUID), and if it needs a
+ * username, prompt for one and persist it.  svc_short is the VPN service short
+ * name ("openvpn", "vpnc", ...) from the import type.  Fire-and-forget. */
+void enm_vpn_username_maybe_prompt(const char *conn_uuid, const char *svc_short);
+
+#endif
diff --git a/src/modules/networkmanager/meson.build b/src/modules/networkmanager/meson.build
index b83a30442..6fb04a071 100644
--- a/src/modules/networkmanager/meson.build
+++ b/src/modules/networkmanager/meson.build
@@ -8,6 +8,8 @@ src = ""
   'e_networkmanager_vpn.h',
   'e_networkmanager_import.c',
   'e_networkmanager_import.h',
+  'e_networkmanager_vpn_username.c',
+  'e_networkmanager_vpn_username.h',
   'e_mod_main.h'
 )
 
diff --git a/src/modules/networkmanager/test_vpn_username.c b/src/modules/networkmanager/test_vpn_username.c
new file mode 100644
index 000000000..740dd4377
--- /dev/null
+++ b/src/modules/networkmanager/test_vpn_username.c
@@ -0,0 +1,33 @@
+/* Standalone unit test for enm_vpn_username_needed.
+ * Build: gcc -DENM_VPN_USERNAME_TEST test_vpn_username.c -o /tmp/t && /tmp/t */
+#include <assert.h>
+#include <stdio.h>
+
+#define ENM_VPN_USERNAME_TEST 1
+#include "e_networkmanager_vpn_username.c"
+
+int main(void)
+{
+   /* openvpn: only password / password-tls need a username */
+   assert(enm_vpn_username_needed("openvpn", "password", NULL));
+   assert(enm_vpn_username_needed("openvpn", "password-tls", ""));
+   assert(!enm_vpn_username_needed("openvpn", "tls", NULL));
+   assert(!enm_vpn_username_needed("openvpn", NULL, NULL));
+
+   /* already has a username -> never needed */
+   assert(!enm_vpn_username_needed("openvpn", "password", "alice"));
+
+   /* xauth / password-based types always need it when empty */
+   assert(enm_vpn_username_needed("pptp", NULL, NULL));
+   assert(enm_vpn_username_needed("l2tp", NULL, ""));
+   assert(enm_vpn_username_needed("vpnc", NULL, NULL));
+   assert(enm_vpn_username_needed("libreswan", NULL, NULL));
+   assert(!enm_vpn_username_needed("libreswan", NULL, "bob"));
+
+   /* unknown / NULL type -> not needed */
+   assert(!enm_vpn_username_needed("wireguard", NULL, NULL));
+   assert(!enm_vpn_username_needed(NULL, NULL, NULL));
+
+   printf("all enm_vpn_username_needed assertions passed\n");
+   return 0;
+}

-- 
To stop receiving notification emails like this one, please contact
the administrator of this repository.

Reply via email to