From 5734b14e7879d689006714b02971e8de1693739d Mon Sep 17 00:00:00 2001 From: deepend Date: Thu, 10 Sep 2026 07:06:25 -0600 Subject: [PATCH] test first start experience --- src/common/cfgfiles.c | 2 + src/common/servlist.c | 17 +- src/common/servlist.h | 1 + src/common/zoitechat.h | 2 + src/fe-gtk/servlistgui.c | 2686 +++++++++++++++++++++++++++++++++++++- src/fe-gtk/setup.c | 3 + 6 files changed, 2692 insertions(+), 19 deletions(-) diff --git a/src/common/cfgfiles.c b/src/common/cfgfiles.c index 5389aa1c..05f6158a 100644 --- a/src/common/cfgfiles.c +++ b/src/common/cfgfiles.c @@ -429,6 +429,8 @@ const struct prefs vars[] = {"gui_lang", P_OFFINT (hex_gui_lang), TYPE_INT}, {"gui_mode_buttons", P_OFFINT (hex_gui_mode_buttons), TYPE_BOOL}, {"gui_mode_buttons_inline", P_OFFINT (hex_gui_mode_buttons_inline), TYPE_BOOL}, + {"gui_onboarding_disable", P_OFFINT (hex_gui_onboarding_disable), TYPE_BOOL}, + {"gui_onboarding_pending", P_OFFINT (hex_gui_onboarding_pending), TYPE_BOOL}, {"gui_pane_divider_position", P_OFFINT (hex_gui_pane_divider_position), TYPE_INT}, {"gui_pane_left_size", P_OFFINT (hex_gui_pane_left_size), TYPE_INT}, {"gui_pane_right_size", P_OFFINT (hex_gui_pane_right_size), TYPE_INT}, diff --git a/src/common/servlist.c b/src/common/servlist.c index 9e4c22b6..4500d139 100644 --- a/src/common/servlist.c +++ b/src/common/servlist.c @@ -191,6 +191,8 @@ struct defaultserver gboolean ssl; }; +static gboolean servlist_needs_onboarding_flag; + static const struct defaultserver def[] = { {"2600net", 0}, @@ -1181,7 +1183,10 @@ servlist_load (void) fp = zoitechat_fopen_file ("servlist.conf", "r", 0); if (!fp) + { + servlist_needs_onboarding_flag = TRUE; return FALSE; + } while (fgets (buf, sizeof (buf) - 2, fp)) { @@ -1245,6 +1250,7 @@ servlist_load (void) net = servlist_net_add (buf + 2, NULL, FALSE); } fclose (fp); + servlist_needs_onboarding_flag = (network_list == NULL); return TRUE; } @@ -1253,8 +1259,16 @@ void servlist_init (void) { if (!network_list) - if (!servlist_load ()) + { + if (!servlist_load () || !network_list) servlist_load_defaults (); + } +} + +gboolean +servlist_needs_onboarding (void) +{ + return servlist_needs_onboarding_flag; } /* check if a charset is known by Iconv */ @@ -1395,6 +1409,7 @@ servlist_save (void) } fclose (fp); + servlist_needs_onboarding_flag = FALSE; return TRUE; } diff --git a/src/common/servlist.h b/src/common/servlist.h index 42f5f280..ab5a7af1 100644 --- a/src/common/servlist.h +++ b/src/common/servlist.h @@ -93,6 +93,7 @@ extern GSList *network_list; #define IRC_DEFAULT_CHARSET "UTF-8 (Unicode)" void servlist_init (void); +gboolean servlist_needs_onboarding (void); int servlist_save (void); int servlist_cycle (server *serv); void servlist_connect (session *sess, ircnet *net, gboolean join); diff --git a/src/common/zoitechat.h b/src/common/zoitechat.h index 415312a8..084a4295 100644 --- a/src/common/zoitechat.h +++ b/src/common/zoitechat.h @@ -134,6 +134,8 @@ struct zoitechatprefs unsigned int hex_gui_join_dialog; unsigned int hex_gui_mode_buttons; unsigned int hex_gui_mode_buttons_inline; + unsigned int hex_gui_onboarding_disable; + unsigned int hex_gui_onboarding_pending; unsigned int hex_gui_quit_dialog; /* unsigned int hex_gui_single; */ unsigned int hex_gui_slist_fav; diff --git a/src/fe-gtk/servlistgui.c b/src/fe-gtk/servlistgui.c index a0e508c8..7e8e59c7 100644 --- a/src/fe-gtk/servlistgui.c +++ b/src/fe-gtk/servlistgui.c @@ -23,6 +23,7 @@ #include #include +#include #include "../common/zoitechat.h" #include "../common/zoitechatc.h" @@ -30,6 +31,7 @@ #include "../common/cfgfiles.h" #include "../common/fe.h" #include "../common/secretstore.h" +#include "../common/server.h" #include "../common/util.h" #include "fe-gtk.h" @@ -3089,6 +3091,2655 @@ button_connect = gtkutil_button (hbuttonbox1, ICON_SERVLIST_CONNECT, NULL, return servlist; } +/* First-run onboarding ---------------------------------------------------- */ + +#define ONBOARDING_ATLAS_HOST "atlas.zoitechat.org" +#define ONBOARDING_ATLAS_CLIENT_PATH "/api/v1/client/networks" +#define ONBOARDING_ATLAS_PUBLIC_PATH "/api/v1/networks" +#define ONBOARDING_ATLAS_MAX_RESPONSE (2 * 1024 * 1024) +#define ONBOARDING_REGISTRATION_TIMEOUT_SECONDS 60 + +typedef enum +{ + ONBOARDING_ACCOUNT_SKIP = 0, + ONBOARDING_ACCOUNT_EXISTING, + ONBOARDING_ACCOUNT_NEW +} ServlistOnboardingAccountMode; + +typedef struct +{ + ircnet *net; + char *slug; + char *name; + char *description; + char *homepage; + char *endpoint_host; + int endpoint_port; + gboolean endpoint_tls; + gboolean atlas_approved; + int users; + char *services_family; + int services_confidence; + gboolean services_present; + gboolean supports_sasl; + gboolean has_nickserv; + char *help_channel; + GPtrArray *starter_channels; + GPtrArray *popular_channels; + gboolean detail_loaded; + gboolean onboarding_verified; + gboolean registration_verified; + char *registration_service; + char *registration_template; + gboolean registration_email_required; + char *identify_method; +} ServlistOnboardingChoice; + +typedef struct +{ + char *client_json; + char *public_json; + char *error; +} ServlistAtlasFetchResult; + +typedef struct +{ + char *slug; + char *json; + char *error; + guint generation; +} ServlistAtlasDetailResult; + +typedef struct +{ + char *slug; + guint generation; +} ServlistAtlasDetailRequest; + +typedef struct +{ + GtkWidget *dialog; + GtkWidget *notebook; + GtkWidget *back_button; + GtkWidget *next_button; + GtkWidget *connect_button; + GtkWidget *use_atlas; + GtkWidget *network_combo; + GtkWidget *atlas_status; + GtkWidget *network_info; + GtkWidget *nick_entry; + GtkWidget *realname_entry; + GtkWidget *account_skip; + GtkWidget *account_existing; + GtkWidget *account_new; + GtkWidget *account_info; + GtkWidget *account_label; + GtkWidget *account_entry; + GtkWidget *password_label; + GtkWidget *password_entry; + GtkWidget *password_confirm_label; + GtkWidget *password_confirm_entry; + GtkWidget *email_label; + GtkWidget *email_entry; + GtkWidget *remember_password; + GtkWidget *channel_suggestions; + GtkWidget *channel_entry; + GtkWidget *summary_label; + GPtrArray *choices; + GCancellable *atlas_cancel; + GCancellable *detail_cancel; + gboolean atlas_pending; + guint detail_pending; + guint detail_generation; + gboolean closing; + session *sess; +} ServlistOnboardingState; + +typedef struct +{ + server *serv; + ServlistOnboardingAccountMode account_mode; + ircnet *net; + char *network_name; + char *nick; + char *password; + char *email; + char *service; + char *command_template; + char *homepage; + char *services_family; + char *identify_method; + int services_confidence; + int preferred_login; + gboolean email_required; + gboolean remember_password; + gboolean verified_recipe; + gboolean common_recipe; + gboolean has_nickserv; + gint64 deadline; +} ServlistRegistrationGuide; + +static void servlist_open_networks_window (session *sess); +static void servlist_onboarding_update_network_info (ServlistOnboardingState *state); +static void servlist_onboarding_account_changed (GtkToggleButton *button, gpointer userdata); +static void servlist_onboarding_refresh_channel_suggestions (ServlistOnboardingState *state); +static void servlist_onboarding_start_detail (ServlistOnboardingState *state, ServlistOnboardingChoice *choice); + +static void +servlist_onboarding_choice_free (ServlistOnboardingChoice *choice) +{ + if (!choice) + return; + g_free (choice->slug); + g_free (choice->name); + g_free (choice->description); + g_free (choice->homepage); + g_free (choice->endpoint_host); + g_free (choice->services_family); + g_free (choice->help_channel); + if (choice->starter_channels) + g_ptr_array_free (choice->starter_channels, TRUE); + if (choice->popular_channels) + g_ptr_array_free (choice->popular_channels, TRUE); + g_free (choice->registration_service); + g_free (choice->registration_template); + g_free (choice->identify_method); + g_free (choice); +} + +static void +servlist_atlas_fetch_result_free (ServlistAtlasFetchResult *result) +{ + if (!result) + return; + g_free (result->client_json); + g_free (result->public_json); + g_free (result->error); + g_free (result); +} + +static void +servlist_atlas_detail_result_free (ServlistAtlasDetailResult *result) +{ + if (!result) + return; + g_free (result->slug); + g_free (result->json); + g_free (result->error); + g_free (result); +} + +static void +servlist_atlas_detail_request_free (ServlistAtlasDetailRequest *request) +{ + if (!request) + return; + g_free (request->slug); + g_free (request); +} + +static void +servlist_onboarding_state_free (ServlistOnboardingState *state) +{ + if (!state) + return; + if (state->choices) + g_ptr_array_free (state->choices, TRUE); + if (state->atlas_cancel) + g_object_unref (state->atlas_cancel); + if (state->detail_cancel) + g_object_unref (state->detail_cancel); + g_free (state); +} + +static void +servlist_onboarding_close_state (ServlistOnboardingState *state) +{ + state->closing = TRUE; + state->dialog = NULL; + if (state->atlas_cancel) + g_cancellable_cancel (state->atlas_cancel); + if (state->detail_cancel) + g_cancellable_cancel (state->detail_cancel); + if (!state->atlas_pending && state->detail_pending == 0) + servlist_onboarding_state_free (state); +} + +static ServlistOnboardingChoice * +servlist_onboarding_choice_new (ircnet *net, const char *name) +{ + ServlistOnboardingChoice *choice = g_new0 (ServlistOnboardingChoice, 1); + choice->net = net; + choice->name = g_strdup (name ? name : ""); + choice->users = -1; + choice->services_confidence = -1; + choice->endpoint_port = 6697; + choice->starter_channels = g_ptr_array_new_with_free_func (g_free); + choice->popular_channels = g_ptr_array_new_with_free_func (g_free); + return choice; +} + +static gboolean +servlist_onboarding_token_is_safe (const char *text) +{ + const unsigned char *p; + + if (!text || !*text) + return FALSE; + for (p = (const unsigned char *) text; *p; p++) + { + if (g_ascii_iscntrl (*p) || g_ascii_isspace (*p)) + return FALSE; + } + return TRUE; +} + +static gboolean +servlist_onboarding_slug_is_safe (const char *text) +{ + const unsigned char *p; + gsize len; + + if (!text || !*text) + return FALSE; + len = strlen (text); + if (len > 80 || !g_ascii_isalnum (text[0])) + return FALSE; + for (p = (const unsigned char *) text; *p; p++) + { + if (!g_ascii_isalnum (*p) && *p != '-') + return FALSE; + } + return TRUE; +} + +static gboolean +servlist_onboarding_endpoint_is_safe (const char *text) +{ + const unsigned char *p; + + if (!servlist_onboarding_token_is_safe (text) || strlen (text) > 255) + return FALSE; + for (p = (const unsigned char *) text; *p; p++) + { + if (*p == '/' || *p == '\\' || *p == ',' || *p == '+') + return FALSE; + } + return TRUE; +} + +static gboolean +servlist_onboarding_service_target_is_safe (const char *text) +{ + const unsigned char *p; + + if (!servlist_onboarding_token_is_safe (text) || strlen (text) > 64) + return FALSE; + for (p = (const unsigned char *) text; *p; p++) + { + if (*p == ':' || *p == ',' || *p == '/' || *p == '\\') + return FALSE; + } + return TRUE; +} + +static gboolean +servlist_onboarding_registration_template_is_safe (const char *command) +{ + const char *p; + + if (!command || !*command || strlen (command) > 300 || + strchr (command, '\r') || strchr (command, '\n')) + return FALSE; + + while (g_ascii_isspace (*command)) + command++; + if (g_ascii_strncasecmp (command, "REGISTER", 8) || + (command[8] && !g_ascii_isspace (command[8]))) + return FALSE; + if (!strstr (command, "{password}")) + return FALSE; + + for (p = command; (p = strchr (p, '{')) != NULL; ) + { + const char *end = strchr (p, '}'); + char *token; + gboolean allowed; + + if (!end) + return FALSE; + token = g_strndup (p, end - p + 1); + allowed = !strcmp (token, "{password}") || !strcmp (token, "{email}") || + !strcmp (token, "{nick}") || !strcmp (token, "{nickname}") || + !strcmp (token, "{account}"); + g_free (token); + if (!allowed) + return FALSE; + p = end + 1; + } + return TRUE; +} + +static void +servlist_onboarding_set_string (char **dest, const char *value) +{ + if (!value || !*value) + return; + g_free (*dest); + *dest = g_strdup (value); +} + +static gboolean +servlist_onboarding_ptr_array_contains (GPtrArray *array, const char *value) +{ + guint i; + + if (!array || !value) + return FALSE; + for (i = 0; i < array->len; i++) + { + if (!g_ascii_strcasecmp (g_ptr_array_index (array, i), value)) + return TRUE; + } + return FALSE; +} + +static void +servlist_onboarding_add_starter_channel (ServlistOnboardingChoice *choice, const char *channel) +{ + if (!choice || !channel || !strchr ("#&+!", channel[0])) + return; + if (!servlist_onboarding_ptr_array_contains (choice->starter_channels, channel)) + g_ptr_array_add (choice->starter_channels, g_strdup (channel)); +} + +/* Small bounded JSON reader. Atlas responses are deliberately parsed here + * without adding json-glib or libsoup to every ZoiteChat platform build. It + * understands the JSON types needed by the read-only Atlas feed and ignores + * additive fields it does not know about. */ +static const char * +servlist_json_skip_ws (const char *p, const char *end) +{ + while (p < end && g_ascii_isspace (*p)) + p++; + return p; +} + +static const char * +servlist_json_string_end (const char *p, const char *end) +{ + if (p >= end || *p != '"') + return NULL; + p++; + while (p < end) + { + if (*p == '\\') + { + p += 2; + continue; + } + if (*p == '"') + return p + 1; + p++; + } + return NULL; +} + +static const char * +servlist_json_value_end (const char *p, const char *end) +{ + char open; + char close; + int depth; + + p = servlist_json_skip_ws (p, end); + if (p >= end) + return NULL; + if (*p == '"') + return servlist_json_string_end (p, end); + if (*p != '{' && *p != '[') + { + while (p < end && *p != ',' && *p != '}' && *p != ']') + p++; + return p; + } + + open = *p; + close = open == '{' ? '}' : ']'; + depth = 1; + p++; + while (p < end && depth > 0) + { + if (*p == '"') + { + p = servlist_json_string_end (p, end); + if (!p) + return NULL; + continue; + } + if (*p == open) + depth++; + else if (*p == close) + depth--; + else if ((open == '{' && *p == '[') || (open == '[' && *p == '{')) + { + const char *nested = servlist_json_value_end (p, end); + if (!nested) + return NULL; + p = nested; + continue; + } + p++; + } + return depth == 0 ? p : NULL; +} + +static char * +servlist_json_dup_string (const char *p, const char *end, const char **after) +{ + GString *out; + gunichar uc; + char utf8[7]; + int n; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p != '"') + return NULL; + p++; + out = g_string_new (NULL); + while (p < end) + { + if (*p == '"') + { + p++; + if (after) + *after = p; + return g_string_free (out, FALSE); + } + if (*p != '\\') + { + g_string_append_c (out, *p++); + continue; + } + p++; + if (p >= end) + break; + switch (*p++) + { + case '"': g_string_append_c (out, '"'); break; + case '\\': g_string_append_c (out, '\\'); break; + case '/': g_string_append_c (out, '/'); break; + case 'b': g_string_append_c (out, '\b'); break; + case 'f': g_string_append_c (out, '\f'); break; + case 'n': g_string_append_c (out, '\n'); break; + case 'r': g_string_append_c (out, '\r'); break; + case 't': g_string_append_c (out, '\t'); break; + case 'u': + if (end - p < 4 || !g_ascii_isxdigit (p[0]) || !g_ascii_isxdigit (p[1]) || + !g_ascii_isxdigit (p[2]) || !g_ascii_isxdigit (p[3])) + goto invalid; + { + char hex[5]; + memcpy (hex, p, 4); + hex[4] = 0; + uc = (gunichar) strtoul (hex, NULL, 16); + p += 4; + if (uc >= 0xd800 && uc <= 0xdbff) + { + gunichar low; + if (end - p < 6 || p[0] != '\\' || p[1] != 'u' || + !g_ascii_isxdigit (p[2]) || !g_ascii_isxdigit (p[3]) || + !g_ascii_isxdigit (p[4]) || !g_ascii_isxdigit (p[5])) + goto invalid; + memcpy (hex, p + 2, 4); + hex[4] = 0; + low = (gunichar) strtoul (hex, NULL, 16); + if (low < 0xdc00 || low > 0xdfff) + goto invalid; + p += 6; + uc = 0x10000 + ((uc - 0xd800) << 10) + (low - 0xdc00); + } + else if (uc >= 0xdc00 && uc <= 0xdfff) + goto invalid; + n = g_unichar_to_utf8 (uc, utf8); + utf8[n] = 0; + g_string_append (out, utf8); + } + break; + default: + goto invalid; + } + } +invalid: + g_string_free (out, TRUE); + return NULL; +} + +static const char * +servlist_json_object_member (const char *object, const char *end, const char *wanted) +{ + const char *p; + char *key; + + p = servlist_json_skip_ws (object, end); + if (p >= end || *p != '{') + return NULL; + p++; + while (p < end) + { + const char *after_key; + const char *value; + const char *next; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p == '}') + return NULL; + key = servlist_json_dup_string (p, end, &after_key); + if (!key) + return NULL; + p = servlist_json_skip_ws (after_key, end); + if (p >= end || *p != ':') + { + g_free (key); + return NULL; + } + value = servlist_json_skip_ws (p + 1, end); + if (!strcmp (key, wanted)) + { + g_free (key); + return value; + } + g_free (key); + next = servlist_json_value_end (value, end); + if (!next) + return NULL; + p = servlist_json_skip_ws (next, end); + if (p < end && *p == ',') + p++; + } + return NULL; +} + +static char * +servlist_json_member_string (const char *object, const char *end, const char *key) +{ + const char *value = servlist_json_object_member (object, end, key); + return value ? servlist_json_dup_string (value, end, NULL) : NULL; +} + +static int +servlist_json_member_int (const char *object, const char *end, const char *key, int fallback) +{ + const char *value = servlist_json_object_member (object, end, key); + char *tail; + long parsed; + + if (!value) + return fallback; + parsed = strtol (value, &tail, 10); + if (tail == value || parsed < G_MININT || parsed > G_MAXINT) + return fallback; + return (int) parsed; +} + +static gboolean +servlist_json_member_bool (const char *object, const char *end, const char *key, gboolean fallback) +{ + const char *value = servlist_json_object_member (object, end, key); + if (!value) + return fallback; + if (!strncmp (value, "true", 4) || *value == '1') + return TRUE; + if (!strncmp (value, "false", 5) || *value == '0') + return FALSE; + return fallback; +} + +static gboolean +servlist_json_array_contains_string (const char *array, const char *end, const char *wanted) +{ + const char *p; + + p = servlist_json_skip_ws (array, end); + if (p >= end || *p != '[') + return FALSE; + p++; + while (p < end) + { + const char *after; + char *value; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p == ']') + break; + if (*p == '"') + { + value = servlist_json_dup_string (p, end, &after); + if (!value) + return FALSE; + if (!g_ascii_strcasecmp (value, wanted)) + { + g_free (value); + return TRUE; + } + g_free (value); + p = after; + } + else + { + p = servlist_json_value_end (p, end); + if (!p) + return FALSE; + } + p = servlist_json_skip_ws (p, end); + if (p < end && *p == ',') + p++; + } + return FALSE; +} + +static gboolean +servlist_json_array_has_prefix_ci (const char *array, const char *end, const char *prefix) +{ + const char *p; + gsize prefix_len; + + if (!prefix) + return FALSE; + prefix_len = strlen (prefix); + p = servlist_json_skip_ws (array, end); + if (p >= end || *p != '[') + return FALSE; + p++; + while (p < end) + { + const char *after; + char *value; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p == ']') + break; + if (*p == '"') + { + value = servlist_json_dup_string (p, end, &after); + if (!value) + return FALSE; + if (!g_ascii_strncasecmp (value, prefix, prefix_len)) + { + g_free (value); + return TRUE; + } + g_free (value); + p = after; + } + else + { + p = servlist_json_value_end (p, end); + if (!p) + return FALSE; + } + p = servlist_json_skip_ws (p, end); + if (p < end && *p == ',') + p++; + } + return FALSE; +} + +static gboolean +servlist_json_array_contains_text_ci (const char *array, const char *end, const char *wanted) +{ + const char *p; + char *wanted_folded; + gboolean found = FALSE; + + if (!wanted) + return FALSE; + wanted_folded = g_ascii_strdown (wanted, -1); + p = servlist_json_skip_ws (array, end); + if (p >= end || *p != '[') + goto done; + p++; + while (p < end) + { + const char *after; + char *value; + char *folded; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p == ']') + break; + if (*p == '"') + { + value = servlist_json_dup_string (p, end, &after); + if (!value) + break; + folded = g_ascii_strdown (value, -1); + found = strstr (folded, wanted_folded) != NULL; + g_free (folded); + g_free (value); + if (found) + break; + p = after; + } + else + { + p = servlist_json_value_end (p, end); + if (!p) + break; + } + p = servlist_json_skip_ws (p, end); + if (p < end && *p == ',') + p++; + } + +done: + g_free (wanted_folded); + return found; +} + +static void +servlist_json_add_string_array (const char *array, const char *end, ServlistOnboardingChoice *choice) +{ + const char *p; + + p = servlist_json_skip_ws (array, end); + if (p >= end || *p != '[') + return; + p++; + while (p < end) + { + const char *after; + char *value; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p == ']') + break; + if (*p != '"') + { + p = servlist_json_value_end (p, end); + if (!p) + break; + } + else + { + value = servlist_json_dup_string (p, end, &after); + if (!value) + break; + servlist_onboarding_add_starter_channel (choice, value); + g_free (value); + p = after; + } + p = servlist_json_skip_ws (p, end); + if (p < end && *p == ',') + p++; + } +} + +static ServlistOnboardingChoice * +servlist_onboarding_find_choice (ServlistOnboardingState *state, const char *name, const char *slug) +{ + guint i; + + for (i = 0; i < state->choices->len; i++) + { + ServlistOnboardingChoice *choice = g_ptr_array_index (state->choices, i); + if (slug && *slug && choice->slug && !g_ascii_strcasecmp (choice->slug, slug)) + return choice; + if (name && *name && choice->name && !g_ascii_strcasecmp (choice->name, name)) + return choice; + } + return NULL; +} + +static void +servlist_onboarding_parse_endpoint (ServlistOnboardingChoice *choice, const char *object, const char *end) +{ + const char *array; + const char *item; + const char *item_end; + char *host; + int port; + gboolean tls; + + array = servlist_json_object_member (object, end, "servers"); + if (!array) + array = servlist_json_object_member (object, end, "endpoints"); + if (array) + { + item = servlist_json_skip_ws (array, end); + if (*item == '[') + { + item = servlist_json_skip_ws (item + 1, end); + if (item < end && *item == '{') + { + item_end = servlist_json_value_end (item, end); + if (item_end) + { + object = item; + end = item_end; + } + } + } + } + + host = servlist_json_member_string (object, end, "host"); + if (!host) + host = servlist_json_member_string (object, end, "hostname"); + if (!host) + host = servlist_json_member_string (object, end, "server"); + if (!host || !servlist_onboarding_endpoint_is_safe (host)) + { + g_free (host); + return; + } + +#ifdef USE_OPENSSL + port = servlist_json_member_int (object, end, "tls_port", 0); + tls = port > 0; + if (port <= 0) + { + port = servlist_json_member_int (object, end, "plain_port", 0); + tls = FALSE; + } +#else + port = servlist_json_member_int (object, end, "plain_port", 0); + tls = FALSE; +#endif + if (port <= 0) + port = servlist_json_member_int (object, end, "port", 0); + if (port <= 0 || port > 65535) + port = tls ? 6697 : 6667; +#ifdef USE_OPENSSL + tls = servlist_json_member_bool (object, end, "tls", tls); +#endif + + g_free (choice->endpoint_host); + choice->endpoint_host = host; + choice->endpoint_port = port; + choice->endpoint_tls = tls; + choice->atlas_approved = TRUE; +} + +static void +servlist_onboarding_parse_services (ServlistOnboardingChoice *choice, const char *object, const char *end) +{ + const char *services = servlist_json_object_member (object, end, "services"); + const char *caps = servlist_json_object_member (object, end, "capabilities"); + const char *nicks; + char *family; + + if (services && *services == '{') + { + const char *services_end = servlist_json_value_end (services, end); + family = services_end ? servlist_json_member_string (services, services_end, "family") : NULL; + if (family) + { + servlist_onboarding_set_string (&choice->services_family, family); + g_free (family); + } + choice->services_confidence = services_end ? servlist_json_member_int (services, services_end, "confidence", choice->services_confidence) : choice->services_confidence; + choice->services_present = services_end ? servlist_json_member_bool (services, services_end, "services_present", choice->services_present) : choice->services_present; + nicks = services_end ? servlist_json_object_member (services, services_end, "service_nicks") : NULL; + if (!nicks && services_end) + nicks = servlist_json_object_member (services, services_end, "nicks"); + if (nicks && servlist_json_array_contains_string (nicks, services_end, "NickServ")) + choice->has_nickserv = TRUE; + } + else + { + family = servlist_json_member_string (object, end, "services_family"); + if (family) + { + servlist_onboarding_set_string (&choice->services_family, family); + g_free (family); + } + choice->services_confidence = servlist_json_member_int (object, end, "services_confidence", choice->services_confidence); + choice->services_present = servlist_json_member_bool (object, end, "services_present", choice->services_present); + } + + if (caps && servlist_json_array_has_prefix_ci (caps, end, "sasl")) + choice->supports_sasl = TRUE; +} + +static void +servlist_onboarding_parse_verified_metadata (ServlistOnboardingChoice *choice, const char *object, const char *end) +{ + const char *onboarding = servlist_json_object_member (object, end, "onboarding"); + const char *onboarding_end; + const char *starter; + const char *account; + const char *account_end; + const char *registration; + const char *registration_end; + char *value; + + if (!onboarding || *onboarding != '{') + return; + onboarding_end = servlist_json_value_end (onboarding, end); + if (!onboarding_end) + return; + choice->onboarding_verified = servlist_json_member_bool (onboarding, onboarding_end, "verified", FALSE); + if (!choice->onboarding_verified) + return; + + value = servlist_json_member_string (onboarding, onboarding_end, "help_channel"); + if (value && strchr ("#&+!", value[0])) + servlist_onboarding_set_string (&choice->help_channel, value); + g_free (value); + starter = servlist_json_object_member (onboarding, onboarding_end, "starter_channels"); + if (starter) + servlist_json_add_string_array (starter, onboarding_end, choice); + + account = servlist_json_object_member (onboarding, onboarding_end, "account"); + if (!account || *account != '{') + return; + account_end = servlist_json_value_end (account, onboarding_end); + if (!account_end) + return; + value = servlist_json_member_string (account, account_end, "identify_method"); + if (value) + { + servlist_onboarding_set_string (&choice->identify_method, value); + if (!g_ascii_strcasecmp (value, "sasl")) + choice->supports_sasl = TRUE; + g_free (value); + } + + registration = servlist_json_object_member (account, account_end, "registration"); + if (!registration || *registration != '{') + return; + registration_end = servlist_json_value_end (registration, account_end); + if (!registration_end || !servlist_json_member_bool (registration, registration_end, "verified", FALSE)) + return; + + value = servlist_json_member_string (registration, registration_end, "service"); + if (value && servlist_onboarding_service_target_is_safe (value)) + servlist_onboarding_set_string (&choice->registration_service, value); + g_free (value); + value = servlist_json_member_string (registration, registration_end, "command"); + if (value && servlist_onboarding_registration_template_is_safe (value)) + servlist_onboarding_set_string (&choice->registration_template, value); + g_free (value); + choice->registration_email_required = servlist_json_member_bool (registration, registration_end, "requires_email", FALSE); + choice->registration_verified = choice->registration_service && choice->registration_template && + (!choice->registration_email_required || strstr (choice->registration_template, "{email}")); +} + +static void +servlist_onboarding_apply_atlas_document (ServlistOnboardingState *state, const char *json, gboolean client_feed) +{ + const char *end; + const char *array; + const char *p; + + if (!json || !*json) + return; + end = json + strlen (json); + array = servlist_json_object_member (json, end, "networks"); + if (!array) + array = servlist_json_object_member (json, end, "data"); + if (!array || *array != '[') + return; + p = array + 1; + while (p < end) + { + const char *object_end; + char *name; + char *slug; + char *description; + char *homepage; + ServlistOnboardingChoice *choice; + gboolean added = FALSE; + + p = servlist_json_skip_ws (p, end); + if (p >= end || *p == ']') + break; + if (*p != '{') + { + p = servlist_json_value_end (p, end); + if (!p) + break; + goto next_value; + } + object_end = servlist_json_value_end (p, end); + if (!object_end) + break; + name = servlist_json_member_string (p, object_end, "name"); + if (!name) + name = servlist_json_member_string (p, object_end, "network"); + slug = servlist_json_member_string (p, object_end, "slug"); + if (!slug) + slug = servlist_json_member_string (p, object_end, "id"); + choice = servlist_onboarding_find_choice (state, name, slug); + if (!choice && client_feed && name && *name) + { + choice = servlist_onboarding_choice_new (NULL, name); + g_ptr_array_add (state->choices, choice); + added = TRUE; + } + if (choice) + { + if (slug && servlist_onboarding_slug_is_safe (slug)) + servlist_onboarding_set_string (&choice->slug, slug); + description = servlist_json_member_string (p, object_end, "description"); + homepage = servlist_json_member_string (p, object_end, "homepage"); + if (!homepage) + homepage = servlist_json_member_string (p, object_end, "homepage_url"); + if (!homepage) + homepage = servlist_json_member_string (p, object_end, "website"); + if (description) + servlist_onboarding_set_string (&choice->description, description); + if (homepage) + servlist_onboarding_set_string (&choice->homepage, homepage); + choice->users = servlist_json_member_int (p, object_end, "users", choice->users); + if (choice->users < 0) + choice->users = servlist_json_member_int (p, object_end, "users_total", choice->users); + servlist_onboarding_parse_services (choice, p, object_end); + if (client_feed) + { + servlist_onboarding_parse_endpoint (choice, p, object_end); + servlist_onboarding_parse_verified_metadata (choice, p, object_end); + } + g_free (description); + g_free (homepage); + if (added && !choice->atlas_approved) + { + g_ptr_array_remove (state->choices, choice); + choice = NULL; + } + } + g_free (name); + g_free (slug); + p = object_end; +next_value: + p = servlist_json_skip_ws (p, end); + if (p < end && *p == ',') + p++; + } +} + +static void +servlist_onboarding_add_popular_channel (ServlistOnboardingChoice *choice, const char *channel) +{ + if (!choice || !channel || !*channel || !strchr ("#&+!", channel[0])) + return; + if (servlist_onboarding_ptr_array_contains (choice->starter_channels, channel) || + servlist_onboarding_ptr_array_contains (choice->popular_channels, channel)) + return; + if (choice->popular_channels->len >= 12) + return; + g_ptr_array_add (choice->popular_channels, g_strdup (channel)); +} + +static void +servlist_onboarding_apply_atlas_detail (ServlistOnboardingChoice *choice, const char *json) +{ + const char *end; + const char *data; + const char *data_end; + const char *evidence; + const char *channels; + const char *p; + char *value; + + if (!choice || !json || !*json) + return; + end = json + strlen (json); + data = servlist_json_object_member (json, end, "data"); + if (!data || *data != '{') + return; + data_end = servlist_json_value_end (data, end); + if (!data_end) + return; + + value = servlist_json_member_string (data, data_end, "description"); + if (value) + { + servlist_onboarding_set_string (&choice->description, value); + g_free (value); + } + value = servlist_json_member_string (data, data_end, "homepage_url"); + if (value) + { + servlist_onboarding_set_string (&choice->homepage, value); + g_free (value); + } + choice->users = servlist_json_member_int (data, data_end, "users_total", choice->users); + servlist_onboarding_parse_services (choice, data, data_end); + + evidence = servlist_json_object_member (data, data_end, "services_evidence"); + if (evidence && servlist_json_array_contains_text_ci (evidence, data_end, "nickserv")) + choice->has_nickserv = TRUE; + if (choice->services_family && !g_ascii_strcasecmp (choice->services_family, "Ergo integrated services") && + choice->services_confidence >= 80) + choice->has_nickserv = TRUE; + + servlist_onboarding_parse_verified_metadata (choice, data, data_end); + + g_ptr_array_set_size (choice->popular_channels, 0); + channels = servlist_json_object_member (data, data_end, "channels"); + if (channels && *channels == '[') + { + p = channels + 1; + while (p < data_end && choice->popular_channels->len < 12) + { + const char *item_end; + char *name; + + p = servlist_json_skip_ws (p, data_end); + if (p >= data_end || *p == ']') + break; + if (*p != '{') + { + p = servlist_json_value_end (p, data_end); + if (!p) + break; + } + else + { + item_end = servlist_json_value_end (p, data_end); + if (!item_end) + break; + name = servlist_json_member_string (p, item_end, "name"); + if (name) + { + servlist_onboarding_add_popular_channel (choice, name); + g_free (name); + } + p = item_end; + } + p = servlist_json_skip_ws (p, data_end); + if (p < data_end && *p == ',') + p++; + } + } + + choice->detail_loaded = TRUE; +} + +static gboolean +servlist_onboarding_common_registration (ServlistOnboardingChoice *choice) +{ + if (!choice || !choice->has_nickserv || !choice->services_family || choice->services_confidence < 90) + return FALSE; + return !g_ascii_strcasecmp (choice->services_family, "Anope") || + !g_ascii_strcasecmp (choice->services_family, "Atheme") || + !g_ascii_strcasecmp (choice->services_family, "Ergo integrated services"); +} + +static gboolean +servlist_onboarding_can_prepare_registration (ServlistOnboardingChoice *choice) +{ + return choice && (choice->registration_verified || servlist_onboarding_common_registration (choice)); +} + +static char * +servlist_atlas_http_get (const char *path, GCancellable *cancellable, GError **error) +{ + GSocketClient *client; + GSocketConnection *connection; + GOutputStream *output; + GInputStream *input; + GByteArray *response; + char request[512]; + char buffer[8192]; + gsize written; + gssize got; + char *raw; + char *body; + gsize body_offset; + + client = g_socket_client_new (); + g_socket_client_set_tls (client, TRUE); + g_socket_client_set_timeout (client, 8); + connection = g_socket_client_connect_to_host (client, ONBOARDING_ATLAS_HOST, 443, cancellable, error); + g_object_unref (client); + if (!connection) + return NULL; + + output = g_io_stream_get_output_stream (G_IO_STREAM (connection)); + input = g_io_stream_get_input_stream (G_IO_STREAM (connection)); + g_snprintf (request, sizeof request, + "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: ZoiteChat/%s\r\nAccept: application/json\r\nConnection: close\r\n\r\n", + path, ONBOARDING_ATLAS_HOST, PACKAGE_VERSION); + if (!g_output_stream_write_all (output, request, strlen (request), &written, cancellable, error) || + !g_output_stream_flush (output, cancellable, error)) + { + g_object_unref (connection); + return NULL; + } + + response = g_byte_array_new (); + for (;;) + { + got = g_input_stream_read (input, buffer, sizeof buffer, cancellable, error); + if (got < 0) + { + g_byte_array_free (response, TRUE); + g_object_unref (connection); + return NULL; + } + if (got == 0) + break; + if (response->len + (guint) got > ONBOARDING_ATLAS_MAX_RESPONSE) + { + g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_FAILED, + "Network Atlas response was too large"); + g_byte_array_free (response, TRUE); + g_object_unref (connection); + return NULL; + } + g_byte_array_append (response, (const guint8 *) buffer, (guint) got); + } + g_object_unref (connection); + g_byte_array_append (response, (const guint8 *) "\0", 1); + raw = (char *) g_byte_array_free (response, FALSE); + if (!g_str_has_prefix (raw, "HTTP/1.0 200 ") && !g_str_has_prefix (raw, "HTTP/1.1 200 ")) + { + g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_FAILED, "Network Atlas returned a non-success HTTP status"); + g_free (raw); + return NULL; + } + body = strstr (raw, "\r\n\r\n"); + if (!body) + { + g_set_error_literal (error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA, "Network Atlas returned an invalid HTTP response"); + g_free (raw); + return NULL; + } + body += 4; + body_offset = (gsize) (body - raw); + memmove (raw, body, strlen (raw + body_offset) + 1); + return raw; +} + +static void +servlist_atlas_detail_thread (GTask *task, gpointer source_object, gpointer task_data, GCancellable *cancellable) +{ + ServlistAtlasDetailRequest *request = task_data; + ServlistAtlasDetailResult *result = g_new0 (ServlistAtlasDetailResult, 1); + GError *error = NULL; + char *path; + (void) source_object; + + result->slug = g_strdup (request->slug); + result->generation = request->generation; + path = g_strdup_printf (ONBOARDING_ATLAS_PUBLIC_PATH "/%s", request->slug); + result->json = servlist_atlas_http_get (path, cancellable, &error); + g_free (path); + if (!result->json && error && !g_cancellable_is_cancelled (cancellable)) + result->error = g_strdup (error->message); + g_clear_error (&error); + g_task_return_pointer (task, result, (GDestroyNotify) servlist_atlas_detail_result_free); +} + +static void +servlist_atlas_fetch_thread (GTask *task, gpointer source_object, gpointer task_data, GCancellable *cancellable) +{ + ServlistAtlasFetchResult *result = g_new0 (ServlistAtlasFetchResult, 1); + GError *client_error = NULL; + GError *public_error = NULL; + (void) source_object; + (void) task_data; + + result->client_json = servlist_atlas_http_get (ONBOARDING_ATLAS_CLIENT_PATH, cancellable, &client_error); + if (!g_cancellable_is_cancelled (cancellable)) + result->public_json = servlist_atlas_http_get (ONBOARDING_ATLAS_PUBLIC_PATH, cancellable, &public_error); + if (!result->client_json && !result->public_json && !g_cancellable_is_cancelled (cancellable)) + { + result->error = g_strdup (client_error ? client_error->message : + public_error ? public_error->message : "Network Atlas is unavailable"); + } + g_clear_error (&client_error); + g_clear_error (&public_error); + g_task_return_pointer (task, result, (GDestroyNotify) servlist_atlas_fetch_result_free); +} + +static ServlistOnboardingChoice * +servlist_onboarding_selected_choice (ServlistOnboardingState *state) +{ + int index = gtk_combo_box_get_active (GTK_COMBO_BOX (state->network_combo)); + if (index < 0 || (guint) index >= state->choices->len) + return NULL; + return g_ptr_array_index (state->choices, (guint) index); +} + +static void +servlist_onboarding_update_network_info (ServlistOnboardingState *state) +{ + ServlistOnboardingChoice *choice = servlist_onboarding_selected_choice (state); + GString *text = g_string_new (NULL); + + if (!choice) + g_string_append (text, _("Choose an IRC network to continue.")); + else + { + if (choice->description && *choice->description) + g_string_append (text, choice->description); + else + g_string_append_printf (text, _("%s is available in ZoiteChat's network list."), choice->name); + if (choice->users >= 0) + g_string_append_printf (text, _("\n\nAtlas recently saw about %d users on this network."), choice->users); + if (choice->services_present && choice->services_family) + { + if (choice->services_confidence >= 0) + g_string_append_printf (text, _("\nAccount services: %s (%d%% detection confidence)."), + choice->services_family, choice->services_confidence); + else + g_string_append_printf (text, _("\nAccount services: %s."), choice->services_family); + } + if (choice->onboarding_verified) + g_string_append (text, _("\nThis network publishes verified setup guidance through the ZoiteChat Network Atlas.")); + else if (choice->atlas_approved) + g_string_append (text, _("\nConnection endpoint approved by the ZoiteChat Network Atlas.")); + } + gtk_label_set_text (GTK_LABEL (state->network_info), text->str); + g_string_free (text, TRUE); +} + +static void +servlist_onboarding_detail_ready (GObject *source, GAsyncResult *async_result, gpointer userdata) +{ + ServlistOnboardingState *state = userdata; + ServlistAtlasDetailResult *result; + GError *error = NULL; + ServlistOnboardingChoice *choice = NULL; + (void) source; + + result = g_task_propagate_pointer (G_TASK (async_result), &error); + if (state->detail_pending > 0) + state->detail_pending--; + + if (!state->closing && result && result->slug) + { + choice = servlist_onboarding_find_choice (state, NULL, result->slug); + if (choice && result->json) + servlist_onboarding_apply_atlas_detail (choice, result->json); + else if (choice) + choice->detail_loaded = TRUE; + if (choice && result->generation == state->detail_generation) + { + servlist_onboarding_update_network_info (state); + if (state->account_skip) + servlist_onboarding_account_changed (GTK_TOGGLE_BUTTON (state->account_skip), state); + if (state->channel_suggestions && gtk_notebook_get_current_page (GTK_NOTEBOOK (state->notebook)) == 4) + servlist_onboarding_refresh_channel_suggestions (state); + } + } + + if (result) + servlist_atlas_detail_result_free (result); + g_clear_error (&error); + if (state->closing && !state->atlas_pending && state->detail_pending == 0) + servlist_onboarding_state_free (state); +} + +static void +servlist_onboarding_start_detail (ServlistOnboardingState *state, ServlistOnboardingChoice *choice) +{ + ServlistAtlasDetailRequest *request; + GTask *task; + + if (!state || !choice || !servlist_onboarding_slug_is_safe (choice->slug) || choice->detail_loaded || + !gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->use_atlas))) + return; + + if (state->detail_cancel) + { + g_cancellable_cancel (state->detail_cancel); + g_object_unref (state->detail_cancel); + } + state->detail_cancel = g_cancellable_new (); + state->detail_generation++; + state->detail_pending++; + request = g_new0 (ServlistAtlasDetailRequest, 1); + request->slug = g_strdup (choice->slug); + request->generation = state->detail_generation; + task = g_task_new (NULL, state->detail_cancel, servlist_onboarding_detail_ready, state); + g_task_set_task_data (task, request, (GDestroyNotify) servlist_atlas_detail_request_free); + g_task_set_return_on_cancel (task, FALSE); + g_task_run_in_thread (task, servlist_atlas_detail_thread); + g_object_unref (task); +} + +static void +servlist_onboarding_network_changed (GtkComboBox *combo, gpointer userdata) +{ + ServlistOnboardingState *state = userdata; + (void) combo; + servlist_onboarding_update_network_info (state); + if (state->account_skip) + servlist_onboarding_account_changed (GTK_TOGGLE_BUTTON (state->account_skip), state); + servlist_onboarding_start_detail (state, servlist_onboarding_selected_choice (state)); +} + +static void +servlist_onboarding_refresh_combo (ServlistOnboardingState *state) +{ + int old = gtk_combo_box_get_active (GTK_COMBO_BOX (state->network_combo)); + guint i; + + gtk_combo_box_text_remove_all (GTK_COMBO_BOX_TEXT (state->network_combo)); + for (i = 0; i < state->choices->len; i++) + { + ServlistOnboardingChoice *choice = g_ptr_array_index (state->choices, i); + gtk_combo_box_text_append_text (GTK_COMBO_BOX_TEXT (state->network_combo), choice->name); + } + if (old < 0 || (guint) old >= state->choices->len) + old = prefs.hex_gui_slist_select; + if (old < 0 || (guint) old >= state->choices->len) + old = 0; + gtk_combo_box_set_active (GTK_COMBO_BOX (state->network_combo), old); + servlist_onboarding_update_network_info (state); +} + +static void +servlist_onboarding_atlas_ready (GObject *source, GAsyncResult *async_result, gpointer userdata) +{ + ServlistOnboardingState *state = userdata; + ServlistAtlasFetchResult *result; + GError *error = NULL; + (void) source; + + result = g_task_propagate_pointer (G_TASK (async_result), &error); + state->atlas_pending = FALSE; + if (state->closing) + { + if (result) + servlist_atlas_fetch_result_free (result); + g_clear_error (&error); + if (state->detail_pending == 0) + servlist_onboarding_state_free (state); + return; + } + + if (error || !result || (!result->client_json && !result->public_json)) + { + const char *message = error ? error->message : result && result->error ? result->error : _("Atlas is unavailable"); + char *status = g_strdup_printf (_("Network Atlas unavailable (%s). The built-in network list still works normally."), message); + gtk_label_set_text (GTK_LABEL (state->atlas_status), status); + g_free (status); + } + else + { + if (result->client_json) + servlist_onboarding_apply_atlas_document (state, result->client_json, TRUE); + if (result->public_json) + servlist_onboarding_apply_atlas_document (state, result->public_json, FALSE); + servlist_onboarding_refresh_combo (state); + gtk_label_set_text (GTK_LABEL (state->atlas_status), + _("Network Atlas loaded. Current approved endpoints and setup hints are available where published.")); + } + if (result) + servlist_atlas_fetch_result_free (result); + g_clear_error (&error); +} + +static void +servlist_onboarding_start_atlas (ServlistOnboardingState *state) +{ + GTask *task; + + if (state->atlas_pending) + return; + state->atlas_pending = TRUE; + gtk_label_set_text (GTK_LABEL (state->atlas_status), _("Checking the ZoiteChat Network Atlas...")); + task = g_task_new (NULL, state->atlas_cancel, servlist_onboarding_atlas_ready, state); + g_task_set_return_on_cancel (task, FALSE); + g_task_run_in_thread (task, servlist_atlas_fetch_thread); + g_object_unref (task); +} + +static void +servlist_onboarding_account_changed (GtkToggleButton *button, gpointer userdata) +{ + ServlistOnboardingState *state = userdata; + ServlistOnboardingChoice *choice; + gboolean existing; + gboolean creating; + gboolean can_prepare; + gboolean needs_email; + GString *info; + (void) button; + + choice = servlist_onboarding_selected_choice (state); + existing = gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->account_existing)); + creating = gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->account_new)); + can_prepare = creating && servlist_onboarding_can_prepare_registration (choice); + needs_email = can_prepare && (!choice->registration_verified || choice->registration_email_required); + gtk_widget_set_visible (state->account_label, existing); + gtk_widget_set_visible (state->account_entry, existing); + gtk_widget_set_visible (state->password_label, existing || can_prepare); + gtk_widget_set_visible (state->password_entry, existing || can_prepare); + gtk_widget_set_visible (state->password_confirm_label, can_prepare); + gtk_widget_set_visible (state->password_confirm_entry, can_prepare); + gtk_widget_set_visible (state->remember_password, existing || can_prepare); + gtk_widget_set_visible (state->email_label, needs_email); + gtk_widget_set_visible (state->email_entry, needs_email); + + info = g_string_new (NULL); + if (!choice) + g_string_append (info, _("Choose a network first.")); + else if (existing) + { + if (choice->supports_sasl || (choice->net && (choice->net->logintype == LOGIN_SASL || + choice->net->logintype == LOGIN_SASL_SCRAM_SHA_1 || + choice->net->logintype == LOGIN_SASL_SCRAM_SHA_256 || + choice->net->logintype == LOGIN_SASL_SCRAM_SHA_512))) + g_string_append (info, _("ZoiteChat can log in to your network account during connection with SASL, so you are already identified when you enter channels.")); + else if (choice->has_nickserv) + g_string_append (info, _("This network appears to use NickServ. ZoiteChat can save the password and identify you after connecting.")); + else + g_string_append (info, _("ZoiteChat will preserve this network's existing login method. If it uses an unusual account system, Advanced setup is still available.")); + } + else if (creating) + { + if (choice->registration_verified) + { + g_string_append_printf (info, _("This network publishes an operator-verified registration recipe through Atlas. ZoiteChat will connect first and ask before sending the one-time registration request to %s."), + choice->registration_service ? choice->registration_service : "NickServ"); + } + else if (servlist_onboarding_common_registration (choice)) + { + g_string_append_printf (info, _("Atlas detected %s with %d%% confidence and found NickServ. These services normally register accounts with a password and email address. ZoiteChat can prepare that common registration request, but the network may customize or disable registration, so nothing is sent until you confirm."), + choice->services_family, choice->services_confidence); + } + else if (choice->has_nickserv) + { + g_string_append (info, _("Atlas found NickServ, but ZoiteChat does not have enough verified information to safely guess a password-bearing registration command. After connecting, ZoiteChat can ask NickServ for its registration help instead.")); + } + else if (choice->services_present && choice->services_family) + { + g_string_append_printf (info, _("Atlas detected %s account services"), choice->services_family); + if (choice->services_confidence >= 0) + g_string_append_printf (info, _(" with %d%% confidence"), choice->services_confidence); + g_string_append (info, _(". ZoiteChat will connect you first, but will not guess a command containing your password.")); + } + else if (state->atlas_pending || (choice->slug && !choice->detail_loaded)) + g_string_append (info, _("ZoiteChat is still checking Atlas for this network's account setup information. You can continue while it loads.")); + else + g_string_append (info, _("This network has not published enough account-registration information for automatic setup. You can still connect now and use the network's own help.")); + } + else + g_string_append (info, _("An IRC account is optional on many networks. You can start chatting with only a nickname and set up an account later.")); + gtk_label_set_text (GTK_LABEL (state->account_info), info->str); + g_string_free (info, TRUE); +} + +static void +servlist_onboarding_clear_box (GtkWidget *box) +{ + GList *children = gtk_container_get_children (GTK_CONTAINER (box)); + GList *node; + for (node = children; node; node = node->next) + gtk_widget_destroy (GTK_WIDGET (node->data)); + g_list_free (children); +} + +static void +servlist_onboarding_refresh_channel_suggestions (ServlistOnboardingState *state) +{ + ServlistOnboardingChoice *choice = servlist_onboarding_selected_choice (state); + guint i; + guint shown = 0; + + servlist_onboarding_clear_box (state->channel_suggestions); + if (choice && choice->starter_channels->len > 0) + { + GtkWidget *heading = gtk_label_new (_("Recommended starting channels")); + gtk_widget_set_halign (heading, GTK_ALIGN_START); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), heading, FALSE, FALSE, 0); + for (i = 0; i < choice->starter_channels->len && shown < 8; i++) + { + const char *channel = g_ptr_array_index (choice->starter_channels, i); + GtkWidget *check = gtk_check_button_new_with_label (channel); + g_object_set_data_full (G_OBJECT (check), "zoitechat-onboarding-channel", g_strdup (channel), g_free); + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (check), shown == 0); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), check, FALSE, FALSE, 0); + shown++; + } + } + if (choice && choice->help_channel && !servlist_onboarding_ptr_array_contains (choice->starter_channels, choice->help_channel)) + { + GtkWidget *check = gtk_check_button_new_with_label (choice->help_channel); + g_object_set_data_full (G_OBJECT (check), "zoitechat-onboarding-channel", g_strdup (choice->help_channel), g_free); + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (check), shown == 0); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), check, FALSE, FALSE, 0); + shown++; + } + if (choice && choice->popular_channels->len > 0) + { + GtkWidget *heading = gtk_label_new (_("Popular public channels recently seen by Atlas")); + GtkWidget *note = gtk_label_new (_("These are based on recent channel counts, not endorsements. Nothing here is joined unless you select it.")); + gtk_widget_set_halign (heading, GTK_ALIGN_START); + gtk_label_set_line_wrap (GTK_LABEL (note), TRUE); + gtk_label_set_xalign (GTK_LABEL (note), 0.0); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), heading, FALSE, FALSE, shown ? 8 : 0); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), note, FALSE, FALSE, 0); + for (i = 0; i < choice->popular_channels->len && i < 8; i++) + { + const char *channel = g_ptr_array_index (choice->popular_channels, i); + GtkWidget *check = gtk_check_button_new_with_label (channel); + g_object_set_data_full (G_OBJECT (check), "zoitechat-onboarding-channel", g_strdup (channel), g_free); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), check, FALSE, FALSE, 0); + shown++; + } + } + if (!shown) + { + GtkWidget *label = gtk_label_new (choice && choice->slug && !choice->detail_loaded ? + _("Atlas is still loading channel information. You can wait a moment, type a channel below, or connect without joining one yet.") : + _("No beginner channel recommendations are available for this network. You can leave this blank and choose a room after connecting.")); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_box_pack_start (GTK_BOX (state->channel_suggestions), label, FALSE, FALSE, 0); + } + gtk_widget_show_all (state->channel_suggestions); +} + +static ServlistOnboardingAccountMode +servlist_onboarding_account_mode (ServlistOnboardingState *state) +{ + if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->account_existing))) + return ONBOARDING_ACCOUNT_EXISTING; + if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->account_new))) + return ONBOARDING_ACCOUNT_NEW; + return ONBOARDING_ACCOUNT_SKIP; +} + +static void +servlist_onboarding_update_summary (ServlistOnboardingState *state) +{ + ServlistOnboardingChoice *choice = servlist_onboarding_selected_choice (state); + ServlistOnboardingAccountMode mode = servlist_onboarding_account_mode (state); + const char *nick = gtk_entry_get_text (GTK_ENTRY (state->nick_entry)); + GString *summary = g_string_new (NULL); + + g_string_append_printf (summary, _("Network: %s\nNickname: %s\n"), + choice ? choice->name : _("(none)"), nick && *nick ? nick : _("(none)")); + if (mode == ONBOARDING_ACCOUNT_EXISTING) + g_string_append (summary, _("Account: use an existing network account\n")); + else if (mode == ONBOARDING_ACCOUNT_NEW) + { + if (choice && choice->registration_verified) + g_string_append (summary, _("Account: connect first, then use operator-verified Atlas registration guidance\n")); + else if (servlist_onboarding_common_registration (choice)) + g_string_append (summary, _("Account: connect first, then confirm the common registration flow Atlas detected\n")); + else if (choice && choice->has_nickserv) + g_string_append (summary, _("Account: connect first, then ask NickServ for registration help\n")); + else + g_string_append (summary, _("Account: connect first, then use the network's own account help\n")); + } + else + g_string_append (summary, _("Account: skip for now\n")); + g_string_append (summary, _("Channels: only the rooms you selected or entered\n\n")); + g_string_append (summary, _("ZoiteChat will save this network and nickname locally. Network Atlas lookups, when enabled, contain no nickname or password.")); + gtk_label_set_text (GTK_LABEL (state->summary_label), summary->str); + g_string_free (summary, TRUE); +} + +static void +servlist_onboarding_update_buttons (ServlistOnboardingState *state) +{ + int page = gtk_notebook_get_current_page (GTK_NOTEBOOK (state->notebook)); + int pages = gtk_notebook_get_n_pages (GTK_NOTEBOOK (state->notebook)); + gtk_widget_set_sensitive (state->back_button, page > 0); + gtk_widget_set_visible (state->next_button, page < pages - 1); + gtk_widget_set_visible (state->connect_button, page == pages - 1); + if (page == pages - 1) + servlist_onboarding_update_summary (state); +} + +static GtkWidget * +servlist_onboarding_page (const char *title, const char *intro) +{ + GtkWidget *box = gtk_box_new (GTK_ORIENTATION_VERTICAL, 12); + GtkWidget *heading; + GtkWidget *label; + char *markup = g_markup_printf_escaped ("%s", title); + gtk_container_set_border_width (GTK_CONTAINER (box), 18); + heading = gtk_label_new (NULL); + gtk_label_set_markup (GTK_LABEL (heading), markup); + gtk_label_set_xalign (GTK_LABEL (heading), 0.0); + gtk_box_pack_start (GTK_BOX (box), heading, FALSE, FALSE, 0); + g_free (markup); + label = gtk_label_new (intro); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_box_pack_start (GTK_BOX (box), label, FALSE, FALSE, 0); + return box; +} + +static GtkWidget * +servlist_onboarding_labeled_entry (GtkWidget *grid, int row, const char *label_text, GtkWidget **out_label) +{ + GtkWidget *label = gtk_label_new (label_text); + GtkWidget *entry = gtk_entry_new (); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_widget_set_halign (label, GTK_ALIGN_START); + gtk_grid_attach (GTK_GRID (grid), label, 0, row, 1, 1); + gtk_grid_attach (GTK_GRID (grid), entry, 1, row, 1, 1); + if (out_label) + *out_label = label; + return entry; +} + +static ircnet * +servlist_onboarding_ensure_network (ServlistOnboardingChoice *choice) +{ + ircnet *net; + char *server_name; + + if (choice->net) + return choice->net; + if (!choice->atlas_approved || !choice->endpoint_host) + return NULL; + + net = servlist_net_find (choice->name, NULL, g_ascii_strcasecmp); + if (!net) + { + net = servlist_net_add (choice->name, NULL, FALSE); + net->encoding = g_strdup (IRC_DEFAULT_CHARSET); + if (choice->endpoint_tls) + net->flags |= FLAG_USE_SSL; + else + net->flags &= ~FLAG_USE_SSL; + if (choice->supports_sasl) + net->logintype = LOGIN_SASL; + server_name = g_strdup_printf (choice->endpoint_tls ? "%s/+%d" : "%s/%d", + choice->endpoint_host, choice->endpoint_port); + servlist_server_add (net, server_name); + g_free (server_name); + } + choice->net = net; + return net; +} + +static void +servlist_onboarding_set_identity (ircnet *net, const char *nick, const char *realname, const char *account_name) +{ + const char *profile = realname && *realname ? realname : nick; + const char *network_user = account_name && *account_name ? account_name : nick; + g_strlcpy (prefs.hex_irc_nick1, nick, sizeof prefs.hex_irc_nick1); + g_strlcpy (prefs.hex_irc_nick2, nick, sizeof prefs.hex_irc_nick2); + g_strlcat (prefs.hex_irc_nick2, "_", sizeof prefs.hex_irc_nick2); + g_strlcpy (prefs.hex_irc_nick3, nick, sizeof prefs.hex_irc_nick3); + g_strlcat (prefs.hex_irc_nick3, "__", sizeof prefs.hex_irc_nick3); + g_strlcpy (prefs.hex_irc_user_name, nick, sizeof prefs.hex_irc_user_name); + g_strlcpy (prefs.hex_irc_real_name, profile, sizeof prefs.hex_irc_real_name); + g_free (net->nick); + net->nick = g_strdup (prefs.hex_irc_nick1); + g_free (net->nick2); + net->nick2 = g_strdup (prefs.hex_irc_nick2); + g_free (net->user); + net->user = g_strdup (network_user); + g_free (net->real); + net->real = g_strdup (profile); + /* The assistant deliberately creates a complete per-network identity. + * This also lets an existing account name differ from the visible nick, + * which ZoiteChat's SASL path represents with the network user field. */ + net->flags &= ~FLAG_USE_GLOBAL; +} + +static gboolean +servlist_onboarding_store_password (ircnet *net, const char *password) +{ + char *stored; + + if (!net || !password || !*password) + return TRUE; + + if (secretstore_is_keyring_available () && secretstore_set_network_password (net->name, password)) + { + if (net->pass) + { + memset (net->pass, 0, strlen (net->pass)); + g_free (net->pass); + net->pass = NULL; + } + net->flags |= FLAG_USE_KEYRING; + return TRUE; + } + + stored = servlist_password_encrypt_for_storage (password); + if (!stored) + return FALSE; + if (net->pass) + { + memset (net->pass, 0, strlen (net->pass)); + g_free (net->pass); + } + net->pass = stored; + net->flags &= ~FLAG_USE_KEYRING; + return TRUE; +} + +static void +servlist_onboarding_clear_password (ircnet *net) +{ + if (!net) + return; + if (net->flags & FLAG_USE_KEYRING) + secretstore_delete_network_password (net->name); + net->flags &= ~FLAG_USE_KEYRING; + if (net->pass) + { + memset (net->pass, 0, strlen (net->pass)); + g_free (net->pass); + net->pass = NULL; + } +} + +static void +servlist_onboarding_add_channels_text (ircnet *net, const char *channels) +{ + char **parts; + int i; + + if (!net || !channels || !*channels) + return; + parts = g_strsplit_set (channels, ", \t", -1); + for (i = 0; parts[i]; i++) + { + if (*parts[i] && strchr ("#&+!", parts[i][0]) && !servlist_favchan_find (net, parts[i], NULL)) + servlist_favchan_add (net, parts[i]); + } + g_strfreev (parts); +} + +static void +servlist_onboarding_add_selected_channels (ServlistOnboardingState *state, ircnet *net) +{ + GList *children = gtk_container_get_children (GTK_CONTAINER (state->channel_suggestions)); + GList *node; + for (node = children; node; node = node->next) + { + GtkWidget *widget = node->data; + const char *channel = g_object_get_data (G_OBJECT (widget), "zoitechat-onboarding-channel"); + if (channel && GTK_IS_TOGGLE_BUTTON (widget) && gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (widget)) && + !servlist_favchan_find (net, (char *) channel, NULL)) + servlist_favchan_add (net, (char *) channel); + } + g_list_free (children); + servlist_onboarding_add_channels_text (net, gtk_entry_get_text (GTK_ENTRY (state->channel_entry))); +} + +static int +servlist_onboarding_preferred_login (ServlistOnboardingChoice *choice, ircnet *net) +{ + if (choice && choice->identify_method) + { + if (!g_ascii_strcasecmp (choice->identify_method, "sasl")) + return LOGIN_SASL; + if (!g_ascii_strcasecmp (choice->identify_method, "nickserv")) + return LOGIN_MSG_NICKSERV; + if (!g_ascii_strcasecmp (choice->identify_method, "server_password")) + return LOGIN_PASS; + } + if (choice && choice->supports_sasl) + return LOGIN_SASL; + if (net && net->logintype) + return net->logintype; + if (choice && choice->has_nickserv) + return LOGIN_MSG_NICKSERV; + return LOGIN_DEFAULT_REAL; +} + +static void +servlist_onboarding_connect_password_once (session *sess, ircnet *net, const char *password) +{ + char *old_pass = net->pass; + guint32 old_flags = net->flags; + char *temporary = g_strdup (password ? password : ""); + + net->pass = temporary; + net->flags &= ~(FLAG_USE_KEYRING | FLAG_PROMPT_PASSWORD); + servlist_connect (sess, net, TRUE); + net->flags = old_flags; + net->pass = old_pass; + memset (temporary, 0, strlen (temporary)); + g_free (temporary); +} + +static char * +servlist_onboarding_replace_all (const char *source, const char *needle, const char *replacement) +{ + GString *out = g_string_new (NULL); + const char *p = source; + const char *hit; + gsize needle_len = strlen (needle); + + while ((hit = strstr (p, needle)) != NULL) + { + g_string_append_len (out, p, hit - p); + g_string_append (out, replacement ? replacement : ""); + p = hit + needle_len; + } + g_string_append (out, p); + return g_string_free (out, FALSE); +} + +static char * +servlist_onboarding_expand_registration (ServlistRegistrationGuide *guide) +{ + char *step1; + char *step2; + char *step3; + char *step4; + char *step5; + + if (!guide || !guide->command_template || !servlist_onboarding_registration_template_is_safe (guide->command_template)) + return NULL; + step1 = servlist_onboarding_replace_all (guide->command_template, "{nick}", guide->nick ? guide->nick : ""); + step2 = servlist_onboarding_replace_all (step1, "{nickname}", guide->nick ? guide->nick : ""); + g_free (step1); + step3 = servlist_onboarding_replace_all (step2, "{account}", guide->nick ? guide->nick : ""); + g_free (step2); + step4 = servlist_onboarding_replace_all (step3, "{password}", guide->password ? guide->password : ""); + g_free (step3); + step5 = servlist_onboarding_replace_all (step4, "{email}", guide->email ? guide->email : ""); + g_free (step4); + if (strchr (step5, '{') || strchr (step5, '}') || strchr (step5, '\r') || strchr (step5, '\n') || strlen (step5) > 350) + { + memset (step5, 0, strlen (step5)); + g_free (step5); + return NULL; + } + return step5; +} + +static void +servlist_registration_guide_free (ServlistRegistrationGuide *guide) +{ + if (!guide) + return; + g_free (guide->network_name); + g_free (guide->nick); + if (guide->password) + { + memset (guide->password, 0, strlen (guide->password)); + g_free (guide->password); + } + if (guide->email) + { + memset (guide->email, 0, strlen (guide->email)); + g_free (guide->email); + } + g_free (guide->service); + g_free (guide->command_template); + g_free (guide->homepage); + g_free (guide->services_family); + g_free (guide->identify_method); + g_free (guide); +} + +static gboolean +servlist_onboarding_login_uses_promptable_sasl (int login) +{ + return login == LOGIN_SASL || login == LOGIN_SASL_SCRAM_SHA_1 || + login == LOGIN_SASL_SCRAM_SHA_256 || login == LOGIN_SASL_SCRAM_SHA_512; +} + +static void +servlist_registration_configure_future_login (ServlistRegistrationGuide *guide) +{ + guide->net->logintype = guide->preferred_login; + guide->net->flags &= ~FLAG_PROMPT_PASSWORD; + if (guide->remember_password) + { + if (!servlist_onboarding_store_password (guide->net, guide->password)) + fe_message (_("The registration request was sent, but ZoiteChat could not save the password for future logins."), FE_MSG_WARN); + } + else + { + servlist_onboarding_clear_password (guide->net); + if (servlist_onboarding_login_uses_promptable_sasl (guide->preferred_login)) + guide->net->flags |= FLAG_PROMPT_PASSWORD; + } + if (!servlist_save ()) + fe_message (_("Could not save the updated network login settings."), FE_MSG_WARN); +} + +static gboolean +servlist_onboarding_persist_success (ServlistRegistrationGuide *guide) +{ + gboolean ok = TRUE; + + if (!guide || !guide->net) + return FALSE; + + if (guide->account_mode == ONBOARDING_ACCOUNT_EXISTING) + { + guide->net->logintype = guide->preferred_login; + guide->net->flags &= ~FLAG_PROMPT_PASSWORD; + if (guide->remember_password) + { + if (!servlist_onboarding_store_password (guide->net, guide->password)) + { + fe_message (_("You are connected, but ZoiteChat could not save the account password. Your network settings will still be kept."), FE_MSG_WARN); + ok = FALSE; + } + } + else + { + servlist_onboarding_clear_password (guide->net); + if (servlist_onboarding_login_uses_promptable_sasl (guide->preferred_login)) + guide->net->flags |= FLAG_PROMPT_PASSWORD; + } + } + + prefs.hex_gui_onboarding_pending = FALSE; + if (!servlist_save ()) + { + fe_message (_("You are connected, but ZoiteChat could not save the server list."), FE_MSG_WARN); + ok = FALSE; + } + if (!save_config ()) + { + fe_message (_("You are connected, but ZoiteChat could not save zoitechat.conf."), FE_MSG_WARN); + ok = FALSE; + } + return ok; +} + +static void +servlist_onboarding_connected_show (ServlistRegistrationGuide *guide) +{ + GtkWidget *dialog; + GtkWidget *area; + GtkWidget *label; + GString *text; + + if (!guide) + return; + dialog = gtk_dialog_new_with_buttons (_("You're connected to IRC"), NULL, GTK_DIALOG_MODAL, + _("Start chatting"), GTK_RESPONSE_OK, + NULL); + if (current_sess && current_sess->gui && current_sess->gui->window) + gtk_window_set_transient_for (GTK_WINDOW (dialog), GTK_WINDOW (current_sess->gui->window)); + gtk_window_set_default_size (GTK_WINDOW (dialog), 560, -1); + area = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); + text = g_string_new (NULL); + g_string_append_printf (text, _("You're online on %s as %s.\n\n"), guide->network_name, guide->nick); + if (guide->account_mode == ONBOARDING_ACCOUNT_EXISTING) + g_string_append (text, _("ZoiteChat used the account details you supplied. Watch the server tab for any login warning from the network.\n\n")); + else if (guide->account_mode == ONBOARDING_ACCOUNT_NEW) + g_string_append (text, _("Your first connection is working. If you sent a registration request, keep the server tab open long enough to read NickServ's reply and finish any verification it requested.\n\n")); + else + g_string_append (text, _("You can use IRC without registering an account. You can set one up later from the network's help or Network List.\n\n")); + g_string_append (text, _("If you selected channels, ZoiteChat is joining them now. Click a channel in the left-hand list, type in the box at the bottom, and press Enter to talk. A private conversation with one person is called a query or direct message.\n\nYou can always open Network List later to add another network, change channels, or edit account login settings.")); + label = gtk_label_new (text->str); + g_string_free (text, TRUE); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_container_set_border_width (GTK_CONTAINER (label), 16); + gtk_box_pack_start (GTK_BOX (area), label, TRUE, TRUE, 0); + gtk_widget_show_all (dialog); + gtk_dialog_run (GTK_DIALOG (dialog)); + gtk_widget_destroy (dialog); +} + +static gboolean +servlist_registration_confirm_automatic_login (ServlistRegistrationGuide *guide) +{ + GtkWidget *dialog; + GtkWidget *area; + GtkWidget *label; + int response; + + dialog = gtk_dialog_new_with_buttons (_("Confirm your IRC account"), NULL, GTK_DIALOG_MODAL, + _("I'll finish this later"), GTK_RESPONSE_CANCEL, + _("Set up automatic login"), GTK_RESPONSE_OK, + NULL); + if (current_sess && current_sess->gui && current_sess->gui->window) + gtk_window_set_transient_for (GTK_WINDOW (dialog), GTK_WINDOW (current_sess->gui->window)); + gtk_window_set_default_size (GTK_WINDOW (dialog), 560, -1); + area = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); + label = gtk_label_new (_("Read NickServ's reply in the server tab first. If it says the account was registered and asks you to confirm an email or code, complete that step before continuing.\n\nWhen the account is ready, ZoiteChat can save the password and configure normal automatic login. If you are unsure whether registration succeeded, choose 'I'll finish this later'. Your password will not be stored automatically.")); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_container_set_border_width (GTK_CONTAINER (label), 16); + gtk_box_pack_start (GTK_BOX (area), label, TRUE, TRUE, 0); + gtk_widget_show_all (dialog); + response = gtk_dialog_run (GTK_DIALOG (dialog)); + gtk_widget_destroy (dialog); + return response == GTK_RESPONSE_OK; +} + +static void +servlist_registration_show (ServlistRegistrationGuide *guide) +{ + GtkWidget *dialog; + GtkWidget *area; + GtkWidget *label; + GString *text; + int response; + gboolean can_send; + gboolean can_ask_help; + + can_send = guide->verified_recipe || guide->common_recipe; + can_ask_help = guide->has_nickserv && guide->serv && guide->serv->p_message; + dialog = gtk_dialog_new_with_buttons (_("Finish setting up your IRC account"), NULL, GTK_DIALOG_MODAL, + _("Not now"), GTK_RESPONSE_CANCEL, + NULL); + if (can_send) + gtk_dialog_add_button (GTK_DIALOG (dialog), _("Send registration request"), GTK_RESPONSE_OK); + else if (can_ask_help) + gtk_dialog_add_button (GTK_DIALOG (dialog), _("Ask NickServ how to register"), GTK_RESPONSE_APPLY); + if (guide->homepage) + gtk_dialog_add_button (GTK_DIALOG (dialog), _("Open network website"), GTK_RESPONSE_HELP); + if (current_sess && current_sess->gui && current_sess->gui->window) + gtk_window_set_transient_for (GTK_WINDOW (dialog), GTK_WINDOW (current_sess->gui->window)); + gtk_window_set_default_size (GTK_WINDOW (dialog), 560, -1); + area = gtk_dialog_get_content_area (GTK_DIALOG (dialog)); + text = g_string_new (NULL); + g_string_append_printf (text, _("You are connected to %s as %s.\n\n"), guide->network_name, guide->nick); + if (guide->verified_recipe) + { + g_string_append_printf (text, _("This network publishes an operator-verified account registration recipe through the ZoiteChat Network Atlas. If you continue, ZoiteChat will send one registration request to %s. The command is never added to reconnect commands."), guide->service); + if (guide->email_required) + g_string_append (text, _("\n\nThe network requires an email address. Read NickServ's reply and complete any email verification it requests.")); + } + else if (guide->common_recipe) + { + g_string_append_printf (text, _("Atlas detected %s account services with %d%% confidence and found NickServ. This services family normally uses REGISTER with a password and email address. The network can customize or disable registration, so ZoiteChat will only send the request if you choose to continue."), + guide->services_family, guide->services_confidence); + g_string_append (text, _("\n\nAfter sending it, read NickServ's reply. If it asks for email verification, complete that before relying on automatic login.")); + } + else + { + if (guide->services_family) + { + if (guide->services_confidence >= 0) + g_string_append_printf (text, _("Atlas detected %s account services with %d%% confidence, but ZoiteChat does not have enough verified information to send your password in a registration command."), + guide->services_family, guide->services_confidence); + else + g_string_append_printf (text, _("Atlas detected %s account services, but ZoiteChat does not have enough verified information to send your password in a registration command."), guide->services_family); + } + else + g_string_append (text, _("Atlas does not currently have enough account-registration information for this network. ZoiteChat will not guess a password-bearing command.")); + if (can_ask_help) + g_string_append (text, _("\n\nZoiteChat can safely ask NickServ for its REGISTER help without sending a password.")); + } + label = gtk_label_new (text->str); + g_string_free (text, TRUE); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_container_set_border_width (GTK_CONTAINER (label), 16); + gtk_box_pack_start (GTK_BOX (area), label, TRUE, TRUE, 0); + gtk_widget_show_all (dialog); + + for (;;) + { + response = gtk_dialog_run (GTK_DIALOG (dialog)); + if (response == GTK_RESPONSE_HELP && guide->homepage) + { + fe_open_url (guide->homepage); + continue; + } + if (response == GTK_RESPONSE_APPLY && can_ask_help) + { + guide->serv->p_message (guide->serv, "NickServ", "HELP REGISTER"); + fe_message (_("Asked NickServ for registration help. Read its reply in the server tab."), FE_MSG_INFO); + break; + } + if (response == GTK_RESPONSE_OK && can_send) + { + char *expanded = servlist_onboarding_expand_registration (guide); + if (!expanded || !servlist_onboarding_service_target_is_safe (guide->service)) + { + if (expanded) + { + memset (expanded, 0, strlen (expanded)); + g_free (expanded); + } + fe_message (_("The Atlas registration instructions could not be expanded safely."), FE_MSG_ERROR); + break; + } + guide->serv->p_message (guide->serv, guide->service, expanded); + memset (expanded, 0, strlen (expanded)); + g_free (expanded); + fe_message (_("Registration request sent. Read NickServ's reply in the server tab before confirming automatic login."), FE_MSG_INFO); + if (servlist_registration_confirm_automatic_login (guide)) + { + servlist_registration_configure_future_login (guide); + fe_message (_("Automatic account login is now configured for future connections."), FE_MSG_INFO); + } + break; + } + break; + } + gtk_widget_destroy (dialog); +} + +static int +servlist_registration_wait_cb (gpointer data) +{ + ServlistRegistrationGuide *guide = data; + if (!is_server (guide->serv)) + { + servlist_registration_guide_free (guide); + return 0; + } + if (guide->serv->end_of_motd) + { + servlist_onboarding_persist_success (guide); + if (guide->account_mode == ONBOARDING_ACCOUNT_NEW) + { + servlist_registration_show (guide); + servlist_onboarding_connected_show (guide); + } + else + servlist_onboarding_connected_show (guide); + servlist_registration_guide_free (guide); + return 0; + } + if (g_get_monotonic_time () >= guide->deadline) + { + fe_message (_("The first connection did not finish in time, so ZoiteChat did not mark onboarding complete. You can retry the connection or reopen Network List."), FE_MSG_WARN); + servlist_registration_guide_free (guide); + return 0; + } + return 1; +} + +static gboolean +servlist_onboarding_validate_page (ServlistOnboardingState *state, int page) +{ + ServlistOnboardingChoice *choice; + ServlistOnboardingAccountMode mode; + const char *nick; + const char *password; + const char *password_confirm; + const char *email; + + if (page == 0) + { + if (gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->use_atlas))) + servlist_onboarding_start_atlas (state); + return TRUE; + } + if (page == 1) + { + choice = servlist_onboarding_selected_choice (state); + if (!choice) + { + fe_message (_("Choose an IRC network before continuing."), FE_MSG_ERROR); + return FALSE; + } + return TRUE; + } + if (page == 2) + { + nick = gtk_entry_get_text (GTK_ENTRY (state->nick_entry)); + if (!nick || !*nick || strpbrk (nick, " \t\r\n,:")) + { + fe_message (_("Choose a nickname without spaces, commas, or colons."), FE_MSG_ERROR); + gtk_widget_grab_focus (state->nick_entry); + return FALSE; + } + return TRUE; + } + if (page == 3) + { + choice = servlist_onboarding_selected_choice (state); + mode = servlist_onboarding_account_mode (state); + password = gtk_entry_get_text (GTK_ENTRY (state->password_entry)); + password_confirm = gtk_entry_get_text (GTK_ENTRY (state->password_confirm_entry)); + email = gtk_entry_get_text (GTK_ENTRY (state->email_entry)); + if (mode == ONBOARDING_ACCOUNT_NEW && choice && choice->slug && !choice->detail_loaded && + gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->use_atlas))) + { + servlist_onboarding_start_detail (state, choice); + fe_message (_("ZoiteChat is still checking this network's Atlas account information. Give it a moment, or choose 'Skip account setup for now' to connect immediately."), FE_MSG_INFO); + return FALSE; + } + if (mode == ONBOARDING_ACCOUNT_EXISTING) + { + const char *account = gtk_entry_get_text (GTK_ENTRY (state->account_entry)); + if (!account || !*account || strpbrk (account, " \t\r\n")) + { + fe_message (_("Enter the account name you registered on this network. It cannot contain spaces."), FE_MSG_ERROR); + return FALSE; + } + if (!password || !*password) + { + fe_message (_("Enter the password for your existing network account, or choose 'Skip account setup for now'."), FE_MSG_ERROR); + return FALSE; + } + } + if (mode == ONBOARDING_ACCOUNT_NEW && servlist_onboarding_can_prepare_registration (choice)) + { + gboolean needs_email = !choice->registration_verified || choice->registration_email_required; + if (!password || !*password || strpbrk (password, " \t\r\n") || strlen (password) < 10) + { + fe_message (_("Choose a registration password of at least 10 characters with no spaces. Do not reuse an important password from another service."), FE_MSG_ERROR); + return FALSE; + } + if (!password_confirm || strcmp (password, password_confirm)) + { + fe_message (_("The two registration password entries do not match."), FE_MSG_ERROR); + gtk_widget_grab_focus (state->password_confirm_entry); + return FALSE; + } + if (needs_email && (!email || !*email || !strchr (email, '@') || strpbrk (email, " \t\r\n"))) + { + fe_message (_("Enter a valid email address for account registration."), FE_MSG_ERROR); + return FALSE; + } + } + return TRUE; + } + return TRUE; +} + +static void +servlist_open_networks_window (session *sess) +{ + servlist_sess = sess; + serverlist_win = servlist_open_networks (); + gtkutil_set_icon (serverlist_win); + servlist_networks_populate (networks_tree, network_list); + g_signal_connect (G_OBJECT (serverlist_win), "delete-event", G_CALLBACK (servlist_delete_cb), 0); + g_signal_connect (G_OBJECT (serverlist_win), "configure-event", G_CALLBACK (servlist_configure_cb), 0); + g_signal_connect (G_OBJECT (gtk_tree_view_get_selection (GTK_TREE_VIEW (networks_tree))), + "changed", G_CALLBACK (servlist_network_row_cb), NULL); + g_signal_connect (G_OBJECT (networks_tree), "key-press-event", + G_CALLBACK (servlist_net_keypress_cb), networks_tree); + gtk_widget_show (serverlist_win); +} + +static gboolean +servlist_open_onboarding (session *sess) +{ + ServlistOnboardingState *state; + GtkWidget *area; + GtkWidget *page; + GtkWidget *grid; + GtkWidget *label; + GtkWidget *scroll; + GSList *group; + GSList *list; + int response; + int page_index; + ircnet *net; + + state = g_new0 (ServlistOnboardingState, 1); + state->sess = sess; + state->choices = g_ptr_array_new_with_free_func ((GDestroyNotify) servlist_onboarding_choice_free); + state->atlas_cancel = g_cancellable_new (); + for (list = network_list; list; list = list->next) + { + net = list->data; + g_ptr_array_add (state->choices, servlist_onboarding_choice_new (net, net->name)); + } + + state->dialog = gtk_dialog_new_with_buttons (_("Welcome to ZoiteChat"), NULL, GTK_DIALOG_MODAL, + _("_Advanced setup..."), GTK_RESPONSE_HELP, + _("_Cancel"), GTK_RESPONSE_CANCEL, + NULL); + gtk_window_set_default_size (GTK_WINDOW (state->dialog), 650, 520); + if (sess && sess->gui && sess->gui->window) + gtk_window_set_transient_for (GTK_WINDOW (state->dialog), GTK_WINDOW (sess->gui->window)); + state->back_button = gtk_dialog_add_button (GTK_DIALOG (state->dialog), _("_Back"), GTK_RESPONSE_NO); + state->next_button = gtk_dialog_add_button (GTK_DIALOG (state->dialog), _("_Next"), GTK_RESPONSE_YES); + state->connect_button = gtk_dialog_add_button (GTK_DIALOG (state->dialog), _("C_onnect"), GTK_RESPONSE_OK); + gtk_widget_set_can_default (state->next_button, TRUE); + gtk_widget_set_can_default (state->connect_button, TRUE); + + area = gtk_dialog_get_content_area (GTK_DIALOG (state->dialog)); + state->notebook = gtk_notebook_new (); + gtk_notebook_set_show_tabs (GTK_NOTEBOOK (state->notebook), FALSE); + gtk_notebook_set_show_border (GTK_NOTEBOOK (state->notebook), FALSE); + gtk_box_pack_start (GTK_BOX (area), state->notebook, TRUE, TRUE, 0); + + page = servlist_onboarding_page (_("Welcome to IRC"), + _("IRC is a collection of independent chat communities called networks. You do not need to know server addresses, commands, or IRC terminology to get started. This assistant will choose a community, set your public chat name, explain accounts, pick rooms, and connect you.")); + label = gtk_label_new (_("ZoiteChat can use the public Network Atlas for current network information and verified setup guidance. This sends a normal HTTPS request to zoitechat.org. Your nickname and password are never included in that request.")); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_box_pack_start (GTK_BOX (page), label, FALSE, FALSE, 0); + state->use_atlas = gtk_check_button_new_with_label (_("Use the ZoiteChat Network Atlas for current setup information")); + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (state->use_atlas), TRUE); + gtk_box_pack_start (GTK_BOX (page), state->use_atlas, FALSE, FALSE, 0); + gtk_notebook_append_page (GTK_NOTEBOOK (state->notebook), page, NULL); + + page = servlist_onboarding_page (_("Choose a network"), + _("A network is an independent IRC community. Pick one that sounds useful to you. You can connect to several networks later.")); + state->network_combo = gtk_combo_box_text_new (); + gtk_box_pack_start (GTK_BOX (page), state->network_combo, FALSE, FALSE, 0); + state->atlas_status = gtk_label_new (_("Using ZoiteChat's built-in network list.")); + gtk_label_set_line_wrap (GTK_LABEL (state->atlas_status), TRUE); + gtk_label_set_xalign (GTK_LABEL (state->atlas_status), 0.0); + gtk_box_pack_start (GTK_BOX (page), state->atlas_status, FALSE, FALSE, 0); + state->network_info = gtk_label_new (""); + gtk_label_set_line_wrap (GTK_LABEL (state->network_info), TRUE); + gtk_label_set_xalign (GTK_LABEL (state->network_info), 0.0); + gtk_box_pack_start (GTK_BOX (page), state->network_info, TRUE, TRUE, 0); + gtk_notebook_append_page (GTK_NOTEBOOK (state->notebook), page, NULL); + g_signal_connect (state->network_combo, "changed", G_CALLBACK (servlist_onboarding_network_changed), state); + servlist_onboarding_refresh_combo (state); + + page = servlist_onboarding_page (_("Choose your chat name"), + _("Your nickname is the public name people see in chat. It does not need to be your real name. If your first choice is already in use, ZoiteChat keeps two automatic fallback versions.")); + grid = gtk_grid_new (); + gtk_grid_set_row_spacing (GTK_GRID (grid), 10); + gtk_grid_set_column_spacing (GTK_GRID (grid), 12); + gtk_box_pack_start (GTK_BOX (page), grid, FALSE, FALSE, 0); + state->nick_entry = servlist_onboarding_labeled_entry (grid, 0, _("Nickname:"), NULL); + /* Leave room for the automatic _ and __ fallback nicknames. */ + gtk_entry_set_max_length (GTK_ENTRY (state->nick_entry), NICKLEN - 3); + gtk_entry_set_text (GTK_ENTRY (state->nick_entry), prefs.hex_irc_nick1); + label = gtk_label_new (_("Examples: alex, MapleLeaf, codecat. Spaces are not allowed.")); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_grid_attach (GTK_GRID (grid), label, 1, 1, 1, 1); + state->realname_entry = servlist_onboarding_labeled_entry (grid, 2, _("Profile name:"), NULL); + gtk_entry_set_max_length (GTK_ENTRY (state->realname_entry), sizeof prefs.hex_irc_real_name - 1); + gtk_entry_set_text (GTK_ENTRY (state->realname_entry), prefs.hex_irc_nick1); + label = gtk_label_new (_("This IRC 'real name' field can be visible in WHOIS. ZoiteChat starts it with your nickname instead of exposing your computer account's real name; change it if you want.")); + gtk_label_set_line_wrap (GTK_LABEL (label), TRUE); + gtk_label_set_xalign (GTK_LABEL (label), 0.0); + gtk_grid_attach (GTK_GRID (grid), label, 1, 3, 1, 1); + gtk_notebook_append_page (GTK_NOTEBOOK (state->notebook), page, NULL); + + page = servlist_onboarding_page (_("Do you want a network account?"), + _("A nickname lets you chat immediately. A network account can reserve that nickname and lets the network recognize you when you return. Accounts belong to the IRC network, not to ZoiteChat.")); + state->account_skip = gtk_radio_button_new_with_label (NULL, _("Skip account setup for now")); + group = gtk_radio_button_get_group (GTK_RADIO_BUTTON (state->account_skip)); + state->account_existing = gtk_radio_button_new_with_label (group, _("I already have an account on this network")); + group = gtk_radio_button_get_group (GTK_RADIO_BUTTON (state->account_existing)); + state->account_new = gtk_radio_button_new_with_label (group, _("I'm new here. Help me reserve this nickname")); + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (state->account_skip), TRUE); + gtk_box_pack_start (GTK_BOX (page), state->account_skip, FALSE, FALSE, 0); + gtk_box_pack_start (GTK_BOX (page), state->account_existing, FALSE, FALSE, 0); + gtk_box_pack_start (GTK_BOX (page), state->account_new, FALSE, FALSE, 0); + state->account_info = gtk_label_new (""); + gtk_label_set_line_wrap (GTK_LABEL (state->account_info), TRUE); + gtk_label_set_xalign (GTK_LABEL (state->account_info), 0.0); + gtk_box_pack_start (GTK_BOX (page), state->account_info, FALSE, FALSE, 0); + grid = gtk_grid_new (); + gtk_grid_set_row_spacing (GTK_GRID (grid), 8); + gtk_grid_set_column_spacing (GTK_GRID (grid), 12); + gtk_box_pack_start (GTK_BOX (page), grid, FALSE, FALSE, 0); + state->account_entry = servlist_onboarding_labeled_entry (grid, 0, _("Account name:"), &state->account_label); + gtk_entry_set_text (GTK_ENTRY (state->account_entry), prefs.hex_irc_nick1); + state->password_entry = servlist_onboarding_labeled_entry (grid, 1, _("Account password:"), &state->password_label); + gtk_entry_set_visibility (GTK_ENTRY (state->password_entry), FALSE); + gtk_entry_set_input_purpose (GTK_ENTRY (state->password_entry), GTK_INPUT_PURPOSE_PASSWORD); + gtk_entry_set_max_length (GTK_ENTRY (state->password_entry), 200); + state->password_confirm_entry = servlist_onboarding_labeled_entry (grid, 2, _("Confirm password:"), &state->password_confirm_label); + gtk_entry_set_visibility (GTK_ENTRY (state->password_confirm_entry), FALSE); + gtk_entry_set_input_purpose (GTK_ENTRY (state->password_confirm_entry), GTK_INPUT_PURPOSE_PASSWORD); + gtk_entry_set_max_length (GTK_ENTRY (state->password_confirm_entry), 200); + state->email_entry = servlist_onboarding_labeled_entry (grid, 3, _("Email address:"), &state->email_label); + gtk_entry_set_max_length (GTK_ENTRY (state->email_entry), 254); + state->remember_password = gtk_check_button_new_with_label (_("Remember this password for future connections")); + gtk_toggle_button_set_active (GTK_TOGGLE_BUTTON (state->remember_password), TRUE); + gtk_grid_attach (GTK_GRID (grid), state->remember_password, 1, 4, 1, 1); + gtk_notebook_append_page (GTK_NOTEBOOK (state->notebook), page, NULL); + g_signal_connect (state->account_skip, "toggled", G_CALLBACK (servlist_onboarding_account_changed), state); + g_signal_connect (state->account_existing, "toggled", G_CALLBACK (servlist_onboarding_account_changed), state); + g_signal_connect (state->account_new, "toggled", G_CALLBACK (servlist_onboarding_account_changed), state); + servlist_onboarding_account_changed (GTK_TOGGLE_BUTTON (state->account_skip), state); + + page = servlist_onboarding_page (_("Choose where to start chatting"), + _("Channels are chat rooms inside a network. Networks can publish beginner-friendly starter rooms through Atlas. You can also type a channel name if someone invited you to one.")); + scroll = gtk_scrolled_window_new (NULL, NULL); + gtk_scrolled_window_set_policy (GTK_SCROLLED_WINDOW (scroll), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request (scroll, -1, 180); + state->channel_suggestions = gtk_box_new (GTK_ORIENTATION_VERTICAL, 6); + gtk_container_add (GTK_CONTAINER (scroll), state->channel_suggestions); + gtk_box_pack_start (GTK_BOX (page), scroll, TRUE, TRUE, 0); + grid = gtk_grid_new (); + gtk_grid_set_column_spacing (GTK_GRID (grid), 12); + gtk_box_pack_start (GTK_BOX (page), grid, FALSE, FALSE, 0); + state->channel_entry = servlist_onboarding_labeled_entry (grid, 0, _("Other channels (optional):"), NULL); + gtk_entry_set_placeholder_text (GTK_ENTRY (state->channel_entry), _("#channel, #another")); + gtk_notebook_append_page (GTK_NOTEBOOK (state->notebook), page, NULL); + + page = servlist_onboarding_page (_("Ready to connect"), + _("That's enough setup to get onto IRC. Nothing here is permanent: the Network List can change networks, nicknames, account login, servers, and channels later.")); + state->summary_label = gtk_label_new (""); + gtk_label_set_line_wrap (GTK_LABEL (state->summary_label), TRUE); + gtk_label_set_xalign (GTK_LABEL (state->summary_label), 0.0); + gtk_box_pack_start (GTK_BOX (page), state->summary_label, TRUE, TRUE, 0); + gtk_notebook_append_page (GTK_NOTEBOOK (state->notebook), page, NULL); + + gtk_widget_show_all (state->dialog); + gtk_notebook_set_current_page (GTK_NOTEBOOK (state->notebook), 0); + servlist_onboarding_update_buttons (state); + + for (;;) + { + response = gtk_dialog_run (GTK_DIALOG (state->dialog)); + page_index = gtk_notebook_get_current_page (GTK_NOTEBOOK (state->notebook)); + if (response == GTK_RESPONSE_HELP) + { + prefs.hex_gui_onboarding_disable = TRUE; + prefs.hex_gui_onboarding_pending = FALSE; + save_config (); + gtk_widget_destroy (state->dialog); + servlist_onboarding_close_state (state); + servlist_open_networks_window (sess); + return TRUE; + } + if (response == GTK_RESPONSE_CANCEL || response == GTK_RESPONSE_DELETE_EVENT) + { + gtk_widget_destroy (state->dialog); + servlist_onboarding_close_state (state); + return TRUE; + } + if (response == GTK_RESPONSE_NO) + { + if (page_index > 0) + gtk_notebook_set_current_page (GTK_NOTEBOOK (state->notebook), page_index - 1); + servlist_onboarding_update_buttons (state); + continue; + } + if (response == GTK_RESPONSE_YES) + { + if (!servlist_onboarding_validate_page (state, page_index)) + continue; + if (page_index == 1) + servlist_onboarding_account_changed (GTK_TOGGLE_BUTTON (state->account_skip), state); + if (page_index == 2 && state->account_entry) + gtk_entry_set_text (GTK_ENTRY (state->account_entry), gtk_entry_get_text (GTK_ENTRY (state->nick_entry))); + if (page_index == 3) + servlist_onboarding_refresh_channel_suggestions (state); + gtk_notebook_set_current_page (GTK_NOTEBOOK (state->notebook), page_index + 1); + servlist_onboarding_update_buttons (state); + continue; + } + if (response == GTK_RESPONSE_OK) + { + ServlistOnboardingChoice *choice = servlist_onboarding_selected_choice (state); + ServlistOnboardingAccountMode mode = servlist_onboarding_account_mode (state); + const char *nick = gtk_entry_get_text (GTK_ENTRY (state->nick_entry)); + const char *password = gtk_entry_get_text (GTK_ENTRY (state->password_entry)); + const char *email = gtk_entry_get_text (GTK_ENTRY (state->email_entry)); + gboolean remember = gtk_toggle_button_get_active (GTK_TOGGLE_BUTTON (state->remember_password)); + int preferred_login; + ServlistRegistrationGuide *guide = NULL; + server *target_server; + + if (!choice || !servlist_onboarding_validate_page (state, 2) || !servlist_onboarding_validate_page (state, 3)) + continue; + net = servlist_onboarding_ensure_network (choice); + if (!net) + { + fe_message (_("ZoiteChat could not create a usable connection for the selected network. Try Advanced setup."), FE_MSG_ERROR); + continue; + } + servlist_onboarding_set_identity (net, nick, gtk_entry_get_text (GTK_ENTRY (state->realname_entry)), + mode == ONBOARDING_ACCOUNT_EXISTING ? gtk_entry_get_text (GTK_ENTRY (state->account_entry)) : nick); + servlist_onboarding_add_selected_channels (state, net); + preferred_login = servlist_onboarding_preferred_login (choice, net); + prefs.hex_gui_slist_select = g_slist_index (network_list, net); + + guide = g_new0 (ServlistRegistrationGuide, 1); + guide->account_mode = mode; + guide->network_name = g_strdup (choice->name); + guide->nick = g_strdup (nick); + guide->homepage = g_strdup (choice->homepage); + guide->services_family = g_strdup (choice->services_family); + guide->identify_method = g_strdup (choice->identify_method); + guide->services_confidence = choice->services_confidence; + guide->preferred_login = preferred_login; + guide->remember_password = remember; + guide->has_nickserv = choice->has_nickserv; + guide->net = net; + + if (mode == ONBOARDING_ACCOUNT_EXISTING) + { + /* Connect with the supplied credential once, but do not persist it until + * the network has actually accepted the connection. */ + guide->password = g_strdup (password); + net->logintype = preferred_login; + } + else if (mode == ONBOARDING_ACCOUNT_NEW) + { + gboolean common_recipe = servlist_onboarding_common_registration (choice); + gboolean can_prepare = choice->registration_verified || common_recipe; + guide->password = can_prepare ? g_strdup (password) : NULL; + guide->email = can_prepare ? g_strdup (email) : NULL; + guide->service = g_strdup (choice->registration_verified ? choice->registration_service : + common_recipe ? "NickServ" : NULL); + guide->command_template = g_strdup (choice->registration_verified ? choice->registration_template : + common_recipe ? "REGISTER {password} {email}" : NULL); + guide->email_required = choice->registration_verified ? choice->registration_email_required : common_recipe; + guide->verified_recipe = choice->registration_verified; + guide->common_recipe = common_recipe; + /* A brand-new account cannot authenticate before it exists. Keep the + * first connection unauthenticated; the guide switches the saved + * login method only after a registration request is sent. */ + net->logintype = LOGIN_PASS; + servlist_onboarding_clear_password (net); + net->flags &= ~FLAG_PROMPT_PASSWORD; + } + + if (!is_session (sess)) + sess = current_sess; + if (!sess) + sess = new_ircwindow (NULL, NULL, SESS_SERVER, TRUE); + target_server = sess->server; + if (mode == ONBOARDING_ACCOUNT_EXISTING) + servlist_onboarding_connect_password_once (sess, net, password); + else + servlist_connect (sess, net, TRUE); + + guide->serv = target_server; + guide->deadline = g_get_monotonic_time () + (gint64) ONBOARDING_REGISTRATION_TIMEOUT_SECONDS * G_USEC_PER_SEC; + fe_timeout_add (500, servlist_registration_wait_cb, guide); + gtk_entry_set_text (GTK_ENTRY (state->password_entry), ""); + gtk_entry_set_text (GTK_ENTRY (state->password_confirm_entry), ""); + gtk_entry_set_text (GTK_ENTRY (state->email_entry), ""); + gtk_widget_destroy (state->dialog); + servlist_onboarding_close_state (state); + return TRUE; + } + } +} + void fe_serverlist_open (session *sess) { @@ -3097,22 +5748,21 @@ fe_serverlist_open (session *sess) gtk_window_present (GTK_WINDOW (serverlist_win)); return; } - - servlist_sess = sess; - - serverlist_win = servlist_open_networks (); - gtkutil_set_icon (serverlist_win); - - servlist_networks_populate (networks_tree, network_list); - - g_signal_connect (G_OBJECT (serverlist_win), "delete-event", - G_CALLBACK (servlist_delete_cb), 0); - g_signal_connect (G_OBJECT (serverlist_win), "configure-event", - G_CALLBACK (servlist_configure_cb), 0); - g_signal_connect (G_OBJECT (gtk_tree_view_get_selection (GTK_TREE_VIEW (networks_tree))), - "changed", G_CALLBACK (servlist_network_row_cb), NULL); - g_signal_connect (G_OBJECT (networks_tree), "key-press-event", - G_CALLBACK (servlist_net_keypress_cb), networks_tree); - - gtk_widget_show (serverlist_win); + if (!prefs.hex_gui_onboarding_disable && + (servlist_needs_onboarding () || prefs.hex_gui_onboarding_pending)) + { + if (servlist_needs_onboarding () && !prefs.hex_gui_onboarding_pending) + { + /* Persist a small pending marker immediately. The normal shutdown path + * can create servlist.conf even after a cancelled or failed first + * connection; without this marker a newcomer would lose the assistant + * on the next launch. */ + prefs.hex_gui_onboarding_pending = TRUE; + if (!save_config ()) + fe_message (_("Could not save the first-run assistant state."), FE_MSG_WARN); + } + if (servlist_open_onboarding (sess)) + return; + } + servlist_open_networks_window (sess); } diff --git a/src/fe-gtk/setup.c b/src/fe-gtk/setup.c index 0b736733..fe311647 100644 --- a/src/fe-gtk/setup.c +++ b/src/fe-gtk/setup.c @@ -645,6 +645,9 @@ static const char *const proxyuse[] = static const setting network_settings[] = { + {ST_HEADER, N_("Startup"), 0, 0, 0, 0}, + {ST_TOGGLE, N_("Disable first-run assistant"), P_OFFINTNL(hex_gui_onboarding_disable), 0, 0, 0}, + {ST_HEADER, N_("Your Address"), 0, 0, 0, 0}, {ST_ENTRY, N_("Bind to:"), P_OFFSETNL(hex_net_bind_host), 0, 0, sizeof prefs.hex_net_bind_host}, {ST_LABEL, N_("Only useful for computers with multiple addresses.")},