Compare commits

..

9 Commits

Author SHA1 Message Date
3f91cbc8b0 fix(ui): clearok(stdscr) in ui_resume to clear vim screen residue
All checks were successful
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Successful in 30s
CI Code / Code Coverage (pull_request) Successful in 3m22s
CI Code / Linux (debian) (pull_request) Successful in 4m47s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m59s
CI Code / Linux (arch) (pull_request) Successful in 6m58s
ncurses didn't know the editor clobbered the physical screen, so the
first refresh after vim exit was a no-op diff. Stale frame stayed
visible until the next keystroke triggered readline redisplay.
2026-05-31 14:54:18 +03:00
e4422ac7a1 fix(editor): Ctrl-Z aborts editor and returns to profanity
Some checks failed
CI Code / Check spelling (pull_request) Successful in 16s
CI Code / Check coding style (pull_request) Failing after 34s
CI Code / Code Coverage (pull_request) Successful in 3m1s
CI Code / Linux (debian) (pull_request) Successful in 4m43s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m55s
CI Code / Linux (arch) (pull_request) Successful in 6m55s
Replaces the SIGTSTP=SIG_IGN inheritance approach (was commit 8) with a
detect-and-abort design that matches user expectation: Ctrl-Z inside
the editor brings you back to profanity instead of leaving the session
suspended.

- src/tools/editor.c: SIGTSTP reset back to SIG_DFL in the child so
  vim's :stop / Ctrl-Z actually stop the editor (revert previous
  SIG_IGN inheritance).
- src/tools/editor.c: new editor_check_stopped() — non-reaping waitpid
  poll for WIFSTOPPED. Lets prof_run detect Ctrl-Z / :stop / gdb-attach
  on the editor child.
- src/tools/editor.c: editor_emergency_kill() now sends SIGCONT before
  SIGTERM so the abort works on a STOPPED child too.
- src/tools/editor.c: _editor_exit_cb handles WIFSIGNALED cleanly with
  "Editor session cancelled." instead of printing garbage via
  WEXITSTATUS on a signaled child.
- src/profanity.c: SIGTSTP / SIGCONT signal handlers set flags consumed
  by the main loop. Editor STOPPED -> editor_emergency_kill (returns to
  the chat with the pre-alt+c readline buffer intact). Plain Ctrl-Z
  with no editor -> mutt-style drop the process group to the shell.
  SIGCONT -> refresh curses with clearok + ui_resize. While editor is
  active, swallow suspend_requested (race: vim's kill(0,SIGTSTP) beats
  waitpid seeing STOPPED — next poll catches it).
- src/tools/editor.h: declare editor_check_stopped and editor_is_active.

Verbose comment blocks across the branch tightened to one to two lines.
2026-05-31 14:08:03 +03:00
bb0af8b7c8 refactor(ui): centralize physical paint through prof_doupdate()
All checks were successful
CI Code / Check spelling (pull_request) Successful in 18s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Linux (debian) (pull_request) Successful in 4m57s
CI Code / Linux (ubuntu) (pull_request) Successful in 5m0s
CI Code / Linux (arch) (pull_request) Successful in 5m46s
CI Code / Code Coverage (pull_request) Successful in 7m3s
Upstream's "guard terminal writes while suspended" fix and commit 2 of
this branch both work by sprinkling `if (!isendwin())` (or
`!is_suspended && !isendwin()`) guards around every doupdate() reachable
during a suspended session. That keeps isendwin() a valid signal but
turns the no-stray-write invariant into a whitelist: every new
doupdate / refresh / wrefresh added later in src/ has to remember the
guard, and the original bug class came from exactly such an oversight
in _inp_write.

Introduce a single primitive, prof_doupdate(), that flushes only when
not suspended (via the existing _ui_redraw_suspended() helper). Replace
every direct doupdate() in src/ui/inputwin.c (inp_get_line,
inp_get_password, _inp_write) and the trailing doupdate in ui_update
with the wrapper, collapsing their inline guards. ui_suspend() now
flushes via prof_doupdate() before flipping ui_suspended, so upstream's
pre-endwin flush is preserved.

The flag-based gate in ui_update / ui_resize / ui_redraw is kept as
belt-and-suspenders: even if a future raw doupdate slips in elsewhere,
ui_update has not been refreshing the virtual screen, so the blit finds
stale content rather than a fresh status-bar clock or chat line.
2026-05-29 08:54:30 +03:00
a51afa53f6 fix(editor): guard against re-entrant launch_editor
The async editor rewrite (g_child_watch + ui_suspend / ui_resume) lost
the editor_task.active check the old pthread implementation had at the
top of get_message_from_editor_async. With the guard gone, a second
launch_editor call while the first session is in flight would call
ui_suspend a second time, fork another editor, and register a second
child watch; whichever child exits first runs ui_resume and clears
suspension while the other still owns the terminal. Triggering this
needs a code path other than the main-loop alt+c / /editor (those can't
fire while the main loop is parked in the suspended iteration), so the
realistic source is a plugin firing launch_editor from a
g_main_context_iteration dispatch.

Restore an explicit editor_active static in src/tools/editor.c: check it
at the start of launch_editor (bail with a console error), set it just
before ui_suspend, and clear it on the fork-error path and at the start
of _editor_exit_cb. Keeps the same simple semantics the pre-merge code
had, without exposing a new ui.h API.
2026-05-29 08:49:57 +03:00
b1ffc750a2 fix(ui): iterate main context inside synchronous input loops
inp_get_line() / inp_get_password() loop `while (!x) { inp_readline();
ui_update(); }` with no g_main_context_iteration call. If such a prompt
is raised from a network handler during an editor session — realistic
triggers are TLS-cert verification on a reconnect (sv_ev_certfail ->
ui_get_line() in src/event/server_events.c) and PGP passphrase on an
incoming encrypted message (ui_ask_pgp_passphrase() in src/pgp/gpg.c) —
the main thread is parked in this loop. inp_readline returns NULL
(suspended), ui_update returns (ui_suspended), and the editor child
watch never dispatches because the main loop never reaches its own
g_main_context_iteration. is_suspended stays TRUE forever, the loop
never receives input, and the app deadlocks until killed externally.

Run the GLib iteration inside the loop body so the child watch can
dispatch, _editor_exit_cb fires, ui_resume clears suspension, and the
next inp_readline reads from the terminal — letting the user answer the
deferred prompt once the editor is closed.
2026-05-29 08:48:51 +03:00
2da2449777 fix(ui): release global lock around suspend-branch sleep in inp_readline
The normal path of inp_readline unlocks the global `lock` around
select(), giving worker threads (http_upload / http_download / ai_client
in src/tools and src/ai) a window to acquire it and update the UI.
The suspend branch (entered while an external editor is active) used to
g_usleep(100000) and return NULL while holding the lock, so the main
thread monopolised it for the whole editor session and every in-flight
transfer froze; on a long composition (the customer's main /editor use
case) this could be long enough for server-side timeouts to fire.

Mirror the normal-path unlock / sleep / lock pattern so the same window
exists during the editor session. Workers in the suspended state print
through wnoutrefresh paths only (no direct doupdate / refresh / wrefresh
in src outside core.c / inputwin.c, all of which are already guarded
against suspended writes), so opening the window introduces no new race.
2026-05-29 08:48:25 +03:00
394df6d391 fix(ui): add authoritative suspend flag gating redraws over the editor
All checks were successful
CI Code / Check spelling (pull_request) Successful in 19s
CI Code / Check coding style (pull_request) Successful in 32s
CI Code / Code Coverage (pull_request) Successful in 2m37s
CI Code / Linux (debian) (pull_request) Successful in 4m41s
CI Code / Linux (ubuntu) (pull_request) Successful in 4m50s
CI Code / Linux (arch) (pull_request) Successful in 5m39s
Builds on the upstream "guard terminal writes while suspended" fix. That
fix keeps isendwin() a valid "suspended" signal by guarding stray writes
(_inp_write, beep, flash) so the existing isendwin() checks in
ui_update/ui_resize/ui_redraw hold. That is correct but fail-open: any
future unguarded doupdate()/refresh() reachable during an editor session
clears isendwin() and the ~10Hz ui_update() repaints over the editor
again (the original bug class).

Track suspension explicitly with a ui_suspended flag set in
ui_suspend()/ui_resume() and gate the three redraw entry points on
ui_suspended || isendwin() via _ui_redraw_suspended(). The flag is set
exactly at the suspend/resume boundary, so the dominant repaint path
fails safe regardless of isendwin() flapping. isendwin() is kept in the
condition for the shutdown/closed-screen case.
2026-05-27 22:16:51 +03:00
b282b12497 fix(ui): guard password/TLS prompt redraw while suspended
inp_get_line()/inp_get_password() issued an unconditional doupdate()
before their input loop. If such a prompt is raised while an external
editor is active (e.g. a reconnect password or TLS-cert prompt arriving
via session_process_events), that doupdate() writes over the editor.
Guard it with is_suspended || isendwin(), matching the inp_readline()
guard, so it fails safe even if a stray write later clears isendwin().
This closes the one suspended-write path the upstream fix left open.
2026-05-27 22:16:51 +03:00
Michael Vetter
562bd28cff fix(ui): guard terminal writes while suspended
We forgot to guard `beep()` and `flush()`.

Ref: 36ec2b0ae1
Ref: dd76f9087f
Signed-off-by: Michael Vetter <jubalh@iodoru.org>
Fixes: https://github.com/profanity-im/profanity/issues/2162
(cherry picked from commit 5e4b5d313a9e055e039e2feb4efae58b6aaead78)
2026-05-27 21:59:27 +03:00
50 changed files with 333 additions and 1902 deletions

View File

@@ -335,7 +335,7 @@ tests_functionaltests_functionaltests_LDADD = -lcmocka -lstabber @FORKPTY_LIB@
# Parallel functional tests target (~3x faster than sequential)
# Usage: make check-functional-parallel
# To add more groups: increase FUNC_TEST_GROUPS and add group in functionaltests.c
FUNC_TEST_GROUPS = 1 2 3 4 5
FUNC_TEST_GROUPS = 1 2 3 4
check-functional-parallel: tests/functionaltests/functionaltests
@echo "Running functional tests in parallel ($(words $(FUNC_TEST_GROUPS)) groups)..."

View File

@@ -71,10 +71,6 @@ AC_ARG_ENABLE([omemo-qrcode],
[AS_HELP_STRING([--enable-omemo-qrcode], [enable ability to display omemo qr code])])
AC_ARG_ENABLE([coverage],
[AS_HELP_STRING([--enable-coverage], [enable code coverage analysis])])
AC_ARG_ENABLE([sanitizers],
[AS_HELP_STRING([--enable-sanitizers], [enable AddressSanitizer + UndefinedBehaviorSanitizer + unsigned-integer-overflow])])
AC_ARG_ENABLE([hardening],
[AS_HELP_STRING([--enable-hardening], [enable extra hardening warnings (e.g. -Wnull-dereference) that may produce false positives under -O2])])
m4_include([m4/ax_valgrind_check.m4])
AX_VALGRIND_DFLT([drd], [off])
@@ -399,7 +395,7 @@ AC_SUBST([FORKPTY_LIB])
## Default parameters
AM_CFLAGS="$AM_CFLAGS -Wall -Wextra -Wformat=2 -Wno-format-zero-length"
AM_CFLAGS="$AM_CFLAGS -Wno-deprecated-declarations -Wno-unused-parameter -Wno-missing-field-initializers -Wno-cast-function-type"
AM_CFLAGS="$AM_CFLAGS -Wno-deprecated-declarations -Wno-unused-parameter -Wno-missing-field-initializers -Wno-sign-compare -Wno-cast-function-type"
AM_CFLAGS="$AM_CFLAGS -Wpointer-arith"
AM_CFLAGS="$AM_CFLAGS -Wimplicit-function-declaration"
AM_CFLAGS="$AM_CFLAGS -Wundef"
@@ -410,8 +406,7 @@ AM_CFLAGS="$AM_CFLAGS -std=gnu99 -ggdb3"
# GCC-specific warnings (not supported by clang) — test each one
saved_CFLAGS="$CFLAGS"
for _flag in -Wlogical-op -Wduplicated-cond -Wduplicated-branches \
-Wstringop-overflow -Warray-bounds=2 -Walloc-zero \
-Wsign-compare; do
-Wstringop-overflow -Warray-bounds=2 -Walloc-zero; do
AC_MSG_CHECKING([whether $CC supports $_flag])
CFLAGS="$saved_CFLAGS $_flag -Werror"
AC_COMPILE_IFELSE([AC_LANG_PROGRAM()],
@@ -438,46 +433,11 @@ AS_IF([test "x$enable_coverage" = xyes],
AM_LDFLAGS="$AM_LDFLAGS --coverage"
AC_MSG_NOTICE([Code coverage analysis enabled])])
AS_IF([test "x$enable_sanitizers" = xyes],
[SAN_FLAGS="-fsanitize=address,undefined"
saved_CFLAGS="$CFLAGS"
CFLAGS="$saved_CFLAGS -fsanitize=unsigned-integer-overflow -Werror"
AC_MSG_CHECKING([whether $CC supports -fsanitize=unsigned-integer-overflow])
AC_COMPILE_IFELSE([AC_LANG_PROGRAM()],
[AC_MSG_RESULT([yes]); SAN_FLAGS="$SAN_FLAGS,unsigned-integer-overflow"],
[AC_MSG_RESULT([no])])
CFLAGS="$saved_CFLAGS"
AM_CFLAGS="$AM_CFLAGS $SAN_FLAGS -fno-sanitize-recover=all -fno-omit-frame-pointer"
AM_LDFLAGS="$AM_LDFLAGS -fsanitize=address,undefined"
AC_MSG_NOTICE([Sanitizers enabled: $SAN_FLAGS])])
AS_IF([test "x$enable_hardening" = xyes],
[saved_CFLAGS="$CFLAGS"
for _flag in -Wnull-dereference; do
AC_MSG_CHECKING([whether $CC supports $_flag])
CFLAGS="$saved_CFLAGS $_flag -Werror"
AC_COMPILE_IFELSE([AC_LANG_PROGRAM()],
[AC_MSG_RESULT([yes]); AM_CFLAGS="$AM_CFLAGS $_flag"],
[AC_MSG_RESULT([no])])
done
CFLAGS="$saved_CFLAGS"
AC_MSG_NOTICE([Extra hardening warnings enabled])])
# Skip _FORTIFY_SOURCE=2 if env already set one (Pikaur/makepkg) or under coverage -O0 (FORTIFY needs -O>0).
AS_IF([test "x$enable_coverage" != xyes],
[AS_CASE([" ${CPPFLAGS} ${CFLAGS} "],
[*_FORTIFY_SOURCE=*], [AC_MSG_NOTICE([_FORTIFY_SOURCE preset from environment, leaving as-is])],
[AM_CFLAGS="$AM_CFLAGS -D_FORTIFY_SOURCE=2"])])
AS_IF([test "x$PACKAGE_STATUS" = xdevelopment],
[AM_CFLAGS="$AM_CFLAGS -Wunused -Werror"])
AS_IF([test "x$PLATFORM" = xosx],
[AM_CFLAGS="$AM_CFLAGS -Qunused-arguments"])
# Treat glib/gio headers as system to keep their macros out of our -W* set.
glib_CFLAGS=$(echo "$glib_CFLAGS" | sed 's/-I/-isystem /g')
gio_CFLAGS=$(echo "$gio_CFLAGS" | sed 's/-I/-isystem /g')
AM_CFLAGS="$AM_CFLAGS $PTHREAD_CFLAGS $glib_CFLAGS $gio_CFLAGS $curl_CFLAGS ${SQLITE_CFLAGS}"
AM_CFLAGS="$AM_CFLAGS $libnotify_CFLAGS ${GTK_CFLAGS} $python_CFLAGS"
AM_CFLAGS="$AM_CFLAGS -DTHEMES_PATH=\"\\\"$THEMES_PATH\\\"\" -DICONS_PATH=\"\\\"$ICONS_PATH\\\"\" -DGLOBAL_PYTHON_PLUGINS_PATH=\"\\\"$GLOBAL_PYTHON_PLUGINS_PATH\\\"\" -DGLOBAL_C_PLUGINS_PATH=\"\\\"$GLOBAL_C_PLUGINS_PATH\\\"\""

File diff suppressed because it is too large Load Diff

View File

@@ -12,38 +12,19 @@ typedef struct ai_message_t
gchar* content; /* Message content */
} AIMessage;
/**
* @brief API flavour used to talk to a provider.
*
* AUTO tries /v1/responses first and falls back to /v1/chat/completions when
* the endpoint is missing (404/405/501) or rejects the payload shape
* (400/422). The working flavour is cached for the rest of the run as a
* first-attempt hint (the other flavour stays available as a fallback). The
* other two values pin the flavour explicitly.
*/
typedef enum {
AI_API_TYPE_AUTO = 0,
AI_API_TYPE_RESPONSES,
AI_API_TYPE_CHAT_COMPLETIONS,
} AIApiType;
/**
* @brief AI provider configuration.
*/
typedef struct ai_provider_t
{
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
gint api_type; /* Configured AIApiType (atomic access; persisted) */
gint resolved_api_type; /* AIApiType detected via 404 fallback (atomic access; runtime cache) */
gint api_epoch; /* Bumped on URL change (atomic); a stale epoch discards resolved_api_type stamps */
pthread_mutex_t settings_lock; /* Protects settings and api_url (read by request threads) */
GHashTable* settings; /* Extensible per-provider settings (e.g., temperature=0.7) */
GList* models; /* Cached models (gchar*) */
gboolean models_fresh; /* Whether models cache is current */
gint32 ref_count; /* Reference count (atomic) */
gchar* name; /* Provider name (e.g., "openai", "perplexity") */
gchar* api_url; /* API endpoint URL */
gchar* project_id; /* Optional project ID (for some providers) */
gchar* default_model; /* Default model for this provider */
GHashTable* settings; /* Extensible per-provider settings (e.g., tools=enabled, search=disabled) */
GList* models; /* Cached models (gchar*) */
gboolean models_fresh; /* Whether models cache is current */
gint32 ref_count; /* Reference count (atomic) */
} AIProvider;
/**
@@ -85,7 +66,7 @@ AIProvider* ai_get_provider(const gchar* name);
* Add or update a provider configuration.
* @param name The provider name
* @param api_url The API endpoint URL
* @return Borrowed AIProvider* owned by the provider table (do not unref)
* @return New AIProvider* (caller must unref when done)
*/
AIProvider* ai_add_provider(const gchar* name, const gchar* api_url);
@@ -159,45 +140,13 @@ void ai_set_provider_default_model(const gchar* provider_name, const gchar* mode
*/
const gchar* ai_get_provider_default_model(const gchar* provider_name);
/**
* Set the API type for a provider.
* @param provider_name The provider name
* @param api_type "responses", "chat-completions", or "auto" (detect by probing)
* @return TRUE on success, FALSE on unknown provider or invalid type string
*/
gboolean ai_set_provider_api_type(const gchar* provider_name, const gchar* api_type);
/**
* Get the configured API type for a provider.
* @param provider_name The provider name
* @return The configured AIApiType (AI_API_TYPE_AUTO for unknown providers)
*/
AIApiType ai_get_provider_api_type(const gchar* provider_name);
/**
* Parse an API type string ("auto", "responses", "chat-completions").
* @param str The string to parse
* @param type Output for the parsed value (untouched on failure)
* @return TRUE if str is a valid API type
*/
gboolean ai_api_type_from_string(const gchar* str, AIApiType* type);
/**
* String form of an AIApiType, e.g. for display.
* @param type The API type
* @return Static string, never NULL
*/
const gchar* ai_api_type_to_string(AIApiType type);
/**
* Set a custom setting for a provider.
* Reserved payload keys (model, messages, input, stream) are rejected.
* @param provider_name The provider name
* @param setting The setting key (e.g., "temperature")
* @param value The setting value, or NULL to remove
* @return TRUE on success, FALSE on unknown provider or reserved key
* @param setting The setting key (e.g., "tools", "search")
* @param value The setting value
*/
gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
void ai_set_provider_setting(const gchar* provider_name, const gchar* setting, const gchar* value);
/**
* Get a custom setting for a provider.
@@ -207,14 +156,6 @@ gboolean ai_set_provider_setting(const gchar* provider_name, const gchar* settin
*/
gchar* ai_get_provider_setting(const gchar* provider_name, const gchar* setting);
/**
* List the custom setting keys currently set on a provider.
* @param provider_name The provider name
* @return GList of gchar* (free with g_list_free_full(keys, g_free)),
* or NULL if the provider is unknown or has no settings
*/
GList* ai_get_provider_setting_keys(const gchar* provider_name);
/**
* Fetch available models from a provider's API.
* @param provider_name The provider name
@@ -243,25 +184,14 @@ gboolean ai_models_are_fresh(const gchar* provider_name);
void ai_parse_models_from_json(AIProvider* provider, const gchar* json);
/**
* Extract assistant content from a provider response body. Detects the
* flavour from the envelope: chat completions
* ({"choices":[{"message":{"content":"..."}}]}) or responses
* ({"output":[{"content":[{"type":"output_text","text":"..."}]}]}).
* Extract assistant content from an LLM response body. Tries Perplexity
* /v1/responses "text" first, falls back to OpenAI "content". Decodes
* the \" and \n escape sequences only.
* @param response_json The JSON body from the provider
* @return Newly allocated content string, or NULL if not found (caller frees)
*/
gchar* ai_parse_response(const gchar* response_json);
/**
* Extract assistant content trying the known request flavour's extractor
* first, then the other flavour, then a bare top-level "content" string.
* ai_parse_response is equivalent to passing AI_API_TYPE_AUTO.
* @param api_type The API flavour the response was requested with
* @param response_json The JSON body from the provider
* @return Newly allocated content string, or NULL if not found (caller frees)
*/
gchar* ai_parse_response_typed(AIApiType api_type, const gchar* response_json);
/**
* Extract human-readable error message from an API error envelope.
* Expected format: {"error":{"message":"...","type":"...","code":...}}

View File

@@ -47,7 +47,6 @@ static char* _notify_autocomplete(ProfWin* window, const char* const input, gboo
static char* _theme_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _spellcheck_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoaway_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoping_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _autoconnect_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _account_autocomplete(ProfWin* window, const char* const input, gboolean previous);
static char* _who_autocomplete(ProfWin* window, const char* const input, gboolean previous);
@@ -264,7 +263,6 @@ static Autocomplete avatar_ac;
static Autocomplete ai_subcommands_ac;
static Autocomplete ai_set_subcommands_ac;
static Autocomplete ai_set_custom_subcommands_ac;
static Autocomplete ai_api_types_ac;
static Autocomplete ai_remove_subcommands_ac;
static Autocomplete url_ac;
static Autocomplete executable_ac;
@@ -454,7 +452,6 @@ static Autocomplete* all_acs[] = {
&ai_subcommands_ac,
&ai_set_subcommands_ac,
&ai_set_custom_subcommands_ac,
&ai_api_types_ac,
&ai_remove_subcommands_ac,
&ai_models_ac
};
@@ -1059,7 +1056,6 @@ cmd_ac_init(void)
autocomplete_add(autoping_ac, "set");
autocomplete_add(autoping_ac, "timeout");
autocomplete_add(autoping_ac, "warning");
autocomplete_add(plugins_ac, "install");
autocomplete_add(plugins_ac, "update");
@@ -1185,12 +1181,12 @@ cmd_ac_init(void)
autocomplete_add_unsorted(ai_set_subcommands_ac, "provider", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "token", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "default-model", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "api-type", FALSE);
autocomplete_add_unsorted(ai_set_subcommands_ac, "custom", FALSE);
autocomplete_add_unsorted(ai_api_types_ac, "chat-completions", FALSE);
autocomplete_add_unsorted(ai_api_types_ac, "responses", FALSE);
autocomplete_add_unsorted(ai_api_types_ac, "auto", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "tools", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "search", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "memory", FALSE);
autocomplete_add_unsorted(ai_set_custom_subcommands_ac, "plugins", FALSE);
autocomplete_add_unsorted(ai_remove_subcommands_ac, "provider", FALSE);
@@ -1411,7 +1407,6 @@ cmd_ac_init(void)
g_hash_table_insert(ac_funcs, "/alias", _alias_autocomplete);
g_hash_table_insert(ac_funcs, "/autoaway", _autoaway_autocomplete);
g_hash_table_insert(ac_funcs, "/autoconnect", _autoconnect_autocomplete);
g_hash_table_insert(ac_funcs, "/autoping", _autoping_autocomplete);
g_hash_table_insert(ac_funcs, "/avatar", _avatar_autocomplete);
g_hash_table_insert(ac_funcs, "/ban", _ban_autocomplete);
g_hash_table_insert(ac_funcs, "/blocked", _blocked_autocomplete);
@@ -1939,6 +1934,7 @@ _cmd_ac_complete_params(ProfWin* window, const char* const input, gboolean previ
{ "/prefs", prefs_ac },
{ "/disco", disco_ac },
{ "/room", room_ac },
{ "/autoping", autoping_ac },
{ "/mainwin", winpos_ac },
{ "/inputwin", winpos_ac },
};
@@ -4268,20 +4264,6 @@ _executable_autocomplete(ProfWin* window, const char* const input, gboolean prev
return result;
}
static char*
_autoping_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
char* result = NULL;
result = autocomplete_param_with_ac(input, "/autoping", autoping_ac, TRUE, previous);
if (result) {
return result;
}
result = autocomplete_param_with_func(input, "/autoping warning", prefs_autocomplete_boolean_choice, previous, NULL);
return result;
}
static char*
_lastactivity_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
@@ -4534,67 +4516,17 @@ _force_encryption_autocomplete(ProfWin* window, const char* const input, gboolea
static char* _ai_provider_and_model_autocomplete(ProfWin* window, const char* const input,
gboolean previous, const char* cmd);
static char*
_ai_api_type_find(const char* const search_str, gboolean previous, void* context)
{
return autocomplete_complete(ai_api_types_ac, search_str, FALSE, previous);
}
static char*
_ai_custom_setting_find(const char* const search_str, gboolean previous, void* context)
{
return autocomplete_complete(ai_set_custom_subcommands_ac, search_str, FALSE, previous);
}
/**
* Autocomplete the setting name for /ai set custom <provider> <setting>.
* Offers a few common payload parameters merged with the keys already set
* on the provider, so existing settings can be retyped or overridden.
*/
static char*
_ai_custom_setting_autocomplete(const char* const input, gboolean previous)
{
if (!g_str_has_prefix(input, "/ai set custom ")) {
return NULL;
}
/* Quote-aware parse: args[2] is the provider, args[3] the setting being
* typed; skip the rebuild when the caret is not at the setting position */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 5, &parse_result);
if (!parse_result) {
return NULL;
}
guint num_args = g_strv_length(args);
if (num_args < 3 || num_args > 4 || !ai_get_provider(args[2])) {
return NULL;
}
/* Rebuild the completion list: common parameters + the provider's keys */
gchar* suggestions[] = { "temperature", "max_tokens", "top_p" };
GList* keys = ai_get_provider_setting_keys(args[2]);
char** items = g_new0(char*, ARRAY_SIZE(suggestions) + g_list_length(keys) + 1);
guint i = 0;
for (; i < ARRAY_SIZE(suggestions); i++) {
items[i] = suggestions[i];
}
for (GList* curr = keys; curr; curr = g_list_next(curr)) {
items[i++] = curr->data;
}
autocomplete_update(ai_set_custom_subcommands_ac, items);
g_free(items);
g_list_free_full(keys, g_free);
/* /ai(1) set(2) custom(3) <provider>(4) <setting>(5) */
return autocomplete_param_no_with_func(input, "/ai set custom", 5, _ai_custom_setting_find, previous, NULL);
}
static char*
_ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
{
char* result = NULL;
// /ai set provider <name> <url> - autocomplete provider names (for updating)
/* Parse once for reuse - /ai <subcommand> [<arg1>] [<arg2>] */
gboolean parse_result = FALSE;
auto_gcharv gchar** args = parse_args(input, 1, 4, &parse_result);
int num_args = g_strv_length(args);
/* Top-level /ai <subcommand> autocomplete (e.g., /ai s<tab> -> /ai set) */
result = autocomplete_param_with_func(input, "/ai set provider", ai_providers_find, previous, NULL);
if (result) {
return result;
@@ -4612,30 +4544,24 @@ _ai_autocomplete(ProfWin* window, const char* const input, gboolean previous)
return result;
}
// /ai set api-type <provider> <type> - autocomplete the type value
result = autocomplete_param_no_with_func(input, "/ai set api-type", 5, _ai_api_type_find, previous, NULL);
if (result) {
return result;
}
// /ai set api-type <provider> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set api-type", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> <value> - autocomplete provider names
result = autocomplete_param_with_func(input, "/ai set custom", ai_providers_find, previous, NULL);
if (result) {
return result;
}
// /ai set custom <provider> <setting> - autocomplete setting names
result = _ai_custom_setting_autocomplete(input, previous);
if (result) {
return result;
// /ai set custom <provider> <setting> - autocomplete settings
/* args[0]="set", args[1]="custom", args[2]=provider (if typed), args[3]=setting (if typed) */
if (num_args == 3 && g_strcmp0(args[1], "custom") == 0) {
/* /ai set custom <provider> - check if provider is valid */
if (ai_get_provider(args[2])) {
/* Valid provider, try settings autocomplete */
result = autocomplete_param_with_ac(input, "/ai set custom ", ai_set_custom_subcommands_ac, TRUE, previous);
if (result) {
return result;
}
}
}
// /ai set <subcommand> - autocomplete subcommands
result = autocomplete_param_with_ac(input, "/ai set", ai_set_subcommands_ac, TRUE, previous);
if (result) {

View File

@@ -1964,14 +1964,12 @@ static const struct cmd_t command_defs[] = {
CMD_TAG_CONNECTION)
CMD_SYN(
"/autoping set <seconds>",
"/autoping timeout <seconds>",
"/autoping warning on|off")
"/autoping timeout <seconds>")
CMD_DESC(
"Set the interval between sending ping requests to the server to ensure the connection is kept alive.")
CMD_ARGS(
{ "set <seconds>", "Number of seconds between sending pings, a value of 0 disables autoping." },
{ "timeout <seconds>", "Seconds to wait for autoping responses, after which the connection is considered broken." },
{ "warning on|off", "Enable or disable autoping availability warning." })
{ "timeout <seconds>", "Seconds to wait for autoping responses, after which the connection is considered broken." })
},
{ CMD_PREAMBLE("/ping",
@@ -2640,8 +2638,7 @@ static const struct cmd_t command_defs[] = {
CMD_DESC(
"Spawn external editor to edit message. "
"After editing the inputline may appear empty. Press enter to send the text anyways. "
"Use /executable to set your favourite editor. "
"Ctrl-Z in the editor aborts the session and returns to Profanity." )
"Use /executable to set your favourite editor." )
},
{ CMD_PREAMBLE("/correct-editor",
@@ -2815,7 +2812,6 @@ static const struct cmd_t command_defs[] = {
"/ai set provider <name> <url>",
"/ai set token <provider> <token>",
"/ai set default-model <provider> <model>",
"/ai set api-type <provider> chat-completions|responses|auto",
"/ai set custom <provider> <setting> <value>",
"/ai remove provider <name>",
"/ai providers",
@@ -2831,11 +2827,10 @@ static const struct cmd_t command_defs[] = {
"Chat history is maintained per session and not persisted locally.")
CMD_ARGS(
{ "", "Display current AI settings and configured providers" },
{ "set provider <name> <url>", "Add or update a provider with a custom API base URL (endpoint paths are appended automatically)" },
{ "set provider <name> <url>", "Add or update a provider with custom API endpoint" },
{ "set token <provider> <token>", "Set API token for a provider (e.g., openai, perplexity)" },
{ "set default-model <provider> <model>", "Set default model for a provider" },
{ "set api-type <provider> chat-completions|responses|auto", "Pin the provider API flavour; auto tries responses and falls back to chat completions when the endpoint is missing" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., temperature, max_tokens)" },
{ "set custom <provider> <setting> <value>", "Set provider-level setting (e.g., tools, search)" },
{ "remove provider <name>", "Remove a custom provider" },
{ "providers", "List configured providers with full details" },
{ "start <provider> [<model>]", "Start new AI chat (space-separated, uses defaults if omitted)" },
@@ -2846,10 +2841,9 @@ static const struct cmd_t command_defs[] = {
"/ai",
"/ai set token openai sk-xxx",
"/ai set token perplexity pplx-xxx",
"/ai set provider custom https://my-api.com/",
"/ai set provider custom https://my-api.com/v1/chat/completions",
"/ai set default-model perplexity sonar",
"/ai set api-type perplexity responses",
"/ai set custom perplexity temperature 0.7",
"/ai set custom perplexity tools enabled",
"/ai remove provider custom",
"/ai start",
"/ai start perplexity",

View File

@@ -6374,8 +6374,6 @@ cmd_autoping(ProfWin* window, const char* const command, gchar** args)
cons_bad_cmd_usage(command);
}
} else if (g_strcmp0(cmd, "warning") == 0) {
_cmd_set_boolean_preference(value, "Autoping availability warning", PREF_AUTOPING_WARNING);
} else {
cons_bad_cmd_usage(command);
}
@@ -10065,14 +10063,8 @@ cmd_vcard_remove(ProfWin* window, const char* const command, gchar** args)
}
if (args[1]) {
int index;
auto_gchar gchar* err_msg = NULL;
if (!strtoi_range(args[1], &index, 0, INT_MAX, &err_msg)) {
cons_show_error("%s", err_msg);
return TRUE;
}
vcard_user_remove_element((unsigned int)index);
cons_show("Removed element at index %d", index);
vcard_user_remove_element(atoi(args[1]));
cons_show("Removed element at index %d", atoi(args[1]));
vcardwin_update();
} else {
cons_bad_cmd_usage(command);
@@ -10703,19 +10695,6 @@ cmd_vcard_save(ProfWin* window, const char* const command, gchar** args)
return TRUE;
}
/* Show the provider's API flavour: pinned, or auto with the detected one */
static void
_cons_show_ai_api_type(AIProvider* provider)
{
AIApiType api_type = g_atomic_int_get(&provider->api_type);
AIApiType resolved = g_atomic_int_get(&provider->resolved_api_type);
if (api_type != AI_API_TYPE_AUTO) {
cons_show(" API type: %s", ai_api_type_to_string(api_type));
} else if (resolved != AI_API_TYPE_AUTO) {
cons_show(" API type: auto (detected: %s)", ai_api_type_to_string(resolved));
}
}
gboolean
cmd_ai(ProfWin* window, const char* const command, gchar** args)
{
@@ -10737,7 +10716,6 @@ cmd_ai(ProfWin* window, const char* const command, gchar** args)
if (default_model) {
cons_show(" Default model: %s", default_model);
}
_cons_show_ai_api_type(provider);
if (provider->models && g_list_length(provider->models) > 0) {
cons_show(" Cached models: %d", g_list_length(provider->models));
}
@@ -10795,30 +10773,14 @@ cmd_ai_set(ProfWin* window, const char* const command, gchar** args)
cons_show("Default model for provider '%s' set to: %s", args[2], args[3]);
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "api-type") == 0) {
// /ai set api-type <provider> chat-completions|responses|auto
if (g_strv_length(args) < 4) {
cons_bad_cmd_usage(command);
return TRUE;
}
if (ai_set_provider_api_type(args[2], args[3])) {
cons_show("API type for provider '%s' set to: %s", args[2], args[3]);
} else {
cons_show_error("Cannot set API type for provider '%s': unknown provider or invalid type (use chat-completions, responses, or auto).", args[2]);
}
cons_show("");
return TRUE;
} else if (g_strcmp0(args[1], "custom") == 0) {
// /ai set custom <provider> <setting> <value>
if (g_strv_length(args) < 5) {
cons_bad_cmd_usage(command);
return TRUE;
}
if (ai_set_provider_setting(args[2], args[3], args[4])) {
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
} else {
cons_show_error("Cannot set '%s' for provider '%s': unknown provider or reserved key (model, messages, input, stream).", args[3], args[2]);
}
ai_set_provider_setting(args[2], args[3], args[4]);
cons_show("Setting '%s' for provider '%s' set to: %s", args[3], args[2], args[4]);
cons_show("");
return TRUE;
}
@@ -11132,7 +11094,6 @@ cmd_ai_providers(ProfWin* window, const char* const command, gchar** args)
cons_show(" %s", provider->name);
cons_show(" URL: %s", provider->api_url);
cons_show(" Key: %s", key ? "configured" : "NOT configured");
_cons_show_ai_api_type(provider);
cons_show("");
}
g_list_free(providers);

View File

@@ -294,7 +294,7 @@ str_replace(const char* string, const char* substr,
wr = newstr;
while ((tok = strstr(head, substr))) {
size_t l = g_diff_to_gsize(tok, head);
size_t l = tok - head;
memcpy(wr, head, l);
wr += l;
memcpy(wr, replacement, len_replacement);
@@ -309,16 +309,6 @@ str_replace(const char* string, const char* substr,
return newstr;
}
gsize
g_diff_to_gsize(const void* end, const void* start)
{
if (end < start) {
log_error("g_diff_to_gsize: end < start (%p < %p)", end, start);
return 0;
}
return (gsize)((const char*)end - (const char*)start);
}
gboolean
strtoi_range(const char* str, int* saveptr, int min, int max, gchar** err_msg)
{
@@ -369,9 +359,9 @@ string_matches_one_of(const char* what, const char* is, gboolean is_can_be_null,
char errmsg[256] = { 0 };
size_t sz = 0;
int s = snprintf(errmsg, sizeof(errmsg) - sz, "%s must be one of:", what);
if (s < 0 || (size_t)s + sz >= sizeof(errmsg))
if (s < 0 || s + sz >= sizeof(errmsg))
return ret;
sz += (size_t)s;
sz += s;
cur = first;
va_start(ap, first);
@@ -385,12 +375,12 @@ string_matches_one_of(const char* what, const char* is, gboolean is_can_be_null,
errmsg[sz] = '\0';
s = snprintf(errmsg + sz, sizeof(errmsg) - sz, " or '%s'.", cur);
}
if (s < 0 || (size_t)s + sz >= sizeof(errmsg)) {
if (s < 0 || s + sz >= sizeof(errmsg)) {
log_debug("Error message too long or some other error occurred (%d).", s);
s = -1;
break;
}
sz += (size_t)s;
sz += s;
cur = next;
}
va_end(ap);
@@ -441,7 +431,7 @@ str_xml_sanitize(const char* const str)
return NULL;
}
GString* sanitized = g_string_new_len(NULL, (gssize)strlen(str));
GString* sanitized = g_string_new_len(NULL, strlen(str));
const char* curr = str;
while (*curr != '\0') {
@@ -700,7 +690,7 @@ get_file_paths_recursive(const char* path, GSList** contents)
}
gchar*
get_random_string(size_t length)
get_random_string(int length)
{
GRand* prng;
gchar* rand;
@@ -711,7 +701,7 @@ get_random_string(size_t length)
prng = g_rand_new();
for (size_t i = 0; i < length; i++) {
for (int i = 0; i < length; i++) {
rand[i] = alphabet[g_rand_int_range(prng, 0, endrange)];
}
g_rand_free(prng);
@@ -758,17 +748,17 @@ call_external(gchar** argv)
*
* This function constructs an argument vector (argv) based on the provided template string, replacing placeholders ("%u" and "%p") with the provided URL and filename, respectively.
*
* @param template_fmt The template string with placeholders.
* @param url The URL to replace "%u" (or NULL to skip).
* @param filename The filename to replace "%p" (or NULL to skip).
* @return The constructed argument vector (argv) as a null-terminated array of strings.
* @param template The template string with placeholders.
* @param url The URL to replace "%u" (or NULL to skip).
* @param filename The filename to replace "%p" (or NULL to skip).
* @return The constructed argument vector (argv) as a null-terminated array of strings.
*
* @note Remember to free the returned argument vector using `auto_gcharv` or `g_strfreev()`.
*/
gchar**
format_call_external_argv(const char* template_fmt, const char* url, const char* filename)
format_call_external_argv(const char* template, const char* url, const char* filename)
{
gchar** argv = g_strsplit(template_fmt, " ", 0);
gchar** argv = g_strsplit(template, " ", 0);
guint num_args = 0;
while (argv[num_args]) {
@@ -777,7 +767,7 @@ format_call_external_argv(const char* template_fmt, const char* url, const char*
argv[num_args] = g_strdup(url);
} else if (0 == g_strcmp0(argv[num_args], "%p") && filename != NULL) {
g_free(argv[num_args]);
argv[num_args] = g_strdup(filename);
argv[num_args] = strdup(filename);
}
num_args++;
}

View File

@@ -158,7 +158,6 @@ gboolean create_dir(const char* name);
gboolean copy_file(const char* const src, const char* const target, const gboolean overwrite_existing);
char* str_replace(const char* string, const char* substr, const char* replacement);
gboolean strtoi_range(const char* str, int* saveptr, int min, int max, char** err_msg);
gsize g_diff_to_gsize(const void* end, const void* start);
int utf8_display_len(const char* const str);
gchar* str_xml_sanitize(const char* const str);
@@ -179,10 +178,10 @@ int is_regular_file(const char* path);
int is_dir(const char* path);
void get_file_paths_recursive(const char* directory, GSList** contents);
gchar* get_random_string(size_t length);
gchar* get_random_string(int length);
gboolean call_external(gchar** argv);
gchar** format_call_external_argv(const char* template_fmt, const char* url, const char* filename);
gchar** format_call_external_argv(const char* template, const char* url, const char* filename);
gchar* unique_filename_from_url(const char* url, const char* path);
gchar* get_expanded_path(const char* path);

View File

@@ -43,8 +43,8 @@ _sanitize_account_name(const char* const name)
gchar* sanitized = g_strdup(name);
gchar* p = sanitized;
while (*p) {
// GKeyFile group header forbids these characters.
if (*p == '[' || *p == ']' || *p == '\n' || *p == '\r') {
// GKeyFile special characters: [ ] = # \n \r
if (*p == '[' || *p == ']' || *p == '=' || *p == '#' || *p == '\n' || *p == '\r') {
*p = '_';
}
p++;

View File

@@ -2002,30 +2002,6 @@ prefs_ai_get_default_model(const char* const provider)
return _ai_get_string(provider, "default_model");
}
/* --- API type --- */
gboolean
prefs_ai_set_api_type(const char* const provider, const char* const api_type)
{
if (!provider || !api_type || !prefs)
return FALSE;
if (api_type[0] == '\0')
return prefs_ai_remove_api_type(provider);
return _ai_set_string(provider, "api_type", api_type);
}
gboolean
prefs_ai_remove_api_type(const char* const provider)
{
return _ai_remove_string(provider, "api_type");
}
char*
prefs_ai_get_api_type(const char* const provider)
{
return _ai_get_string(provider, "api_type");
}
/* --- Provider URL --- */
gboolean
@@ -2314,7 +2290,6 @@ _get_group(preference_t pref)
case PREF_TRAY:
case PREF_TRAY_READ:
case PREF_ADV_NOTIFY_DISCO_OR_VERSION:
case PREF_AUTOPING_WARNING:
return PREF_GROUP_NOTIFICATIONS;
case PREF_DBLOG:
case PREF_CHLOG:
@@ -2671,8 +2646,6 @@ _get_key(preference_t pref)
return "enabled";
case PREF_SPELLCHECK_LANG:
return "lang";
case PREF_AUTOPING_WARNING:
return "autoping.warning";
default:
return NULL;
}
@@ -2726,7 +2699,6 @@ _get_default_boolean(preference_t pref)
case PREF_MOOD:
case PREF_STROPHE_SM_ENABLED:
case PREF_STROPHE_SM_RESEND:
case PREF_AUTOPING_WARNING:
return TRUE;
case PREF_SPELLCHECK_ENABLE:
case PREF_PGP_PUBKEY_AUTOIMPORT:

View File

@@ -169,7 +169,6 @@ typedef enum {
PREF_FORCE_ENCRYPTION_MODE,
PREF_SPELLCHECK_ENABLE,
PREF_SPELLCHECK_LANG,
PREF_AUTOPING_WARNING
} preference_t;
typedef struct prof_alias_t
@@ -368,11 +367,6 @@ gboolean prefs_ai_set_default_model(const char* const provider, const char* cons
gboolean prefs_ai_remove_default_model(const char* const provider);
char* prefs_ai_get_default_model(const char* const provider);
/* AI API type per provider ("responses" or "chat-completions") */
gboolean prefs_ai_set_api_type(const char* const provider, const char* const api_type);
gboolean prefs_ai_remove_api_type(const char* const provider);
char* prefs_ai_get_api_type(const char* const provider);
/* AI per-provider custom settings (e.g. tools, search) — persisted as
* setting.<name>=<value> inside [ai/<provider>]. Pass NULL value to remove. */
gboolean prefs_ai_set_setting(const char* const provider, const char* const setting, const char* const value);

View File

@@ -282,7 +282,7 @@ _export_one_contact(const char* contact)
gchar* key = _make_dedup_key(pl->stanza_id, pl->timestamp_str, pl->from_jid, pl->message);
g_hash_table_add(seen_keys, key);
}
int existing_count = (int)g_slist_length(existing);
int existing_count = g_slist_length(existing);
// 2. Query SQLite for this contact — get ALL messages via direct SQL
GSList* sqlite_lines = db_sqlite_get_all_chat(contact);
@@ -581,7 +581,7 @@ log_database_import_from_flatfile(const gchar* const contact_jid)
// Wrap in a transaction for atomicity and performance
int contact_imported = 0;
int contact_skipped = 0;
int total_lines = (int)g_slist_length(ff_lines);
int total_lines = g_slist_length(ff_lines);
db_sqlite_begin_transaction();
gboolean import_ok = TRUE;

View File

@@ -88,7 +88,7 @@ _ff_cache_line_ids(ff_contact_state_t* state, const char* line)
return;
// Split metadata on unescaped '|'
auto_gchar gchar* meta_str = g_strndup(bracket + 1, g_diff_to_gsize(close, bracket + 1));
auto_gchar gchar* meta_str = g_strndup(bracket + 1, close - bracket - 1);
char** parts = ff_split_meta(meta_str);
char* stanza_id = NULL;
@@ -111,9 +111,9 @@ _ff_cache_line_ids(ff_contact_state_t* state, const char* line)
const char* sender_start = close + 2;
const char* colonspace = ff_find_unescaped_colonspace(sender_start);
if (colonspace) {
const char* slash = memchr(sender_start, '/', g_diff_to_gsize(colonspace, sender_start));
const char* slash = memchr(sender_start, '/', colonspace - sender_start);
const char* jid_end = slash ? slash : colonspace;
from_jid = g_strndup(sender_start, g_diff_to_gsize(jid_end, sender_start));
from_jid = g_strndup(sender_start, jid_end - sender_start);
}
}
@@ -140,7 +140,7 @@ _ff_maybe_index_line(ff_contact_state_t* state, const char* buf, off_t pos)
if (!space)
return;
auto_gchar gchar* ts_str = g_strndup(buf, g_diff_to_gsize(space, buf));
auto_gchar gchar* ts_str = g_strndup(buf, space - buf);
GDateTime* dt = g_date_time_new_from_iso8601(ts_str, NULL);
if (!dt) {
log_warning("flatfile: unparsable timestamp in %s at offset %ld: %s",
@@ -771,7 +771,7 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
// For "last N messages": back up from read_to using index
if (!from_start) {
size_t margin_entries = (MESSAGES_TO_RETRIEVE * 3) / FF_INDEX_STEP + 2;
int margin_entries = (MESSAGES_TO_RETRIEVE * 3) / FF_INDEX_STEP + 2;
// Find index entry closest to (but before) read_to
size_t entry_idx = 0;
@@ -781,7 +781,7 @@ _flatfile_get_previous_chat(const gchar* const contact_barejid, const gchar* sta
entry_idx = i;
}
size_t start_entry = entry_idx > margin_entries
size_t start_entry = entry_idx > (size_t)margin_entries
? entry_idx - margin_entries
: 0;
off_t backed = state->entries[start_entry].byte_offset;

View File

@@ -553,7 +553,7 @@ ff_split_meta(const char* meta)
continue;
}
if (*p == '|' || *p == '\0') {
g_ptr_array_add(arr, g_strndup(start, g_diff_to_gsize(p, start)));
g_ptr_array_add(arr, g_strndup(start, p - start));
if (*p == '\0')
break;
start = p + 1;
@@ -710,7 +710,7 @@ ff_parse_line(const char* line)
if (bracket_start && first_space && first_space < bracket_start) {
// Standard format with metadata: {timestamp} [{meta}] {sender}: {msg}
result->timestamp_str = g_strndup(work, g_diff_to_gsize(first_space, work));
result->timestamp_str = g_strndup(work, first_space - work);
// Parse metadata section [...]
const char* bracket_end = ff_find_unescaped_char(bracket_start + 1, ']');
@@ -721,7 +721,7 @@ ff_parse_line(const char* line)
return NULL;
}
auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, g_diff_to_gsize(bracket_end, bracket_start + 1));
auto_gchar gchar* meta_content = g_strndup(bracket_start + 1, bracket_end - bracket_start - 1);
char** parts = ff_split_meta(meta_content);
if (parts) {
_ff_parse_meta_parts(parts, result);
@@ -736,12 +736,12 @@ ff_parse_line(const char* line)
// Find first *unescaped* ': ' which separates sender from message.
const char* colon = ff_find_unescaped_colonspace(after_meta);
if (colon) {
auto_gchar gchar* raw_sender = g_strndup(after_meta, g_diff_to_gsize(colon, after_meta));
auto_gchar gchar* raw_sender = g_strndup(after_meta, colon - after_meta);
// Split sender into jid/resource, then unescape resource
char* slash = strchr(raw_sender, '/');
if (slash) {
result->from_jid = g_strndup(raw_sender, g_diff_to_gsize(slash, raw_sender));
result->from_jid = g_strndup(raw_sender, slash - raw_sender);
result->from_resource = ff_unescape_sender_resource(slash + 1);
} else {
result->from_jid = g_strdup(raw_sender);
@@ -755,7 +755,7 @@ ff_parse_line(const char* line)
}
} else if (first_space) {
// Legacy/simple format without metadata: {timestamp} - {sender}: {msg}
result->timestamp_str = g_strndup(work, g_diff_to_gsize(first_space, work));
result->timestamp_str = g_strndup(work, first_space - work);
result->type = g_strdup("chat");
result->enc = g_strdup("none");
@@ -767,10 +767,10 @@ ff_parse_line(const char* line)
char* colon = strstr(rest, ": ");
if (colon) {
char* sender = g_strndup(rest, g_diff_to_gsize(colon, rest));
char* sender = g_strndup(rest, colon - rest);
char* slash = strchr(sender, '/');
if (slash) {
result->from_jid = g_strndup(sender, g_diff_to_gsize(slash, sender));
result->from_jid = g_strndup(sender, slash - sender);
result->from_resource = g_strdup(slash + 1);
} else {
result->from_jid = g_strdup(sender);

View File

@@ -10,8 +10,6 @@
#ifndef EVENT_COMMON_H
#define EVENT_COMMON_H
#include "glib.h"
void ev_disconnect_cleanup(void);
void ev_inc_connection_counter(void);
void ev_reset_connection_counter(void);

6
src/gitversion.h.in Normal file
View File

@@ -0,0 +1,6 @@
#ifndef PROF_GIT_BRANCH
#define PROF_GIT_BRANCH @PROF_GIT_BRANCH@
#endif
#ifndef PROF_GIT_REVISION
#define PROF_GIT_REVISION @PROF_GIT_REVISION@
#endif

View File

@@ -260,12 +260,7 @@ omemo_on_connect(ProfAccount* account)
return;
}
if (!_omemo_finalize_identity_load(account)) {
omemo_ctx.loaded = FALSE;
log_error("[OMEMO] failed to load OMEMO state from disk for %s", account->jid);
cons_show_error("OMEMO: could not load encryption state from disk; OMEMO will be unavailable this session.");
return;
}
_omemo_finalize_identity_load(account);
wins_omemo_trust_changed(NULL);
}
@@ -1283,12 +1278,8 @@ omemo_is_trusted_identity(const char* const jid, const char* const fingerprint)
.device_id = GPOINTER_TO_UINT(device_id),
};
size_t fingerprint_len = 0;
size_t fingerprint_len;
unsigned char* fingerprint_raw = _omemo_fingerprint_decode(fingerprint, &fingerprint_len);
if (!fingerprint_raw) {
log_error("[OMEMO] omemo_is_trusted_identity: failed to decode fingerprint for %s", jid);
return FALSE;
}
unsigned char djb_type[] = { '\x05' };
signal_buffer* buffer = signal_buffer_create(djb_type, 1);
buffer = signal_buffer_append(buffer, fingerprint_raw, fingerprint_len);
@@ -1472,10 +1463,6 @@ _omemo_fingerprint(ec_public_key* identity, gboolean formatted)
static unsigned char*
_omemo_fingerprint_decode(const char* const fingerprint, size_t* len)
{
if (!fingerprint) {
*len = 0;
return NULL;
}
unsigned char* output = malloc(strlen(fingerprint) / 2 + 1);
if (!output) {
*len = 0;
@@ -1542,11 +1529,6 @@ omemo_trust(const char* const jid, const char* const fingerprint_formatted)
};
unsigned char* fingerprint_raw = _omemo_fingerprint_decode(fingerprint_formatted, &len);
if (!fingerprint_raw) {
log_error("[OMEMO] omemo_trust: failed to decode fingerprint for %s", jid);
cons_show_error("Failed to trust device: could not decode fingerprint.");
return;
}
unsigned char djb_type[] = { '\x05' };
signal_buffer* buffer = signal_buffer_create(djb_type, 1);
buffer = signal_buffer_append(buffer, fingerprint_raw, len);
@@ -1564,10 +1546,8 @@ omemo_untrust(const char* const jid, const char* const fingerprint_formatted)
{
size_t len;
unsigned char* identity = _omemo_fingerprint_decode(fingerprint_formatted, &len);
if (!identity) {
log_error("[OMEMO] omemo_untrust: failed to decode fingerprint for %s", jid);
if (!identity)
return;
}
GHashTableIter iter;
gpointer key, value;

View File

@@ -278,8 +278,7 @@ otr_on_message_recv(const char* const barejid, const char* const resource, const
if (strstr(message, OTRL_MESSAGE_TAG_V2) && strstr(message, OTRL_MESSAGE_TAG_V1)) {
tag_length = 32;
}
// Move the rest of the message (with NUL) over the tag.
memmove(whitespace_base, whitespace_base + tag_length, strlen(whitespace_base + tag_length) + 1);
memmove(whitespace_base, whitespace_base + tag_length, tag_length);
char* otr_query_message = otr_start_query();
cons_show("OTR Whitespace pattern detected. Attempting to start OTR session…");
free(message_send_chat_otr(barejid, otr_query_message, FALSE, NULL));

View File

@@ -519,15 +519,13 @@ p_gpg_sign(const gchar* const str, const gchar* const fp)
}
gchar*
p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp, gchar** err)
p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp)
{
ProfPGPPubKeyId* pubkeyid = g_hash_table_lookup(pubkeys, barejid);
if (!pubkeyid) {
*err = g_strdup("No PGP key found for recipient");
return NULL;
}
if (!pubkeyid->id) {
*err = g_strdup("No key ID found for recipient");
return NULL;
}
@@ -540,7 +538,6 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_ctx_t ctx;
gpgme_error_t error = gpgme_new(&ctx);
if (error) {
*err = g_strdup_printf("Failed to create GPGME context: %s %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to create gpgme context. %s %s", gpgme_strsource(error), gpgme_strerror(error));
return NULL;
}
@@ -548,7 +545,6 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_t receiver_key;
error = gpgme_get_key(ctx, pubkeyid->id, &receiver_key, 0);
if (error || receiver_key == NULL) {
*err = g_strdup_printf("Failed to get receiver key '%s': %s %s", pubkeyid->id, gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to get receiver_key. %s %s", gpgme_strsource(error), gpgme_strerror(error));
gpgme_release(ctx);
return NULL;
@@ -558,10 +554,8 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_t sender_key = NULL;
error = gpgme_get_key(ctx, fp, &sender_key, 0);
if (error || sender_key == NULL) {
*err = g_strdup_printf("Failed to get sender key '%s': %s %s", fp, gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to get sender_key. %s %s", gpgme_strsource(error), gpgme_strerror(error));
gpgme_release(ctx);
gpgme_key_unref(receiver_key);
return NULL;
}
keys[1] = sender_key;
@@ -580,8 +574,7 @@ p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gcha
gpgme_key_unref(sender_key);
if (error) {
*err = g_strdup_printf("Encryption failed: (%s) %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to encrypt message. (%s) %s", gpgme_strsource(error), gpgme_strerror(error));
log_error("GPG: Failed to encrypt message. %s %s", gpgme_strsource(error), gpgme_strerror(error));
return NULL;
}

View File

@@ -10,8 +10,6 @@
#ifndef PGP_GPG_H
#define PGP_GPG_H
#include "glib.h"
typedef struct pgp_key_t
{
gchar* id;
@@ -42,7 +40,7 @@ gboolean p_gpg_available(const gchar* const barejid);
const gchar* p_gpg_libver(void);
gchar* p_gpg_sign(const gchar* const str, const gchar* const fp);
void p_gpg_verify(const gchar* const barejid, const gchar* const sign);
gchar* p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp, gchar** err);
gchar* p_gpg_encrypt(const gchar* const barejid, const gchar* const message, const gchar* const fp);
gchar* p_gpg_decrypt(const gchar* const cipher);
void p_gpg_free_decrypted(gchar* decrypted);
gchar* p_gpg_autocomplete_key(const gchar* const search_str, gboolean previous, void* context);

View File

@@ -20,6 +20,7 @@
#include <string.h>
#include <glib.h>
#include <glib-unix.h>
#include "profanity.h"
#include "common.h"
@@ -70,38 +71,12 @@ static void _connect_default(const char* const account);
pthread_mutex_t lock;
static gboolean force_quit = FALSE;
// Signal-driven flags consumed by the main loop. SIGTSTP and a STOPPED editor
// child → drop the process group to the shell (mutt-style). SIGCONT →
// refresh curses on return.
static volatile sig_atomic_t suspend_requested = FALSE;
static volatile sig_atomic_t cont_requested = FALSE;
static void
_handle_suspend_signals(void)
{
if (cont_requested) {
cont_requested = FALSE;
if (!editor_is_active()) {
clearok(stdscr, TRUE);
ui_resize();
}
}
if (editor_check_stopped()) {
log_info("[Editor] stopped; aborting back to profanity");
suspend_requested = FALSE;
editor_emergency_kill();
} else if (suspend_requested) {
suspend_requested = FALSE;
if (editor_is_active()) {
log_info("[Editor] SIGTSTP from vim group; deferring to next poll");
} else {
log_info("[Suspend] dropping to shell");
if (!isendwin()) {
endwin();
}
kill(0, SIGSTOP);
}
}
}
void
prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_file, gchar* theme_name, gchar** commands)
{
@@ -125,7 +100,36 @@ prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_f
int waittime = 0;
while (cont && !force_quit) {
log_stderr_handler();
_handle_suspend_signals();
// Resume from fg after SIGSTOP. Vim handles its own refresh.
if (cont_requested) {
cont_requested = FALSE;
if (!editor_is_active()) {
clearok(stdscr, TRUE);
ui_resize();
}
}
// Editor STOPPED → abort. Plain Ctrl-Z → drop to shell. While editor
// is active, swallow suspend_requested (race: vim's kill(0,SIGTSTP)
// beats waitpid seeing STOPPED) — next poll catches it.
if (editor_check_stopped()) {
log_info("[Editor] stopped; aborting back to profanity");
suspend_requested = FALSE;
editor_emergency_kill();
} else if (suspend_requested) {
suspend_requested = FALSE;
if (editor_is_active()) {
log_info("[Editor] SIGTSTP from vim group; deferring to next poll");
} else {
log_info("[Suspend] dropping to shell");
if (!isendwin()) {
endwin();
}
kill(0, SIGSTOP);
}
}
session_check_autoaway();
line = commands ? *commands : inp_readline();
@@ -138,7 +142,7 @@ prof_run(gchar* log_level, gchar* account_name, gchar* config_file, gchar* log_f
* so we can be sure there's runtime left after executing
* the last command.
*/
min_runtime += (unsigned int)waittime;
min_runtime += waittime;
} else {
log_error("%s", err_msg);
g_free(err_msg);
@@ -215,6 +219,15 @@ sigterm_handler(int sig)
force_quit = TRUE;
}
// SIGUSR1 escape for an alive-but-stuck editor (WUNTRACED can't catch it).
// Dispatched in the main loop. Trigger: `pkill -USR1 profanity`.
static gboolean
_emergency_resume_handler(gpointer data)
{
editor_emergency_kill();
return G_SOURCE_CONTINUE;
}
static void
sigtstp_handler(int sig)
{
@@ -239,6 +252,7 @@ _init(char* log_level, char* config_file, char* log_file, char* theme_name)
signal(SIGWINCH, ui_sigwinch_handler);
signal(SIGTERM, sigterm_handler);
signal(SIGHUP, sigterm_handler);
g_unix_signal_add(SIGUSR1, _emergency_resume_handler, NULL);
if (pthread_mutex_init(&lock, NULL) != 0) {
log_error("Mutex init failed");
exit(1);

View File

@@ -10,10 +10,8 @@
#ifndef BOOKMARK_IGNORE_H
#define BOOKMARK_IGNORE_H
#include "xmpp/xmpp.h"
void bookmark_ignore_on_connect(const char* const barejid);
void bookmark_ignore_on_disconnect();
void bookmark_ignore_on_disconnect(void);
gboolean bookmark_ignored(Bookmark* bookmark);
gchar** bookmark_ignore_list(gsize* len);
void bookmark_ignore_add(const char* const barejid);

View File

@@ -32,7 +32,9 @@ typedef struct EditorContext
void* user_data;
} EditorContext;
static gboolean editor_active = FALSE; // re-entrancy guard
// Re-entrancy guard (lost in the async-editor rewrite). Prevents a plugin
// from spawning a second editor that would fight the first for the terminal.
static gboolean editor_active = FALSE;
static pid_t editor_pid = 0;
static void

View File

@@ -16,7 +16,9 @@
gboolean launch_editor(gchar* initial_content, void (*callback)(gchar* content, void* data), void* user_data);
void editor_emergency_kill(void); // SIGCONT+SIGTERM the editor child
// SIGUSR1 escape: SIGTERM the editor child. Use when WUNTRACED can't catch
// it (alive but stuck). Wired in profanity.c.
void editor_emergency_kill(void);
// TRUE if editor child is STOPPED (Ctrl-Z / :stop / gdb-attach). Does not
// reap; g_child_watch keeps handling exit.

View File

@@ -399,6 +399,8 @@ chatwin_outgoing_msg(ProfChatWin* chatwin, const char* const message, const char
win_print_outgoing((ProfWin*)chatwin, enc_char, id, replace_id, display_message);
}
plugins_post_chat_message_display(myjid->barejid, myjid->resourcepart, display_message);
// Save last id and message for LMC
// Note: if the same message is to be corrected several times, the id of the original message is used in each case
// https://xmpp.org/extensions/xep-0308.html#rules
@@ -432,6 +434,7 @@ chatwin_outgoing_carbon(ProfChatWin* chatwin, ProfMessage* message)
win_print_outgoing(window, enc_char, message->id, message->replace_id, message->plain);
plugins_post_chat_message_display(message->from_jid->barejid, message->from_jid->resourcepart, message->plain);
message->plain = old_plain;
int num = wins_get_num(window);
@@ -558,6 +561,7 @@ _chatwin_history(ProfChatWin* chatwin, const char* const contact_barejid)
if (plugin_msg)
msg->plain = plugin_msg;
win_print_history((ProfWin*)chatwin, msg);
plugins_post_chat_message_display(msg->from_jid->barejid, msg->from_jid->resourcepart, msg->plain);
msg->plain = old_plain;
curr = g_slist_next(curr);
}
@@ -597,6 +601,7 @@ chatwin_db_history(ProfChatWin* chatwin, const gchar* start_time, const gchar* e
} else {
win_print_history((ProfWin*)chatwin, msg);
}
plugins_post_chat_message_display(msg->from_jid->barejid, msg->from_jid->resourcepart, msg->plain);
msg->plain = old_plain;
curr = g_slist_next(curr);
}

View File

@@ -1703,11 +1703,6 @@ cons_notify_setting(void)
else
cons_show("Subscription requests (/notify sub) : OFF");
if (prefs_get_boolean(PREF_AUTOPING_WARNING))
cons_show("Autoping warning (/autoping warning): ON");
else
cons_show("Autoping warning (/autoping warning): OFF");
gint remind_period = prefs_get_notify_remind();
if (remind_period == 0) {
cons_show("Reminder period (/notify remind) : OFF");
@@ -2008,26 +2003,21 @@ cons_autoping_setting(void)
{
gint autoping_interval = prefs_get_autoping();
if (autoping_interval == 0) {
cons_show("Autoping interval (/autoping) : OFF");
cons_show("Autoping interval (/autoping) : OFF");
} else if (autoping_interval == 1) {
cons_show("Autoping interval (/autoping) : 1 second");
cons_show("Autoping interval (/autoping) : 1 second");
} else {
cons_show("Autoping interval (/autoping) : %d seconds", autoping_interval);
cons_show("Autoping interval (/autoping) : %d seconds", autoping_interval);
}
gint autoping_timeout = prefs_get_autoping_timeout();
if (autoping_timeout == 0) {
cons_show("Autoping timeout (/autoping) : OFF");
cons_show("Autoping timeout (/autoping) : OFF");
} else if (autoping_timeout == 1) {
cons_show("Autoping timeout (/autoping) : 1 second");
cons_show("Autoping timeout (/autoping) : 1 second");
} else {
cons_show("Autoping timeout (/autoping) : %d seconds", autoping_timeout);
cons_show("Autoping timeout (/autoping) : %d seconds", autoping_timeout);
}
if (prefs_get_boolean(PREF_AUTOPING_WARNING))
cons_show("Autoping warning (/autoping warning): ON");
else
cons_show("Autoping warning (/autoping warning): OFF");
}
void

View File

@@ -320,7 +320,9 @@ inp_get_line(void)
while (!line) {
line = inp_readline();
ui_update();
while (g_main_context_iteration(NULL, FALSE)) // non-blocking; bounded by ready sources
// Let the editor child watch dispatch — otherwise a prompt raised
// mid-editor-session deadlocks here.
while (g_main_context_iteration(NULL, FALSE))
;
}
status_bar_clear_prompt();
@@ -350,7 +352,9 @@ inp_get_password(void)
while (!password) {
password = inp_readline();
ui_update();
while (g_main_context_iteration(NULL, FALSE)) // non-blocking; bounded by ready sources
// Let the editor child watch dispatch — otherwise a prompt raised
// mid-editor-session deadlocks here.
while (g_main_context_iteration(NULL, FALSE))
;
}
get_password = FALSE;

View File

@@ -10,8 +10,6 @@
#ifndef UI_TITLEBAR_H
#define UI_TITLEBAR_H
#include "glib.h"
void create_title_bar(void);
void free_title_bar(void);
void title_bar_update_virtual(void);

View File

@@ -43,7 +43,7 @@
#include "ai/ai_client.h"
static const int PAD_MIN_HEIGHT = 100;
static const int PAD_DEAD_SPACE_LIMIT = 2000; // reclaim once the pad holds this much dead space (cursor past live buffer); size-independent, so it never fires while scrolling
static const int PAD_THRESHOLD = 3000;
static gboolean _in_redraw = FALSE;
static void
@@ -56,10 +56,9 @@ _win_ensure_pad_capacity(ProfWin* window, WINDOW* win, int lines_needed)
int cur_height = getmaxy(win);
int cur_width = getmaxx(win);
if (lines_needed >= cur_height - 1) {
// redraw to reclaim dead pad space (cursor far past live buffer, e.g. a long
// append-only session); dead space never accrues while scrolling, only here
int dead = window ? getcury(win) - window->layout->buffer->lines : 0;
if (window && dead > PAD_DEAD_SPACE_LIMIT && !_in_redraw) {
// If we are getting too large, trigger a redraw to clean up old lines
// but only if we are not already in a redraw process.
if (window && cur_height >= PAD_THRESHOLD && !_in_redraw) {
win_redraw(window);
} else {
// resize to required lines + some buffer for next messages
@@ -68,20 +67,6 @@ _win_ensure_pad_capacity(ProfWin* window, WINDOW* win, int lines_needed)
}
}
}
// upper-bound estimate of rows a message body occupies (hard newlines + soft-wrap), to reserve pad space before printing so a tall message isn't clipped (a clipped height desyncs the scroll offset)
static int
_win_estimated_lines(WINDOW* win, const char* const message, int indent, int pad_indent)
{
int usable = MAX(1, getmaxx(win) - (indent + pad_indent));
int newlines = 0;
for (const char* p = message; *p; p++) {
if (*p == '\n') {
newlines++;
}
}
return newlines + utf8_display_len(message) / usable + 2;
}
static const char* LOADING_MESSAGE = "Loading older messages…";
static const char* CONS_WIN_TITLE = "CProof. Type /help for help information.";
static const char* XML_WIN_TITLE = "XML Console";
@@ -2016,7 +2001,7 @@ _win_print_internal(ProfWin* window, const char* show_char, int pad_indent, GDat
}
}
_win_ensure_pad_capacity(window, window->layout->win, getcury(window->layout->win) + _win_estimated_lines(window->layout->win, message + offset, indent, pad_indent));
_win_ensure_pad_capacity(window, window->layout->win, getcury(window->layout->win));
if (prefs_get_boolean(PREF_WRAP)) {
_win_print_wrapped(window->layout->win, message + offset, indent, pad_indent);
@@ -2187,10 +2172,9 @@ win_redraw(ProfWin* window)
unsigned int size = buffer_size(window->layout->buffer);
_in_redraw = TRUE;
// size the pad to fit the whole buffer (plus headroom) and erase it
// shrink pad back to minimum size and erase it
int cols = getmaxx(window->layout->win);
int needed = window->layout->buffer->lines + 100;
wresize(window->layout->win, MAX(needed, PAD_MIN_HEIGHT), cols);
wresize(window->layout->win, PAD_MIN_HEIGHT, cols);
werase(window->layout->win);
for (unsigned int i = 0; i < size; i++) {

View File

@@ -10,8 +10,6 @@
#ifndef XMPP_BLOCKING_H
#define XMPP_BLOCKING_H
#include <strophe.h>
void blocking_request(void);
int blocked_set_handler(xmpp_stanza_t* stanza);
int reporting_set_handler(xmpp_stanza_t* stanza);

View File

@@ -99,7 +99,6 @@ static void _disco_items_result_handler(xmpp_stanza_t* const stanza);
static void _last_activity_get_handler(xmpp_stanza_t* const stanza);
static void _version_get_handler(xmpp_stanza_t* const stanza);
static void _ping_get_handler(xmpp_stanza_t* const stanza);
static void _disco_autoping_warning_message(GHashTable* features);
static int _version_result_id_handler(xmpp_stanza_t* const stanza, void* const userdata);
static int _disco_info_response_id_handler(xmpp_stanza_t* const stanza, void* const userdata);
@@ -2430,12 +2429,6 @@ _disco_info_response_id_handler_onconnect(xmpp_stanza_t* const stanza, void* con
}
child = xmpp_stanza_get_next(child);
}
// Prevent repetitions by avoiding checks of disco items (from connection_set_disco_items)
const char* domain = connection_get_domain();
if (from && domain && g_ascii_strcasecmp(from, domain) == 0) {
_disco_autoping_warning_message(features);
}
}
connection_features_received(from);
@@ -2443,21 +2436,6 @@ _disco_info_response_id_handler_onconnect(xmpp_stanza_t* const stanza, void* con
return 0;
}
static void
_disco_autoping_warning_message(GHashTable* features)
{
gboolean server_supports_ping = g_hash_table_contains(features, "urn:xmpp:ping");
gboolean user_prefers_warning = prefs_get_boolean(PREF_AUTOPING_WARNING);
gboolean is_autoping_enabled = prefs_get_autoping() != 0;
if (!is_autoping_enabled && server_supports_ping && user_prefers_warning) {
cons_show("This server supports XEP-0199: XMPP Ping (better keepalive detection),\n"
"but autoping feature is currently disabled in settings.\n"
"Consider enabling it (e.g., `/autoping set 30`) for improved connection stability.\n"
"Use `/autoping warning off` to disable this message.");
}
}
static int
_http_upload_response_id_handler(xmpp_stanza_t* const stanza, void* const userdata)
{

View File

@@ -10,8 +10,6 @@
#ifndef XMPP_IQ_H
#define XMPP_IQ_H
#include <strophe.h>
typedef int (*ProfIqCallback)(xmpp_stanza_t* const stanza, void* const userdata);
typedef void (*ProfIqFreeCallback)(void* userdata);

View File

@@ -117,7 +117,7 @@ jid_is_valid(const gchar* const str)
// Localpart validation
if (at) {
size_t local_len = g_diff_to_gsize(at, str);
size_t local_len = at - str;
if (local_len == 0 || local_len > JID_MAX_PART_LEN) {
return FALSE;
}
@@ -135,7 +135,7 @@ jid_is_valid(const gchar* const str)
// Resourcepart validation if present
if (slash) {
domain_len = g_diff_to_gsize(slash, domain_start);
domain_len = slash - domain_start;
size_t resource_len = strlen(slash + 1);
if (resource_len > JID_MAX_PART_LEN) {
return FALSE;

View File

@@ -65,7 +65,6 @@ static void _handle_pubsub(xmpp_stanza_t* const stanza, xmpp_stanza_t* const eve
static gboolean _handle_form(xmpp_stanza_t* const stanza);
static gboolean _handle_jingle_message(xmpp_stanza_t* const stanza);
static gboolean _should_ignore_based_on_silence(xmpp_stanza_t* const stanza);
static gboolean _stanza_id_by_trusted(const char* by);
#ifdef HAVE_OMEMO
static void _receive_omemo(xmpp_stanza_t* const stanza, ProfMessage* message);
#endif
@@ -467,15 +466,12 @@ message_send_chat_pgp(const char* const barejid, const char* const msg, gboolean
auto_char char* jid = chat_session_get_jid(barejid);
char* id = connection_create_stanza_id();
ProfWin* current = wins_get_current();
xmpp_stanza_t* message = NULL;
#ifdef HAVE_LIBGPGME
ProfAccount* account = accounts_get_account(session_get_account_name());
if (account->pgp_keyid) {
auto_jid Jid* jidp = jid_create(jid);
auto_gchar gchar* err = NULL;
auto_gchar gchar* encrypted = p_gpg_encrypt(jidp->barejid, msg, account->pgp_keyid, &err);
auto_gchar gchar* encrypted = p_gpg_encrypt(jidp->barejid, msg, account->pgp_keyid);
if (encrypted) {
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, "This message is encrypted (XEP-0027).");
@@ -489,31 +485,18 @@ message_send_chat_pgp(const char* const barejid, const char* const msg, gboolean
xmpp_stanza_add_child(message, x);
xmpp_stanza_release(x);
} else {
if (current) {
win_println(current, THEME_ERROR, "-", "Unable to encrypt message for %s: %s.", jid, err);
}
log_error("Message not encrypted for %s: %s.", jid, err);
account_free(account);
free(id);
return NULL;
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
}
} else {
if (current) {
win_println(current, THEME_ERROR, "-", "No PGP key configured for this account.");
}
log_error("No PGP key configured, message not sent.");
account_free(account);
free(id);
return NULL;
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
}
account_free(account);
#else
if (current) {
win_println(current, THEME_ERROR, "-", "LibGPGME not available, message not sent.");
}
log_error("LibGPGME not available, message not sent.");
free(id);
return NULL;
// ?
message = xmpp_message_new(ctx, STANZA_TYPE_CHAT, jid, id);
xmpp_message_set_body(message, msg);
#endif
if (state) {
@@ -562,10 +545,7 @@ message_send_chat_ox(const char* const barejid, const char* const msg, gboolean
xmpp_stanza_to_text(signcrypt, &c, &s);
char* signcrypt_e = p_ox_gpg_signcrypt(account->jid, barejid, c);
if (signcrypt_e == NULL) {
ProfWin* current = wins_get_current();
if (current) {
win_println(current, THEME_ERROR, "-", "Unable to send OX message. Check log file and profanity-ox-setup man page for details.");
}
cons_show("Unable to send OX message. Check log file and profanity-ox-setup man page for details.");
log_error("Message not signcrypted.");
return NULL;
}
@@ -1108,7 +1088,7 @@ _handle_groupchat(xmpp_stanza_t* const stanza)
xmpp_stanza_t* stanzaidst = xmpp_stanza_get_child_by_name_and_ns(stanza, STANZA_NAME_STANZA_ID, STANZA_NS_STABLE_ID);
if (stanzaidst) {
const char* by = xmpp_stanza_get_attribute(stanzaidst, "by");
if (by && g_strcmp0(by, from_jid->barejid) == 0 && _stanza_id_by_trusted(by)) {
if (by && (g_strcmp0(by, from_jid->barejid) == 0)) {
stanzaid = (char*)xmpp_stanza_get_attribute(stanzaidst, STANZA_ATTR_ID);
if (stanzaid) {
message->stanzaid = strdup(stanzaid);
@@ -1417,7 +1397,7 @@ _handle_chat(xmpp_stanza_t* const stanza, gboolean is_mam, gboolean is_carbon, c
xmpp_stanza_t* stanzaidst = xmpp_stanza_get_child_by_name_and_ns(stanza, STANZA_NAME_STANZA_ID, STANZA_NS_STABLE_ID);
if (stanzaidst) {
const char* by = xmpp_stanza_get_attribute(stanzaidst, "by");
if (by && equals_our_barejid(by) && _stanza_id_by_trusted(by)) {
if (by && equals_our_barejid(by)) {
stanzaid = (char*)xmpp_stanza_get_attribute(stanzaidst, STANZA_ATTR_ID);
if (stanzaid) {
message->stanzaid = strdup(stanzaid);
@@ -1755,22 +1735,3 @@ _should_ignore_based_on_silence(xmpp_stanza_t* const stanza)
}
return FALSE;
}
// XEP-0359 §6 disco-gate; falls back to domain since that's what disco is run against.
static gboolean
_stanza_id_by_trusted(const char* by)
{
if (!by) {
return FALSE;
}
GHashTable* features = connection_get_features(by);
if (features && g_hash_table_contains(features, STANZA_NS_STABLE_ID)) {
return TRUE;
}
const char* at = strchr(by, '@');
if (!at) {
return FALSE;
}
features = connection_get_features(at + 1);
return features && g_hash_table_contains(features, STANZA_NS_STABLE_ID);
}

View File

@@ -707,7 +707,7 @@ muc_autocomplete(ProfWin* window, const char* const input, gboolean previous)
} else {
search_str = last_space + 1;
if (!chat_room->autocomplete_prefix) {
chat_room->autocomplete_prefix = g_strndup(input, g_diff_to_gsize(search_str, input));
chat_room->autocomplete_prefix = g_strndup(input, search_str - input);
}
}

View File

@@ -7,8 +7,6 @@
* SPDX-License-Identifier: GPL-3.0-or-later WITH OpenSSL-exception
*/
#include "glib.h"
/*!
* \page OX OX Implementation
*

View File

@@ -621,20 +621,15 @@ _available_handler(xmpp_stanza_t* const stanza)
}
if (g_strcmp0(xmpp_presence->jid->barejid, my_jid->barejid) == 0) {
// Copy what we read before connection_add_available_resource() takes ownership.
Resource* resource_for_roster = resource_copy(resource);
auto_gchar gchar* resource_name = g_strdup(resource->name);
auto_gchar gchar* resource_status = resource->status ? g_strdup(resource->status) : NULL;
int resource_priority = resource->priority;
resource_presence_t resource_presence = resource->presence;
connection_add_available_resource(resource);
Resource* resource_for_roster = resource_copy(resource);
sv_ev_contact_online(xmpp_presence->jid->barejid, resource_for_roster, xmpp_presence->last_activity, pgpsig);
const char* account_name = session_get_account_name();
int max_sessions = accounts_get_max_sessions(account_name);
if (max_sessions > 0) {
auto_gchar gchar* cur_resource = accounts_get_resource(account_name);
int res_count = connection_count_available_resources();
if (res_count > max_sessions && g_strcmp0(cur_resource, resource_name)) {
if (res_count > max_sessions && g_strcmp0(cur_resource, resource->name)) {
ProfWin* console = wins_get_console();
ProfWin* current_window = wins_get_current();
auto_gchar gchar* message = g_strdup_printf("Max sessions alarm! (%d/%d devices in use)", res_count, max_sessions);
@@ -644,14 +639,14 @@ _available_handler(xmpp_stanza_t* const stanza)
}
notify(message, 10000, "Security alert");
const char* resource_presence_str = string_from_resource_presence(resource_presence);
win_print(console, THEME_DEFAULT, "|", "New device info: \n %s (%d), %s", resource_name, resource_priority, resource_presence_str);
const char* resource_presence = string_from_resource_presence(resource->presence);
win_print(console, THEME_DEFAULT, "|", "New device info: \n %s (%d), %s", resource->name, resource->priority, resource_presence);
if (resource_status) {
win_append(console, THEME_DEFAULT, ", \"%s\"", resource_status);
if (resource->status) {
win_append(console, THEME_DEFAULT, ", \"%s\"", resource->status);
}
win_appendln(console, THEME_DEFAULT, "");
auto_jid Jid* jidp = jid_create_from_bare_and_resource(my_jid->barejid, resource_name);
auto_jid Jid* jidp = jid_create_from_bare_and_resource(my_jid->barejid, resource->name);
EntityCapabilities* caps = caps_lookup(jidp->fulljid);
if (caps) {

View File

@@ -10,9 +10,6 @@
#ifndef XMPP_ROSTER_H
#define XMPP_ROSTER_H
#include "glib.h"
#include <strophe.h>
void roster_request(void);
void roster_set_handler(xmpp_stanza_t* const stanza);
void roster_result_handler(xmpp_stanza_t* const stanza);

View File

@@ -12,7 +12,6 @@
#include "ui/win_types.h"
#include "xmpp/vcard.h"
#include <strophe.h>
vCard* vcard_new();
void vcard_free(vCard* vcard);

View File

@@ -150,14 +150,6 @@ main(int argc, char* argv[])
PROF_FUNC_TEST(autoping_sends_ping_after_interval),
PROF_FUNC_TEST(autoping_server_not_supporting_ping),
/* Autoping availability warning - negative cases wait ~3s */
PROF_FUNC_TEST(autoping_warning_shown_when_disabled),
PROF_FUNC_TEST(autoping_warning_not_shown_when_server_unsupported),
PROF_FUNC_TEST(autoping_warning_not_shown_when_autoping_enabled),
PROF_FUNC_TEST(autoping_warning_not_shown_when_user_disabled),
PROF_FUNC_TEST(autoping_warning_not_shown_for_user_jid),
PROF_FUNC_TEST(autoping_warning_not_shown_for_subdomain_service),
/* Service Discovery - XEP-0030 */
PROF_FUNC_TEST(disco_info_shows_identity),
PROF_FUNC_TEST(disco_info_shows_features),
@@ -261,10 +253,6 @@ main(int argc, char* argv[])
PROF_FUNC_TEST(message_receive_console),
PROF_FUNC_TEST(message_receive_chatwin),
/* XEP-0359 disco gate for stanza-id trust */
PROF_FUNC_TEST(stanza_id_dedup_fires_when_server_announces_sid0),
PROF_FUNC_TEST(stanza_id_not_trusted_when_server_does_not_announce_sid0),
#ifdef HAVE_SQLITE
/* MUC (groupchat) database — export/import of type="muc" messages */
PROF_FUNC_TEST(muc_export_sqlite_to_flatfile),

View File

@@ -119,9 +119,10 @@ _create_dir(const char* name)
gboolean
_mkdir_recursive(const char* dir)
{
int i;
gboolean result = TRUE;
for (size_t i = 1; i <= strlen(dir); i++) {
for (i = 1; i <= strlen(dir); i++) {
if (dir[i] == '/' || dir[i] == '\0') {
gchar* next_dir = g_strndup(dir, i);
result = _create_dir(next_dir);

View File

@@ -55,11 +55,11 @@ ai_no_args_shows_help(void** state)
void
ai_providers_lists_defaults(void** state)
{
/* `/ai providers` (no "list") shows the configured list with URLs. */
/* `/ai providers` (no "list") shows the built-in list with URLs. */
prof_input("/ai providers");
prof_timeout(5);
assert_true(prof_output_exact("Configured providers:"));
assert_true(prof_output_exact("Available AI providers:"));
/* At least one URL line is rendered — exact name agnostic. */
assert_true(prof_output_regex("https?://"));
prof_timeout_reset();

View File

@@ -112,144 +112,3 @@ autoping_server_not_supporting_ping(void** state)
// Should show error about ping not being supported
assert_true(prof_output_regex("Server ping not supported"));
}
/*
* Autoping availability warning.
*
* The warning fires from the on-connect disco handler, so the three inputs
* (server ping support, autoping interval, warning preference) must all be
* set before prof_connect().
*/
/* Stable substring of the warning text emitted by iq.c. */
#define AUTOPING_WARNING_MATCH "This server supports XEP-0199"
static void
_stub_server_disco(gboolean with_ping)
{
if (with_ping) {
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='Stabber'/>"
"<feature var='urn:xmpp:ping'/>"
"</query>"
"</iq>"
);
} else {
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='Stabber'/>"
"</query>"
"</iq>"
);
}
}
void
autoping_warning_shown_when_disabled(void** state)
{
// All conditions met: server supports ping, autoping off, warning on (default).
_stub_server_disco(TRUE);
prof_input("/autoping set 0");
assert_true(prof_output_exact("Autoping disabled."));
prof_connect();
assert_true(prof_output_regex(AUTOPING_WARNING_MATCH));
}
void
autoping_warning_not_shown_when_server_unsupported(void** state)
{
// Server does not advertise urn:xmpp:ping -> no warning even with autoping off.
_stub_server_disco(FALSE);
prof_input("/autoping set 0");
assert_true(prof_output_exact("Autoping disabled."));
prof_connect();
prof_timeout(NEGATIVE_ASSERT_TIMEOUT);
assert_false(prof_output_regex(AUTOPING_WARNING_MATCH));
}
void
autoping_warning_not_shown_when_autoping_enabled(void** state)
{
// Warning is suppressed while autoping is enabled.
_stub_server_disco(TRUE);
prof_input("/autoping set 30");
assert_true(prof_output_exact("Autoping interval set to 30 seconds."));
prof_connect();
prof_timeout(NEGATIVE_ASSERT_TIMEOUT);
assert_false(prof_output_regex(AUTOPING_WARNING_MATCH));
}
void
autoping_warning_not_shown_when_user_disabled(void** state)
{
// User opted out -> no warning.
_stub_server_disco(TRUE);
prof_input("/autoping set 0");
assert_true(prof_output_exact("Autoping disabled."));
prof_input("/autoping warning off");
assert_true(prof_output_exact("Autoping availability warning disabled."));
prof_connect();
prof_timeout(NEGATIVE_ASSERT_TIMEOUT);
assert_false(prof_output_regex(AUTOPING_WARNING_MATCH));
}
void
autoping_warning_not_shown_for_user_jid(void** state)
{
// disco#info from a user JID (with '@') should not trigger the warning,
// only server responses (no '@') should.
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='buddy1@localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='person' type='chat' name='Buddy1'/>"
"<feature var='urn:xmpp:ping'/>"
"</query>"
"</iq>"
);
prof_input("/autoping set 0");
assert_true(prof_output_exact("Autoping disabled."));
prof_connect();
prof_timeout(NEGATIVE_ASSERT_TIMEOUT);
assert_false(prof_output_regex(AUTOPING_WARNING_MATCH));
}
void
autoping_warning_not_shown_for_subdomain_service(void** state)
{
// disco#info from a subdomain service should not trigger the warning, only the server's own domain should
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq to='stabber@localhost/profanity' type='result' from='conf.localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='conference' type='text' name='Conference Service'/>"
"<feature var='urn:xmpp:ping'/>"
"</query>"
"</iq>"
);
prof_input("/autoping set 0");
assert_true(prof_output_exact("Autoping disabled."));
prof_connect();
prof_timeout(NEGATIVE_ASSERT_TIMEOUT);
assert_false(prof_output_regex(AUTOPING_WARNING_MATCH));
}

View File

@@ -4,9 +4,3 @@ void autoping_timeout_set(void** state);
void autoping_timeout_zero_disables(void** state);
void autoping_sends_ping_after_interval(void** state);
void autoping_server_not_supporting_ping(void** state);
void autoping_warning_shown_when_disabled(void** state);
void autoping_warning_not_shown_when_server_unsupported(void** state);
void autoping_warning_not_shown_when_autoping_enabled(void** state);
void autoping_warning_not_shown_when_user_disabled(void** state);
void autoping_warning_not_shown_for_user_jid(void** state);
void autoping_warning_not_shown_for_subdomain_service(void** state);

View File

@@ -56,70 +56,3 @@ message_receive_chatwin(void **state)
assert_true(prof_output_regex("someuser@chatserv.org/laptop: .+How are you?"));
}
// XEP-0359 disco gate: server announces urn:xmpp:sid:0 -> stanza-id trusted -> replay flagged as duplicate.
void
stanza_id_dedup_fires_when_server_announces_sid0(void **state)
{
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq type='result' to='stabber@localhost/profanity' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='TestServer'/>"
"<feature var='urn:xmpp:sid:0'/>"
"</query>"
"</iq>"
);
prof_connect();
stbbr_send(
"<message id='m-trust-1' to='stabber@localhost' from='someuser@chatserv.org/laptop' type='chat'>"
"<body>first</body>"
"<stanza-id xmlns='urn:xmpp:sid:0' by='stabber@localhost' id='archive-id-42'/>"
"</message>"
);
assert_true(prof_output_exact("<< chat message: someuser@chatserv.org/laptop (win 2)"));
stbbr_send(
"<message id='m-trust-2' to='stabber@localhost' from='someuser@chatserv.org/laptop' type='chat'>"
"<body>replay</body>"
"<stanza-id xmlns='urn:xmpp:sid:0' by='stabber@localhost' id='archive-id-42'/>"
"</message>"
);
assert_true(prof_output_exact("Got a message with duplicate (server-generated) stanza-id from someuser@chatserv.org/laptop."));
}
// XEP-0359 disco gate: server does NOT announce urn:xmpp:sid:0 -> stanza-id untrusted -> no replay error.
void
stanza_id_not_trusted_when_server_does_not_announce_sid0(void **state)
{
stbbr_for_query("http://jabber.org/protocol/disco#info",
"<iq type='result' to='stabber@localhost/profanity' from='localhost'>"
"<query xmlns='http://jabber.org/protocol/disco#info'>"
"<identity category='server' type='im' name='TestServer'/>"
"<feature var='urn:xmpp:ping'/>"
"</query>"
"</iq>"
);
prof_connect();
stbbr_send(
"<message id='m-untrust-1' to='stabber@localhost' from='someuser@chatserv.org/laptop' type='chat'>"
"<body>first</body>"
"<stanza-id xmlns='urn:xmpp:sid:0' by='stabber@localhost' id='archive-id-77'/>"
"</message>"
);
assert_true(prof_output_exact("<< chat message: someuser@chatserv.org/laptop (win 2)"));
stbbr_send(
"<message id='m-untrust-2' to='stabber@localhost' from='someuser@chatserv.org/laptop' type='chat'>"
"<body>replay</body>"
"<stanza-id xmlns='urn:xmpp:sid:0' by='stabber@localhost' id='archive-id-77'/>"
"</message>"
);
prof_timeout(2);
assert_false(prof_output_exact("Got a message with duplicate (server-generated) stanza-id"));
prof_timeout_reset();
}

View File

@@ -1,5 +1,3 @@
void message_send(void **state);
void message_receive_console(void **state);
void message_receive_chatwin(void **state);
void stanza_id_dedup_fires_when_server_announces_sid0(void **state);
void stanza_id_not_trusted_when_server_does_not_announce_sid0(void **state);

View File

@@ -62,10 +62,12 @@ test_ai_add_provider(void** state)
assert_string_equal("custom", provider->name);
assert_string_equal("https://custom.api.com/v1", provider->api_url);
/* Update existing provider (borrowed pointer, same as the create path) */
/* Update existing provider (returns ref; caller owns it) */
AIProvider* updated = ai_add_provider("custom", "https://new.api.com/v1");
assert_non_null(updated);
assert_string_equal("https://new.api.com/v1", updated->api_url);
ai_provider_unref(updated);
}
void
@@ -327,10 +329,11 @@ test_ai_add_provider_update_existing(void** state)
assert_non_null(provider);
assert_string_equal("https://first.api.com/v1", provider->api_url);
/* Update the same provider (borrowed pointer) */
/* Update the same provider (returns ref) */
provider = ai_add_provider("custom", "https://second.api.com/v1");
assert_non_null(provider);
assert_string_equal("https://second.api.com/v1", provider->api_url);
ai_provider_unref(provider);
}
void
@@ -611,112 +614,6 @@ test_ai_set_provider_setting(void** state)
assert_null(ai_get_provider_setting("p_set_settings", "nonexistent_setting"));
}
void
test_ai_set_provider_setting_reserved_key(void** state)
{
ai_add_provider("p_reserved_keys", "https://example.test/rk/");
/* Reserved payload keys are rejected (would duplicate fixed fields) */
assert_false(ai_set_provider_setting("p_reserved_keys", "model", "x"));
assert_false(ai_set_provider_setting("p_reserved_keys", "messages", "[]"));
assert_false(ai_set_provider_setting("p_reserved_keys", "input", "[]"));
assert_false(ai_set_provider_setting("p_reserved_keys", "stream", "true"));
assert_null(ai_get_provider_setting("p_reserved_keys", "model"));
/* "store" is a legitimate chat-completions parameter and overrides the
* responses-flavour "store":false default, so it stays settable */
assert_true(ai_set_provider_setting("p_reserved_keys", "store", "true"));
auto_gchar gchar* store = ai_get_provider_setting("p_reserved_keys", "store");
assert_non_null(store);
assert_string_equal("true", store);
/* Ordinary keys still work */
assert_true(ai_set_provider_setting("p_reserved_keys", "temperature", "0.7"));
auto_gchar gchar* temp = ai_get_provider_setting("p_reserved_keys", "temperature");
assert_non_null(temp);
assert_string_equal("0.7", temp);
}
void
test_ai_get_provider_setting_keys(void** state)
{
ai_add_provider("p_setting_keys", "https://example.test/skeys/");
/* No settings yet, unknown provider, NULL — all yield NULL */
assert_null(ai_get_provider_setting_keys("p_setting_keys"));
assert_null(ai_get_provider_setting_keys("nonexistent"));
assert_null(ai_get_provider_setting_keys(NULL));
ai_set_provider_setting("p_setting_keys", "temperature", "0.7");
ai_set_provider_setting("p_setting_keys", "reasoning_effort", "high");
/* Both keys listed (order is hash-defined, assert membership) */
GList* keys = ai_get_provider_setting_keys("p_setting_keys");
assert_int_equal(2, g_list_length(keys));
assert_non_null(g_list_find_custom(keys, "temperature", (GCompareFunc)g_strcmp0));
assert_non_null(g_list_find_custom(keys, "reasoning_effort", (GCompareFunc)g_strcmp0));
g_list_free_full(keys, g_free);
/* Removing a setting removes its key */
ai_set_provider_setting("p_setting_keys", "temperature", NULL);
keys = ai_get_provider_setting_keys("p_setting_keys");
assert_int_equal(1, g_list_length(keys));
assert_string_equal("reasoning_effort", keys->data);
g_list_free_full(keys, g_free);
}
void
test_ai_set_provider_api_type(void** state)
{
ai_add_provider("p_api_type", "https://example.test/at/");
/* Default is auto */
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
/* Pin each flavour explicitly */
assert_true(ai_set_provider_api_type("p_api_type", "chat-completions"));
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_api_type"));
assert_true(ai_set_provider_api_type("p_api_type", "responses"));
assert_int_equal(AI_API_TYPE_RESPONSES, ai_get_provider_api_type("p_api_type"));
/* auto resets to detection */
assert_true(ai_set_provider_api_type("p_api_type", "auto"));
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_api_type"));
/* Invalid values and unknown providers are rejected */
assert_false(ai_set_provider_api_type("p_api_type", "v2"));
assert_false(ai_set_provider_api_type("p_api_type", NULL));
assert_false(ai_set_provider_api_type("nonexistent", "responses"));
assert_false(ai_set_provider_api_type(NULL, "responses"));
/* Unknown provider reads as auto */
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("nonexistent"));
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type(NULL));
}
void
test_ai_api_type_string_round_trip(void** state)
{
AIApiType type = AI_API_TYPE_AUTO;
assert_true(ai_api_type_from_string("responses", &type));
assert_int_equal(AI_API_TYPE_RESPONSES, type);
assert_string_equal("responses", ai_api_type_to_string(type));
assert_true(ai_api_type_from_string("chat-completions", &type));
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, type);
assert_string_equal("chat-completions", ai_api_type_to_string(type));
assert_true(ai_api_type_from_string("auto", &type));
assert_int_equal(AI_API_TYPE_AUTO, type);
assert_string_equal("auto", ai_api_type_to_string(type));
assert_false(ai_api_type_from_string("completions", &type));
assert_false(ai_api_type_from_string("", &type));
assert_false(ai_api_type_from_string(NULL, &type));
}
void
test_ai_get_provider_setting(void** state)
{
@@ -1022,10 +919,8 @@ test_ai_parse_response_openai_content(void** state)
}
void
test_ai_parse_response_responses_output_text(void** state)
test_ai_parse_response_perplexity_text(void** state)
{
/* Responses API: "content" is an array of typed parts; the inner "text"
* of the output_text part is the assistant message. */
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"Search result\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
@@ -1033,180 +928,13 @@ test_ai_parse_response_responses_output_text(void** state)
}
void
test_ai_parse_response_responses_full_envelope(void** state)
test_ai_parse_response_text_preferred_over_content(void** state)
{
/* Realistic responses body: a reasoning item precedes the message item;
* the parser must still land on the message's output_text. */
const gchar* json = "{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\","
"\"output\":["
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[]},"
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Hello!\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Hello!", out);
}
void
test_ai_parse_response_choices_win_over_output(void** state)
{
/* A chat-completions envelope is authoritative even if the content
* happens to mention "output". */
const gchar* json = "{\"choices\":[{\"message\":{\"content\":\"chat result\"}}],\"output\":[{\"content\":[{\"text\":\"wrong\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("chat result", out);
}
void
test_ai_parse_response_responses_reasoning_summary_skipped(void** state)
{
/* A reasoning item with a non-empty summary precedes the message item;
* the summary's "text" must not be mistaken for the assistant reply. */
const gchar* json = "{\"output\":["
"{\"id\":\"rs_1\",\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"User asks X\"}]},"
"{\"id\":\"msg_1\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\","
"\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"text\":\"Actual answer\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("Actual answer", out);
}
void
test_ai_parse_response_responses_reasoning_only_returns_null(void** state)
{
/* An incomplete/truncated body with only a reasoning item (no message
* "content" array at all) must yield a parse failure, not leak the
* reasoning summary's "text" as the assistant reply. */
const gchar* json = "{\"status\":\"incomplete\",\"output\":["
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret reasoning\"}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_reasoning_after_message_skipped(void** state)
{
/* A reasoning item ordered after the message, with the part's "text"
* preceding "type":"output_text": the forward scan must stay inside the
* part's object and the backscan must find the real answer. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[{\"text\":\"answer\",\"type\":\"output_text\"}]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("answer", out);
}
void
test_ai_parse_response_responses_empty_content_not_leaked(void** state)
{
/* An empty content array followed by a reasoning item must yield a parse
* failure; the scan must not escape the array and return the summary. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_text_value_before_later_item(void** state)
{
/* A reply that is exactly "text" plus a reasoning item after the message:
* the backscan's own extraction must also stay inside the part's object. */
const gchar* json = "{\"output\":["
"{\"type\":\"message\",\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]},"
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]}"
"]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("text", out);
}
void
test_ai_parse_response_choices_in_string_value(void** state)
{
/* "choices" occurring as a string value must not hijack a valid
* responses envelope. */
const gchar* json = "{\"kind\":\"choices\",\"output\":[{\"content\":[{\"type\":\"output_text\",\"text\":\"hi\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("hi", out);
}
void
test_ai_parse_response_content_before_choices(void** state)
{
/* Nonstandard body: a top-level content string precedes an empty
* choices array; the bare-content fallback must still find it. */
const gchar* json = "{\"content\":\"hi\",\"choices\":[]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("hi", out);
}
void
test_ai_parse_response_content_preferred_over_text(void** state)
{
/* Chat-completions "content" is extracted; a stray "text" field is ignored. */
/* When both formats are present, "text" wins (Perplexity path tried first). */
const gchar* json = "{\"text\":\"from text field\",\"content\":\"from content field\"}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("from content field", out);
}
void
test_ai_parse_response_responses_brace_in_text(void** state)
{
/* Responses part with "text" before "type":"output_text" and a '{' inside
* the answer: the nearest preceding "text" key must be extracted, not a
* brace inside the string value. */
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"use { here\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("use { here", out);
}
void
test_ai_parse_response_choices_logprobs_before_content(void** state)
{
/* A non-string "content" (logprobs array) ordered before message.content
* must not defeat extraction; anchoring on "message" finds the reply. */
const gchar* json = "{\"choices\":[{\"logprobs\":{\"content\":[{\"token\":\"hi\"}]},"
"\"message\":{\"role\":\"assistant\",\"content\":\"real answer\"}}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("real answer", out);
}
void
test_ai_parse_response_reasoning_not_leaked_without_text(void** state)
{
/* An output_text part with no "text" at all (truncated body) must yield a
* parse failure, not leak the preceding reasoning summary's text. */
const gchar* json = "{\"output\":["
"{\"type\":\"reasoning\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"secret\"}]},"
"{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"annotations\":[]}]}"
"]}";
gchar* out = ai_parse_response(json);
assert_null(out);
}
void
test_ai_parse_response_responses_text_value_text(void** state)
{
/* A reply that is exactly the string "text" must not confuse the
* backscan (the value's own quoted bytes match the key needle). */
const gchar* json = "{\"output\":[{\"content\":[{\"text\":\"text\",\"type\":\"output_text\"}]}]}";
auto_gchar gchar* out = ai_parse_response(json);
assert_non_null(out);
assert_string_equal("text", out);
assert_string_equal("from text field", out);
}
void
@@ -1746,26 +1474,6 @@ test_ai_prefs_round_trip_remove_key(void** state)
assert_null(after);
}
void
test_ai_prefs_round_trip_api_type(void** state)
{
ai_add_provider("p_persist_api_type", "https://example.test/pat/");
assert_true(ai_set_provider_api_type("p_persist_api_type", "chat-completions"));
ai_client_shutdown();
ai_client_init();
assert_int_equal(AI_API_TYPE_CHAT_COMPLETIONS, ai_get_provider_api_type("p_persist_api_type"));
/* Setting auto removes the persisted value */
assert_true(ai_set_provider_api_type("p_persist_api_type", "auto"));
ai_client_shutdown();
ai_client_init();
assert_int_equal(AI_API_TYPE_AUTO, ai_get_provider_api_type("p_persist_api_type"));
}
void
test_ai_prefs_multiple_providers_persist(void** state)
{

View File

@@ -43,10 +43,6 @@ void test_ai_providers_find_case_insensitive(void** state);
void test_ai_set_provider_default_model(void** state);
void test_ai_get_provider_default_model(void** state);
void test_ai_set_provider_setting(void** state);
void test_ai_set_provider_setting_reserved_key(void** state);
void test_ai_get_provider_setting_keys(void** state);
void test_ai_set_provider_api_type(void** state);
void test_ai_api_type_string_round_trip(void** state);
void test_ai_get_provider_setting(void** state);
/* Model caching tests */
void test_ai_models_are_fresh_initial(void** state);
@@ -68,21 +64,8 @@ int ai_client_teardown_with_prefs(void** state);
/* Chat response parser tests (ai_parse_response) */
void test_ai_parse_response_openai_content(void** state);
void test_ai_parse_response_responses_output_text(void** state);
void test_ai_parse_response_responses_full_envelope(void** state);
void test_ai_parse_response_responses_reasoning_summary_skipped(void** state);
void test_ai_parse_response_choices_win_over_output(void** state);
void test_ai_parse_response_choices_in_string_value(void** state);
void test_ai_parse_response_content_before_choices(void** state);
void test_ai_parse_response_content_preferred_over_text(void** state);
void test_ai_parse_response_responses_brace_in_text(void** state);
void test_ai_parse_response_choices_logprobs_before_content(void** state);
void test_ai_parse_response_reasoning_not_leaked_without_text(void** state);
void test_ai_parse_response_responses_reasoning_only_returns_null(void** state);
void test_ai_parse_response_responses_reasoning_after_message_skipped(void** state);
void test_ai_parse_response_responses_empty_content_not_leaked(void** state);
void test_ai_parse_response_responses_text_value_before_later_item(void** state);
void test_ai_parse_response_responses_text_value_text(void** state);
void test_ai_parse_response_perplexity_text(void** state);
void test_ai_parse_response_text_preferred_over_content(void** state);
void test_ai_parse_response_escaped_quote(void** state);
void test_ai_parse_response_newline_escape(void** state);
void test_ai_parse_response_empty_content(void** state);
@@ -145,7 +128,6 @@ void test_ai_parse_models_multiple_models(void** state);
/* Prefs round-trip tests */
void test_ai_prefs_round_trip_api_key(void** state);
void test_ai_prefs_round_trip_remove_key(void** state);
void test_ai_prefs_round_trip_api_type(void** state);
void test_ai_prefs_multiple_providers_persist(void** state);
/* Autocomplete deeper coverage */

View File

@@ -752,10 +752,6 @@ main(int argc, char* argv[])
cmocka_unit_test_setup_teardown(test_ai_set_provider_default_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_default_model, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_setting_reserved_key, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting_keys, ai_client_setup, ai_client_teardown),
cmocka_unit_test_setup_teardown(test_ai_set_provider_api_type, ai_client_setup, ai_client_teardown),
cmocka_unit_test(test_ai_api_type_string_round_trip),
cmocka_unit_test_setup_teardown(test_ai_get_provider_setting, ai_client_setup, ai_client_teardown),
/* Model caching tests */
cmocka_unit_test_setup_teardown(test_ai_models_are_fresh_initial, ai_client_setup, ai_client_teardown),
@@ -778,21 +774,8 @@ main(int argc, char* argv[])
cmocka_unit_test(test_ai_json_escape_backslash_quote),
/* Chat response parser */
cmocka_unit_test(test_ai_parse_response_openai_content),
cmocka_unit_test(test_ai_parse_response_responses_output_text),
cmocka_unit_test(test_ai_parse_response_responses_full_envelope),
cmocka_unit_test(test_ai_parse_response_responses_reasoning_summary_skipped),
cmocka_unit_test(test_ai_parse_response_choices_win_over_output),
cmocka_unit_test(test_ai_parse_response_choices_in_string_value),
cmocka_unit_test(test_ai_parse_response_content_before_choices),
cmocka_unit_test(test_ai_parse_response_content_preferred_over_text),
cmocka_unit_test(test_ai_parse_response_responses_brace_in_text),
cmocka_unit_test(test_ai_parse_response_choices_logprobs_before_content),
cmocka_unit_test(test_ai_parse_response_reasoning_not_leaked_without_text),
cmocka_unit_test(test_ai_parse_response_responses_reasoning_only_returns_null),
cmocka_unit_test(test_ai_parse_response_responses_reasoning_after_message_skipped),
cmocka_unit_test(test_ai_parse_response_responses_empty_content_not_leaked),
cmocka_unit_test(test_ai_parse_response_responses_text_value_before_later_item),
cmocka_unit_test(test_ai_parse_response_responses_text_value_text),
cmocka_unit_test(test_ai_parse_response_perplexity_text),
cmocka_unit_test(test_ai_parse_response_text_preferred_over_content),
cmocka_unit_test(test_ai_parse_response_escaped_quote),
cmocka_unit_test(test_ai_parse_response_newline_escape),
cmocka_unit_test(test_ai_parse_response_empty_content),
@@ -847,7 +830,6 @@ main(int argc, char* argv[])
/* Prefs round-trip (uses prefs+ai setup) */
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_remove_key, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_round_trip_api_type, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
cmocka_unit_test_setup_teardown(test_ai_prefs_multiple_providers_persist, ai_client_setup_with_prefs, ai_client_teardown_with_prefs),
/* Autocomplete deeper coverage */
cmocka_unit_test_setup_teardown(test_ai_providers_find_after_remove_skips_removed, ai_client_setup, ai_client_teardown),