Compare commits

..
35 changed files with 1579 additions and 3252 deletions

View file

@ -6,19 +6,18 @@ name: package
# plain 'apk upgrade' without an fp6-img image run.
#
# Build: crafter-build cross-compiles against an Alpine aarch64 sysroot
# (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the
# (packaging/build-package.sh — the README's "Cross-compiling" flow), the
# test suite runs natively, and packaging/APKBUILD.binary wraps the result.
# crafter-build is the Crafter.Build Flatpak, and bubblewrap inside the job
# container needs a privileged runner: the job runs on the `flatpak` label
# (a privileged runner registered to this repository), in an alpine:edge
# container.
# This needs no privileged runner: it runs in an alpine:edge container on
# the ordinary arch-latest runner.
#
# Release gating is the version: pkgver comes from implementations/main.cpp,
# the registry answers 409 for an already-published version, and the publish
# step treats that as "nothing to do" — so pushes only release when the
# Version constant bumps. This is the only producer of the imsd apk: fp6-img
# images install the published package from the registry instead of
# building their own, so image and 'apk upgrade' carry the same binary.
# images install the published package from the registry (pinned version,
# pinned sha256) instead of building their own, so image and 'apk upgrade'
# carry the same binary.
#
# Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an
# org-level secret on Catcrafts since 2026-09-02, shared with fp6-img;
@ -31,7 +30,7 @@ on:
jobs:
package:
runs-on: flatpak
runs-on: arch-latest
container:
image: alpine:edge
timeout-minutes: 90

1024
AGENTS.md

File diff suppressed because it is too large Load diff

157
Makefile Normal file
View file

@ -0,0 +1,157 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# Distro-packaging build path. project.cpp (crafter-build) remains the
# canonical build description; this Makefile mirrors it for environments
# where crafter-build isn't packaged — same clang module pipeline, same
# flags. Keep the two in sync when products, module partitions or link
# flags change.
#
# make -> build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod}
# make check -> build + run the 7 unit-test suites
# make install -> DESTDIR/PREFIX staged install (binaries + systemd
# units, D-Bus policies, dialerd autostart, the GNOME
# Calls plugin override, ims-pdn-up.sh)
#
# Requires clang++ with libc++ and the libc++ std module sources
# (std.cppm — Alpine: llvm-runtimes, Arch: libc++). Override STD_CPPM if
# yours lives elsewhere.
# make's builtin default is CXX=g++, and distro build environments (abuild)
# export CXX=c++ — both gcc, which has no --precompile. This build requires
# clang: the file assignment deliberately ignores the environment while still
# yielding to an explicit `make CXX=...`.
CXX = clang++
AR ?= ar
PKG_CONFIG ?= pkg-config
STD_CPPM ?= /usr/share/libc++/v1/std.cppm
O ?= build/make
PCM = $(O)/pcm
OBJ = $(O)/obj
OPT ?= -O3
# One flag set for every TU: BMIs are rejected on compiler-option mismatch,
# so per-target extras are limited to -I/-D (compile) and -l/-L (link).
ALL_CXXFLAGS = -std=c++26 -stdlib=libc++ $(OPT) $(CXXFLAGS)
ALL_LDFLAGS = -fuse-ld=lld $(LDFLAGS)
# pkg-config gio-2.0: only -I/-D pass through — it also emits -pthread,
# which would config-mismatch the shared std BMI (project.cpp filters
# identically). pthread lives in libc on every platform we target.
GIO_CFLAGS := $(filter -I% -D%,$(shell $(PKG_CONFIG) --cflags gio-2.0))
GIO_LIBS := $(filter -l% -L%,$(shell $(PKG_CONFIG) --libs gio-2.0))
ifeq ($(strip $(GIO_LIBS)),)
GIO_LIBS := -lgio-2.0 -lgobject-2.0 -lglib-2.0
endif
# Imsd module partitions, plus the primary interface last. The BMI file
# names must stay <module>-<partition>.pcm — that is how clang resolves
# `import :Part;` under -fprebuilt-module-path.
PARTITIONS = Imsd-Util Imsd-Sip Imsd-Sdp Imsd-Aka Imsd-Ipsec \
Imsd-Messages Imsd-Engine
CORE_MODULES = $(PARTITIONS) Imsd
CORE_OBJS = $(CORE_MODULES:%=$(OBJ)/%.o)
TESTS = Util Aka Ipsec Messages Engine Sip Sdp
all: $(O)/imsd $(O)/imsd-media $(O)/imsd-dialerd $(O)/imsd-ofonod
# ---- module BMIs ----------------------------------------------------------
$(PCM)/std.pcm: $(STD_CPPM) | $(PCM)
$(CXX) $(ALL_CXXFLAGS) -Wno-reserved-identifier \
-Wno-reserved-module-identifier --precompile $< -o $@
$(PCM)/%.pcm: interfaces/%.cppm $(PCM)/std.pcm | $(PCM)
$(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) --precompile $< -o $@
# partition import graph (see the `import :X;` lines in interfaces/)
$(PCM)/Imsd-Aka.pcm $(PCM)/Imsd-Ipsec.pcm: $(PCM)/Imsd-Util.pcm
$(PCM)/Imsd-Messages.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Aka.pcm \
$(PCM)/Imsd-Sip.pcm
$(PCM)/Imsd-Engine.pcm: $(PCM)/Imsd-Util.pcm $(PCM)/Imsd-Sip.pcm \
$(PCM)/Imsd-Sdp.pcm $(PCM)/Imsd-Messages.pcm
$(PCM)/Imsd.pcm: $(PARTITIONS:%=$(PCM)/%.pcm)
# ---- imsd-core (static lib of the pure-C++ engine modules) ----------------
$(OBJ)/%.o: $(PCM)/%.pcm | $(OBJ)
$(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) -c $< -o $@
$(O)/libimsd-core.a: $(CORE_OBJS)
$(AR) rcs $@ $^
# ---- executables -----------------------------------------------------------
$(OBJ)/main.o: implementations/main.cpp $(PCM)/Imsd.pcm | $(OBJ)
$(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \
-c $< -o $@
$(O)/imsd: $(OBJ)/main.o $(O)/libimsd-core.a
$(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -lpthread -o $@
$(OBJ)/media.o: implementations/media.cpp $(PCM)/std.pcm | $(OBJ)
$(CXX) $(ALL_CXXFLAGS) -fprebuilt-module-path=$(PCM) -c $< -o $@
$(O)/imsd-media: $(OBJ)/media.o
$(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ -ldl -lpthread -o $@
$(OBJ)/dialerd.o: implementations/dialerd.cpp $(PCM)/std.pcm | $(OBJ)
$(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \
-c $< -o $@
$(O)/imsd-dialerd: $(OBJ)/dialerd.o
$(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@
$(OBJ)/ofonod.o: implementations/ofonod.cpp $(PCM)/std.pcm | $(OBJ)
$(CXX) $(ALL_CXXFLAGS) $(GIO_CFLAGS) -fprebuilt-module-path=$(PCM) \
-c $< -o $@
$(O)/imsd-ofonod: $(OBJ)/ofonod.o
$(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) $^ $(GIO_LIBS) -o $@
# ---- tests -----------------------------------------------------------------
$(O)/tests/%: tests/%/main.cpp $(PCM)/Imsd.pcm $(O)/libimsd-core.a | $(O)/tests
$(CXX) $(ALL_CXXFLAGS) $(ALL_LDFLAGS) -fprebuilt-module-path=$(PCM) \
$< $(O)/libimsd-core.a -lpthread -o $@
check: $(TESTS:%=$(O)/tests/%)
@fail=0; for t in $(TESTS); do \
echo "== $$t"; $(O)/tests/$$t || fail=1; \
done; exit $$fail
# ---- install ----------------------------------------------------------------
PREFIX ?= /usr
install: all
install -Dm755 $(O)/imsd $(DESTDIR)$(PREFIX)/bin/imsd
install -Dm755 $(O)/imsd-dialerd $(DESTDIR)$(PREFIX)/bin/imsd-dialerd
install -Dm755 $(O)/imsd-ofonod $(DESTDIR)$(PREFIX)/bin/imsd-ofonod
install -Dm755 $(O)/imsd-media $(DESTDIR)$(PREFIX)/libexec/imsd-media
install -Dm755 packaging/ims-pdn-up.sh \
$(DESTDIR)$(PREFIX)/libexec/ims-pdn-up.sh
install -Dm644 packaging/imsd.service \
$(DESTDIR)$(PREFIX)/lib/systemd/system/imsd.service
install -Dm644 packaging/imsd-dialerd.desktop \
$(DESTDIR)/etc/xdg/autostart/imsd-dialerd.desktop
install -Dm644 packaging/net.catcrafts.IMS1.conf \
$(DESTDIR)$(PREFIX)/share/dbus-1/system.d/net.catcrafts.IMS1.conf
install -Dm644 packaging/imsd-ofonod.service \
$(DESTDIR)$(PREFIX)/lib/systemd/system/imsd-ofonod.service
install -Dm644 packaging/org.ofono.conf \
$(DESTDIR)$(PREFIX)/share/dbus-1/system.d/imsd-ofono.conf
install -Dm644 packaging/90_imsd-ofono.gschema.override \
$(DESTDIR)$(PREFIX)/share/glib-2.0/schemas/90_imsd-ofono.gschema.override
install -Dm644 packaging/80-imsd-ofono.preset \
$(DESTDIR)$(PREFIX)/lib/systemd/system-preset/80-imsd-ofono.preset
$(PCM) $(OBJ) $(O)/tests:
mkdir -p $@
clean:
rm -rf $(O)
.PHONY: all check install clean

341
README.md
View file

@ -5,137 +5,306 @@
Userspace IMS/VoLTE daemon for mainline Linux phones.
Most Linux phones drive the modem's internal IMS engine.
imsd implements the whole IMS client in userspace instead:
SIP registration with USIM AKA authentication over kernel
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but
can't be used for actual call audio. imsd implements the whole IMS client in
userspace instead: SIP registration with USIM AKA authentication over kernel
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh
can place and receive real VoLTE calls on a commercial network.
PipeWire so a stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh
and GNOME Mobile — can place and receive real VoLTE calls on a commercial
network.
## Status
Call-capable, both directions: outgoing calls place, answer, carry full-duplex
AMR-WB audio and tear down. Incoming calls ring, answer and carry media the
same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI.
Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting,
USSD, P-CSCF discovery (the address is configured by hand).
Call-capable, both directions. Outgoing VoLTE calls place, answer, carry
full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via
`Accept`, and carry media the same way — driven end-to-end from a stock
dialer over the frozen D-Bus ABI. Current state:
Verified by community members to be fully working:
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose
every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the
`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev
box with no bus, modem, or phone; several suites pin output byte-for-byte
against recorded network traffic. GLib/GDBus, sockets, and subprocess control
live only in the `imsd` daemon shell.
| Device | OS | Carrier |
## Architecture
C++26 modules, built with Crafter Build. Five build products:
- **imsd-core** (static library) — the engine, pure `import std` C++ with no
GLib and no I/O side effects. Everything here is unit-testable on any dev
box without a modem, a bus, or a phone; several suites pin output
byte-for-byte against recorded network traffic.
- **imsd** (executable) — the daemon shell: GLib main loop, GDBus service,
sockets, subprocess control (`qmicli`/`mmcli`/`ip`), and the registration
sequence + run loop that pumps the core. All GLib and I/O live here.
- **imsd-media** (executable) — the RTP/AMR-WB data plane, spawned once per
call. Pure `import std` + POSIX, no GLib; links `-ldl` and dlopen's the
codecs. A separate process for crash isolation and the far-end-hangup
exit-code contract.
- **imsd-dialerd** (executable) — the Plasma Mobile integration: a session
daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`,
bridging them to imsd on the system bus. GDBus translation only, no core.
It is a separate process because those are per-user *session*-bus names
that the root system daemon cannot own; built with `-- --product=dialerd`,
autostarted from an `.desktop` file in place of plasma-dialer's
`modem-daemon` (whose autostart must be disabled).
- **imsd-ofonod** (executable) — the GNOME Calls integration, for Phosh and
GNOME Mobile: a system daemon owning `org.ofono` and presenting imsd as an
oFono modem, which is the seam GNOME Calls' bundled `ofono` provider plugin
drives. GDBus translation only, no core; built with `-- --product=ofonod`.
See [GNOME Calls (Phosh, GNOME Mobile)](#gnome-calls-phosh-gnome-mobile).
### Desktop integration
imsd itself is desktop-agnostic: everything a dialer needs is on the system
bus as `net.catcrafts.IMS1`. Each desktop gets a small translation daemon
speaking whatever backend protocol its dialer already consumes, so the dialer,
the shell and the call-history/contacts stack all stay unmodified.
| Desktop | Daemon | Owns | Consumed by |
|---|---|---|---|
| Plasma Mobile | `imsd-dialerd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` |
| Phosh / GNOME Mobile | `imsd-ofonod` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin |
Phosh's own call UI needs nothing extra: it consumes `org.gnome.Calls`, which
GNOME Calls exports once it has a working origin.
### Terminating-network dependency (incoming)
A UE only receives a VoLTE call as a SIP INVITE if the network's Terminating
Access Domain Selection (T-ADS) routes it to the PS/IMS domain rather than
paging the modem over CS. That requires the IMS registration to advertise
voice capability — the MMTEL ICSI (`+g.3gpp.icsi-ref=...mmtel`) and
`+sip.instance` media-feature tags in the REGISTER Contact, which
`Imsd:Messages` emits. T-ADS may still select CS for reasons outside the
client's control (operator policy, the modem's own radio-capability
signalling, a competing registration). When it does, no INVITE reaches imsd;
that is a network/modem-integration matter, not a daemon defect.
## Supported hardware & carriers
imsd talks to the modem through ModemManager plus `qmicli` over QRTR (USIM
AKA via a UIM logical channel), so in practice it currently needs a Qualcomm
modem on a mainline kernel with working WWAN/MM integration, kernel ESP
(`ip xfrm`), and PipeWire for audio.
Everything below has been verified by community members to be fully workking:
| Device | OS | Carrier
|---|---|---|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
## What it needs
## Build
imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs
its security associations with `ip xfrm` and plays audio through PipeWire, so
in practice: a Qualcomm modem on a mainline kernel with working
WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and
your carrier's P-CSCF address.
## Install and configure
For automatic updates add the Catcrafts Alpine repository. Note this does also pull in other packages.
Otherwise just download the latest `imsd` and `imsd-systemd` APKs and install them.
Both can be found in the packages tab:
```
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
```
Configuration is environment variables in `/etc/imsd.env`; the one required
setting is the P-CSCF:
[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) +
gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine).
```sh
# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android
crafter-build # bin/imsd-<target>-<march>/imsd (the daemon)
crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg)
crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (Plasma backend)
crafter-build -- --product=ofonod # bin/imsd-ofonod-.../imsd-ofonod (GNOME Calls backend)
crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp)
```
### Building with make (distro packaging)
For environments without crafter-build, the Makefile mirrors the same clang
module pipeline: GNU make, clang++ with libc++ and the libc++ std module
sources (`std.cppm` — package `llvm-runtimes` on Alpine, `libc++` on Arch),
lld, and gio-2.0 headers.
```sh
make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod}
make check # the same 7 unit-test suites
make install # DESTDIR/PREFIX staged install incl. the packaging/ files
```
`project.cpp` stays the canonical build description; the Makefile tracks it.
### Cross-compiling for a phone (aarch64 Alpine/postmarketOS)
```sh
packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN
crafter-build -- --target=aarch64-alpine-linux-musl \
--sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \
--march=armv8.6-a+fp16fml+aes+sha3+sm4 --mtune=cortex-a720
# same flags after `crafter-build test --target=aarch64-alpine-linux-musl`
# run the suite under qemu-aarch64 against the sysroot
```
## Run (development)
```sh
./bin/imsd-*/imsd --session # own net.catcrafts.IMS1 on the session bus
busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
```
On a phone the daemon runs as root on the system bus (policy in
`packaging/net.catcrafts.IMS1.conf` — it grants the postmarketOS default
account `user` access; adjust it alongside `AUDIO_USER` if your session
account is named differently), started by `packaging/imsd.service`
after `packaging/ims-pdn-up.sh` has brought up the ims PDN through
ModemManager. The bring-up never lets the ims PDN become the phone's IPv6
default route: the PDN's router advertisement is not accepted as a default
router (`accept_ra_defrtr=0` on the netdev; its prefix and MTU still apply)
and the P-CSCF gets a host route instead, so on IPv4-only wifi the phone's
other IPv6 traffic (apk, browsers) is not sent into the IMS walled garden,
where it would hang. Media peers get a host route per call; anything else
on the IMS network (its DNS servers, a traceroute target) needs its own
(`ip -6 route add <addr> dev qmapmux0.0`). The PDN MTU ModemManager reports
is set on the link. It spawns
`/usr/libexec/imsd-media` per call. Registration
resumes an in-kernel IPsec SA when one is present (avoiding the network's
fresh-SA throttle) and keeps itself alive with a re-REGISTER refresh at half
the granted lifetime. `packaging/APKBUILD` builds the apk from source via
the Makefile; `packaging/APKBUILD.binary` + `make-bin-tarball.sh` wrap a
cross-compiled build into an apk instead.
## GNOME Calls (Phosh, GNOME Mobile)
`imsd-ofonod` presents imsd on the system bus as an oFono modem — a Manager at
`/`, one Modem + VoiceCallManager at `/imsd`, and a VoiceCall object per live
call — which is what GNOME Calls' bundled `ofono` provider plugin drives.
Calls, Phosh and GNOME Shell run unmodified.
`org.ofono` is the seam because it is the only one a third party can
implement. `org.gnome.Calls.Call` is what Calls *exports* so a shell can
observe and control a call that already exists (Accept, Hangup, SendDtmf,
Silence — there is no Dial); Phosh consumes it from Calls. Calls' own backends
are libpeas provider plugins (`mm`, `ofono`, `sip`, `dummy`) linked against
private headers that no distro installs.
Setup:
```sh
apk add imsd-ofono # enables imsd-ofonod via a systemd preset
systemctl enable --now imsd-ofonod # hand installs: system service, org.ofono is a system name
# point Calls at the ofono provider (the package ships this as a gschema
# override; this is the per-user equivalent)
gsettings set org.gnome.Calls autoload-plugins "['ofono']"
```
Drop `mm` from `autoload-plugins` rather than adding `ofono` alongside it: the
ModemManager provider would offer the modem's own CS voice path as a second
origin, and on a phone whose voice path is imsd that origin carries no audio.
Do not run this alongside a real `ofonod` — there is one `org.ofono` name and
one owner; the unit declares `Conflicts=ofono.service` and the daemon exits if
it loses the name. Running `imsd-dialerd` at the same time is fine (different
bus, different consumer).
Verified on a Fairphone 6 (postmarketOS, dbus-broker) with GNOME Calls 50.0:
outgoing and incoming calls with audio both ways, answered and hung up through
`org.gnome.Calls.Call` — the surface Phosh drives. One dbus-broker detail is
load-bearing and lives in the policy file: dbus-broker checks a broadcast
against the sender's send policy for every name the receiver owns, so root
(imsd) must be allowed `send_destination="org.ofono"` or imsd's call signals
never reach the daemon.
Known gaps, all inherited rather than introduced:
- **USSD is refused** with `org.ofono.Error.NotSupported`; the IMS stack has no
USSD path. It is deliberately not advertised in the modem's `Interfaces`.
- **No hold, multiparty, transfer or call waiting** — imsd is a single-call
stack, so those methods return `org.ofono.Error.NotImplemented`.
- **Per-call caller-ID withholding is refused** rather than silently ignored:
imsd cannot honour a CLIR request, and failing is safer than dialling with
the caller ID exposed.
- **Phosh's emergency-call screen lists nothing.** Not for want of the data:
`imsd-ofonod` publishes `org.ofono.VoiceCallManager.EmergencyNumbers` (112,
911, plus `EMERGENCY_NUMBERS`), which is oFono's standard place for it.
Calls' ofono provider simply never reads it — it makes no `GetProperties`
call on the VoiceCallManager, and returns NULL for the origin's
`emergency-numbers`, which is what
`org.gnome.Calls.EmergencyCalls.GetEmergencyContacts` is built from. (The
ModemManager provider does read it, via `mm_sim_dup_emergency_numbers()`.)
An upstream fix would close this. Dialling an emergency number from the
normal dialpad is unaffected — that reaches imsd's own classifier.
- DTMF is forwarded to imsd, where `SendDtmf` is still a stub (RFC 4733 is not
implemented yet) — the same limitation Plasma has.
- **Restart GNOME Calls after restarting `imsd-ofonod` itself.** When the
modem's `VoiceCallManager` interface goes away and comes back, Calls 50.0
adds a new origin but leaves the old origin's `CallAdded` handler connected:
after one such cycle every call is added twice, after a few the stale handler
runs on freed memory and Calls dies with SIGBUS in `g_hash_table_lookup`
(measured on the FP6). Calls-side; an upstream fix belongs in
`plugins/provider/ofono/calls-ofono-provider.c`. To keep imsd's own
restarts and registration drops from triggering it, `imsd-ofonod` lists the
interface permanently: `Online` tracks registration, and `Dial` fails with
"imsd is not registered" / "imsd is not running" instead. Only `org.ofono`
vanishing — this daemon restarting — still cycles the origin.
Development, without a phone or root:
```sh
./bin/imsd-*/imsd --session & # imsd's ABI on the session bus
./bin/imsd-ofonod-*/imsd-ofonod --session
busctl --user call org.ofono / org.ofono.Manager GetModems
# or keep org.ofono on the system bus and imsd on the session bus:
IMSD_BUS=session imsd-ofonod
```
## Configuration
Everything is environment variables. The packaged unit reads
`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev) and
then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable
`/etc/imsd.env`:
```sh
# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your
# carrier's P-CSCF address. Find it in a stock-firmware capture or your
# carrier's IMS documentation.
PCSCF=2001:db8::105
```
The apk's `imsd.service` is enabled by preset and starts at boot once that
file exists (the condition is checked when the unit starts, so after writing
the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers:
resuming an existing registration when it can, registering fresh otherwise,
and waiting out whatever the network tells it to wait out. Check on it with:
```sh
journalctl -u imsd -f
busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
```
## Dialers
imsd itself is desktop-agnostic: everything a dialer needs is on the system
bus as `net.catcrafts.IMS1`. A small translation daemon per desktop speaks
the backend protocol its dialer already consumes, so dialer, shell and the
call-history/contacts stack run unmodified.
| Desktop | Daemon | Package | Owns | Consumed by |
|---|---|---|---|---|
| Plasma Mobile | `imsd-dialerd` | `imsd` | `org.kde.telephony.*` (session bus) | plasma-dialer + kde-telephony-daemon, replacing `modem-daemon` |
| Phosh / GNOME Mobile | `imsd-ofonod` | `imsd-ofono` | `org.ofono` (system bus) | GNOME Calls' `ofono` provider plugin |
Plasma Mobile needs nothing beyond the `imsd` package: `imsd-dialerd` is
autostarted in the session in place of plasma-dialer's `modem-daemon`, whose
own autostart must be off — the package hides it for accounts created after
the install (`/etc/skel`); an existing account hides it itself with a
`Hidden=true` copy of `org.kde.modem.daemon.desktop` in `~/.config/autostart`.
For Phosh and GNOME Mobile, `apk add imsd-ofono` enables `imsd-ofonod` and
points GNOME Calls at its `ofono` provider (a gschema override; the per-user
equivalent is `gsettings set org.gnome.Calls autoload-plugins "['ofono']"`).
Do not run it alongside a real `ofonod`.
Call control is polkit's `net.catcrafts.IMS1.Voice`: the active local
session and root may place, answer and end calls; an ssh login may not.
## Full config:
### imsd
| Variable | Default | Meaning |
|---|---|---|
| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error |
| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error |
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) |
| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` |
| `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header |
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume |
| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too |
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |
| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet. `imsd-ofonod` reads it too, for the oFono `EmergencyNumbers` property |
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file (`/var/lib/imsd/imsreg.state`) records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |
| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet |
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
| `CODECS` | *(empty — defaults)* | comma-separated codec preference list over `AMR-WB`, `AMR` (or `AMR-NB`), `PCMA`, `PCMU`: restricts and orders both the codecs offered on an outgoing call and those accepted from an inbound offer (default: offer AMR-WB + AMR, accept all four in that order). A bench knob — a network whose gateway transcodes every caller up to AMR-WB otherwise never lets the narrowband path run |
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers |
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses |
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) |
The protected client/server ports are fixed at 45061/45062
(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires
(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires
updating the firewall rules `ims-pdn-up.sh` installs.
### imsd-media (per-call, set through imsd's environment)
| Variable | Default | Meaning |
|---|---|---|
| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire, as imsd passes them (`imsd-media` started by hand defaults both to off) |
| `AUDIO_USER` | *(auto)* | account whose PipeWire session carries call audio; imsd sets it per call from logind's seat0 session. Set in imsd's own environment it pins an account instead |
| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain, as imsd passes them (`imsd-media` started by hand defaults `GAIN` to `1.0`) |
| `AMR_MODE` | `2` | AMR encoder mode of whichever AMR codec is active, as imsd passes it (12.65 kbit/s for AMR-WB; `imsd-media` started by hand defaults to 2 for AMR-WB and 7 for AMR) |
| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire |
| `AUDIO_USER` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) |
| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain |
| `AMR_MODE` | `2` | AMR-WB encoder mode (0–8) |
| `DTX` | `0` | encoder discontinuous transmission |
| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) |
| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file |
## D-Bus ABI
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen.
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer
integrations depend on it:
| Member | Signature |
|---|---|
@ -150,8 +319,8 @@ updating the firewall rules `ims-pdn-up.sh` installs.
| `CallDeleted` | `s` |
| `RegistrationChanged` | `b` |
Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI.
A new desktop needs a new translation daemon, not changes here.
Both `imsd-dialerd` and `imsd-ofonod` are pure translations of this ABI; a new
desktop needs a new translation daemon, not changes here.
## License

File diff suppressed because it is too large Load diff

View file

@ -410,56 +410,41 @@ std::size_t RtpPayloadOffset(std::span<const std::uint8_t> pkt) {
return off;
}
// The desktop session whose PipeWire instance carries call audio, resolved
// ONCE and thread-safely: getpwnam() hands out one static buffer, and the
// mic and playout threads spawn their pw helpers at the same instant — the
// race sent both into /run/user/0, where no PipeWire session lives, and a
// call was answered into static (dev phone, 2026-09-08 20:01: tx_mic=0
// rx_played=0 qdrop=883). AUDIO_USER names the account: imsd sets it per
// call from logind's seat0 session (or its own AUDIO_USER), and sets it
// empty when nobody is logged in. The packaged default used to be
// postmarketOS's account name `user`: a unit whose account was called
// otherwise got no audio and no message (alyx report 14, 2026-09-20).
// That default stays for a caller that does not set the variable at all —
// an imsd older than 0.3.10, still running while an upgrade replaces this
// binary, or a hand run — so its calls keep their audio.
// Logged per call so a journal shows which session the helpers were aimed at.
struct AudioSession { uid_t uid = 0; std::string name; };
const AudioSession& Session() {
// The desktop user's uid, resolved ONCE and thread-safely. getpwnam() hands
// out one static buffer, and the mic and playout threads spawn their pw
// helpers at the same instant — the race sent both into /run/user/0, where
// no PipeWire session lives, and a call was answered into static (dev phone,
// 2026-09-08 20:01: tx_mic=0 rx_played=0 qdrop=883). Logged per call so a
// journal shows which session the helpers were aimed at.
uid_t AudioUid() {
static std::once_flag once;
static AudioSession s;
static uid_t uid = 0;
std::call_once(once, [] {
std::string user = EnvOr("AUDIO_USER", "user");
passwd pw{};
passwd* res = nullptr;
char buf[4096];
const char* given = std::getenv("AUDIO_USER");
std::string want = given ? given : "user";
if (!given) std::println(std::cerr, "imsd-media: no AUDIO_USER in the environment (a caller older than 0.3.10, or a hand run); trying the account `user`");
if (want.empty())
std::println(std::cerr, "imsd-media: AUDIO_USER empty (no session to aim at); the helpers run as uid 0 (no audio)");
else if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) { s.uid = res->pw_uid; s.name = want; }
if (getpwnam_r(user.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) uid = res->pw_uid;
else
std::println(std::cerr, "imsd-media: AUDIO_USER '{}' is not an account; the helpers run as uid 0 (no audio)", want);
std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", s.name.empty() ? std::string("(none)") : s.name, s.uid);
std::println(std::cerr, "imsd-media: AUDIO_USER '{}' not found; no PipeWire session for uid 0", user);
std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", user, uid);
});
return s;
return uid;
}
// Spawn pw-record/pw-play in the desktop session's PipeWire instance (sudo -u
// when run as root; as ourselves when there is no session to aim at).
// `toChild` true = we write the child's stdin (pw-play); false = we read its
// stdout (pw-record). Returns {pid, fd}.
// Spawn pw-record/pw-play in the desktop user's PipeWire session (sudo -u
// when run as root). AUDIO_USER names the session owner; the default "user"
// is postmarketOS's standard account. `toChild` true = we write the child's
// stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}.
struct Child { pid_t pid = -1; int fd = -1; };
Child SpawnPw(bool play, bool toChild, int rate) {
int pipefd[2];
if (pipe(pipefd) != 0) return {};
std::vector<std::string> argv;
if (geteuid() == 0) {
const AudioSession& session = Session();
if (!session.name.empty())
argv = {"sudo", "-u", session.name, "env",
std::format("XDG_RUNTIME_DIR=/run/user/{}", session.uid)};
std::string user = EnvOr("AUDIO_USER", "user");
argv = {"sudo", "-u", user, "env",
std::format("XDG_RUNTIME_DIR=/run/user/{}", AudioUid())};
}
const char* tool = play ? "pw-play" : "pw-record";
const char* lat = play ? "40ms" : "20ms";

View file

@ -45,12 +45,11 @@ plugins/provider/ofono/ — get these wrong and the plugin silently ignores us:
Runs as root because `org.ofono` is a system-bus name (policy in
packaging/org.ofono.conf). It does no I/O beyond D-Bus: every call decision
stays in imsd. Its calls reach imsd as root, so it asks polkit about its own
callers (ImsdBus): call control is net.catcrafts.IMS1.Voice, as on imsd.
Everything happens on the GLib main loop, so there is no locking.
stays in imsd. Everything happens on the GLib main loop, so there is no
locking.
--session own org.ofono on the session bus and talk to imsd there
(development: no root, no policy file, no polkit)
(development: no root, no policy file)
IMSD_BUS=session|system override only the imsd side of that choice
*/
@ -58,7 +57,6 @@ Everything happens on the GLib main loop, so there is no locking.
#include <glib-unix.h>
import std;
import ImsdBus;
namespace {
@ -317,9 +315,7 @@ void HandleCallMethod(GDBusConnection*, const gchar*, const gchar* objectPath, c
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
}
bool IsRead(std::string_view method) { return method == "GetProperties" || method == "GetCalls" || method == "GetModems"; }
constexpr char ErrAccessDenied[] = "org.ofono.Error.AccessDenied";
const GDBusInterfaceVTable CallVtable = { imsd::bus::Gated<HandleCallMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} };
guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) {
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr);
@ -600,9 +596,9 @@ void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*,
}
const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} };
const GDBusInterfaceVTable ModemVtable = { imsd::bus::Gated<HandleModemMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
const GDBusInterfaceVTable VcmVtable = { imsd::bus::Gated<HandleVcmMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
const GDBusInterfaceVTable SsVtable = { imsd::bus::Gated<HandleSsMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} };
const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} };
const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} };
// ---- imsd events ------------------------------------------------------------
void PublishVoiceState() {
@ -731,7 +727,6 @@ int main(int argc, char** argv) {
if (std::string_view(argv[i]) == "--session") session = true;
GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;
imsd::bus::AuthorizeCalls = !session;
std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system");
GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;

View file

@ -3,9 +3,7 @@
// lint-disable-file fixed-width-types
/*
Imsd:Engine — the daemon's decisions as pure reducers: the call state
machine (CallMachine) and the registration recovery policy (RegRecovery,
its own section below).
Imsd:Engine — the call state machine as a pure reducer.
A CallMachine owns one call's SIP dialog, outgoing or incoming. It never
touches a socket, a clock, or a subprocess: each event (the INVITE's
@ -54,215 +52,6 @@ export namespace imsd::engine {
return d == Direction::Incoming ? "incoming" : "outgoing";
}
// ---- registration recovery policy ---------------------------------
//
// What the daemon does when the registration stops working after
// bring-up, as a pure reducer: the shell supplies the clock and the
// network, this decides the next step and how long to wait for it. Two
// kinds of failure reach it. A REFUSAL: the network answered a refresh
// (or a fresh REGISTER) with a final other than 200/401. SILENCE: the
// flow died and reconnecting through the SA, or the refresh over it,
// got no answer. Neither is retried for long on the same registration.
// TS 24.229 5.1.1.4.1 has the UE perform an initial registration after
// a 408, 500, 504 or 403 on a re-REGISTER (5.1.1.2.1: not before a
// Retry-After has passed), and RFC 5626 has it register again when its
// flow cannot be recovered. imsd does so after any refusal: the 503s
// below are not in that list. Before this reducer the daemon looped
// reconnect + re-REGISTER on the same Call-ID and SA forever — O2 UK,
// 2026-09-24: 21 × 503 over 70 min, then 33 unanswered reconnect
// cycles over 2 h 39 min, each ended only by a manual restart whose
// fresh registration the network accepted within two seconds (ims
// ledger F16).
//
// Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates
// to a fresh registration after two (one 503 may be a momentary
// hiccup a retried refresh clears), silence after two reconnect cycles
// that end without an answer — a TCP connect series whose last attempt
// was not a local refusal, a bind() the kernel refused, or a refresh
// that fails short of a refusal: unanswered, an unusable challenge, a
// failed send (the resume at start gives up after two unanswered
// connects).
// A fresh registration is the expensive step: an AKA run, a
// new SA pair, and on KPN a refusal (spi-s=0) while the registration on
// the address is live, which costs that registration. So a refused or
// throttled fresh attempt
// waits on the throttle schedule (21 min, growing ×1.5 to 60) and a
// silent one backs off 30 s → 10 min. Retry-After (RFC 3261 20.33)
// always wins over a shorter wait. A 403 is a refusal like the others:
// never hammered (the schedule), never abandoned (a SIM that is
// unbarred registers within the hour); once a fresh registration is
// due it also says the old registration is gone (KPN drops it the
// moment an initial REGISTER arrives, throttled or not), as does a
// fresh attempt that got past the SA setup, a binding the network
// terminated, and a resume that failed at bring-up (OldGone) — no
// reconnect over the old SA from then on. Every wait
// is either the self-imposed backoff, which a user's Dial cuts short,
// or the network's own word (a Retry-After, the schedule after a
// refused or throttled fresh attempt, a terminated binding's probation
// or its refusal schedule), which nothing cuts short — a
// restart of the process neither: the shell keeps the holds (Holds)
// and hands them to the next one (Restore). The shell supplies the
// clock and the network; this holds the deadlines and names the next
// step (Next). Success resets everything.
struct RegRecovery {
enum class Failure { Refused, Silent, Throttled };
enum class Step { Reconnect, Fresh };
// What comes next: the step, and the earliest time it may run (the
// shell's clock; at <= now means now).
struct Plan { Step step; double at; };
static constexpr int RefusalsBeforeFresh = 2;
static constexpr int SilentCyclesBeforeFresh = 2;
static constexpr double ReconnectDelayMin = 5;
static constexpr double ReconnectDelayMax = 300;
static constexpr double FreshDelayMin = 30;
static constexpr double FreshDelayMax = 600;
static constexpr int ThrottleMinutesMin = 21;
static constexpr int ThrottleMinutesMax = 60;
// The network's word is honoured up to the schedule's own longest
// wait: a Retry-After beyond an hour is cut to it, and the refusal
// heard again then — one REGISTER an hour at most, never a day
// unregistered on one answer.
static constexpr long RetryAfterMax = ThrottleMinutesMax * 60;
// A connect refused locally — our own 4-tuple still in TIME_WAIT
// (up to 60 s after we closed it), no route yet — is not the
// network's answer: retried soon, never counted, the backoff
// untouched. Dev phone 2026-09-24: four such refusals in 26 s read
// as two silent cycles cost a fresh registration into the throttle.
static constexpr double LocalRetry = 5;
int refusals = 0;
int silentCycles = 0;
int freshAttempts = 0;
double reconnectDelay = ReconnectDelayMin;
double freshDelay = FreshDelayMin;
int throttleMinutes = ThrottleMinutesMin;
bool fresh = false; // the next registration attempt is a fresh one
bool oldGone = false; // the old registration is gone: nothing to reconnect over
bool terminated = false; // the network terminated our binding, and the policy has counted it
// the deadlines, on the shell's clock
double backoffUntil = 0; // self-imposed: no step before this; a Dial clears it
double holdUntil = 0; // the network's word for any attempt (a Retry-After on a refresh): nothing clears it
double freshHoldUntil = 0; // no fresh attempt before this (the schedule after a refused or throttled fresh attempt, a fresh Retry-After, probation, rejected/unregistered)
bool Fresh() const { return fresh; }
bool Reconnectable() const { return !oldGone; }
// The network's holds as seconds still to run, and where the
// schedule stands: what a restart of the process must not forget.
struct Held {
double hold = 0;
double freshHold = 0;
int throttleMinutes = ThrottleMinutesMin;
bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over
// a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; }
};
Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; }
// A restarted process takes them over; nothing here shortens a
// hold it already has, or lengthens one past RetryAfterMax.
void Restore(double now, const Held& h) {
if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast<double>(RetryAfterMax)));
if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast<double>(RetryAfterMax)));
throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax);
if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off
}
// The next step and when. Not escalated: a reconnect after the
// backoff and any hold. Escalated: the fresh attempt once the
// backoff, any hold and the fresh hold have passed — and while a
// fresh hold still runs, a reconnect over the old SA meanwhile, the
// cheap thing, unless that registration is gone.
Plan Next(double now) const {
double any = std::max(backoffUntil, holdUntil);
double freshAt = std::max(any, freshHoldUntil);
if (!fresh) return {Step::Reconnect, any};
if (!oldGone && freshHoldUntil > any && now < freshHoldUntil) return {Step::Reconnect, any};
return {Step::Fresh, freshAt};
}
void OnLocalRefusal(double now) { backoffUntil = now + LocalRetry; }
// A refused keepalive refresh, or a failed reconnect + refresh
// cycle; `status` is the refusal's. The backoff paces the next step;
// a Retry-After holds every attempt for its own length (up to
// RetryAfterMax), whichever is longer. A 403 once a fresh
// registration is due says the old registration is gone (KPN drops
// it the moment an initial REGISTER arrives, throttled or not).
void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) {
if (retryAfter > 0) holdUntil = std::max(holdUntil, now + static_cast<double>(std::min(retryAfter, RetryAfterMax)));
// A cycle over a registration already known gone — the network
// terminated the binding while the cycle waited for its final —
// fails by definition: not a silent cycle, not a refusal, and no
// backoff before the fresh registration that verdict scheduled.
if (oldGone) return;
if (f == Failure::Refused) refusals++;
else silentCycles++;
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
if (status == 403 && fresh) oldGone = true;
backoffUntil = now + reconnectDelay;
reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax);
}
// A failed fresh registration; `saReplaced`: it got past the SA
// setup, so the old registration's SAs went with it. Refusals and
// throttles hold the next fresh attempt on the schedule (a Retry-After
// replaces it, never below the short backoff's floor nor above
// RetryAfterMax); silence backs
// it off 30 s → 10 min. A reconnect over the old SA meanwhile waits
// at least the reconnect backoff.
void OnFreshFailure(double now, Failure f, long retryAfter = 0, bool saReplaced = false) {
freshAttempts++;
fresh = true;
if (saReplaced) oldGone = true;
backoffUntil = std::max(backoffUntil, now + reconnectDelay);
if (f == Failure::Silent) {
backoffUntil = std::max(backoffUntil, now + freshDelay);
freshDelay = std::min(freshDelay * 2, FreshDelayMax);
return;
}
double wait = throttleMinutes * 60.0;
throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax);
if (retryAfter > 0) wait = std::max(static_cast<double>(std::min(retryAfter, RetryAfterMax)), FreshDelayMin);
freshHoldUntil = now + wait;
}
// The network terminated our binding (a reg-event NOTIFY, RFC
// 3680): the old registration is gone. deactivated and expired
// mean a fresh registration now; probation after the network's
// retry-after (up to RetryAfterMax); rejected and unregistered on the
// refusal schedule —
// the network's word is neither hammered nor taken as final. One
// termination counts once, however often it is reported (every
// reg-event dialog of the account carries it, a NOTIFY may be
// retransmitted), and never shortens a hold already running.
void OnBindingTerminated(double now, std::string_view event, long retryAfter = 0) {
bool reported = terminated;
terminated = true;
oldGone = true;
fresh = true;
if (event == "probation") {
freshHoldUntil = std::max(freshHoldUntil, now + static_cast<double>(std::clamp(retryAfter, 1L, RetryAfterMax)));
} else if (reported) {
return;
} else if (event == "rejected" || event == "unregistered") {
freshHoldUntil = std::max(freshHoldUntil, now + throttleMinutes * 60.0);
throttleMinutes = std::min(throttleMinutes * 3 / 2, ThrottleMinutesMax);
} else {
backoffUntil = 0;
}
}
// The stored registration is not there to reconnect over (a resume
// that failed at bring-up found its SAs unusable, or there are none).
void OldGone() { oldGone = true; fresh = true; }
// A user's Dial cuts the backoff short, never the network's word.
void OnDial() { backoffUntil = 0; }
void OnSuccess() { *this = RegRecovery{}; }
};
// Parsed SDP answer needed to bring up the media leg. `codec` is one of
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
struct MediaLeg {

View file

@ -400,17 +400,4 @@ export namespace imsd::ipsec {
sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs);
return sa;
}
// Every local address holding an SA to the P-CSCF, once each, in kernel
// order: where a registration made on another address of the link (the
// kernel's temporary one, before 0.3.10 — ims ledger F18) still lives.
std::vector<std::string> SaLocals(std::string_view stateText, std::string_view pcscf) {
std::vector<std::string> locals;
for (std::string_view b : detail::SrcBlocks(stateText)) {
std::string_view src = detail::TokenAfter(b, "src ");
if (src.empty() || detail::TokenAfter(b, "dst ") != pcscf) continue;
if (std::ranges::find(locals, src) == locals.end()) locals.emplace_back(src);
}
return locals;
}
}

View file

@ -121,93 +121,6 @@ export namespace imsd::sip {
return v;
}
// True for a response of the transaction (callid, cseq) — the reply a
// wait for that request may judge. A request, another transaction's
// response or anything else a socket delivers is not.
bool ResponseOf(std::string_view msg, std::string_view callid, std::string_view cseq) {
return Status(msg).has_value() && Header(msg, "Call-ID") == callid && Header(msg, "CSeq") == cseq;
}
// Retry-After of a response (RFC 3261 20.33), in seconds; nullopt when
// absent or not a number.
std::optional<long> RetryAfter(std::string_view msg) {
auto v = Header(msg, "Retry-After");
if (!v) return std::nullopt;
return LeadingDigits(*v);
}
// A refused final for the journal: its status line, then the headers
// that say who refused and for how long — Retry-After (RFC 3261 20.33),
// Warning (20.43: the node's own words), Reason (RFC 3326), Server
// (20.35). "503 Service Unavailable; Retry-After: 60; Warning: 399
// pcscf1.example \"...\"". A bare status code says neither which node
// nor why: O2 UK answered a refresh 503 twenty-one times and a
// subscription 500 (alyx, 2026-09-24) and the journal could not tell
// the P-CSCF from the S-CSCF (ims ledger F17).
std::string RefusalSummary(std::string_view msg) {
std::string_view line = msg.substr(0, msg.find_first_of("\r\n"));
if (line.starts_with("SIP/2.0 ")) line.remove_prefix(8);
std::string s(Trim(line));
for (std::string_view name : {"Retry-After", "Warning", "Reason", "Server"})
for (std::string_view v : Headers(msg, name))
s += std::format("; {}: {}", name, v);
return s;
}
// Our binding in a reg-event NOTIFY body (RFC 3680 reginfo): the
// <contact> whose <uri> carries `user` as its SIP user part, across every
// <registration> of the implicit set (the sip: and the tel: identity
// list the same contact). Active anywhere = Active; otherwise the first
// terminated occurrence's event and retry-after attribute; NotListed
// when no contact of ours is in the document — a partial update about
// other bindings, or a previous registration's binding (the contact our
// own fresh registration deactivated, O2 UK 2026-09-24).
struct RegInfoContact {
enum class State { NotListed, Active, Terminated };
State state = State::NotListed;
std::string event; // terminated: deactivated | probation | rejected | unregistered | expired | …
long retryAfter = 0; // terminated: the retry-after attribute (probation), seconds
};
RegInfoContact RegInfoContactState(std::string_view reginfo, std::string_view user) {
constexpr std::size_t npos = std::string_view::npos;
RegInfoContact out;
std::string needle = std::format(":{}@", user); // sip:<user>@… / sips:<user>@…
std::size_t pos = 0;
while ((pos = reginfo.find("<contact", pos)) != npos) {
std::size_t tagEnd = reginfo.find('>', pos);
if (tagEnd == npos) break;
std::string_view tag = reginfo.substr(pos, tagEnd - pos);
std::size_t end = reginfo.find("</contact>", tagEnd);
std::string_view body = reginfo.substr(tagEnd + 1, end == npos ? npos : end - tagEnd - 1);
pos = end == npos ? reginfo.size() : end + 10;
std::size_t u = body.find("<uri>");
if (u == npos) continue;
std::size_t ue = body.find("</uri>", u);
std::string_view uri = Trim(body.substr(u + 5, ue == npos ? npos : ue - u - 5));
if (!uri.contains(needle)) continue;
auto attr = [&](std::string_view name) -> std::string_view {
std::string key = std::format(" {}=\"", name);
std::size_t a = tag.find(key);
if (a == npos) return {};
std::size_t b = tag.find('"', a + key.size());
return tag.substr(a + key.size(), b == npos ? npos : b - a - key.size());
};
std::string_view state = attr("state");
if (state == "active") {
out.state = RegInfoContact::State::Active;
out.event.clear();
out.retryAfter = 0;
return out;
}
if (state == "terminated" && out.state == RegInfoContact::State::NotListed) {
out.state = RegInfoContact::State::Terminated;
out.event = std::string(attr("event"));
out.retryAfter = LeadingDigits(attr("retry-after")).value_or(0);
}
}
return out;
}
// Where a UDP response to this request must go (RFC 3261 18.2.2): the
// top Via's sent-by port — unless the Via carries rport, in which case
// the caller should reply to the request's source port instead

View file

@ -1,106 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types no-char-pointer
/*
ImsdBus — what the two system-bus daemons (imsd, imsd-ofonod) share on the
bus side: the polkit gate in front of call control.
The bus policies let any local account reach both names, so the session
account can dial whatever it is called; whether a caller may place, answer
or end a call is polkit's net.catcrafts.IMS1.Voice
(packaging/net.catcrafts.IMS1.policy), shaped like ModemManager's Voice
action: the active local session and root, not an ssh login, an inactive
session or a system service account. Reads (status, the call list) stay
open, as ModemManager's are to every local account; the call signals reach
them all anyway.
GLib, so not part of the GLib-free imsd-core.
*/
module;
#include <gio/gio.h>
export module ImsdBus;
import std;
namespace imsd::bus {
constexpr const char* VoiceAction = "net.catcrafts.IMS1.Voice";
// CheckAuthorization's AllowUserInteraction, as ModemManager passes it: a
// rule an administrator sets to auth_self prompts through the session's
// polkit agent.
constexpr guint32 AllowUserInteraction = 1;
struct Pending {
GDBusMethodInvocation* inv;
GDBusInterfaceMethodCallFunc body;
const char* deniedError;
void (*log)(std::string_view);
};
void OnCheck(GObject* src, GAsyncResult* res, gpointer data) {
std::unique_ptr<Pending> p(static_cast<Pending*>(data));
GDBusMethodInvocation* inv = p->inv;
GError* err = nullptr;
GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err);
gboolean authorized = FALSE;
gboolean challenge = FALSE;
if (r) {
GVariant* details = nullptr;
g_variant_get(r, "((bb@a{ss}))", &authorized, &challenge, &details);
g_variant_unref(details);
g_variant_unref(r);
}
if (authorized) {
p->body(g_dbus_method_invocation_get_connection(inv), g_dbus_method_invocation_get_sender(inv),
g_dbus_method_invocation_get_object_path(inv), g_dbus_method_invocation_get_interface_name(inv),
g_dbus_method_invocation_get_method_name(inv), g_dbus_method_invocation_get_parameters(inv), inv,
g_dbus_method_invocation_get_user_data(inv));
return;
}
std::string why = err ? std::format("{}: polkit did not answer: {}", g_dbus_method_invocation_get_sender(inv), err->message)
: std::format("{} is not authorized for {}", g_dbus_method_invocation_get_sender(inv), VoiceAction);
p->log(std::format("{} refused: {}", g_dbus_method_invocation_get_method_name(inv), why));
g_dbus_method_invocation_return_dbus_error(inv, p->deniedError, why.c_str());
if (err) g_error_free(err);
}
} // namespace imsd::bus
export namespace imsd::bus {
// Off for a --session run: the session bus has no polkit, and its callers
// are the owner's own processes.
inline bool AuthorizeCalls = true;
// Run `body` for the call `inv` once polkit allows its sender
// net.catcrafts.IMS1.Voice; otherwise answer `deniedError` and log why. The
// check is asynchronous — nothing else on the main loop waits for it.
void Authorize(GDBusMethodInvocation* inv, GDBusInterfaceMethodCallFunc body, const char* deniedError, void (*log)(std::string_view)) {
GVariantBuilder subject;
g_variant_builder_init(&subject, G_VARIANT_TYPE("a{sv}"));
g_variant_builder_add(&subject, "{sv}", "name", g_variant_new_string(g_dbus_method_invocation_get_sender(inv)));
GVariantBuilder details;
g_variant_builder_init(&details, G_VARIANT_TYPE("a{ss}"));
g_dbus_connection_call(g_dbus_method_invocation_get_connection(inv), "org.freedesktop.PolicyKit1",
"/org/freedesktop/PolicyKit1/Authority", "org.freedesktop.PolicyKit1.Authority",
"CheckAuthorization",
g_variant_new("((sa{sv})sa{ss}us)", "system-bus-name", &subject, VoiceAction, &details, AllowUserInteraction, ""),
G_VARIANT_TYPE("((bba{ss}))"), G_DBUS_CALL_FLAGS_NONE, G_MAXINT, nullptr, OnCheck,
new Pending{ inv, body, deniedError, log });
}
// A vtable method handler for `Body` that lets `IsRead` methods through and
// puts every other method — call control, and anything added later — behind
// Authorize.
template <GDBusInterfaceMethodCallFunc Body, bool (*IsRead)(std::string_view), const char* DeniedError, void (*Log)(std::string_view)>
void Gated(GDBusConnection* conn, const gchar* sender, const gchar* path, const gchar* iface, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer user) {
if (!AuthorizeCalls || IsRead(method)) {
Body(conn, sender, path, iface, method, params, inv, user);
return;
}
Authorize(inv, Body, DeniedError, Log);
}
} // namespace imsd::bus

858
lint-rules.h Normal file
View file

@ -0,0 +1,858 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#pragma once
// This repo's house-style lint rules. Deliberately project-local — the rule
// set is NOT part of the crafter-build library, so downstream consumers are
// never nudged toward one team's style. Include from project.cpp AFTER
// `import std;` and `import Crafter.Build;` (the header uses both and has no
// includes of its own), then call AddProjectLintRules(cfg).
//
// Report-only rules are registered first so their file:line numbers always
// match the on-disk file; transforms run last because each one sees the
// previous transform's output, which can shift line numbers.
//
// All detection runs on ctx.CommentStripped() — comments and string/char
// literal bodies are blanked to spaces with newlines preserved, so byte
// offsets and line numbers in the stripped text are valid in ctx.content.
// Transforms use that property directly: find in stripped, edit in content.
namespace ProjectLint {
inline bool IsCppFile(const Crafter::LintContext& ctx) {
std::string ext = ctx.file.extension().string();
return ext == ".cpp" || ext == ".cppm" || ext == ".h";
}
inline std::vector<std::string_view> Lines(std::string_view text) {
std::vector<std::string_view> lines;
std::size_t start = 0;
while (start <= text.size()) {
std::size_t end = text.find('\n', start);
if (end == std::string_view::npos) {
if (start < text.size()) lines.push_back(text.substr(start));
break;
}
lines.push_back(text.substr(start, end - start));
start = end + 1;
}
return lines;
}
inline std::string_view Trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1);
return s;
}
inline bool IsWordChar(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
}
// Net '(' minus ')' on one stripped line.
inline std::int64_t ParenDelta(std::string_view s) {
std::int64_t d = 0;
for (char c : s) {
if (c == '(') ++d;
if (c == ')') --d;
}
return d;
}
inline bool IsPascalCase(std::string_view name) {
return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_');
}
inline bool IsCamelCase(std::string_view name) {
// One trailing underscore is the member-shadowing-a-keyword convention
// (requires_, label_) — allowed.
if (name.ends_with('_')) name.remove_suffix(1);
return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos;
}
// The identifier ending right before position `pos` (exclusive), or empty.
inline std::string_view WordBefore(std::string_view s, std::size_t pos) {
std::size_t end = pos;
while (end > 0 && (s[end - 1] == ' ' || s[end - 1] == '\t')) --end;
std::size_t begin = end;
while (begin > 0 && (IsWordChar(s[begin - 1]) || s[begin - 1] == '~')) --begin;
return s.substr(begin, end - begin);
}
inline void AddProjectLintRules(Crafter::Configuration& cfg) {
using Crafter::LintContext;
// ---------------- report-only rules ----------------
// License header: SPDX identifier on line 1, copyright on line 2, blank
// line 3.
cfg.AddLintRule("spdx-header", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
if (!ctx.Line(1).starts_with("// SPDX-License-Identifier:")) {
ctx.Report(1, "first line must be // SPDX-License-Identifier: ...");
}
if (!ctx.Line(2).starts_with("// SPDX-FileCopyrightText:")) {
ctx.Report(2, "second line must be // SPDX-FileCopyrightText: ...");
}
if (ctx.lines.size() >= 3 && !Trim(ctx.Line(3)).empty()) {
ctx.Report(3, "third line must be blank (separates the license header)");
}
});
cfg.AddLintRule("no-tabs", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
}
});
// Naming: functions/types PascalCase, variables camelCase, statics and
// namespace-scope globals PascalCase. A line-based scope tracker decides
// whether a declaration sits at namespace scope (global) or inside a
// function/type (local/member). Heuristic by nature — report-only.
cfg.AddLintRule("naming", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
enum class Scope { Namespace, Type, Function, Other };
std::vector<Scope> stack;
auto currentScope = [&]() { return stack.empty() ? Scope::Namespace : stack.back(); };
static const std::regex typeDecl(R"(\b(?:class|struct|union)\s+(?:CRAFTER_API\s+)?([A-Za-z_]\w*))");
static const std::regex enumDecl(R"(\benum\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*))");
static const std::regex usingDecl(R"(^\s*using\s+([A-Za-z_]\w*)\s*=)");
static const std::regex varDecl(
R"(^\s*((?:static|constexpr|const|inline|mutable|thread_local|export|CRAFTER_API)\s+)*)"
R"((?:std::)?[A-Za-z_][\w:]*(?:<[^;={]*>)?(?:\s*[&*])*\s+([A-Za-z_]\w*)\s*(=|;|\{))");
static const std::unordered_set<std::string_view> keywords = {
"if", "for", "while", "switch", "catch", "return", "else", "do", "case", "goto",
"new", "delete", "throw", "using", "namespace", "template", "typedef", "friend",
"public", "private", "protected", "class", "struct", "enum", "union", "import",
"module", "export", "break", "continue", "co_return", "co_await", "co_yield",
"sizeof", "alignof", "decltype", "static_assert", "operator", "try", "requires",
};
// Cumulative paren depth at the start of each line: declaration and
// function checks only run at depth 0, so wrapped parameter lists and
// continuation lines (`) {` closers) never look like declarations.
std::int64_t parenDepth = 0;
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string_view trimmed = Trim(lines[i]);
std::string lineStr(lines[i]);
std::smatch m;
bool atDepth0 = parenDepth == 0;
parenDepth = std::max<std::int64_t>(0, parenDepth + ParenDelta(lines[i]));
if (!trimmed.starts_with('#') && atDepth0) {
// Type / alias names must be PascalCase.
if (std::regex_search(lineStr, m, typeDecl) || std::regex_search(lineStr, m, enumDecl)) {
std::string name = m[1].str();
if (!keywords.contains(name) && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("type '{}' should be PascalCase", name));
}
}
if (std::regex_search(lineStr, m, usingDecl) && !IsPascalCase(m[1].str())) {
ctx.Report(i + 1, std::format("type alias '{}' should be PascalCase", m[1].str()));
}
// Function definitions: identifier before the first '(' on a
// line that ends where a body opens or closes — a multi-line
// def's trailing '{' or a one-liner's trailing '}'. Statement
// calls with inline lambda arguments look similar
// (`bool x = std::any_of(..., [](T v) { ... });`) but end in
// ';' and/or carry '=' before the name — both excluded.
// Lambdas ("](") and control keywords are skipped; ctors/
// dtors pass the Pascal check by construction; `main` and
// operators exempt.
std::size_t bodyBrace = trimmed.find('{');
bool functionShaped = trimmed.ends_with('{') || trimmed.ends_with('}');
if (functionShaped && currentScope() != Scope::Function && !trimmed.starts_with("return")) {
std::size_t paren = trimmed.find('(');
if (paren != std::string_view::npos && paren > 0 && paren < bodyBrace) {
std::string_view name = WordBefore(trimmed, paren);
char before = trimmed[paren - 1];
bool looksLikeDef = !name.empty() && IsWordChar(before)
&& !keywords.contains(name) && name != "main"
&& !name.starts_with('~');
// Require a type token before the name (or a
// qualified Class::Name), with no '=' in front —
// plain calls and initializations don't match.
if (looksLikeDef) {
std::size_t nameStart = trimmed.rfind(name, paren);
std::string_view prefix = Trim(trimmed.substr(0, nameStart));
looksLikeDef = !prefix.empty() && !prefix.contains('=')
&& (IsWordChar(prefix.back()) || prefix.back() == '>'
|| prefix.back() == '*' || prefix.back() == '&'
|| prefix.ends_with("::"));
}
if (looksLikeDef && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("function '{}' should be PascalCase", name));
}
}
}
// Variable declarations: camelCase locally, PascalCase for
// statics and namespace-scope globals.
if (std::regex_search(lineStr, m, varDecl)) {
std::string name = m[2].str();
std::string_view typeToken = Trim(std::string_view(lineStr).substr(0, static_cast<std::size_t>(m.position(2))));
std::string_view firstWord = typeToken.substr(0, typeToken.find_first_of(" \t<"));
if (!keywords.contains(firstWord) && !keywords.contains(name)) {
bool isStatic = lineStr.contains("static ");
// constexpr variables are compile-time constants —
// constant naming (PascalCase) like statics/globals.
bool isConstexpr = lineStr.contains("constexpr ");
bool global = currentScope() == Scope::Namespace;
if ((isStatic || global || isConstexpr) && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("{} '{}' should be PascalCase",
isStatic ? "static variable"
: isConstexpr ? "constexpr constant"
: "global variable", name));
} else if (!isStatic && !global && !isConstexpr && !IsCamelCase(name)) {
ctx.Report(i + 1, std::format("variable '{}' should be camelCase", name));
}
}
}
}
// Scope tracking: classify each '{' opened on this line; pop on '}'.
for (std::size_t c = 0; c < lines[i].size(); ++c) {
if (lines[i][c] == '{') {
Scope kind = Scope::Other;
std::string_view upTo = Trim(lines[i].substr(0, c));
if (trimmed.starts_with("namespace") || upTo.contains("namespace ")) {
kind = Scope::Namespace;
} else if (std::regex_search(lineStr, typeDecl) || std::regex_search(lineStr, enumDecl)) {
kind = Scope::Type;
} else if (upTo.ends_with(')') || upTo.ends_with("const") || upTo.ends_with("noexcept")
|| upTo.ends_with("->") || trimmed.starts_with("extern")) {
kind = Scope::Function; // function/lambda/control body — all non-global
}
stack.push_back(kind);
} else if (lines[i][c] == '}') {
if (!stack.empty()) stack.pop_back();
}
}
}
});
cfg.AddLintRule("enum-class", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex plainEnum(R"(\benum\s+(?!class\b|struct\b)[A-Za-z_])");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
if (std::regex_search(lineStr, plainEnum)) {
ctx.Report(i + 1, "use enum class instead of plain enum");
}
}
});
cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
for (std::size_t i = 0; i < lines.size(); ++i) {
if (lines[i].contains("std::cout")) {
ctx.Report(i + 1, "use std::println instead of std::cout");
} else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) {
ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr");
}
}
});
// Prefer std::string / std::string_view over char*. OS interop stays:
// getenv returns char*, argv is char**, extern "C" prototypes mirror C.
cfg.AddLintRule("no-char-pointer", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex charPtr(R"(\bchar\s*\*)");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
// C-interop stays char*: argv, getenv/setenv, dlerror, C APIs fed
// by c_str()/data(), binary IO reinterpret_casts, extern "C"
// prototypes. (extern " matches with the literal body blanked.)
if (lineStr.contains("argv") || lineStr.contains("getenv") || lineStr.contains("setenv")
|| lineStr.contains("dlerror") || lineStr.contains("c_str") || lineStr.contains(".data(")
|| lineStr.contains("reinterpret_cast") || lineStr.contains("extern \"")) continue;
if (std::regex_search(lineStr, charPtr)) {
ctx.Report(i + 1, "prefer std::string / std::string_view over char*");
}
}
});
// String building via `+` with a literal operand → std::format. Plain
// `a += b` accumulation (builder pattern) stays legal. Single-line chains
// of simple operands (identifier / member / call chains, parenthesized
// groups, literals) are REWRITTEN automatically; anything the operand
// scanner can't prove safe — raw-string lines, ternaries, mixed
// operators, multi-line expressions — is reported for a human instead.
cfg.AddLintRule("format-concat", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
std::vector<std::string_view> stripped = Lines(code);
// Walk one operand leftwards from `from` (exclusive). Returns the
// operand's begin, or npos when the shape isn't a simple postfix
// chain (then the whole chain bails to a report).
auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t {
std::size_t i = from;
while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i;
bool any = false;
for (;;) {
if (i == 0) break;
char c = s[i - 1];
if (c == ')' || c == ']') {
char open = c == ')' ? '(' : '[';
std::size_t depth = 0;
do {
--i;
if (s[i] == c) ++depth;
if (s[i] == open) --depth;
if (depth == 0) break;
} while (i > 0);
if (depth != 0) return std::string_view::npos;
any = true;
// A group directly preceded by an identifier (or another
// group) is a call/index postfix — keep consuming the
// callee: `path.string()` is ONE operand, not `()`.
if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue;
} else if (c == '"') {
--i; // closing quote; interior is blanked, find the opener
while (i > 0 && s[i - 1] != '"') --i;
if (i == 0) return std::string_view::npos;
--i;
any = true;
} else if (IsWordChar(c)) {
while (i > 0 && IsWordChar(s[i - 1])) --i;
any = true;
} else if (c == '.' && any) {
--i;
continue;
} else if (c == ':' && i > 1 && s[i - 2] == ':' && any) {
i -= 2;
continue;
} else if (c == '>' && i > 1 && s[i - 2] == '-' && any) {
i -= 2;
continue;
} else {
break;
}
// After a primary, only connectors continue the operand.
if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':')
|| (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue;
break;
}
if (!any) return std::string_view::npos;
// A unary operator, ternary, or other-precedence operator in
// front means expression structure we don't reason about — bail.
std::size_t b = i;
while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b;
if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos;
return i;
};
// Walk one operand rightwards from `from` (inclusive). Returns one
// past the operand's end, or npos on bail.
auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t {
std::size_t i = from;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
bool any = false;
for (;;) {
if (i >= s.size()) break;
char c = s[i];
if (c == '(' || c == '[') {
char close = c == '(' ? ')' : ']';
std::size_t depth = 0;
while (i < s.size()) {
if (s[i] == c) ++depth;
if (s[i] == close && --depth == 0) { ++i; break; }
++i;
}
if (depth != 0) return std::string_view::npos;
any = true;
} else if (c == '"') {
++i;
while (i < s.size() && s[i] != '"') ++i;
if (i >= s.size()) return std::string_view::npos;
++i;
any = true;
} else if (IsWordChar(c)) {
while (i < s.size() && IsWordChar(s[i])) ++i;
any = true;
} else if (any && c == '.') {
++i;
continue;
} else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') {
i += 2;
continue;
} else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') {
i += 2;
continue;
} else {
break;
}
if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.'
|| (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':')
|| (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue;
break;
}
return any ? i : std::string_view::npos;
};
struct Edit { std::size_t begin; std::size_t end; std::string replacement; };
std::vector<Edit> edits; // offsets into ctx.content
std::size_t lineStart = 0;
for (std::size_t li = 0; li < stripped.size(); ++li) {
std::string_view line = stripped[li];
std::size_t lineOff = lineStart;
lineStart += line.size() + 1;
std::string_view trimmed = Trim(line);
if (trimmed.starts_with('#')) continue;
// Raw strings defeat the comment stripper's quote tracking; any
// literal-+ pattern on such a line is a manual fix.
bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\"");
std::size_t searchFrom = 0;
while (searchFrom < line.size()) {
// A candidate `+` that isn't ++ / += and touches a literal.
std::size_t plus = line.find('+', searchFrom);
if (plus == std::string_view::npos) break;
searchFrom = plus + 1;
if (plus + 1 < line.size() && (line[plus + 1] == '+' || line[plus + 1] == '=')) { ++searchFrom; continue; }
if (plus > 0 && line[plus - 1] == '+') continue;
std::size_t leftEnd = plus;
while (leftEnd > 0 && (line[leftEnd - 1] == ' ' || line[leftEnd - 1] == '\t')) --leftEnd;
std::size_t rightBegin = plus + 1;
while (rightBegin < line.size() && (line[rightBegin] == ' ' || line[rightBegin] == '\t')) ++rightBegin;
bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"')
|| (rightBegin < line.size() && line[rightBegin] == '"');
if (!literalAdjacent) continue;
if (hasRaw) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)");
break;
}
// Expand to the full chain: operands joined by `+`.
std::size_t chainBegin = operandLeft(line, plus);
std::size_t chainEnd = operandRight(line, plus + 1);
bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos;
while (!bail) {
std::size_t b = chainBegin;
while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b;
if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) {
std::size_t prev = operandLeft(line, b - 1);
if (prev == std::string_view::npos) { bail = true; break; }
chainBegin = prev;
} else break;
}
while (!bail) {
std::size_t e = chainEnd;
while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e;
if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) {
std::size_t next = operandRight(line, e + 1);
if (next == std::string_view::npos) { bail = true; break; }
chainEnd = next;
} else break;
}
if (bail) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
break;
}
// Split the chain at depth-0 '+' into parts; literals feed the
// format string, everything else becomes an argument.
std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin);
std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin);
std::string fmt;
std::vector<std::string_view> args;
std::size_t partBegin = 0;
std::int64_t depth = 0;
bool inLit = false;
for (std::size_t p = 0; p <= chain.size(); ++p) {
if (p < chain.size()) {
char c = chain[p];
if (c == '"') inLit = !inLit;
if (inLit) continue;
if (c == '(' || c == '[') ++depth;
if (c == ')' || c == ']') --depth;
if (!(c == '+' && depth == 0)) continue;
}
std::string_view part = Trim(chain.substr(partBegin, p - partBegin));
std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin));
if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2
&& std::count(part.begin(), part.end(), '"') == 2) {
for (char c : rawPart.substr(1, rawPart.size() - 2)) {
fmt += c;
if (c == '{') fmt += '{';
if (c == '}') fmt += '}';
}
} else if (part.contains('"') && part.contains('+')) {
bail = true; // nested concat inside an operand — human territory
break;
} else {
fmt += "{}";
args.push_back(rawPart);
}
partBegin = p + 1;
}
if (bail || args.empty()) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
break;
}
std::string replacement = std::format("std::format(\"{}\"", fmt);
for (std::string_view a : args) replacement += std::format(", {}", a);
replacement += ")";
edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)});
searchFrom = chainEnd;
}
}
if (edits.empty()) return;
std::string out = ctx.content;
std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; });
for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement);
ctx.SetContent(std::move(out));
});
// ---------------- transforms (auto-fixed by `crafter-build format`) ----------------
// short/int/long → fixed-width types. Lines mentioning main/argc/argv or
// extern "C" keep their C-conventional ints.
cfg.AddLintRule("fixed-width-types", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
struct Rep { std::size_t pos; std::size_t len; std::string to; };
std::vector<Rep> reps;
// Builtin integer type specifiers combine in any order (`unsigned
// long`, `long unsigned int`, ...), so match whole RUNS of these
// keywords and classify the run, rather than the words one by one.
static const std::unordered_set<std::string_view> IntWords = {
"unsigned", "signed", "short", "long", "int", "char",
};
std::size_t lineStart = 0;
while (lineStart <= code.size()) {
std::size_t lineEnd = code.find('\n', lineStart);
if (lineEnd == std::string::npos) lineEnd = code.size();
std::string_view line(code.data() + lineStart, lineEnd - lineStart);
// `int main` / argc / argv keep their C-conventional type; so do
// extern "C" prototypes (the literal body is blanked in the
// stripped text, so match `extern "`).
bool exempt = line.contains("int main") || line.contains("argc") || line.contains("argv")
|| line.contains("extern \"");
std::size_t pos = 0;
while (!exempt && pos < line.size()) {
if (!IsWordChar(line[pos])) { ++pos; continue; }
std::size_t wordEnd = pos;
while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd;
if (!IntWords.contains(line.substr(pos, wordEnd - pos))) { pos = wordEnd; continue; }
// Extend the run over consecutive specifier keywords.
std::size_t runBegin = pos;
std::size_t runEnd = wordEnd;
bool hasUnsigned = false;
bool hasSigned = false;
bool hasShort = false;
bool hasChar = false;
bool hasLong = false;
std::string_view nextWord;
for (;;) {
std::string_view word = line.substr(pos, wordEnd - pos);
if (word == "unsigned") hasUnsigned = true;
else if (word == "signed") hasSigned = true;
else if (word == "short") hasShort = true;
else if (word == "char") hasChar = true;
else if (word == "long") hasLong = true;
runEnd = wordEnd;
pos = wordEnd;
while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) ++pos;
wordEnd = pos;
while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd;
nextWord = line.substr(pos, wordEnd - pos);
if (!IntWords.contains(nextWord)) break;
}
pos = runEnd;
// Bare `char` is text, not an integer — only signed/unsigned
// char is byte arithmetic. `long double` is a floating type.
if (hasChar && !hasUnsigned && !hasSigned) continue;
if (nextWord == "double") continue;
std::string_view width = hasChar ? "8" : hasShort ? "16" : hasLong ? "64" : "32";
reps.push_back({lineStart + runBegin, runEnd - runBegin,
std::format("std::{}int{}_t", hasUnsigned ? "u" : "", width)});
}
lineStart = lineEnd + 1;
}
if (reps.empty()) return;
std::string out = ctx.content;
std::sort(reps.begin(), reps.end(), [](const Rep& a, const Rep& b) { return a.pos > b.pos; });
for (const Rep& r : reps) out.replace(r.pos, r.len, r.to);
ctx.SetContent(std::move(out));
});
// One declaration per statement. Auto-splits the simple initialized form
// `Type a = x, b = y;`; anything with pointers/references, parens, or
// templates in the declarators is only reported (splitting `int* a, b;`
// would change b's type).
cfg.AddLintRule("single-declaration", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
// The declarator char class excludes quotes: string-literal bodies are
// blanked in the stripped text, so reconstructing them would corrupt
// the file — those lines are left alone.
static const std::regex simpleMulti(
R"(^(\s*)((?:std::)?[A-Za-z_][\w:]*)\s+([A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+(?:,\s*[A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+)+);\s*$)");
std::string out;
bool changed = false;
for (std::size_t i = 0; i < stripped.size(); ++i) {
std::string lineStr(stripped[i]);
std::smatch m;
std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{};
if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1)
&& std::regex_match(lineStr, m, simpleMulti)) {
std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str();
std::size_t start = 0;
bool first = true;
while (start < decls.size()) {
std::size_t comma = decls.find(',', start);
std::string_view d = Trim(std::string_view(decls).substr(start, comma == std::string::npos ? std::string::npos : comma - start));
if (!first) out += '\n';
out += std::format("{}{} {};", indent, type, d);
first = false;
if (comma == std::string::npos) break;
start = comma + 1;
}
changed = true;
} else {
out += raw;
}
if (i + 1 < stripped.size() || ctx.content.ends_with('\n')) out += '\n';
}
if (changed) ctx.SetContent(std::move(out));
});
// K&R braces: `{` on its own line after a `)`/else/do/try header joins
// onto the header line. Standalone scope blocks (previous line ends with
// `;`, `{`, a comment, …) are intentional and stay.
cfg.AddLintRule("brace-style", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> outLines;
outLines.reserve(ctx.lines.size());
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string_view trimmed = Trim(stripped[i]);
if (trimmed == "{" && !outLines.empty()) {
std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{};
bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try";
bool hasComment = ctx.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//"));
bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1);
if (headerBefore && !hasComment && !suppressed) {
std::string& prev = outLines.back();
while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back();
prev += " {";
changed = true;
continue;
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
// Single-statement if bodies join onto the if line: `if (x)\n y;` →
// `if (x) y;` — when the condition's parens balance on one line, the body
// is one `;`-terminated statement, neither line carries a comment, and
// the joined line stays ≤ 250 columns.
cfg.AddLintRule("if-single-line", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)");
std::vector<std::string> outLines;
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string lineStr(stripped[i]);
if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) {
std::string_view body = Trim(stripped[i + 1]);
bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//")
&& !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2);
std::string joined = std::string(ctx.Line(i + 1));
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
joined += " ";
joined += Trim(ctx.Line(i + 2));
if (joinable && joined.size() <= 250) {
outLines.push_back(std::move(joined));
++i; // consume the body line
changed = true;
continue;
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
// Wrapped call arguments join back onto one line when the whole
// expression fits in 250 columns. Lambda bodies (lines ending `{`),
// comments, raw strings, and preprocessor lines are left alone.
// Operator-style continuations (next line starts with && / || / |) join
// under the same length limit.
cfg.AddLintRule("wrap-join", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
// Fixpoint loop: a join can enable another (joining an inner paren
// wrap balances the line an operator continuation hangs off), so one
// pass is not idempotent. Iterate until a pass changes nothing; the
// cap is a safety net — joins strictly reduce the line count, so
// termination is guaranteed anyway.
for (std::int32_t pass = 0; pass < 16; ++pass) {
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> outLines;
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string_view trimmed = Trim(stripped[i]);
std::int64_t delta = ParenDelta(stripped[i]);
bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#')
&& !trimmed.ends_with('{') && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"");
if (candidate) {
std::string joined(ctx.Line(i + 1));
std::size_t j = i + 1;
bool ok = true;
while (delta > 0 && j < ctx.lines.size() && j - i <= 4) {
std::string_view next = Trim(stripped[j]);
// A `{`-ending line is fine when it closes the expression
// (a control header's `) {`); mid-expression it means a
// lambda body starts — leave those wrapped.
bool closes = delta + ParenDelta(stripped[j]) <= 0;
if (next.empty() || (next.ends_with('{') && !closes)
|| ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
ok = false;
break;
}
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
std::string_view fragment = Trim(ctx.Line(j + 1));
// No separator right after an opening paren or before a
// closing one — joining must not manufacture `( x` / `x )`.
if (!joined.ends_with('(') && !fragment.starts_with(')') && !fragment.starts_with(',')) joined += " ";
joined += fragment;
delta += ParenDelta(stripped[j]);
++j;
}
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
if (ok && delta <= 0 && joined.size() <= 250) {
outLines.push_back(std::move(joined));
i = j - 1; // consumed through line j-1 (0-based i)
changed = true;
continue;
}
} else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) {
auto isOpStart = [](std::string_view s) {
return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| ");
};
// Operator-style continuation chain (`a\n && b\n && c`).
// Joined when the WHOLE chain stays within 250 columns —
// longer conditions legitimately wrap. Same mechanics as the
// paren join: chain lines must be comment-free, raw-string
// free, and paren-balanced.
if (isOpStart(Trim(stripped[i + 1])) && !isOpStart(trimmed)) {
std::string joined(ctx.Line(i + 1));
std::size_t j = i + 1;
bool ok = true;
while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) {
if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
ok = false;
break;
}
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
joined += " ";
joined += Trim(ctx.Line(j + 1));
++j;
}
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
if (ok && joined.size() <= 250) {
outLines.push_back(std::move(joined));
i = j - 1; // consumed the chain
changed = true;
continue;
}
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
}
});
// No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only
// intra-line (a line legitimately ends with '(' when a call wraps);
// string/comment interiors are excluded via the stripped text.
cfg.AddLintRule("paren-spacing", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
std::vector<std::pair<std::size_t, std::size_t>> cuts; // [begin, end) spans of spaces to delete
for (std::size_t i = 0; i < code.size(); ++i) {
if (code[i] == '(' ) {
std::size_t j = i + 1;
while (j < code.size() && code[j] == ' ') ++j;
if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j});
} else if (code[i] == ')') {
std::size_t j = i;
while (j > 0 && code[j - 1] == ' ') --j;
// Only single-space padding: multi-space runs are usually
// deliberate alignment columns.
if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i});
}
}
if (cuts.empty()) return;
std::string out = ctx.content;
for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first);
ctx.SetContent(std::move(out));
});
cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::string out;
out.reserve(ctx.content.size());
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
std::string_view line = ctx.Line(n);
// Byte fidelity on CRLF files: peel the \r, trim, put it back.
bool crlf = line.ends_with('\r');
if (crlf) line.remove_suffix(1);
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
out += line;
if (crlf) out += '\r';
// The last line only had a newline if the file ended with one —
// preserve that byte exactly so this rule doesn't shadow
// final-newline.
if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
cfg.AddLintRule("final-newline", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
ctx.SetContent(ctx.content + '\n');
}
});
}
} // namespace ProjectLint

54
packaging/APKBUILD Normal file
View file

@ -0,0 +1,54 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
pkgname=imsd
pkgver=0.3.0
pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd"
arch="aarch64 x86_64"
license="GPL-3.0-only"
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire —
# none of which abuild's .so auto-scan can see
depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools"
# clang/libc++ C++26 modules build (see Makefile); llvm-runtimes ships the
# libc++ std module sources (/usr/share/libc++/v1/std.cppm) it precompiles
makedepends="clang lld libc++-dev llvm-libunwind-dev llvm-runtimes glib-dev pkgconf"
# the versioned provides both satisfies soc-qcom-modem's 81voltd dependency
# and excludes the real package: 81voltd serves the modem's own ims-PDN
# requests, which races imsd for the PDN and flaps it with a new prefix every
# ~2.5 min — the two IMS stacks cannot share one PDN
provides="81voltd=$pkgver-r$pkgrel"
# imsd-ofono is opt-in: it carries the org.ofono shim that lets GNOME Calls
# (Phosh, GNOME Mobile) drive imsd, plus the gschema override that repoints
# Calls at the ofono provider. A Plasma Mobile phone wants imsd-dialerd, which
# is in the main package, and must NOT get that override.
subpackages="$pkgname-ofono:ofono"
source="$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz"
builddir="$srcdir/$pkgname"
build() {
make
}
check() {
make check
}
package() {
make install DESTDIR="$pkgdir"
}
ofono() {
pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd"
depends="$pkgname=$pkgver-r$pkgrel calls"
amove usr/bin/imsd-ofonod
amove usr/lib/systemd/system/imsd-ofonod.service
amove usr/share/dbus-1/system.d/imsd-ofono.conf
amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override
amove usr/lib/systemd/system-preset/80-imsd-ofono.preset
# enabled on install, like imsd.service in the main package
mkdir -p "$subpkgdir"/etc/systemd/system/multi-user.target.wants
ln -s /usr/lib/systemd/system/imsd-ofonod.service \
"$subpkgdir"/etc/systemd/system/multi-user.target.wants/imsd-ofonod.service
}

View file

@ -2,12 +2,11 @@
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
# Binary packaging: wraps a crafter-build binary cross-compiled per the
# cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's
# README's "Cross-compiling" section into a proper apk — used by this repo's
# package CI (packaging/build-package.sh, which also seds pkgver from
# implementations/main.cpp) and runnable by hand. The source tarball is
# produced by packaging/make-bin-tarball.sh; the desktop/preset files and
# the install scripts (*.post-upgrade, *.post-install) live next to it in
# packaging/ (copy them beside this file). Since 2026-09-02
# produced by packaging/make-bin-tarball.sh; the desktop/preset files live
# next to it in packaging/ (copy them beside this file). Since 2026-09-02
# this is the only imsd packaging: fp6-img images install the apk this
# builds from the registry rather than building their own.
pkgname=imsd
@ -17,9 +16,8 @@ pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd"
arch="aarch64"
license="GPL-3.0-only"
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire;
# polkit answers the daemons' call-control check (ImsdBus)
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools polkit"
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools"
# versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND
# excludes the real package: 81voltd serves the modem's own ims-PDN requests,
# which races imsd for the PDN and flaps it with a new prefix every ~2.5 min
@ -31,9 +29,6 @@ provides="81voltd=$pkgver-r$pkgrel"
# (ofono before systemd: systemd() amoves all of usr/lib/systemd/system,
# so ofono() must take imsd-ofonod.service first)
subpackages="$pkgname-ofono:ofono $pkgname-systemd"
# restart the running daemons onto an upgrade (systemd's enqueue-marked); the
# systemd subpackage marks imsd.service too, since it brings the unit file
install="$pkgname.post-upgrade $pkgname.post-install $pkgname-ofono.post-upgrade $pkgname-ofono.post-install $pkgname-systemd.post-upgrade $pkgname-systemd.post-install"
options="!check !tracedeps"
source="
imsd-$pkgver.tar.gz
@ -47,14 +42,11 @@ package() {
install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media
install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh
install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh
install -Dm644 imsd.service "$pkgdir"/usr/lib/systemd/system/imsd.service
install -Dm644 imsd-dialerd.desktop \
"$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop
install -Dm644 net.catcrafts.IMS1.conf \
"$pkgdir"/usr/share/dbus-1/system.d/net.catcrafts.IMS1.conf
install -Dm644 net.catcrafts.IMS1.policy \
"$pkgdir"/usr/share/polkit-1/actions/net.catcrafts.IMS1.policy
# the skel override hides kde-telephony's modem daemon autostart for the
# account created at install — imsd-dialerd owns those session bus names
install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \
@ -72,8 +64,6 @@ package() {
printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \
> "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf
install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod
install -Dm755 imsd-ofono-restart-calls.sh \
"$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh
install -Dm644 imsd-ofonod.service \
"$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service
install -Dm644 org.ofono.conf \
@ -88,7 +78,6 @@ ofono() {
pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd"
depends="$pkgname=$pkgver-r$pkgrel calls"
amove usr/bin/imsd-ofonod
amove usr/libexec/imsd-ofono-restart-calls.sh
amove usr/lib/systemd/system/imsd-ofonod.service
amove usr/share/dbus-1/system.d/imsd-ofono.conf
amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override

View file

@ -2,31 +2,47 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# CI package build: cross-compile imsd for aarch64 with crafter-build (the
# cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package
# README's "Cross-compiling" flow), run the test suite natively, and package
# the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine
# environment with root — the workflow runs it in an alpine:edge job
# container on the privileged `flatpak` runner: crafter-build is the
# Crafter.Build Flatpak, and bubblewrap inside the container needs the
# privilege. Root only installs packages and hands off to a scratch user: the
# sysroot is built with apk.static --usermode (which refuses root) and abuild
# wants a user too.
# container on an ordinary runner. Root only installs packages and hands off
# to a scratch user: the sysroot is built with apk.static --usermode (which
# refuses root) and abuild wants a user too.
#
# Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk;
# the workflow's publish step uploads them to the Forgejo Alpine registry.
set -eu
# crafter-build is the Crafter.Build Flatpak: it carries its own clang, lld
# and libc++, and its SDK has glib, so nothing that compiles is installed
# here. `stable` is the branch Crafter.Build publishes from its release tags
# (`beta` follows every push to its master), so the toolchain of a release
# moves only with a release of the toolchain. The branch is overridable
# for a rehearsal against another one.
CRAFTER_REMOTE=${CRAFTER_REMOTE:-https://flatpak.catcrafts.net/catcrafts.flatpakrepo}
CRAFTER_REF=${CRAFTER_REF:-net.catcrafts.CrafterBuild//stable}
# The musl build of crafter-build (Crafter.Build CI's release-musl job):
# this container is Alpine, and the glibc launcher cannot run on musl. v2 =
# SSE4.2 baseline: the CI box is an Intel N5105 (no AVX). Overridable for
# local rehearsals (file:// works).
CRAFTER_URL=${CRAFTER_URL:-https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-musl-v2.tar.gz}
SRC=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
# clang cross-targets aarch64 natively and the target's libc++/glib come from
# the sysroot; llvm-runtimes/libc++-dev/glib-dev here serve the NATIVE
# test-suite run. build-base = Alpine's standard build environment (the one
# abuild implies): binutils' ld/ar for clang's default link driver, gcc's
# libgcc_s/crt objects the musl clang driver links against.
if [ "$(id -u)" = 0 ]; then
apk add -q git curl tar clang lld llvm llvm-runtimes libc++-dev llvm-libunwind-dev glib-dev \
build-base abuild sudo
id build >/dev/null 2>&1 || adduser -D build
addgroup build abuild 2>/dev/null || true
echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build
# abuild in cross mode strips with $CHOST-strip; llvm-strip handles any
# ELF arch, so give it that name
ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip
# the CI checkout arrives root-owned; crafter-build writes bin/ into it
chown -R build "$SRC"
# -l: a login shell, so HOME really is /home/build (abuild keys + output);
# it scrubs the environment, so carry the one knob that matters across
exec su -l build -c "CRAFTER_URL='${CRAFTER_URL:-}' sh -eu '$SRC/packaging/build-package.sh'"
fi
retry() { # retry <description> <cmd...>
_desc=$1; shift
for _i in 1 2 3; do
@ -38,49 +54,26 @@ retry() { # retry <description> <cmd...>
return 1
}
# llvm for llvm-strip (abuild in cross mode strips with $CHOST-strip); the
# compiler is the Flatpak's.
if [ "$(id -u)" = 0 ]; then
apk add -q git curl tar flatpak llvm abuild sudo
# the Flatpak as a system installation the build user runs; its SDK
# (org.freedesktop.Sdk) comes from Flathub
flatpak remote-add --if-not-exists catcrafts "$CRAFTER_REMOTE"
flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
retry "install crafter-build" flatpak install -y --noninteractive catcrafts "$CRAFTER_REF"
id build >/dev/null 2>&1 || adduser -D build
addgroup build abuild 2>/dev/null || true
echo 'build ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/build
# abuild in cross mode strips with $CHOST-strip; llvm-strip handles any
# ELF arch, so give it that name
ln -sf "$(command -v llvm-strip)" /usr/local/bin/aarch64-alpine-linux-musl-strip
# the CI checkout arrives root-owned; crafter-build writes bin/ into it
chown -R build "$SRC"
# -l: a login shell, so HOME really is /home/build (abuild keys + output);
# it scrubs the environment, so carry the one knob that matters across
exec su -l build -c "CRAFTER_REF='$CRAFTER_REF' sh -eu '$SRC/packaging/build-package.sh'"
fi
# implementations/main.cpp is the version's single source of truth (same
# derivation as make-bin-tarball.sh)
VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp")
[ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; }
echo ">> packaging imsd $VER"
# --- crafter-build: the Flatpak, under the name the rest of this file uses.
# The build log names the build it ran with.
mkdir -p "$HOME/bin"
printf '#!/bin/sh\nexec flatpak run %s "$@"\n' "$CRAFTER_REF" > "$HOME/bin/crafter-build"
chmod +x "$HOME/bin/crafter-build"
PATH="$HOME/bin:$PATH"
flatpak info "$CRAFTER_REF" | sed -n 's/^ *\(Version\|Commit\|Date\): */crafter-build \1: /p'
crafter-build --version
# --- crafter-build: static launcher from the rolling release
mkdir -p "$HOME/crafter-build"
retry "fetch crafter-build" \
sh -c "curl -fsSL '$CRAFTER_URL' | tar -xz -C '$HOME/crafter-build'"
PATH="$HOME/crafter-build/bin:$PATH"
export CRAFTER_BUILD_HOME="$HOME/crafter-build/share/crafter-build"
# --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode)
SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine"
retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT"
# --- cross-compile the products; run the suites natively, inside the Flatpak
# (same sources, host arch: no suite runs on the aarch64 binaries)
# --- cross-compile the products; run the suites natively (same sources,
# host arch — the aarch64 binaries are additionally exercised by fp6-img's
# chroot `make check` whenever an image builds)
cd "$SRC"
# The phone is one SoC, 4x Cortex-A520 + 4x Cortex-A720, so build for it.
# armv8.6-a is the highest level whose mandatory set it exposes (8.7 would
@ -101,7 +94,7 @@ rm -rf "$PKG"
mkdir -p "$PKG"
cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD"
cp "$SRC/packaging/org.kde.modem.daemon.desktop" \
"$SRC/packaging/80-imsd.preset" "$SRC"/packaging/*.post-upgrade "$SRC"/packaging/*.post-install "$PKG/"
"$SRC/packaging/80-imsd.preset" "$PKG/"
mv "imsd-$VER.tar.gz" "$PKG/"
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
# a throwaway signing key: phones trust the registry-signed APKINDEX, not

View file

@ -39,12 +39,6 @@ bearer_paths() {
kv -m "$MODEM" | sed -n 's/^modem\.generic\.bearers\.value\[[0-9]*\] *: *//p'
}
# A disabled modem has its radio off (airplane mode): leave it alone. A bearer
# connect on it fails with no-service and leaves ModemManager's state
# `enabled` with the modem still in low power; switching the radio back on
# then no longer brings the modem up (dev phone 2026-10-06).
radio_off() { [ "$(modem_state)" = disabled ]; }
envval() { # $1 = key — for manual runs; under systemd the vars are inherited
sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"'
}
@ -71,49 +65,34 @@ log "modem $MODEM"
# lookup itself can fail while ModemManager is still enabling the modem; a
# failed lookup is not "no profile" (the APN-string request it would fall
# back to is the one the modem refuses under an IPv4 attach), so it is
# retried after the registration wait; a list without the APN is final —
# the APN string is used — only once the modem is registered.
# retried after the registration wait and only a list without the APN
# falls back to the APN string.
LOOKUP=pending
if [ -n "$PROFILE_ID" ]; then
[ "$PROFILE_ID" = none ] && PROFILE_ID=
LOOKUP=done
fi
lookup_profile() { # $1 = modem state, $2 = log suffix; sets PROFILE_ID, LOOKUP=done on a final answer
lookup_profile() { # $1 = log suffix; sets PROFILE_ID, LOOKUP=done on a usable list
[ "$LOOKUP" = done ] && return 0
if OUT=$(qmicli -d qrtr://0 --wds-get-profile-list=3gpp 2>&1); then
# a list with no profiles at all is the modem still coming up, not
# a modem without an ims profile (dev phone cold boot 2026-09-20
# 16:47: exit 0, no entries, index 2 present a minute later)
if ! printf '%s\n' "$OUT" | grep -q -E '^[[:space:]]*\[[0-9]+\] 3gpp'; then
log "ims profile lookup returned no profiles$2"
log "ims profile lookup returned no profiles$1"
return 0
fi
PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" '
/^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) }
/APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a);
if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }')
if [ -n "$PROFILE_ID" ]; then
LOOKUP=done
log "ims profile: index $PROFILE_ID (apn $IMS_APN)"
return 0
fi
# a list without the APN is final only once the modem is registered:
# while it enables, the list can be partial (dev phone cold boot
# 2026-09-24 18:18: entries but no ims at `enabling`, ims listed when
# read minutes later), and on a walled carrier the APN-string fallback
# that would follow is the request the modem refuses
case "$1" in
registered|connecting|connected)
LOOKUP=done
log "ims profile: none for apn $IMS_APN, connecting by APN string" ;;
*) log "ims profile: not in the list yet$2" ;;
esac
LOOKUP=done
[ -n "$PROFILE_ID" ] && log "ims profile: index $PROFILE_ID (apn $IMS_APN)" || log "ims profile: none for apn $IMS_APN, connecting by APN string"
else
log "ims profile lookup failed$2: $(squash "$OUT")"
log "ims profile lookup failed$1: $(squash "$OUT")"
fi
}
STATE=$(modem_state)
lookup_profile "$STATE" " (modem state: $STATE)"
lookup_profile " (modem state: $(modem_state))"
# ---- find a connected ims bearer; else find-or-create one and connect it.
# APN names are case-insensitive. A DISCONNECTED bearer is reused only when
@ -171,10 +150,6 @@ if [ -z "$BEARER" ]; then
[ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})"
last=$STATE
if [ "$waited" -ge "$REG_TIMEOUT" ]; then
if [ "$STATE" = disabled ]; then
log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting"
exit 1
fi
log "not registered after $REG_TIMEOUT s — attempting anyway"
break
fi
@ -182,14 +157,12 @@ if [ -z "$BEARER" ]; then
waited=$((waited + 5))
done
log "modem state: $(modem_state), packet service: $(packet_state)"
# a lookup with no final answer yet: it failed, or its list lacked the
# APN while the modem was still coming up
# a lookup that failed while the modem was still coming up
n=0
while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do
n=$((n + 1))
sleep 5
STATE=$(modem_state)
lookup_profile "$STATE" " (retry $n, modem state: $STATE)"
lookup_profile " (retry $n)"
done
if [ "$LOOKUP" != done ]; then
PROFILE_ID=
@ -203,7 +176,6 @@ n=0
while [ -z "$BEARER" ]; do
n=$((n + 1))
[ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; }
radio_off && { log "modem disabled (radio off): not connecting"; exit 1; }
B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape
if [ -z "$B" ]; then
if [ -n "$PROFILE_ID" ]; then
@ -308,13 +280,9 @@ case "$PCSCF" in
*) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;;
esac
# ---- hand the connected ims netdev and the PDN's own address to imsd
# (imsd.service reads /run/imsd.env): the daemon carries no baked-in
# interface name and registers on the address the network assigned — not
# on the kernel's temporary SLAAC address from the RA, which the netdev
# lists first and which is deprecated after a day and removed after two
# (ims ledger F18; the SAs and the Contact would go with it)
printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env
# ---- hand the connected ims netdev to imsd (imsd.service reads
# /run/imsd.env) so the daemon carries no baked-in interface name
echo "DEV=$IFACE" > /run/imsd.env
# ---- open the IMS protected ports in the firewall (rung 5b root cause,
# journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and

View file

@ -1,83 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# ExecStartPre of imsd.service, after ims-pdn-up.sh: hand a daemon older
# than 0.3.10 its bring-up throttle wait over. Such a daemon kept the wait
# in memory only, so the restart an upgrade makes registered fresh at once
# instead of waiting the schedule out. imsd.post-upgrade
# records the running daemon's invocation in /run/imsd.handover; here, with
# that daemon stopped and its journal final, the invocation's last bring-up
# line is read and, when it is the old throttle's `bring-up deferred:
# fresh-SA throttle active (spi-s=0) — … next fresh registration in
# N min`, what is left of the N min is written to
# the holds file (/run/imsd.holds, or HOLDS_FILE) the way 0.3.10 keeps
# it — with the verdict that the old registration is gone: that line
# follows a throttled initial REGISTER, at which KPN drops the
# registration, so the new start does not resume over the written-off SAs.
# The old daemon deferred the same way after a 401 it could not use (no
# ipsec-3gpp mechanism, an unsupported algorithm); nothing says the
# network dropped the registration then, and that wait is not handed over.
# The wait itself is handed over only for the address it was earned on
# (the old daemon logged its LOCAL once at start): KPN's refusal is the
# address's — a fresh pair is refused where a registration is live or has
# just gone — and on the normal hop the old daemon sat on the kernel's
# temporary address while this start registers on the PDN's own, which
# holds none: then only the verdict is written and the new start registers
# fresh at once (should the network refuse it there, the daemon's own
# schedule takes over from its start: the old daemon's place in it was
# the other address's).
# When the old address cannot be read the wait is handed over anyway.
# Reading at the start, not at the upgrade, means a wait that
# ended between the two — the old daemon making another attempt — is
# read as it ended.
set -u
marker=/run/imsd.handover
[ -r "$marker" ] || exit 0
inv=$(cat "$marker"); rm -f "$marker"
[ -n "$inv" ] || exit 0
holds=${HOLDS_FILE:-/run/imsd.holds} # the holds file as imsd reads it (HoldsPath)
[ -e "$holds" ] && exit 0 # a 0.3.10 or later daemon kept its own holds
line=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o json --no-pager 2>/dev/null \
| grep -E '"MESSAGE" *: *"imsd: (bring-up deferred: |REGISTERED|FRESH registration|a hold the network)' | tail -n 1)
case "$line" in *'imsd: bring-up deferred: fresh-SA throttle active (spi-s=0)'*) ;; *) exit 0 ;; esac
mins=$(printf '%s\n' "$line" | sed -n 's/.*next fresh registration in \([0-9][0-9]*\) min.*/\1/p')
# the line's age on the monotonic clock (journald stamps every entry with it;
# /proc/timer_list reads it now), which no clock step moves — this phone's
# wall clock reads 1970 until its first NTP sync. It stops in a suspend, so
# the age can only come out short and the wait handed over long, never
# short of the network's window.
at=$(printf '%s\n' "$line" | sed -n 's/.*"__MONOTONIC_TIMESTAMP" *: *"\([0-9][0-9]*\)".*/\1/p')
now=$(sed -n 's/^now at \([0-9][0-9]*\) nsecs$/\1/p' /proc/timer_list 2>/dev/null | head -n 1)
if [ -z "$mins" ] || [ -z "$at" ] || [ -z "$now" ]; then
echo "imsd-handover: the previous daemon waited for a fresh registration, but its wait could not be timed: NOT handed over"
exit 0
fi
left=$((mins * 60 - (now / 1000000000 - at / 1000000)))
[ "$left" -gt 0 ] || exit 0
# the address this start registers on, as systemd hands it to imsd: the unit
# reads its EnvironmentFile= lines anew for every command it spawns, so LOCAL
# here is the /etc/imsd.env pin when there is one, else the one ims-pdn-up.sh
# just wrote to /run/imsd.env — unquoted by systemd's parser, the same bytes
# imsd compares the holds file's pdn with. The file read is for a run outside
# the unit.
pdn=${LOCAL:-$(sed -n 's/^LOCAL=//p' /run/imsd.env 2>/dev/null | tail -n 1)}
if [ -z "$pdn" ]; then
echo "imsd-handover: the previous daemon waited $mins min for a fresh registration, but this start has no LOCAL: NOT handed over"
exit 0
fi
# the address the wait was earned on: the old daemon's own start line
old=$(journalctl -u imsd.service "_SYSTEMD_INVOCATION_ID=$inv" -o cat --no-pager 2>/dev/null \
| sed -n 's/^imsd: LOCAL=\([^ ]*\) P-CSCF=.*/\1/p' | tail -n 1)
boot=$(cat /proc/sys/kernel/random/boot_id); now_s=$(cut -d. -f1 /proc/uptime)
if [ -n "$old" ] && [ "$old" != "$pdn" ]; then
printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": 0, "old_gone": true}\n' \
"$pdn" "$boot" "$now_s" > "$holds" \
&& echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) was earned on $old, not this start's $pdn: only the verdict that its registration is gone is handed over"
exit 0
fi
tm=$((mins * 3 / 2)); [ "$tm" -gt 60 ] && tm=60
printf '{"pdn": "%s", "boot": "%s", "written_at": %s, "hold": 0, "fresh_hold": %s, "throttle_minutes": %s, "old_gone": true}\n' \
"$pdn" "$boot" "$now_s" "$left" "$tm" > "$holds" \
&& echo "imsd-handover: the previous daemon's fresh-registration wait ($left s of $mins min left) handed over for $pdn$([ -n "$old" ] && echo " (its own address)" || echo "; its address could not be read")"
exit 0

View file

@ -1,24 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# ExecStartPost of imsd-ofonod.service. GNOME Calls 50.0 keeps a stale modem
# when org.ofono vanishes and dials nothing until it is restarted (AGENTS.md
# §8.3, Known gaps), so every start of imsd-ofonod after org.ofono went away
# restarts the Calls daemon of each user running one. Its first start since
# boot is not one: no Calls has seen org.ofono go, and a Calls that runs
# already (a login during the bring-up) is left alone. The unit's own
# timestamp tells the first start from a restart (an upgrade from 0.3.9 is
# a restart); the marker covers a stop and a later start.
marker=/run/imsd-ofonod.started
if [ ! -e "$marker" ] && [ "$(systemctl show -P ActiveExitTimestampMonotonic imsd-ofonod.service)" = 0 ]; then
: > "$marker"
echo "imsd-ofono-restart-calls: first start since boot, Calls left alone"
exit 0
fi
: > "$marker"
for uid in $(systemctl list-units 'user@*.service' --legend=no --plain | sed -n 's/^user@\([0-9]*\)\.service.*/\1/p'); do
echo "imsd-ofono-restart-calls: restarting the Calls daemon of uid $uid, if it runs"
systemctl --user -M "$uid@" try-restart --no-block calls-daemon.service || :
done
exit 0

View file

@ -1 +0,0 @@
imsd-ofono.post-upgrade

View file

@ -1,21 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# As imsd.post-upgrade, for imsd-ofonod: restarted once apk is done, and
# its ExecStartPost restarts GNOME Calls with it. Also installed as
# imsd-ofono.post-install, for a package added again over the daemon an
# `apk del` left running.
# A unit whose file an apk del removed is not-found to systemd until a
# reload and refuses properties ("failed to load properly"); its file is
# back on disk by the time post-install runs, so reload and mark again.
mark() {
systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0
systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || :
}
if [ -d /run/systemd/system ] && systemctl is-active -q imsd-ofonod.service; then
mark imsd-ofonod.service
fi
exit 0

View file

@ -3,11 +3,8 @@
[Unit]
Description=oFono-compatible GNOME Calls backend for imsd
Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd
# org.ofono is a system-bus name only root may own (org.ofono.conf), so this
# is a system service run as root: imsd admits the calls it forwards as
# root's (it has asked polkit about its own caller), and its start hook
# restarts GNOME Calls in the users' own managers. Only enable it on a
# GNOME/Phosh session
# org.ofono is a system-bus name, so this is a system service; it needs no
# privileges beyond owning that name. Only enable it on a GNOME/Phosh session
# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different
# buses, different consumers), but a real ofonod must not.
Conflicts=ofono.service
@ -20,9 +17,6 @@ Type=simple
# matches imsd's configuration; imsd itself owns emergency classification.
EnvironmentFile=-/etc/imsd.env
ExecStart=/usr/bin/imsd-ofonod
# GNOME Calls keeps a stale modem when org.ofono restarts and dials nothing
# until it restarts too: restart the Calls daemon of each user running one
ExecStartPost=-/usr/libexec/imsd-ofono-restart-calls.sh
# it holds no call state of its own — imsd is re-queried on every reconnect
Restart=on-failure
RestartSec=5

View file

@ -1 +0,0 @@
imsd-systemd.post-upgrade

View file

@ -1,23 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# imsd.service ships in this subpackage, which apk installs after imsd.
# When imsd is added back over a daemon an `apk del` left running,
# imsd.post-install runs before this unit file is on disk and cannot mark
# the unit (not-found to systemd), so the package that brings the file
# marks it too; on an upgrade both mark it, which is one marker.
# Also installed as imsd-systemd.post-install.
# A unit whose file an apk del removed is not-found to systemd until a
# reload and refuses properties ("failed to load properly"); the file is
# on disk by now, so reload and mark again.
mark() {
systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0
systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || :
}
if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then
mark imsd.service
fi
exit 0

View file

@ -1 +0,0 @@
imsd.post-upgrade

View file

@ -1,48 +0,0 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#
# Restart imsd onto the upgraded binary once apk is done: the systemd
# trigger restarts every unit marked needs-restart (enqueue-marked). A
# changed bus policy is live the moment apk writes it (dbus-broker-launch
# reloads on its own), so it must not be left facing the old daemon. The
# restart resumes the registration over its existing SAs, or goes on
# waiting when the network had imsd wait; a call in progress ends.
#
# Also installed as imsd.post-install: `apk del imsd` removes the files
# and leaves the daemon running, so a later `apk add` of the package is
# this upgrade for that daemon, and apk runs post-install for it. A first
# install finds no unit active and marks nothing. On the binary route
# (APKBUILD.binary) the unit file ships in imsd-systemd, installed after
# this package; its imsd-systemd.post-upgrade marks the unit then.
#
# A daemon older than 0.3.10 kept its bring-up throttle wait in memory
# only, so a restart inside it registered fresh at once instead of
# waiting the schedule out. Before marking, record the running
# invocation in /run/imsd.handover: the next start (imsd-handover.sh, an
# ExecStartPre after ims-pdn-up.sh) reads that invocation's last bring-up
# line once the daemon has stopped and hands what is left of its wait over
# as imsd's holds file (on another address only the verdict that its
# registration is gone). Read then, not here: a wait that ends between this
# script and the restart at the end of the transaction — the old daemon
# making another attempt — is read as it ended.
# A unit whose file an apk del removed is not-found to systemd until a
# reload and refuses properties ("failed to load properly"); its file is
# back on disk by the time post-install runs, so reload and mark again.
mark() {
systemctl set-property "$1" Markers=+needs-restart 2>/dev/null && return 0
systemctl daemon-reload && systemctl set-property "$1" Markers=+needs-restart || :
}
record_old_invocation() {
[ -e /run/imsd.holds ] && return 0 # a 0.3.10 or later daemon keeps its own holds
inv=$(systemctl show -P InvocationID imsd.service 2>/dev/null)
[ -n "$inv" ] && echo "$inv" > /run/imsd.handover
}
if [ -d /run/systemd/system ] && systemctl is-active -q imsd.service; then
record_old_invocation
mark imsd.service
fi
exit 0

View file

@ -13,16 +13,10 @@ Type=simple
# idempotent. worst case ~14 min (2 min modem wait + 5 min registration
# gate + connect retries), hence TimeoutStartSec
ExecStartPre=/usr/libexec/ims-pdn-up.sh
# a daemon older than 0.3.10 that an upgrade restarted inside its bring-up
# throttle wait: the wait — on another address only the verdict that its
# registration is gone — is handed over from its journal now that it has
# stopped (imsd.post-upgrade recorded the invocation in /run/imsd.handover)
ExecStartPre=-/usr/libexec/imsd-handover.sh
TimeoutStartSec=900
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev,
# LOCAL= the PDN address ModemManager assigned to it).
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev).
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's
# P-CSCF address; see AGENTS.md §10, or the README) and wins over the
# P-CSCF address; see the README's Configuration section) and wins over the
# runtime-derived values.
EnvironmentFile=-/run/imsd.env
EnvironmentFile=-/etc/imsd.env
@ -30,8 +24,7 @@ EnvironmentFile=-/etc/imsd.env
StateDirectory=imsd
ExecStart=/usr/bin/imsd
# registration: resume an existing SA when possible (avoids fresh-SA
# throttles); a failed bring-up exits nonzero and systemd retries, as does
# a registered imsd whose PDN address left the netdev
# throttles); a failed bring-up exits nonzero and systemd retries
Restart=on-failure
RestartSec=120
# SIGTERM: engine keeps the SA + registration state for the next resume

View file

@ -24,12 +24,10 @@ cp "$BIN" "$stage/imsd-$VER/imsd"
cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod"
cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \
cp packaging/ims-pdn-up.sh packaging/imsd.service \
packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \
packaging/imsd-ofono-restart-calls.sh \
packaging/org.ofono.conf packaging/90_imsd-ofono.gschema.override \
packaging/80-imsd-ofono.preset \
packaging/net.catcrafts.IMS1.conf packaging/net.catcrafts.IMS1.policy \
"$stage/imsd-$VER/"
packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/"
tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER"
echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))"

View file

@ -8,8 +8,7 @@
# clang can use via --sysroot. No root, no qemu, no device needed:
#
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
# crafter-build -- --target=aarch64-alpine-linux-musl --sysroot=<dir> \
# --march=… --mtune=… # the phone's values: AGENTS.md §4.3
# crafter-build --target=aarch64-alpine-linux-musl --sysroot=<dir>
set -eu
SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}"
@ -17,13 +16,8 @@ MIRROR="${MIRROR:-https://dl-cdn.alpinelinux.org/alpine/edge}"
ARCH=aarch64
# gcc is in the list for its crtbeginS.o/libgcc: clang's driver for
# *-alpine-linux-musl links against the GCC runtime it detects under
# <sysroot>/usr/lib/gcc/<triple>/. llvm-runtimes is in it for the std module
# source of this very libc++ (usr/share/libc++/v1/std.cppm; libc++-dev ships
# none): crafter-build compiles the sysroot's own copy first. --upgrade below
# is for a run over an existing sysroot: added alone beside libc++ 22,
# llvm-runtimes 23's source did not compile against its headers; upgraded
# with it, it did.
PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc"
# <sysroot>/usr/lib/gcc/<triple>/
PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
@ -46,6 +40,6 @@ mkdir -p "$SYSROOT"
--arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \
--usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \
-X "$MIRROR/main" -X "$MIRROR/community" \
add --upgrade $PKGS
add $PKGS
echo ">> done: $SYSROOT"

View file

@ -11,13 +11,12 @@
<allow send_destination="net.catcrafts.IMS1"/>
<allow receive_sender="net.catcrafts.IMS1"/>
</policy>
<!-- Any local account may reach imsd: the dialer shim runs as the session
account, whatever it is called. imsd itself decides who may place,
answer or end a call — polkit's net.catcrafts.IMS1.Voice
(net.catcrafts.IMS1.policy: the active local session). Owning the name
stays root's. -->
<policy context="default">
<!-- the phone session user drives calls via the imsdialerd shim -->
<policy user="user">
<allow send_destination="net.catcrafts.IMS1"/>
<allow receive_sender="net.catcrafts.IMS1"/>
</policy>
<policy context="default">
<deny send_destination="net.catcrafts.IMS1"/>
</policy>
</busconfig>

View file

@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?> <!--*-nxml-*-->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<!-- Call control on imsd (net.catcrafts.IMS1) and imsd-ofonod (org.ofono):
both daemons ask polkit about this action before they place, answer or
end a call for a caller. The defaults are ModemManager's Voice action as
the permissive polkit build ships it (the strict build has
allow_active auth_self_keep): the active local session, not an ssh
login, an inactive session or a system service account. root is always
allowed.
Install: /usr/share/polkit-1/actions/net.catcrafts.IMS1.policy -->
<policyconfig>
<vendor>Catcrafts</vendor>
<vendor_url>https://forgejo.catcrafts.net/Catcrafts/imsd</vendor_url>
<action id="net.catcrafts.IMS1.Voice">
<description>Place, answer and end voice calls over IMS</description>
<message>System policy prevents voice calls.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
</action>
</policyconfig>

View file

@ -15,20 +15,20 @@
<allow own="org.ofono"/>
<!-- imsd (root) reaches this daemon only by broadcast signals, and
dbus-broker checks a broadcast against the sender's send policy for
every name the receiver owns (dbus-daemon does not). The default
below allows sending too since 0.3.10; this line stays so that a
tighter default can never again silently drop every CallAdded /
CallStateChanged from imsd on the way to imsd-ofonod, as the old
default deny did (found on the FP6, 2026-09-20). -->
every name the receiver owns (dbus-daemon does not): without this
line the default deny below silently drops every CallAdded /
CallStateChanged from imsd on the way to imsd-ofonod (found on the
FP6, 2026-09-20). -->
<allow send_destination="org.ofono"/>
</policy>
<!-- Any local account's GNOME Calls may reach imsd-ofonod, whatever the
session account is called; imsd-ofonod asks polkit before call control
(net.catcrafts.IMS1.Voice, as imsd does). Owning the name stays
root's. -->
<policy context="default">
<deny own="org.ofono"/>
<!-- the session user's GNOME Calls drives the modem; call control is
exactly what net.catcrafts.IMS1 already grants it through the dialerd -->
<policy user="user">
<allow send_destination="org.ofono"/>
<allow receive_sender="org.ofono"/>
</policy>
<policy context="default">
<deny own="org.ofono"/>
<deny send_destination="org.ofono"/>
</policy>
</busconfig>

View file

@ -4,8 +4,7 @@
// lint-disable-file no-char-pointer
import std;
import Crafter.Build;
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "e906f797da2cd0b1776dd1b420f9b04d3cd24baf"
import Crafter.LintRules;
#include "lint-rules.h"
namespace fs = std::filesystem;
using namespace Crafter;
@ -58,24 +57,6 @@ static void ApplyGioFlags(Configuration& cfg) {
}
}
// ImsdBus — the polkit gate imsd and imsd-ofonod put in front of call
// control. A static library of its own: GLib, so outside imsd-core.
static std::unique_ptr<Configuration> BusLibrary(std::span<const std::string_view> args) {
auto bus = std::make_unique<Configuration>();
bus->path = "./";
bus->name = "imsd-bus";
bus->outputName = "imsd-bus";
ApplyStandardArgs(*bus, args);
bus->type = ConfigurationType::LibraryStatic;
{
std::array<fs::path, 1> ifaces = { "interfaces/ImsdBus" };
std::array<fs::path, 0> impls = {};
bus->GetInterfacesAndImplementations(ifaces, impls);
}
ApplyGioFlags(*bus);
return bus;
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// Four executables come out of this repo; which one a build produces is
// chosen by `--product=`:
@ -89,8 +70,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// (pure GDBus translation, no core).
// ofonod — imsd-ofonod, the system-bus GNOME Calls backend
// (org.ofono.*) bridging to imsd, for Phosh and
// GNOME Mobile (GDBus translation plus the imsd-bus
// polkit gate on its callers, no core).
// GNOME Mobile (pure GDBus translation, no core).
bool wantMedia = false;
bool wantDialerd = false;
bool wantOfonod = false;
@ -106,15 +86,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
ofonod.outputName = "imsd-ofonod";
ApplyStandardArgs(ofonod, args);
ofonod.type = ConfigurationType::Executable;
static auto Bus = BusLibrary(args);
ofonod.dependencies = { Bus.get() };
{
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/ofonod" };
ofonod.GetInterfacesAndImplementations(ifaces, impls);
}
ApplyGioFlags(ofonod);
LintRules::AddRules(ofonod);
ProjectLint::AddProjectLintRules(ofonod);
return ofonod;
}
if (wantDialerd) {
@ -130,7 +108,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
dialerd.GetInterfacesAndImplementations(ifaces, impls);
}
ApplyGioFlags(dialerd);
LintRules::AddRules(dialerd);
ProjectLint::AddProjectLintRules(dialerd);
return dialerd;
}
if (wantMedia) {
@ -147,7 +125,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
}
media.linkFlags.push_back("-ldl"); // dlopen the AMR-WB codecs
media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic
LintRules::AddRules(media);
ProjectLint::AddProjectLintRules(media);
return media;
}
@ -183,8 +161,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.outputName = "imsd";
ApplyStandardArgs(cfg, args);
cfg.type = ConfigurationType::Executable;
static auto Bus = BusLibrary(args);
cfg.dependencies = { Core.get(), Bus.get() };
cfg.dependencies = { Core.get() };
{
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/main" };
@ -202,7 +179,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("Sip").Dependencies({ Core.get() });
cfg.AddTest("Sdp").Dependencies({ Core.get() });
LintRules::AddRules(cfg);
ProjectLint::AddProjectLintRules(cfg);
return cfg;
}

View file

@ -11,12 +11,7 @@
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
// what was offered), a landline gateway's G.711-only and AMR-NB offers
// ring and are answered at the offer's own payload types, ring timeout ->
// 480, BYE both ways. Then RegRecovery, the registration recovery policy:
// the refusal and silence thresholds, local refusals, the reconnect and
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
// may cut short, when the old registration counts as gone, the holds a
// restart takes over (Holds/Restore), and a binding the network terminated
// (OnBindingTerminated). Pure reducers, no I/O.
// 480, BYE both ways. Pure reducer, no I/O.
import std;
import Imsd;
@ -859,248 +854,6 @@ int main() {
Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI");
}
// ---- RegRecovery: when a registration stops working after bring-up.
// The clock is ours: seconds, starting at 1000.
{
using F = RegRecovery::Failure;
using S = RegRecovery::Step;
auto reconnectAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Reconnect && p.at == at; };
auto freshAt = [](const RegRecovery& r, double now, double at) { auto p = r.Next(now); return p.step == S::Fresh && p.at == at; };
RegRecovery r;
Check(!r.Fresh() && r.Reconnectable() && reconnectAt(r, 1000, 0), "the first step is a reconnect, at once");
r.OnLocalRefusal(1000);
Check(r.refusals == 0 && r.silentCycles == 0 && r.reconnectDelay == 5 && reconnectAt(r, 1000, 1005), "a local connect refusal: retried in 5 s, not counted, backoff untouched");
// O2 2026-09-24, 09:29: the keepalive refresh refused (503), then a
// reconnect's refresh refused again -> fresh, not a third refresh
r.OnReconnectFailure(1000, F::Refused);
Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first refusal: reconnect after 5 s, not yet an escalation");
r.OnReconnectFailure(1005, F::Refused);
Check(r.Fresh() && r.refusals == 2 && freshAt(r, 1005, 1015), "second refusal: escalated, the fresh attempt after the doubled backoff");
Check(r.Reconnectable(), "…and the old SA is still there");
r.OnSuccess();
Check(!r.Fresh() && r.refusals == 0 && r.reconnectDelay == 5, "success resets the policy");
// Retry-After is the network's word: it holds every attempt for its
// own length, next to the backoff, and a Dial cuts only the backoff
r.OnReconnectFailure(1000, F::Refused, 90);
Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff");
r.OnDial();
Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it");
r.OnReconnectFailure(1090, F::Refused, 3);
Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it");
r.OnDial();
Check(freshAt(r, 1090, 1093), "…but a Dial cuts the backoff down to it, no further");
r.OnSuccess();
// a 403 once a fresh registration is due: the old registration is gone
{
RegRecovery k;
k.OnReconnectFailure(1000, F::Refused, 0, 403);
Check(!k.Fresh() && k.Reconnectable(), "a first 403 is a refusal like the others");
k.OnReconnectFailure(1005, F::Refused, 0, 403);
Check(k.Fresh() && !k.Reconnectable() && freshAt(k, 1005, 1015), "a 403 once fresh is due: gone, the fresh attempt after the backoff");
k.OnSuccess();
Check(k.Reconnectable(), "success forgets it");
}
// O2 10:43: the flow killed 8 s after the 200, every SYN unanswered
r.OnReconnectFailure(1000, F::Silent);
Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s");
r.OnReconnectFailure(1005, F::Silent);
Check(r.Fresh() && r.silentCycles == 2, "two silent cycles escalate");
// a mix: one refusal + one silence keeps reconnecting (neither
// threshold reached)
RegRecovery m;
m.OnReconnectFailure(1000, F::Refused);
m.OnReconnectFailure(1000, F::Silent);
Check(!m.Fresh(), "one refusal + one silence: still reconnecting");
m.OnReconnectFailure(1000, F::Silent);
Check(m.Fresh(), "…until a threshold is reached");
// the reconnect backoff caps at 5 min
RegRecovery b;
for (int i = 0; i < 10; i++) b.OnReconnectFailure(1000, F::Silent);
Check(b.reconnectDelay == 300 && b.Next(1000).at == 1300, "reconnect backoff caps at 300 s");
// fresh attempts: silence backs off 30 s -> 10 min (a Dial may cut
// it); a refusal or a throttle holds on the throttle schedule (21
// min growing to 60), the network's word, and the old SA is
// reconnected meanwhile
RegRecovery f;
f.OldGone();
Check(f.Fresh() && !f.Reconnectable() && freshAt(f, 1000, 0), "the old registration gone: fresh now, nothing to reconnect to");
f.OnFreshFailure(1000, F::Silent);
Check(freshAt(f, 1000, 1030), "silent fresh attempt: 30 s");
f.OnDial();
Check(freshAt(f, 1000, 0), "…which a Dial cuts");
f.OnFreshFailure(1030, F::Silent);
Check(freshAt(f, 1030, 1090), "…then 60 s");
double last = 0;
for (int i = 0; i < 8; i++) { f.OnFreshFailure(2000, F::Silent); last = f.Next(2000).at - 2000; }
Check(last == 600 && f.freshAttempts == 10, "silent fresh backoff caps at 600 s; attempts counted");
RegRecovery g;
g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused); // escalated, backoff 5 then 10
g.OnFreshFailure(1010, F::Refused);
Check(g.Reconnectable() && reconnectAt(g, 1010, 1030) && g.freshHoldUntil == 1010 + 21 * 60, "refused fresh attempt: the old SA is reconnected after the backoff, the next fresh attempt held 21 min");
Check(freshAt(g, 1010 + 21 * 60, 1010 + 21 * 60), "…and runs once the hold ends");
g.OnDial();
Check(g.Next(1010).step == S::Reconnect && g.Next(1010).at <= 1010, "a Dial meanwhile: the reconnect at once, never the held fresh attempt");
g.OnFreshFailure(2000, F::Throttled);
Check(g.freshHoldUntil == 2000 + 31 * 60, "throttled next: 31 min (×1.5)");
g.OnFreshFailure(3000, F::Refused);
Check(g.freshHoldUntil == 3000 + 46 * 60, "then 46 min");
g.OnFreshFailure(4000, F::Refused);
Check(g.freshHoldUntil == 4000 + 60 * 60, "capped at 60 min");
g.OnFreshFailure(5000, F::Refused);
Check(g.freshHoldUntil == 5000 + 60 * 60, "stays at 60 min");
g.OnFreshFailure(6000, F::Refused, 7200);
Check(g.freshHoldUntil == 6000 + 3600, "a Retry-After beyond an hour is cut to the hour, the schedule's own ceiling");
RegRecovery ra;
ra.OnFreshFailure(1000, F::Refused, 2700);
Check(ra.freshHoldUntil == 3700, "a Retry-After longer than the schedule's wait holds for its own length");
ra.OnFreshFailure(5000, F::Refused, 86400);
Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then");
RegRecovery rb;
rb.OnReconnectFailure(1000, F::Refused, 86400);
Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same");
RegRecovery h;
h.OnFreshFailure(1000, F::Refused, 60);
Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule");
h.OnFreshFailure(1000, F::Refused, 5);
Check(h.freshHoldUntil == 1030, "…but never under the 30-s floor");
Check(h.throttleMinutes == 46, "the schedule still advances underneath (21 -> 31 -> 46)");
h.OnFreshFailure(1000, F::Silent, 600);
Check(h.freshHoldUntil == 1030 && h.Next(1000).at == 1030, "Retry-After is a refusal's word; silence keeps its own backoff");
Check(g.Fresh(), "a failed fresh attempt stays on the fresh step");
g.OnSuccess();
Check(!g.Fresh() && g.throttleMinutes == 21 && g.freshDelay == 30, "success resets the fresh schedule too");
// an attempt that got past the SA setup took the old SAs with it
{
RegRecovery s;
s.OnFreshFailure(1000, F::Silent, 0, /*saReplaced=*/true);
Check(!s.Reconnectable() && freshAt(s, 1000, 1030), "no reply past the SA setup: the old registration is gone, the next fresh attempt on the short backoff");
RegRecovery t;
t.OnFreshFailure(1000, F::Refused, 0, /*saReplaced=*/true);
Check(!t.Reconnectable() && freshAt(t, 1000, 1000 + 21 * 60), "refused past the SA setup: gone, and on the schedule");
}
// the network terminated our binding
{
RegRecovery n;
n.OnBindingTerminated(1000, "expired");
Check(n.Fresh() && !n.Reconnectable() && freshAt(n, 1000, 0), "expired: fresh now, nothing to reconnect to");
RegRecovery o;
o.OnBindingTerminated(1000, "probation", 300);
Check(freshAt(o, 1000, 1300), "probation: after the network's retry-after");
o.OnDial();
Check(freshAt(o, 1000, 1300), "…which a Dial does not cut");
RegRecovery p;
p.OnBindingTerminated(1000, "probation");
Check(freshAt(p, 1000, 1001), "probation without a retry-after: at least 1 s");
RegRecovery q;
q.OnBindingTerminated(1000, "rejected");
Check(freshAt(q, 1000, 1000 + 21 * 60) && !q.Reconnectable(), "rejected: on the refusal schedule, a hold, the old SA not retried");
q.OnBindingTerminated(1001, "rejected");
q.OnBindingTerminated(2000, "unregistered");
Check(q.freshHoldUntil == 1000 + 21 * 60 && q.throttleMinutes == 31, "the same termination reported again (another dialog, a retransmission): counted once");
RegRecovery u;
u.OnBindingTerminated(1000, "deactivated");
u.OnFreshFailure(1000, F::Silent);
u.OnBindingTerminated(1005, "deactivated");
Check(freshAt(u, 1005, 1030), "…nor does a repeat cut the backoff of the attempt made since");
RegRecovery v;
v.OnFreshFailure(1000, F::Refused, 3000);
v.OnBindingTerminated(1010, "rejected");
Check(!v.Reconnectable() && freshAt(v, 1010, 4000) && v.throttleMinutes == 46, "a rejected binding never shortens the network's Retry-After");
RegRecovery w;
w.OnBindingTerminated(1000, "probation", 3000);
w.OnBindingTerminated(1010, "probation", 60);
Check(freshAt(w, 1010, 4000), "…nor a second probation report the first one's retry-after");
RegRecovery x;
x.OnBindingTerminated(1000, "probation", 86400);
Check(freshAt(x, 1000, 1000 + 3600), "probation's retry-after is cut to the hour like a Retry-After");
// terminated while a reconnect cycle waited for its re-REGISTER final: the cycle's failure is no news
RegRecovery y;
y.OnReconnectFailure(1000, F::Silent);
y.OnBindingTerminated(1010, "expired");
y.OnReconnectFailure(1011, F::Silent);
Check(y.silentCycles == 1 && freshAt(y, 1011, 0), "the flow closed under the cycle: not a silent cycle, the fresh registration now");
y.OnReconnectFailure(1012, F::Refused, 90, 503);
Check(y.refusals == 0 && freshAt(y, 1012, 1102), "…a refusal with a Retry-After on it: not counted, the network's hold kept");
}
// a terminated binding's verdict outlives the process with its hold
{
RegRecovery tg;
tg.OnBindingTerminated(1000, "rejected");
auto htg = tg.Holds(1100);
Check(htg.Any() && htg.oldGone && htg.freshHold == 1000 + 21 * 60 - 1100, "a terminated binding's holds carry that the old registration is gone");
RegRecovery th;
th.Restore(50, htg);
Check(!th.Reconnectable() && freshAt(th, 50, 50 + htg.freshHold), "…and the restart does not reconnect over its SAs: the fresh attempt waits what was left");
RegRecovery ti;
ti.OnBindingTerminated(1000, "probation", 1800);
RegRecovery tj;
tj.Restore(0, ti.Holds(1300));
Check(!tj.Reconnectable() && freshAt(tj, 0, 1500), "probation's retry-after survives a restart the same way");
}
// the network's holds outlive the process
{
RegRecovery a;
a.OnReconnectFailure(1000, F::Refused); a.OnReconnectFailure(1000, F::Refused);
a.OnFreshFailure(1010, F::Throttled);
auto h = a.Holds(1100);
Check(h.Any() && h.hold == 0 && h.freshHold == 1010 + 21 * 60 - 1100 && h.throttleMinutes == 31, "holds: the seconds still to run and the schedule's place");
RegRecovery b;
b.Restore(50, h);
Check(reconnectAt(b, 50, 0) && b.throttleMinutes == 31, "restored fresh hold: the resume (a reconnect) is free at once");
b.OldGone();
Check(freshAt(b, 50, 50 + h.freshHold), "…and the fresh step waits what was left of it");
b.OnDial();
Check(freshAt(b, 50, 50 + h.freshHold), "…which a Dial does not cut");
RegRecovery c;
c.OnReconnectFailure(1000, F::Refused, 600);
RegRecovery d;
d.Restore(10, c.Holds(1200));
Check(reconnectAt(d, 10, 10 + 400), "restored Retry-After: no step before it ends");
d.Restore(20, RegRecovery::Held{30, 0, 21});
Check(reconnectAt(d, 20, 10 + 400), "a restore never shortens a hold");
Check(!RegRecovery{}.Holds(5).Any(), "no hold, nothing to keep");
auto k = a.Holds(1010 + 21 * 60 + 5);
Check(k.Any() && k.hold == 0 && k.freshHold == 0 && k.throttleMinutes == 31, "the hold run out, the schedule's place is still kept");
RegRecovery e;
e.Restore(10, k);
Check(reconnectAt(e, 10, 0) && e.throttleMinutes == 31, "…and a restart takes it over: no wait, the next refused or throttled fresh attempt on the advanced schedule");
e.OldGone(); e.OnFreshFailure(20, F::Throttled);
Check(freshAt(e, 20, 20 + 31 * 60), "…31 min, not 21");
RegRecovery g;
g.OnReconnectFailure(1000, F::Refused); g.OnReconnectFailure(1000, F::Refused, 0, 403);
auto hg = g.Holds(1100);
Check(hg.Any() && hg.oldGone, "a 403 once fresh is due: the holds carry that the old registration is gone");
RegRecovery h2;
h2.Restore(50, hg);
Check(!h2.Reconnectable() && h2.Fresh() && freshAt(h2, 50, 0), "…and the restart does not reconnect over its SAs: fresh at once");
RegRecovery i;
i.OnFreshFailure(1000, F::Throttled, 0, true);
auto hi = i.Holds(1100);
RegRecovery j;
j.Restore(50, hi);
Check(hi.oldGone && !j.Reconnectable() && freshAt(j, 50, 50 + hi.freshHold), "an attempt past the SA setup: gone travels with the hold, the restart waits it out without resuming");
RegRecovery m;
m.OldGone();
Check(m.Holds(5).Any() && m.Holds(5).oldGone && m.Holds(5).freshHold == 0, "gone with no hold left is still kept");
RegRecovery n;
n.Restore(5, m.Holds(5));
Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming");
Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep");
RegRecovery x;
x.Restore(2000, RegRecovery::Held{86400, 86400, 21});
Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from");
}
// bring-up: a resume that failed leaves nothing to reconnect over
{
RegRecovery u;
u.OldGone();
u.OnFreshFailure(1000, F::Throttled);
Check(!u.Reconnectable() && freshAt(u, 1000, 1000 + 21 * 60), "a throttled bring-up: the next fresh attempt in 21 min, nothing meanwhile");
u.OnDial();
Check(freshAt(u, 1000, 1000 + 21 * 60), "…a Dial changes nothing");
}
}
if (Failures == 0) std::println("Engine: all tests passed");
return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that
return Failures;
}

View file

@ -80,14 +80,6 @@ int main() {
}
// incomplete input -> nullopt
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt");
{
// a registration still on another address of the link, plus an SA to
// another P-CSCF that is none of ours
std::string two = std::format("{}src 2001:db8:1111:2222:aaaa:bbbb:cccc:dddd dst 2001:db8::105\n\tproto esp spi 0x00000001 reqid 1 mode transport\nsrc 2001:db8:1111:2222:eeee:ffff:0:1 dst 2001:db8::999\n\tproto esp spi 0x00000002 reqid 1 mode transport\n", State);
auto locals = SaLocals(two, "2001:db8::105");
Check(locals.size() == 2 && locals[0] == "2001:db8:1111:2222:3333:4444:5555:db43" && locals[1] == "2001:db8:1111:2222:aaaa:bbbb:cccc:dddd", "SaLocals: every local address with an SA to the P-CSCF, once each, in kernel order");
Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none");
}
// ---- fresh setup command sequence
{

View file

@ -196,98 +196,6 @@ int main() {
Check(!m4.has_value(), "absurd Content-Length: discarded, no overflow");
}
// ---- RetryAfter / RefusalSummary (what a refused final is logged as)
{
std::string r503 =
"SIP/2.0 503 Service Unavailable\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKx\r\n"
"Retry-After: 120\r\n"
"Warning: 399 pcscf1.example \"S-CSCF unreachable\"\r\n"
"Server: Mavenir-P-CSCF/4.2\r\n"
"Content-Length: 0\r\n\r\n";
Check(RetryAfter(r503) == 120, "Retry-After parsed");
Check(RefusalSummary(r503) == "503 Service Unavailable; Retry-After: 120; Warning: 399 pcscf1.example \"S-CSCF unreachable\"; Server: Mavenir-P-CSCF/4.2", "summary: status line + reason headers in order");
std::string r500 =
"SIP/2.0 500 Server Internal Error\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(!RetryAfter(r500).has_value(), "no Retry-After -> nullopt");
Check(RefusalSummary(r500) == "500 Server Internal Error", "summary without reason headers is the status line");
std::string two =
"SIP/2.0 403 Forbidden\r\n"
"Reason: Q.850;cause=21;text=\"Call rejected\"\r\n"
"Reason: SIP;cause=403\r\n"
"Retry-After: soon\r\n"
"Content-Length: 0\r\n\r\n";
Check(!RetryAfter(two).has_value(), "non-numeric Retry-After -> nullopt");
Check(RefusalSummary(two) == "403 Forbidden; Retry-After: soon; Reason: Q.850;cause=21;text=\"Call rejected\"; Reason: SIP;cause=403", "every instance of a reason header is kept");
Check(RefusalSummary("NOTIFY sip:x SIP/2.0\r\nEvent: reg\r\n\r\n") == "NOTIFY sip:x SIP/2.0", "a request summarises to its request line");
Check(RefusalSummary("") == "", "empty message -> empty summary");
}
// ---- ResponseOf (which datagram a REGISTER wait may judge)
{
Check(ResponseOf(Resp200, "12345@host", "2 REGISTER"), "the transaction's own final");
Check(!ResponseOf(Resp200, "12345@host", "1 REGISTER"), "another CSeq is not it");
std::string stale =
"SIP/2.0 408 Request Timeout\r\n"
"Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKa;rport=5060\r\n"
"Call-ID: earlier@[2001:db8::db43]\r\n"
"CSeq: 1 REGISTER\r\n"
"Content-Length: 0\r\n\r\n";
Check(!ResponseOf(stale, "12345@host", "1 REGISTER"), "an earlier attempt's late final is not it");
Check(ResponseOf(stale, "earlier@[2001:db8::db43]", "1 REGISTER"), "matched by Call-ID and CSeq");
Check(!ResponseOf("NOTIFY sip:x SIP/2.0\r\nCall-ID: 12345@host\r\nCSeq: 2 REGISTER\r\n\r\n", "12345@host", "2 REGISTER"), "a request is never a response");
Check(!ResponseOf("", "12345@host", "2 REGISTER"), "nothing is not it");
}
// ---- RegInfoContactState: our binding in a reg-event NOTIFY (RFC 3680)
{
using S = RegInfoContact::State;
auto contact = [](std::string_view id, std::string_view state, std::string_view extra, std::string_view user) {
return std::format(
"\t\t<contact id=\"{}\" state=\"{}\"{}>\n"
"\t\t\t<uri>sip:{}@[2a03:db8::1]:45062</uri>\n"
"\t\t\t<unknown-param name=\"+sip.instance\">&quot;&lt;urn:gsma:imei:35901961-000000-0&gt;&quot; </unknown-param>\n"
"\t\t</contact>\n", id, state, extra, user);
};
auto reginfo = [](std::string_view contacts) {
return std::format(
"<?xml version=\"1.0\"?>\n"
"<reginfo xmlns=\"urn:ietf:params:xml:ns:reginfo\" version=\"1\" state=\"full\">\n"
"\t<registration aor=\"sip:+447700900000@example.net\" id=\"2\" state=\"active\">\n{0}\t</registration>\n"
"\t<registration aor=\"tel:+447700900000\" id=\"3\" state=\"active\">\n{0}\t</registration>\n"
"</reginfo>\n", contacts);
};
const std::string me = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const std::string old = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
// the first NOTIFY of a registration: our contact active in both registrations
auto r0 = RegInfoContactState(reginfo(contact("3", "active", " event=\"created\" expires=\"10800\"", me)), me);
Check(r0.state == S::Active, "our contact active -> Active");
// O2 2026-09-24 10:43: the previous registration's contact deactivated by ours, ours created
std::string both = contact("3", "terminated", " event=\"deactivated\"", old) + contact("4", "active", " event=\"created\" expires=\"10800\"", me);
auto r1 = RegInfoContactState(reginfo(both), me);
Check(r1.state == S::Active, "old contact terminated + ours active -> Active (the old one is not ours)");
auto r1o = RegInfoContactState(reginfo(both), old);
Check(r1o.state == S::Terminated && r1o.event == "deactivated" && r1o.retryAfter == 0, "seen from the old contact's side: Terminated/deactivated");
// a network-initiated deregistration of our own binding
auto r2 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"probation\" retry-after=\"120\"", me)), me);
Check(r2.state == S::Terminated && r2.event == "probation" && r2.retryAfter == 120, "probation with retry-after");
auto r3 = RegInfoContactState(reginfo(contact("4", "terminated", " event=\"rejected\"", me)), me);
Check(r3.state == S::Terminated && r3.event == "rejected", "rejected");
// active in one registration of the set beats terminated in another
std::string mixed =
"<reginfo version=\"2\" state=\"partial\">\n<registration aor=\"tel:+1\" id=\"3\" state=\"active\">\n" + contact("4", "terminated", " event=\"expired\"", me) +
"</registration>\n<registration aor=\"sip:+1@x\" id=\"2\" state=\"active\">\n" + contact("4", "active", "", me) + "</registration>\n</reginfo>";
Check(RegInfoContactState(mixed, me).state == S::Active, "active anywhere in the set -> Active");
// not ours at all
auto r4 = RegInfoContactState(reginfo(contact("9", "terminated", " event=\"unregistered\"", old)), me);
Check(r4.state == S::NotListed && r4.event.empty(), "a document without our contact -> NotListed");
Check(RegInfoContactState("", me).state == S::NotListed, "empty body -> NotListed");
Check(RegInfoContactState("<reginfo><registration><contact id=\"1\" state=\"active\"><uri>sip:aaaaaaaa@x</uri></contact>", me).state == S::NotListed, "a user part that is only a prefix of ours does not match");
Check(RegInfoContactState("<contact state=\"terminated\" event=\"expired\"><uri>sip:" + me + "@h</uri>", me).state == S::Terminated, "an unterminated element still parses");
}
if (Failures == 0) std::println("Sip: all tests passed");
return Failures;
}