From 6bae96668ae35a5c40417ff7ac628cf335fbd28d Mon Sep 17 00:00:00 2001 From: deepend Date: Tue, 20 Oct 2026 23:47:29 -0600 Subject: [PATCH] add autoaway --- .github/workflows/appimage-build.yml | 4 +- .github/workflows/windows-build.yml | 6 +- .github/workflows/windows-msi-build.yml | 6 +- docs/settings.rst | 35 +++ flatpak/net.zoite.Zoitechat.json | 2 +- packaging/fedora/zoitechat.spec | 1 + src/common/autoaway.c | 135 +++++++++++ src/common/autoaway.h | 19 ++ src/common/cfgfiles.c | 6 + src/common/common.vcxproj | 2 + src/common/common.vcxproj.filters | 6 + src/common/fe.h | 1 + src/common/meson.build | 8 + src/common/outbound.c | 13 +- src/common/proto-irc.c | 7 + src/common/server.c | 2 + src/common/tests/test-autoaway.c | 296 ++++++++++++++++++++++++ src/common/zoitechat.c | 3 + src/common/zoitechat.h | 5 + src/fe-gtk/fe-gtk.c | 2 + src/fe-gtk/fe-gtk.vcxproj | 4 +- src/fe-gtk/fe-gtk.vcxproj.filters | 6 + src/fe-gtk/idle.c | 98 ++++++++ src/fe-gtk/idle.h | 10 + src/fe-gtk/meson.build | 23 +- src/fe-gtk/setup.c | 4 + src/fe-gtk/tests/test-idle.c | 85 +++++++ src/fe-text/fe-text.c | 6 + win32/autoaway-tests.vcxproj | 41 ++++ 29 files changed, 828 insertions(+), 8 deletions(-) create mode 100644 src/common/autoaway.c create mode 100644 src/common/autoaway.h create mode 100644 src/common/tests/test-autoaway.c create mode 100644 src/fe-gtk/idle.c create mode 100644 src/fe-gtk/idle.h create mode 100644 src/fe-gtk/tests/test-idle.c create mode 100644 win32/autoaway-tests.vcxproj diff --git a/.github/workflows/appimage-build.yml b/.github/workflows/appimage-build.yml index 3432a694..fb3149b8 100644 --- a/.github/workflows/appimage-build.yml +++ b/.github/workflows/appimage-build.yml @@ -29,7 +29,7 @@ jobs: build-essential pkg-config meson ninja-build cmake \ gettext \ libcanberra-dev libglib2.0-dev \ - libarchive-dev \ + libarchive-dev libxss-dev \ libgtk-3-dev libfontconfig1-dev libpango1.0-dev xvfb xauth dbus-daemon at-spi2-core \ libwayland-client0 libwayland-cursor0 libwayland-egl1 \ libxkbcommon0 \ @@ -57,7 +57,7 @@ jobs: ninja -C build - name: Test emoji catalog, font coverage and picker - run: xvfb-run -a dbus-run-session -- meson test -C build --print-errorlogs "Emoji Data Tests" "Emoji Font Tests" "Emoji UI Tests" + run: xvfb-run -a dbus-run-session -- meson test -C build --print-errorlogs "Autoaway Tests" "Idle Backend Tests" "Emoji Data Tests" "Emoji Font Tests" "Emoji UI Tests" - name: Install to AppDir run: | diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index f72bd69b..7aa4f75c 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -198,7 +198,7 @@ jobs: ) shell: cmd - - name: Test emoji catalog, font coverage and picker + - name: Test autoaway, emoji catalog, font coverage and picker shell: cmd run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat" @@ -213,6 +213,10 @@ jobs: ..\zoitechat-build\x64\tests\emoji-%%T-tests.exe --tap if errorlevel 1 exit /b 1 ) + msbuild win32\autoaway-tests.vcxproj /m /verbosity:minimal /p:Configuration=Release /p:Platform=x64 + if errorlevel 1 exit /b 1 + ..\zoitechat-build\x64\tests\autoaway-tests.exe --tap + if errorlevel 1 exit /b 1 - name: Preparing Artifacts run: | diff --git a/.github/workflows/windows-msi-build.yml b/.github/workflows/windows-msi-build.yml index bdea47fa..6ab8e00e 100644 --- a/.github/workflows/windows-msi-build.yml +++ b/.github/workflows/windows-msi-build.yml @@ -209,7 +209,7 @@ jobs: # Master added native emoji font staging and tests after the release # branch split. Run these before packaging so MSI/MSIX use verified files. - - name: Test emoji catalog, font coverage and picker + - name: Test autoaway, emoji catalog, font coverage and picker shell: cmd run: | call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\VsDevCmd.bat" @@ -224,6 +224,10 @@ jobs: ..\zoitechat-build\x64\tests\emoji-%%T-tests.exe --tap if errorlevel 1 exit /b 1 ) + msbuild win32\autoaway-tests.vcxproj /m /verbosity:minimal /p:Configuration=Release /p:Platform=x64 + if errorlevel 1 exit /b 1 + ..\zoitechat-build\x64\tests\autoaway-tests.exe --tap + if errorlevel 1 exit /b 1 - name: Install WiX Toolset shell: pwsh diff --git a/docs/settings.rst b/docs/settings.rst index 81581454..66620e9b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -628,3 +628,38 @@ List of Settings +------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | **url\_logging** | Toggle logging URLs to */url.log*. | +------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +Autoaway +-------- + +In **Preferences > Chatting > General > Away**, set **Autoaway after** to a +number of minutes. The default is 0 (disabled). The separate **Autoaway +message** defaults to "Idle". No plugin or scripting language is required. +The same options are available immediately through ``/SET``:: + + /SET away_auto_timeout 10 + /SET away_auto_reason Idle + /SET away_auto_system ON + +With desktop activity enabled, Windows uses the current login session's last +input time, macOS uses CoreGraphics input time, and native X11 uses the +XScreenSaver extension if its development library was available at build time. +Debian/Ubuntu builders can install ``libxss-dev``; Fedora builders can install +``libXScrnSaver-devel``. Meson detects this optional dependency automatically. + +On native Wayland, XWayland, unsupported desktops, and builds without XScreenSaver, +autoaway falls back to keyboard, mouse, scroll and touch activity **inside +ZoiteChat**. Activity in other applications does not reset that fallback timer. +Set ``away_auto_system OFF`` to always use ZoiteChat activity on every platform. +The text frontend uses submitted user input. Network traffic, redraws and window +focus changes do not count as user activity. + +Autoaway checks every five seconds, after IRC login finishes, independently on +each connected network. Returning clears only away states set by autoaway. +Manual ``/AWAY`` (including ``/AWAY`` while already automatically away) takes +ownership, so activity does not clear it. ``/BACK`` resets the inactivity timer. +The existing **Automatically unmark away** setting keeps its separate behaviour +when sending messages. Do not run another autoaway script at the same time. +Disabling autoaway clears its own away states. Reconnecting re-evaluates current +inactivity instead of restoring an outdated automatic away state; manual away +restoration is unchanged. diff --git a/flatpak/net.zoite.Zoitechat.json b/flatpak/net.zoite.Zoitechat.json index 43808fd7..cd830ac5 100644 --- a/flatpak/net.zoite.Zoitechat.json +++ b/flatpak/net.zoite.Zoitechat.json @@ -55,7 +55,7 @@ "run-tests": true, "test-rule": "", "test-commands": [ - "meson test --print-errorlogs 'Emoji Data Tests' 'Emoji Font Tests'" + "meson test --print-errorlogs 'Autoaway Tests' 'Idle Backend Tests' 'Emoji Data Tests' 'Emoji Font Tests'" ], "config-opts": [ "-Ddbus-service-use-appid=true", diff --git a/packaging/fedora/zoitechat.spec b/packaging/fedora/zoitechat.spec index 06387a76..0b6a9247 100644 --- a/packaging/fedora/zoitechat.spec +++ b/packaging/fedora/zoitechat.spec @@ -25,6 +25,7 @@ BuildRequires: pkgconfig(gmodule-2.0) BuildRequires: pkgconfig(gtk+-3.0) >= 3.22 BuildRequires: pkgconfig(fontconfig) >= 2.13 BuildRequires: pkgconfig(pangoft2) >= 1.44 +BuildRequires: pkgconfig(xscrnsaver) BuildRequires: pkgconfig(iso-codes) BuildRequires: pkgconfig(libarchive) BuildRequires: pkgconfig(libcanberra) >= 0.22 diff --git a/src/common/autoaway.c b/src/common/autoaway.c new file mode 100644 index 00000000..1dda7fa3 --- /dev/null +++ b/src/common/autoaway.c @@ -0,0 +1,135 @@ +/* ZoiteChat + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "zoitechat.h" +#include "zoitechatc.h" +#include "server.h" +#include "fe.h" +#include "autoaway.h" + +static gint64 last_activity; +static int timer_tag; +static int previous_timeout; +static unsigned int previous_system; + +void +autoaway_activity (void) +{ + last_activity = g_get_monotonic_time (); +} + +/* A manual /AWAY takes ownership even if the server already says away. */ +void +autoaway_manual (server *serv) +{ + serv->autoaway_owned = FALSE; + autoaway_activity (); +} + +void +autoaway_reset (server *serv) +{ + if (!serv->autoaway_owned) + return; + + serv->autoaway_owned = FALSE; + serv->reconnect_away = FALSE; + g_clear_pointer (&serv->last_away_reason, g_free); +} + +static void +autoaway_release (server *serv) +{ + if (!serv->autoaway_owned) + return; + + /* Cancel even an unacknowledged AWAY. IRC replies may arrive later. */ + if (serv->connected && serv->end_of_motd) + serv->p_set_back (serv); + autoaway_reset (serv); +} + +void +autoaway_check (gint64 now, gint64 desktop_idle) +{ + GSList *list; + gint64 idle = MAX ((gint64)0, now - last_activity); + gint64 limit = (gint64)prefs.hex_away_auto_timeout * 60 * G_USEC_PER_SEC; + + if (prefs.hex_away_auto_system && desktop_idle >= 0) + idle = MIN (idle, desktop_idle); + + for (list = serv_list; list; list = list->next) + { + server *serv = list->data; + + if (!serv->connected) + { + /* Re-evaluate idle after login instead of restoring stale autoaway. */ + autoaway_reset (serv); + continue; + } + if (!serv->end_of_motd) + continue; + + if (limit <= 0 || idle < limit) + { + autoaway_release (serv); + continue; + } + if (serv->is_away || serv->reconnect_away) + continue; + + /* Retry a rejected/lost request slowly, never once per poll. */ + if (serv->autoaway_owned && + now - serv->autoaway_sent_at < 30 * G_USEC_PER_SEC) + continue; + + g_free (serv->last_away_reason); + serv->last_away_reason = g_strdup (prefs.hex_away_auto_reason[0] ? + prefs.hex_away_auto_reason : _("Idle")); + serv->autoaway_owned = TRUE; + serv->autoaway_sent_at = now; + serv->p_set_away (serv, serv->last_away_reason); + } +} + +static int +autoaway_timer (void) +{ + autoaway_check (g_get_monotonic_time (), + prefs.hex_away_auto_system ? fe_get_idle_time () : -1); + return 1; +} + +void +autoaway_reinit (void) +{ + if (previous_timeout != prefs.hex_away_auto_timeout || + previous_system != prefs.hex_away_auto_system) + { + autoaway_activity (); + previous_timeout = prefs.hex_away_auto_timeout; + previous_system = prefs.hex_away_auto_system; + } + + if (prefs.hex_away_auto_timeout > 0) + { + if (!timer_tag) + { + autoaway_activity (); + timer_tag = fe_timeout_add_seconds (5, autoaway_timer, NULL); + } + } + else + { + if (timer_tag) + { + fe_timeout_remove (timer_tag); + timer_tag = 0; + } + /* Disabling autoaway must also work without another input event. */ + autoaway_check (g_get_monotonic_time (), -1); + } +} diff --git a/src/common/autoaway.h b/src/common/autoaway.h new file mode 100644 index 00000000..cd974287 --- /dev/null +++ b/src/common/autoaway.h @@ -0,0 +1,19 @@ +/* ZoiteChat + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef ZOITECHAT_AUTOAWAY_H +#define ZOITECHAT_AUTOAWAY_H + +#include + +struct server; + +void autoaway_activity (void); +void autoaway_manual (struct server *serv); +void autoaway_reset (struct server *serv); +void autoaway_reinit (void); +/* Both samples are in microseconds; -1 means desktop idle is unavailable. */ +void autoaway_check (gint64 now, gint64 desktop_idle); + +#endif diff --git a/src/common/cfgfiles.c b/src/common/cfgfiles.c index 663b6051..78194f0e 100644 --- a/src/common/cfgfiles.c +++ b/src/common/cfgfiles.c @@ -29,6 +29,7 @@ #include "fe.h" #include "text.h" #include "zoitechatc.h" +#include "autoaway.h" #include "typedef.h" #ifdef WIN32 @@ -359,6 +360,9 @@ default_file (void) const struct prefs vars[] = { + {"away_auto_reason", P_OFFSET (hex_away_auto_reason), TYPE_STR}, + {"away_auto_system", P_OFFINT (hex_away_auto_system), TYPE_BOOL, autoaway_reinit}, + {"away_auto_timeout", P_OFFINT (hex_away_auto_timeout), TYPE_INT, autoaway_reinit}, {"away_auto_unmark", P_OFFINT (hex_away_auto_unmark), TYPE_BOOL}, {"away_omit_alerts", P_OFFINT (hex_away_omit_alerts), TYPE_BOOL}, {"away_reason", P_OFFSET (hex_away_reason), TYPE_STR}, @@ -767,6 +771,7 @@ load_default_config(void) /* put in default values, anything left out is automatically zero */ /* BOOLEANS */ + prefs.hex_away_auto_system = 1; prefs.hex_away_show_once = 1; prefs.hex_away_track = 1; prefs.hex_dcc_auto_resume = 1; @@ -887,6 +892,7 @@ load_default_config(void) prefs.hex_url_grabber_limit = 100; /* 0 means unlimited */ /* STRINGS */ + strcpy (prefs.hex_away_auto_reason, _("Idle")); strcpy (prefs.hex_away_reason, _("I'm busy")); strcpy (prefs.hex_completion_suffix, ","); #ifdef WIN32 diff --git a/src/common/common.vcxproj b/src/common/common.vcxproj index 5f2107c2..da6390dd 100644 --- a/src/common/common.vcxproj +++ b/src/common/common.vcxproj @@ -11,6 +11,7 @@ + @@ -52,6 +53,7 @@ + diff --git a/src/common/common.vcxproj.filters b/src/common/common.vcxproj.filters index 76723605..7e65bc2d 100644 --- a/src/common/common.vcxproj.filters +++ b/src/common/common.vcxproj.filters @@ -17,6 +17,9 @@ + + Header Files + Header Files @@ -136,6 +139,9 @@ + + Source Files + Source Files diff --git a/src/common/fe.h b/src/common/fe.h index 2790dfa7..229d8f82 100644 --- a/src/common/fe.h +++ b/src/common/fe.h @@ -142,6 +142,7 @@ void fe_lastlog (session *sess, session *lastlog_sess, char *sstr, gtk_xtext_sea void fe_set_lag (server *serv, long lag); void fe_set_throttle (server *serv); void fe_set_away (server *serv); +gint64 fe_get_idle_time (void); void fe_serverlist_open (session *sess); void fe_get_bool (char *title, char *prompt, void *callback, void *userdata); void fe_get_str (char *prompt, char *def, void *callback, void *ud); diff --git a/src/common/meson.build b/src/common/meson.build index b7ca6afe..414dd2a1 100644 --- a/src/common/meson.build +++ b/src/common/meson.build @@ -1,4 +1,5 @@ common_sources = [ + 'autoaway.c', 'cfgfiles.c', 'chanopt.c', 'ctcp.c', @@ -224,3 +225,10 @@ test('Hex Color Tests', hexcolor_tests, protocol: 'tap', timeout: 120, ) + +autoaway_tests = executable('autoaway_tests', + ['tests/test-autoaway.c', 'autoaway.c'], + include_directories: [config_h_include, include_directories('.')], + dependencies: zoitechat_common_header_deps, +) +test('Autoaway Tests', autoaway_tests, protocol: 'tap', timeout: 30) diff --git a/src/common/outbound.c b/src/common/outbound.c index 9049f0a2..71ed0583 100644 --- a/src/common/outbound.c +++ b/src/common/outbound.c @@ -54,6 +54,7 @@ #include "server.h" #include "tree.h" #include "outbound.h" +#include "autoaway.h" #include "chanopt.h" #define TBUFSIZE 4096 @@ -360,6 +361,10 @@ cmd_away (struct session *sess, char *tbuf, char *word[], char *word_eol[]) { char *reason = word_eol[2]; + /* Restoring a manual away after reconnect is not new user input. */ + if (!sess->server->reconnect_away) + autoaway_manual (sess->server); + if (!(*reason)) { if (sess->server->is_away) @@ -396,7 +401,10 @@ cmd_away (struct session *sess, char *tbuf, char *word[], char *word_eol[]) static int cmd_back (struct session *sess, char *tbuf, char *word[], char *word_eol[]) { - if (sess->server->is_away) + gboolean was_autoaway = sess->server->autoaway_owned; + + autoaway_manual (sess->server); + if (sess->server->is_away || was_autoaway) { sess->server->p_set_back (sess->server); } @@ -5051,6 +5059,9 @@ handle_user_input (session *sess, char *text, int history, int nocommand) if (*text == '\0') return 1; + if (history) + autoaway_activity (); + if (history) history_add (&sess->history, text); diff --git a/src/common/proto-irc.c b/src/common/proto-irc.c index 5f1cf62c..5ace3772 100644 --- a/src/common/proto-irc.c +++ b/src/common/proto-irc.c @@ -30,6 +30,7 @@ #include "zoitechat.h" #include "proto-irc.h" +#include "autoaway.h" #include "ctcp.h" #include "fe.h" #include "ignore.h" @@ -438,6 +439,12 @@ irc_raw (server *serv, char *raw) { int len; char tbuf[8704]; + + /* /QUOTE AWAY and scripts sending raw AWAY also take manual ownership. */ + if (!g_ascii_strncasecmp (raw, "AWAY", 4) && + (raw[4] == 0 || raw[4] == ' ')) + autoaway_manual (serv); + if (*raw) { len = strlen (raw); diff --git a/src/common/server.c b/src/common/server.c index ec6097fd..a637b06e 100644 --- a/src/common/server.c +++ b/src/common/server.c @@ -54,6 +54,7 @@ #include "proto-irc.h" #include "servlist.h" #include "server.h" +#include "autoaway.h" #include "sts.h" #ifdef USE_OPENSSL @@ -1873,6 +1874,7 @@ is_server (server *serv) void server_set_defaults (server *serv) { + autoaway_reset (serv); g_free (serv->chantypes); g_clear_pointer (&serv->clienttagdeny, g_free); g_free (serv->chanmodes); diff --git a/src/common/tests/test-autoaway.c b/src/common/tests/test-autoaway.c new file mode 100644 index 00000000..616683f2 --- /dev/null +++ b/src/common/tests/test-autoaway.c @@ -0,0 +1,296 @@ +/* ZoiteChat + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include "../zoitechat.h" +#include "../autoaway.h" + +struct zoitechatprefs prefs; +GSList *serv_list; +static server servers[3]; +static int away_count, back_count, timer_adds, timer_removes; +static gint64 base; +static char *sent_reason; + +/* Protocol stubs deliberately do not acknowledge requests. */ +static void +send_away (server *serv, char *reason) +{ + away_count++; + g_free (sent_reason); + sent_reason = g_strdup (reason); +} + +static void +send_back (server *serv) +{ + back_count++; +} + +int +fe_timeout_add_seconds (int interval, void *callback, void *data) +{ + g_assert_cmpint (interval, ==, 5); + timer_adds++; + return 42; +} + +void +fe_timeout_remove (int tag) +{ + g_assert_cmpint (tag, ==, 42); + timer_removes++; +} + +gint64 +fe_get_idle_time (void) +{ + return -1; +} + +static void +setup (void) +{ + int i; + + prefs.hex_away_auto_timeout = 0; + autoaway_reinit (); + for (i = 0; i < 3; i++) + g_free (servers[i].last_away_reason); + g_slist_free (serv_list); + serv_list = NULL; + memset (servers, 0, sizeof servers); + memset (&prefs, 0, sizeof prefs); + away_count = back_count = timer_adds = timer_removes = 0; + g_clear_pointer (&sent_reason, g_free); + prefs.hex_away_auto_timeout = 10; + prefs.hex_away_auto_system = TRUE; + g_strlcpy (prefs.hex_away_auto_reason, "Auto idle", sizeof prefs.hex_away_auto_reason); + for (i = 0; i < 3; i++) + { + servers[i].connected = TRUE; + servers[i].end_of_motd = TRUE; + servers[i].p_set_away = send_away; + servers[i].p_set_back = send_back; + } + serv_list = g_slist_append (NULL, &servers[0]); + autoaway_activity (); + base = g_get_monotonic_time (); +} + +static void +test_fallback_boundary (void) +{ + setup (); + autoaway_check (base + 599 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 0); + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 1); + g_assert_true (servers[0].autoaway_owned); + g_assert_cmpstr (sent_reason, ==, "Auto idle"); + /* An ACK is not required to avoid flooding the connection. */ + autoaway_check (base + 605 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 1); + servers[0].is_away = TRUE; + autoaway_check (base + 640 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 1); + autoaway_activity (); + autoaway_check (g_get_monotonic_time (), -1); + g_assert_cmpint (back_count, ==, 1); + g_assert_false (servers[0].autoaway_owned); + g_assert_null (servers[0].last_away_reason); +} + +static void +test_desktop_activity (void) +{ + setup (); + autoaway_check (base + 900 * G_USEC_PER_SEC, 5 * G_USEC_PER_SEC); + g_assert_cmpint (away_count, ==, 0); + autoaway_check (base + 900 * G_USEC_PER_SEC, 700 * G_USEC_PER_SEC); + g_assert_cmpint (away_count, ==, 1); + servers[0].is_away = TRUE; + autoaway_check (base + 905 * G_USEC_PER_SEC, 0); + g_assert_cmpint (back_count, ==, 1); + /* App input wins even if the native backend still has an old sample. */ + autoaway_activity (); + autoaway_check (g_get_monotonic_time (), 900 * G_USEC_PER_SEC); + g_assert_cmpint (away_count, ==, 1); +} + +static void +test_app_mode (void) +{ + setup (); + prefs.hex_away_auto_system = FALSE; + autoaway_check (base + 600 * G_USEC_PER_SEC, 0); + g_assert_cmpint (away_count, ==, 1); +} + +static void +test_manual_away (void) +{ + setup (); + servers[0].is_away = TRUE; + servers[0].last_away_reason = g_strdup ("Lunch"); + autoaway_check (base + 900 * G_USEC_PER_SEC, -1); + autoaway_check (base + 905 * G_USEC_PER_SEC, 0); + prefs.hex_away_auto_timeout = 0; + autoaway_reinit (); + g_assert_cmpint (away_count, ==, 0); + g_assert_cmpint (back_count, ==, 0); + g_assert_cmpstr (servers[0].last_away_reason, ==, "Lunch"); +} + +static void +test_manual_takeover (void) +{ + setup (); + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + servers[0].is_away = TRUE; + /* This is also called for /AWAY with no reason, even if already away. */ + autoaway_manual (&servers[0]); + autoaway_check (g_get_monotonic_time (), 0); + prefs.hex_away_auto_timeout = 0; + autoaway_reinit (); + g_assert_cmpint (back_count, ==, 0); + g_assert_true (servers[0].is_away); +} + +static void +test_pending_away_cancel (void) +{ + setup (); + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + g_assert_false (servers[0].is_away); + autoaway_activity (); + autoaway_check (g_get_monotonic_time (), -1); + g_assert_cmpint (back_count, ==, 1); + /* A late 306 must not make us own the away state again. */ + servers[0].is_away = TRUE; + autoaway_check (g_get_monotonic_time (), -1); + g_assert_cmpint (back_count, ==, 1); +} + +static void +test_login_and_multiple_servers (void) +{ + setup (); + serv_list = g_slist_append (serv_list, &servers[1]); + serv_list = g_slist_append (serv_list, &servers[2]); + servers[1].is_away = TRUE; + servers[1].last_away_reason = g_strdup ("Manual"); + servers[2].end_of_motd = FALSE; + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 1); + servers[2].end_of_motd = TRUE; + autoaway_check (base + 605 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 2); + autoaway_check (base + 610 * G_USEC_PER_SEC, 0); + g_assert_cmpint (back_count, ==, 2); + g_assert_cmpstr (servers[1].last_away_reason, ==, "Manual"); +} + +static void +test_disconnect_and_reconnect (void) +{ + setup (); + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + servers[0].connected = FALSE; + servers[0].is_away = TRUE; + servers[0].reconnect_away = TRUE; + autoaway_check (base + 605 * G_USEC_PER_SEC, -1); + g_assert_false (servers[0].autoaway_owned); + g_assert_false (servers[0].reconnect_away); + g_assert_null (servers[0].last_away_reason); + g_assert_cmpint (back_count, ==, 0); + servers[0].is_away = FALSE; + servers[0].connected = TRUE; + servers[0].end_of_motd = FALSE; + autoaway_check (base + 610 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 1); + servers[0].end_of_motd = TRUE; + autoaway_check (base + 615 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 2); + /* server_set_defaults resets ownership even before the next timer tick. */ + servers[0].reconnect_away = TRUE; + autoaway_reset (&servers[0]); + g_assert_false (servers[0].reconnect_away); + /* Existing manual reconnect-away must be left alone. */ + servers[0].reconnect_away = TRUE; + servers[0].last_away_reason = g_strdup ("Manual"); + autoaway_reset (&servers[0]); + g_assert_true (servers[0].reconnect_away); + g_assert_cmpstr (servers[0].last_away_reason, ==, "Manual"); +} + +static void +test_retry (void) +{ + setup (); + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + autoaway_check (base + 629 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 1); + autoaway_check (base + 630 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 2); +} + +static void +test_disable_and_timers (void) +{ + setup (); + autoaway_reinit (); + autoaway_reinit (); + g_assert_cmpint (timer_adds, ==, 1); + base = g_get_monotonic_time (); + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + prefs.hex_away_auto_timeout = 0; + autoaway_reinit (); + g_assert_cmpint (back_count, ==, 1); + g_assert_cmpint (timer_removes, ==, 1); + g_assert_false (servers[0].autoaway_owned); + autoaway_reinit (); + g_assert_cmpint (back_count, ==, 1); + prefs.hex_away_auto_timeout = -1; + autoaway_reinit (); + g_assert_cmpint (away_count, ==, 1); +} + +static void +test_empty_reason_and_large_timeout (void) +{ + setup (); + prefs.hex_away_auto_reason[0] = 0; + autoaway_check (base + 600 * G_USEC_PER_SEC, -1); + g_assert_nonnull (sent_reason); + g_assert_cmpstr (sent_reason, !=, ""); + setup (); + prefs.hex_away_auto_timeout = G_MAXINT; + autoaway_check (base + 900 * G_USEC_PER_SEC, -1); + g_assert_cmpint (away_count, ==, 0); +} + +int +main (int argc, char **argv) +{ + int result, i; + g_test_init (&argc, &argv, NULL); + g_test_add_func ("/autoaway/fallback-boundary", test_fallback_boundary); + g_test_add_func ("/autoaway/desktop-activity", test_desktop_activity); + g_test_add_func ("/autoaway/app-mode", test_app_mode); + g_test_add_func ("/autoaway/manual-away", test_manual_away); + g_test_add_func ("/autoaway/manual-takeover", test_manual_takeover); + g_test_add_func ("/autoaway/pending-cancel", test_pending_away_cancel); + g_test_add_func ("/autoaway/login-multiple-servers", test_login_and_multiple_servers); + g_test_add_func ("/autoaway/reconnect", test_disconnect_and_reconnect); + g_test_add_func ("/autoaway/retry", test_retry); + g_test_add_func ("/autoaway/disable-timers", test_disable_and_timers); + g_test_add_func ("/autoaway/empty-reason-large-timeout", test_empty_reason_and_large_timeout); + result = g_test_run (); + for (i = 0; i < 3; i++) + g_free (servers[i].last_away_reason); + g_free (sent_reason); + g_slist_free (serv_list); + return result; +} diff --git a/src/common/zoitechat.c b/src/common/zoitechat.c index af616c84..8e3c94f7 100644 --- a/src/common/zoitechat.c +++ b/src/common/zoitechat.c @@ -46,6 +46,7 @@ #include "plugin.h" #include "plugin-identd.h" #include "plugin-timer.h" +#include "autoaway.h" #include "notify.h" #include "server.h" #include "servlist.h" @@ -499,6 +500,8 @@ zoitechat_reinit_timers (void) static int lag_check_tag = 0; static int away_tag = 0; + autoaway_reinit (); + if (prefs.hex_notify_timeout && notify_tag == 0) { notify_tag = fe_timeout_add_seconds (prefs.hex_notify_timeout, diff --git a/src/common/zoitechat.h b/src/common/zoitechat.h index ee60e6df..aa90431e 100644 --- a/src/common/zoitechat.h +++ b/src/common/zoitechat.h @@ -105,6 +105,7 @@ struct zoitechatprefs /* these are the rebranded, consistent, sorted zoitechat variables */ /* BOOLEANS */ + unsigned int hex_away_auto_system; unsigned int hex_away_auto_unmark; unsigned int hex_away_omit_alerts; unsigned int hex_away_show_once; @@ -233,6 +234,7 @@ struct zoitechatprefs unsigned int hex_url_logging; /* NUMBERS */ + int hex_away_auto_timeout; int hex_away_size_max; int hex_away_timeout; int hex_completion_amount; @@ -311,6 +313,7 @@ struct zoitechatprefs int hex_url_grabber_limit; /* STRINGS */ + char hex_away_auto_reason[256]; char hex_away_reason[256]; char hex_completion_suffix[4]; /* Only ever holds a one-character string. */ char hex_dcc_completed_dir[PATHLEN + 1]; @@ -597,6 +600,7 @@ typedef struct server /*time_t connect_time;*/ /* when did it connect? */ unsigned long lag_sent; /* we are still waiting for this ping response*/ time_t ping_recv; /* when we last got a ping reply */ + gint64 autoaway_sent_at; time_t away_time; /* when we were marked away */ char *encoding; @@ -616,6 +620,7 @@ typedef struct server unsigned int end_of_motd:1; /* end of motd reached (logged in) */ unsigned int sent_quit:1; /* sent a QUIT already? */ unsigned int use_listargs:1; /* undernet and dalnet need /list >0,<10000 */ + unsigned int autoaway_owned:1; unsigned int is_away:1; unsigned int reconnect_away:1; /* whether to reconnect in is_away state */ unsigned int dont_use_proxy:1; /* to proxy or not to proxy */ diff --git a/src/fe-gtk/fe-gtk.c b/src/fe-gtk/fe-gtk.c index 14495fdb..98c43012 100644 --- a/src/fe-gtk/fe-gtk.c +++ b/src/fe-gtk/fe-gtk.c @@ -59,6 +59,7 @@ #include "notifygui.h" #include "textgui.h" #include "fkeys.h" +#include "idle.h" #include "plugin-tray.h" #include "urlgrab.h" #include "setup.h" @@ -638,6 +639,7 @@ fe_init (void) { theme_manager_init (); key_init (); + idle_init (); pixmaps_init (); #ifdef HAVE_GTK_MAC diff --git a/src/fe-gtk/fe-gtk.vcxproj b/src/fe-gtk/fe-gtk.vcxproj index d1fa5bf6..930633cb 100644 --- a/src/fe-gtk/fe-gtk.vcxproj +++ b/src/fe-gtk/fe-gtk.vcxproj @@ -31,7 +31,7 @@ $(ArchiveLibDir);$(DepsRoot)\lib;%(AdditionalLibraryDirectories) - $(DepLibs);$(ZoiteChatLib)common.lib;wbemuuid.lib;dwmapi.lib;pangoft2-1.0.lib;fontconfig.lib;%(AdditionalDependencies) + $(DepLibs);$(ZoiteChatLib)common.lib;wbemuuid.lib;dwmapi.lib;user32.lib;pangoft2-1.0.lib;fontconfig.lib;%(AdditionalDependencies) mainCRTStartup @@ -49,6 +49,7 @@ powershell "Get-Content -Encoding UTF8 '$(ZoiteChatLib)zoitechat.rc.utf8' | Out- + @@ -91,6 +92,7 @@ powershell "Get-Content -Encoding UTF8 '$(ZoiteChatLib)zoitechat.rc.utf8' | Out- + diff --git a/src/fe-gtk/fe-gtk.vcxproj.filters b/src/fe-gtk/fe-gtk.vcxproj.filters index 0c218a8a..8035f58b 100644 --- a/src/fe-gtk/fe-gtk.vcxproj.filters +++ b/src/fe-gtk/fe-gtk.vcxproj.filters @@ -15,6 +15,9 @@ + + Header Files + Header Files @@ -137,6 +140,9 @@ + + Source Files + Source Files diff --git a/src/fe-gtk/idle.c b/src/fe-gtk/idle.c new file mode 100644 index 00000000..2241d2b6 --- /dev/null +++ b/src/fe-gtk/idle.c @@ -0,0 +1,98 @@ +/* ZoiteChat + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "config.h" +#include +#include "../common/autoaway.h" +#include "idle.h" + +#ifdef WIN32 +#include +#elif defined(__APPLE__) +#include +#include +#elif defined(HAVE_XSS) && defined(GDK_WINDOWING_X11) +#include +#include +#endif + +/* Do not count focus changes, redraws, timers or incoming IRC traffic. */ +static void +idle_event (GdkEvent *event, gpointer data) +{ + switch (event->type) + { + case GDK_KEY_PRESS: + case GDK_BUTTON_PRESS: + case GDK_SCROLL: + case GDK_MOTION_NOTIFY: + case GDK_TOUCH_BEGIN: + case GDK_TOUCH_UPDATE: + autoaway_activity (); + break; + default: + break; + } + + gtk_main_do_event (event); +} + +void +idle_init (void) +{ + autoaway_activity (); + /* Keep GTK's normal dispatch, including detached windows and dialogs. */ + gdk_event_handler_set (idle_event, NULL, NULL); +} + +/* Microseconds since desktop input, or -1 for the portable app fallback. */ +gint64 +fe_get_idle_time (void) +{ +#ifdef WIN32 + LASTINPUTINFO input = {0}; + DWORD elapsed; + + input.cbSize = sizeof input; + if (!GetLastInputInfo (&input)) + return -1; + + /* DWORD subtraction handles the 49-day GetTickCount wrap. Some injected + * input has a slightly newer timestamp; do not treat it as weeks idle. */ + elapsed = GetTickCount () - input.dwTime; + if ((gint32)elapsed < 0) + elapsed = 0; + return (gint64)elapsed * 1000; +#elif defined(__APPLE__) + CFTimeInterval seconds = CGEventSourceSecondsSinceLastEventType ( + kCGEventSourceStateCombinedSessionState, kCGAnyInputEventType); + if (!isfinite (seconds) || seconds < 0 || + seconds >= (double)G_MAXINT64 / G_USEC_PER_SEC) + return -1; + return (gint64)(seconds * G_USEC_PER_SEC); +#elif defined(HAVE_XSS) && defined(GDK_WINDOWING_X11) + GdkDisplay *display = gdk_display_get_default (); + XScreenSaverInfo info = {0}; + Display *xdisplay; + int event_base, error_base, error; + Status ok; + const char *wayland = g_getenv ("WAYLAND_DISPLAY"); + + /* XWayland idle is not desktop-wide. Never pass a Wayland display to Xlib. */ + if (!display || !GDK_IS_X11_DISPLAY (display) || (wayland && *wayland)) + return -1; + + xdisplay = gdk_x11_display_get_xdisplay (display); + gdk_x11_display_error_trap_push (display); + ok = XScreenSaverQueryExtension (xdisplay, &event_base, &error_base); + if (ok) + ok = XScreenSaverQueryInfo (xdisplay, DefaultRootWindow (xdisplay), &info); + error = gdk_x11_display_error_trap_pop (display); + if (!ok || error) + return -1; + return (gint64)info.idle * 1000; +#else + return -1; +#endif +} diff --git a/src/fe-gtk/idle.h b/src/fe-gtk/idle.h new file mode 100644 index 00000000..06ef6849 --- /dev/null +++ b/src/fe-gtk/idle.h @@ -0,0 +1,10 @@ +/* ZoiteChat + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef ZOITECHAT_IDLE_H +#define ZOITECHAT_IDLE_H + +void idle_init (void); + +#endif diff --git a/src/fe-gtk/meson.build b/src/fe-gtk/meson.build index 699d0ccc..76c40f23 100644 --- a/src/fe-gtk/meson.build +++ b/src/fe-gtk/meson.build @@ -25,6 +25,7 @@ zoitechat_gtk_sources = [ 'gtkutil.c', 'icon-resolver.c', 'ignoregui.c', + 'idle.c', 'inline-image.c', 'joind.c', 'menu.c', @@ -45,6 +46,7 @@ zoitechat_gtk_sources = [ ] zoitechat_gtk_cflags = [] +idle_deps = [] zoitechat_gtk_deps = [ zoitechat_common_dep, @@ -93,9 +95,17 @@ if host_machine.system() != 'windows' zoitechat_gtk_deps += gdk_x11_dep endif + xss_dep = dependency('xscrnsaver', required: false) + if gdk_x11_dep.found() and xss_dep.found() + zoitechat_gtk_deps += xss_dep + idle_deps += [gdk_x11_dep, xss_dep] + zoitechat_gtk_cflags += '-DHAVE_XSS' + endif + x11_dep = dependency('x11', required: false) if x11_dep.found() zoitechat_gtk_deps += x11_dep + idle_deps += x11_dep endif endif @@ -105,13 +115,16 @@ zoitechat_gtk_objcflags = [] if host_machine.system() == 'windows' zoitechat_gtk_sources += 'notifications/notification-windows.c' zoitechat_gtk_deps += cc.find_library('dwmapi', required: true) + idle_deps += cc.find_library('user32', required: true) + zoitechat_gtk_deps += idle_deps zoitechat_theme_deps += cc.find_library('dwmapi', required: true) elif host_machine.system() == 'darwin' add_languages('objc', required: true) zoitechat_gtk_sources += 'macos-shortcuts.c' zoitechat_gtk_sources += 'notifications/notification-osx.m' - zoitechat_gtk_deps += dependency('appleframeworks', modules: ['Cocoa', 'UserNotifications']) + zoitechat_gtk_deps += dependency('appleframeworks', modules: ['Cocoa', 'UserNotifications', 'CoreGraphics']) + idle_deps += dependency('appleframeworks', modules: ['CoreGraphics']) zoitechat_gtk_objcflags += '-fobjc-arc' else zoitechat_gtk_sources += 'notifications/notification-freedesktop.c' @@ -302,3 +315,11 @@ xtext_tests = executable('xtext_tests', dependencies: [gtk_dep], ) test('XText Tests', xtext_tests, protocol: 'tap', timeout: 120) + +idle_tests = executable('idle_tests', + 'tests/test-idle.c', + include_directories: [config_h_include], + dependencies: [gtk_dep] + idle_deps, + c_args: zoitechat_gtk_cflags, +) +test('Idle Backend Tests', idle_tests, protocol: 'tap', timeout: 30) diff --git a/src/fe-gtk/setup.c b/src/fe-gtk/setup.c index 5b74013d..87d30c6b 100644 --- a/src/fe-gtk/setup.c +++ b/src/fe-gtk/setup.c @@ -558,6 +558,10 @@ static const setting general_settings[] = {ST_ENTRY, N_("Away:"), P_OFFSETNL(hex_away_reason), 0, 0, sizeof prefs.hex_away_reason}, {ST_HEADER, N_("Away"),0,0,0}, + {ST_NUMBER, N_("Autoaway after:"), P_OFFINTNL(hex_away_auto_timeout), N_("Set to 0 to disable automatic away."), (const char **)N_("minutes (0 = off)."), 1440}, + {ST_ENTRY, N_("Autoaway message:"), P_OFFSETNL(hex_away_auto_reason), 0, 0, sizeof prefs.hex_away_auto_reason}, + {ST_TOGGLE, N_("Use desktop activity when available"), P_OFFINTNL(hex_away_auto_system), N_("Use desktop input on Windows, macOS and X11. Otherwise, use keyboard and mouse activity in ZoiteChat."), 0, 0}, + {ST_LABEL, N_("On Wayland, autoaway uses activity in ZoiteChat.\nOnly automatically set away states are cleared when activity resumes.")}, {ST_TOGGLE, N_("Show away once"), P_OFFINTNL(hex_away_show_once), N_("Show identical away messages only once."), 0, 0}, {ST_TOGGLE, N_("Automatically unmark away"), P_OFFINTNL(hex_away_auto_unmark), N_("Unmark yourself as away before sending messages."), 0, 0}, diff --git a/src/fe-gtk/tests/test-idle.c b/src/fe-gtk/tests/test-idle.c new file mode 100644 index 00000000..8e304bc3 --- /dev/null +++ b/src/fe-gtk/tests/test-idle.c @@ -0,0 +1,85 @@ +/* ZoiteChat + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include "../idle.h" + +static int activity_count, dispatch_count; +static void test_gtk_dispatch (GdkEvent *event); + +void +autoaway_activity (void) +{ + activity_count++; +} + +/* Exercise the real handler without delivering synthetic input to a widget. */ +#define gtk_main_do_event test_gtk_dispatch +#include "../idle.c" +#undef gtk_main_do_event + +static void +test_gtk_dispatch (GdkEvent *event) +{ + dispatch_count++; +} + +static void +test_user_input_and_dispatch (void) +{ + GdkEvent event = {0}; + GdkEventType input[] = {GDK_KEY_PRESS, GDK_BUTTON_PRESS, GDK_SCROLL, + GDK_MOTION_NOTIFY, GDK_TOUCH_BEGIN, GDK_TOUCH_UPDATE}; + GdkEventType other[] = {GDK_FOCUS_CHANGE, GDK_CONFIGURE, GDK_WINDOW_STATE, + GDK_PROPERTY_NOTIFY, GDK_VISIBILITY_NOTIFY}; + unsigned int i; + + activity_count = dispatch_count = 0; + for (i = 0; i < G_N_ELEMENTS (input); i++) + { + event.type = input[i]; + idle_event (&event, NULL); + } + g_assert_cmpint (activity_count, ==, G_N_ELEMENTS (input)); + for (i = 0; i < G_N_ELEMENTS (other); i++) + { + event.type = other[i]; + idle_event (&event, NULL); + } + g_assert_cmpint (activity_count, ==, G_N_ELEMENTS (input)); + g_assert_cmpint (dispatch_count, ==, G_N_ELEMENTS (input) + G_N_ELEMENTS (other)); +} + +static void +test_native_idle (void) +{ + gint64 idle = fe_get_idle_time (); + g_assert_cmpint (idle, >=, -1); +#if defined(HAVE_XSS) && defined(GDK_WINDOWING_X11) + if (gdk_display_get_default () && GDK_IS_X11_DISPLAY (gdk_display_get_default ())) + { + /* XWayland must use the app fallback, even if XSS is compiled in. */ + char *saved = g_strdup (g_getenv ("WAYLAND_DISPLAY")); + g_unsetenv ("WAYLAND_DISPLAY"); + g_assert_cmpint (fe_get_idle_time (), >=, 0); + g_setenv ("WAYLAND_DISPLAY", "wayland-test", TRUE); + g_assert_cmpint (fe_get_idle_time (), ==, -1); + if (saved) + g_setenv ("WAYLAND_DISPLAY", saved, TRUE); + else + g_unsetenv ("WAYLAND_DISPLAY"); + g_free (saved); + } +#endif +} + +int +main (int argc, char **argv) +{ + g_test_init (&argc, &argv, NULL); + gtk_init_check (&argc, &argv); + g_test_add_func ("/idle/user-input-and-dispatch", test_user_input_and_dispatch); + g_test_add_func ("/idle/native-backend", test_native_idle); + return g_test_run (); +} diff --git a/src/fe-text/fe-text.c b/src/fe-text/fe-text.c index ffa962c7..fada591a 100644 --- a/src/fe-text/fe-text.c +++ b/src/fe-text/fe-text.c @@ -48,6 +48,12 @@ #endif +gint64 +fe_get_idle_time (void) +{ + return -1; +} + static int done = FALSE; diff --git a/win32/autoaway-tests.vcxproj b/win32/autoaway-tests.vcxproj new file mode 100644 index 00000000..595ea105 --- /dev/null +++ b/win32/autoaway-tests.vcxproj @@ -0,0 +1,41 @@ + + + + + Release + x64 + + + + {EE388A27-6992-43C0-BA9B-61D8968D42E3} + $(MSBuildThisFileDirectory) + + + + v142 + Application + + + + + autoaway-tests + $(ZoiteChatBuild)\$(ZoiteChatPlatform)\tests\ + $(ZoiteChatObj)autoaway-tests\ + + + + WIN32;_WIN64;_CONSOLE;$(OwnFlags);%(PreprocessorDefinitions) + $(ZoiteChatLib);$(DepsRoot)\include;$(OpenSslInclude);$(Glib);$(Gtk);%(AdditionalIncludeDirectories) + + + Console + $(DepsRoot)\lib;%(AdditionalLibraryDirectories) + gio-2.0.lib;gobject-2.0.lib;glib-2.0.lib;$(IntlLib);%(AdditionalDependencies) + + + + + + + +