diff --git a/src/meson.build b/src/meson.build
index bdb195656..923d95220 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -122,6 +122,7 @@ libshell_public_headers = [
'shell-screenshot.h',
'shell-square-bin.h',
'shell-stack.h',
+ 'shell-time-change-source.h',
'shell-util.h',
'shell-window-preview.h',
'shell-window-preview-layout.h',
@@ -177,6 +178,7 @@ libshell_sources = [
'shell-secure-text-buffer.h',
'shell-square-bin.c',
'shell-stack.c',
+ 'shell-time-change-source.c',
'shell-util.c',
'shell-window-preview.c',
'shell-window-preview-layout.c',
diff --git a/src/shell-time-change-source.c b/src/shell-time-change-source.c
new file mode 100644
index 000000000..3c7a994d2
--- /dev/null
+++ b/src/shell-time-change-source.c
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2024 GNOME Foundation, Inc.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General
+ * Public License along with this library; if not, see .
+ *
+ * Author: Philip Withnall
+ */
+
+#include "config.h"
+
+#include
+#include
+#include
+#include
+
+#include "shell-time-change-source.h"
+
+typedef struct
+{
+ GSource source;
+
+ int fd; /* (owned) (nullable) */
+ void *tag; /* (owned) (nullable) */
+} ShellTimeChangeSource;
+
+static int
+arm_timerfd (int fd)
+{
+ struct itimerspec its = {
+ /* Get the biggest value we can in the `time_t`, as the timerfd will fire
+ * spuriously when that time is reached. Unfortunately there is no
+ * `TIME_T_MAX`. */
+ .it_value.tv_sec = (sizeof (time_t) >= 8) ? UINT64_MAX : UINT32_MAX,
+ };
+ int flags = TFD_TIMER_ABSTIME | TFD_TIMER_CANCEL_ON_SET;
+
+ if (timerfd_settime (fd, flags, &its, NULL) == 0)
+ return 0;
+
+ if (errno != EINVAL)
+ return -1;
+
+ /* Try again with a smaller timeout. It’s possible that libc supports
+ * 64-bit time while the kernel doesn’t. */
+ its.it_value.tv_sec = UINT32_MAX;
+ return timerfd_settime (fd, flags, &its, NULL);
+}
+
+static void
+shell_time_change_source_cleanup_fd (ShellTimeChangeSource *self)
+{
+ /* Make sure the FD is closed. */
+ if (self->tag != NULL)
+ {
+ g_source_remove_unix_fd ((GSource *) self, self->tag);
+ self->tag = NULL;
+ }
+
+ g_clear_fd (&self->fd, NULL);
+}
+
+static gboolean
+shell_time_change_source_dispatch (GSource *source,
+ GSourceFunc callback,
+ gpointer user_data)
+{
+ ShellTimeChangeSource *self = (ShellTimeChangeSource *) source;
+
+ if (callback == NULL)
+ {
+ g_warning ("ShellTimeChangeSource dispatched without callback. "
+ "You must call g_source_set_callback().");
+ return G_SOURCE_REMOVE;
+ }
+
+ if (callback (user_data))
+ {
+ /* The timerfd_settime() call can’t really fail in this situation.
+ * The man page says it can return ECANCELED, but will still be re-armed. */
+ int retval = arm_timerfd (self->fd);
+ int errsv = errno;
+ g_assert (retval == 0 ||
+ (retval < 0 && errsv == ECANCELED));
+
+ return G_SOURCE_CONTINUE;
+ }
+
+ /* Clean up the source’s resources early, as FDs are precious, and the user
+ * might leave the source hanging around for a long time before finalising it. */
+ shell_time_change_source_cleanup_fd (self);
+
+ return G_SOURCE_REMOVE;
+}
+
+static void
+shell_time_change_source_finalize (GSource *source)
+{
+ ShellTimeChangeSource *self = (ShellTimeChangeSource *) source;
+
+ shell_time_change_source_cleanup_fd (self);
+}
+
+static const GSourceFuncs shell_time_change_source_funcs = {
+ NULL, /* prepare */
+ NULL, /* check */
+ shell_time_change_source_dispatch,
+ shell_time_change_source_finalize,
+ NULL, NULL
+};
+
+/**
+ * shell_time_change_source_new:
+ * @error: return location for a #GError, or %NULL
+ *
+ * Creates a #GSource which is dispatched every time the system realtime clock
+ * changes relative to the monotonic clock.
+ *
+ * This typically happens after NTP synchronisation.
+ *
+ * On error, a #GFileError will be returned. This happens if a timerfd cannot be
+ * created.
+ *
+ * Any callback attached to the returned #GSource must have type
+ * #GSourceFunc.
+ *
+ * Returns: (transfer full): the newly created #GSource, or %NULL on error
+ */
+GSource *
+shell_time_change_source_new (GError **error)
+{
+ ShellTimeChangeSource *self;
+ g_autoptr(GSource) source = NULL;
+ g_autofd int fd = -1;
+
+ g_return_val_if_fail (error == NULL || *error == NULL, NULL);
+
+ /* Create a timerfd with the maximum possible timeout, but set
+ * `TFD_TIMER_CANCEL_ON_SET` so that it fires if the realtime clock changes
+ * relative to the monotonic clock.
+ *
+ * This is a one-shot source: it’ll need to be recreated after that. */
+ fd = timerfd_create (CLOCK_REALTIME, TFD_NONBLOCK | TFD_CLOEXEC);
+ if (fd < 0 || arm_timerfd (fd) < 0)
+ {
+ int errsv = errno;
+ g_set_error (error, G_FILE_ERROR, g_file_error_from_errno (errsv),
+ "Error creating timerfd: %s", g_strerror (errsv));
+ return NULL;
+ }
+
+ source = g_source_new ((GSourceFuncs *) &shell_time_change_source_funcs,
+ sizeof (ShellTimeChangeSource));
+ self = (ShellTimeChangeSource *) source;
+
+ self->tag = g_source_add_unix_fd (source, fd, G_IO_IN);
+ self->fd = g_steal_fd (&fd);
+
+ return g_steal_pointer (&source);
+}
diff --git a/src/shell-time-change-source.h b/src/shell-time-change-source.h
new file mode 100644
index 000000000..227ed0a92
--- /dev/null
+++ b/src/shell-time-change-source.h
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2024 GNOME Foundation, Inc.
+ *
+ * SPDX-License-Identifier: LGPL-2.1-or-later
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General
+ * Public License along with this library; if not, see .
+ *
+ * Author: Philip Withnall
+ */
+
+#ifndef __SHELL_TIME_CHANGE_SOURCE_H__
+#define __SHELL_TIME_CHANGE_SOURCE_H__
+
+#include
+
+G_BEGIN_DECLS
+
+GSource *shell_time_change_source_new (GError **error);
+
+G_END_DECLS
+
+#endif /* __SHELL_TIME_CHANGE_SOURCE_H__ */