Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
7583ee3606 |
|||
|
7d38846bb6 |
|||
|
af5b98ec8b |
|||
|
f089c8dd71 |
|||
|
19346908f9 |
|||
|
a625248e18 |
|||
|
828b0af95b |
|||
|
f18baf2790 |
|||
|
2fe991a14e |
|||
|
0a91050635 |
|||
|
7eb2deae0c |
|||
|
f8fd5a3aa8 |
|||
|
db848c6490 |
|||
|
142adf057c |
|||
|
46bf363915 |
35 changed files with 3252 additions and 1579 deletions
|
|
@ -6,18 +6,19 @@ name: package
|
||||||
# plain 'apk upgrade' without an fp6-img image run.
|
# plain 'apk upgrade' without an fp6-img image run.
|
||||||
#
|
#
|
||||||
# Build: crafter-build cross-compiles against an Alpine aarch64 sysroot
|
# Build: crafter-build cross-compiles against an Alpine aarch64 sysroot
|
||||||
# (packaging/build-package.sh — the README's "Cross-compiling" flow), the
|
# (packaging/build-package.sh — AGENTS.md §4.3's cross-compiling flow), the
|
||||||
# test suite runs natively, and packaging/APKBUILD.binary wraps the result.
|
# test suite runs natively, and packaging/APKBUILD.binary wraps the result.
|
||||||
# This needs no privileged runner: it runs in an alpine:edge container on
|
# crafter-build is the Crafter.Build Flatpak, and bubblewrap inside the job
|
||||||
# the ordinary arch-latest runner.
|
# container needs a privileged runner: the job runs on the `flatpak` label
|
||||||
|
# (a privileged runner registered to this repository), in an alpine:edge
|
||||||
|
# container.
|
||||||
#
|
#
|
||||||
# Release gating is the version: pkgver comes from implementations/main.cpp,
|
# Release gating is the version: pkgver comes from implementations/main.cpp,
|
||||||
# the registry answers 409 for an already-published version, and the publish
|
# 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
|
# 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
|
# Version constant bumps. This is the only producer of the imsd apk: fp6-img
|
||||||
# images install the published package from the registry (pinned version,
|
# images install the published package from the registry instead of
|
||||||
# pinned sha256) instead of building their own, so image and 'apk upgrade'
|
# building their own, so image and 'apk upgrade' carry the same binary.
|
||||||
# carry the same binary.
|
|
||||||
#
|
#
|
||||||
# Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an
|
# Publishing needs PACKAGE_TOKEN (catbot account, package:write scope) — an
|
||||||
# org-level secret on Catcrafts since 2026-09-02, shared with fp6-img;
|
# org-level secret on Catcrafts since 2026-09-02, shared with fp6-img;
|
||||||
|
|
@ -30,7 +31,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
package:
|
package:
|
||||||
runs-on: arch-latest
|
runs-on: flatpak
|
||||||
container:
|
container:
|
||||||
image: alpine:edge
|
image: alpine:edge
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
|
|
|
||||||
157
Makefile
157
Makefile
|
|
@ -1,157 +0,0 @@
|
||||||
# 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
|
|
||||||
335
README.md
335
README.md
|
|
@ -5,306 +5,137 @@
|
||||||
|
|
||||||
Userspace IMS/VoLTE daemon for mainline Linux phones.
|
Userspace IMS/VoLTE daemon for mainline Linux phones.
|
||||||
|
|
||||||
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but
|
Most Linux phones drive the modem's internal IMS engine.
|
||||||
can't be used for actual call audio. imsd implements the whole IMS client in
|
imsd implements the whole IMS client in userspace instead:
|
||||||
userspace instead: SIP registration with USIM AKA authentication over kernel
|
SIP registration with USIM AKA authentication over kernel
|
||||||
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
|
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
|
||||||
PipeWire so a stock mobile dialer — Plasma Dialer, or GNOME Calls under Phosh
|
PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh
|
||||||
and GNOME Mobile — can place and receive real VoLTE calls on a commercial
|
can place and receive real VoLTE calls on a commercial network.
|
||||||
network.
|
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Call-capable, both directions. Outgoing VoLTE calls place, answer, carry
|
Call-capable, both directions: outgoing calls place, answer, carry full-duplex
|
||||||
full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via
|
AMR-WB audio and tear down. Incoming calls ring, answer and carry media the
|
||||||
`Accept`, and carry media the same way — driven end-to-end from a stock
|
same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI.
|
||||||
dialer over the frozen D-Bus ABI. Current state:
|
Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting,
|
||||||
|
USSD, P-CSCF discovery (the address is configured by hand).
|
||||||
|
|
||||||
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose
|
Verified by community members to be fully working:
|
||||||
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.
|
|
||||||
|
|
||||||
## Architecture
|
| Device | OS | Carrier |
|
||||||
|
|
||||||
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 | KPN NL |
|
||||||
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER |
|
| 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 | Phonero |
|
||||||
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
|
||||||
|
|
||||||
## Build
|
## What it needs
|
||||||
|
|
||||||
[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) +
|
imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs
|
||||||
gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine).
|
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.
|
||||||
|
|
||||||
```sh
|
## Install and configure
|
||||||
crafter-build # bin/imsd-<target>-<march>/imsd (the daemon)
|
|
||||||
crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg)
|
For automatic updates add the Catcrafts Alpine repository. Note this does also pull in other packages.
|
||||||
crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (Plasma backend)
|
|
||||||
crafter-build -- --product=ofonod # bin/imsd-ofonod-.../imsd-ofonod (GNOME Calls backend)
|
Otherwise just download the latest `imsd` and `imsd-systemd` APKs and install them.
|
||||||
crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp)
|
|
||||||
|
Both can be found in the packages tab:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building with make (distro packaging)
|
Configuration is environment variables in `/etc/imsd.env`; the one required
|
||||||
|
setting is the P-CSCF:
|
||||||
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
|
```sh
|
||||||
make # build/make/{imsd,imsd-media,imsd-dialerd,imsd-ofonod}
|
# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android
|
||||||
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
|
PCSCF=2001:db8::105
|
||||||
```
|
```
|
||||||
|
|
||||||
### imsd
|
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:
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error |
|
| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error |
|
||||||
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
|
| `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` |
|
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
|
||||||
| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` |
|
| `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) |
|
||||||
| `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 |
|
| `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 |
|
| `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) |
|
| `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] |
|
| `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 |
|
| `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 (`/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 |
|
| `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 |
|
| `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 |
|
||||||
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
|
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
|
||||||
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
|
| `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 |
|
| `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 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_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_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
|
| `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`) |
|
| `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
|
The protected client/server ports are fixed at 45061/45062
|
||||||
(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires
|
(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires
|
||||||
updating the firewall rules `ims-pdn-up.sh` installs.
|
updating the firewall rules `ims-pdn-up.sh` installs.
|
||||||
|
|
||||||
### imsd-media (per-call, set through imsd's environment)
|
### imsd-media (per-call, set through imsd's environment)
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire |
|
| `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` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) |
|
| `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 |
|
| `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-WB encoder mode (0–8) |
|
| `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) |
|
||||||
| `DTX` | `0` | encoder discontinuous transmission |
|
| `DTX` | `0` | encoder discontinuous transmission |
|
||||||
| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) |
|
| `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 |
|
| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file |
|
||||||
|
|
||||||
## D-Bus ABI
|
## D-Bus ABI
|
||||||
|
|
||||||
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer
|
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen.
|
||||||
integrations depend on it:
|
|
||||||
|
|
||||||
| Member | Signature |
|
| Member | Signature |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
@ -319,8 +150,8 @@ integrations depend on it:
|
||||||
| `CallDeleted` | `s` |
|
| `CallDeleted` | `s` |
|
||||||
| `RegistrationChanged` | `b` |
|
| `RegistrationChanged` | `b` |
|
||||||
|
|
||||||
Both `imsd-dialerd` and `imsd-ofonod` are pure translations of this ABI; a new
|
Both `imsd-dialerd` and `imsd-ofonod` are users of this ABI.
|
||||||
desktop needs a new translation daemon, not changes here.
|
A new desktop needs a new translation daemon, not changes here.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -410,41 +410,56 @@ std::size_t RtpPayloadOffset(std::span<const std::uint8_t> pkt) {
|
||||||
return off;
|
return off;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The desktop user's uid, resolved ONCE and thread-safely. getpwnam() hands
|
// The desktop session whose PipeWire instance carries call audio, resolved
|
||||||
// out one static buffer, and the mic and playout threads spawn their pw
|
// ONCE and thread-safely: getpwnam() hands out one static buffer, and the
|
||||||
// helpers at the same instant — the race sent both into /run/user/0, where
|
// mic and playout threads spawn their pw helpers at the same instant — the
|
||||||
// no PipeWire session lives, and a call was answered into static (dev phone,
|
// race sent both into /run/user/0, where no PipeWire session lives, and a
|
||||||
// 2026-09-08 20:01: tx_mic=0 rx_played=0 qdrop=883). Logged per call so a
|
// call was answered into static (dev phone, 2026-09-08 20:01: tx_mic=0
|
||||||
// journal shows which session the helpers were aimed at.
|
// rx_played=0 qdrop=883). AUDIO_USER names the account: imsd sets it per
|
||||||
uid_t AudioUid() {
|
// 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() {
|
||||||
static std::once_flag once;
|
static std::once_flag once;
|
||||||
static uid_t uid = 0;
|
static AudioSession s;
|
||||||
std::call_once(once, [] {
|
std::call_once(once, [] {
|
||||||
std::string user = EnvOr("AUDIO_USER", "user");
|
|
||||||
passwd pw{};
|
passwd pw{};
|
||||||
passwd* res = nullptr;
|
passwd* res = nullptr;
|
||||||
char buf[4096];
|
char buf[4096];
|
||||||
if (getpwnam_r(user.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) uid = res->pw_uid;
|
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; }
|
||||||
else
|
else
|
||||||
std::println(std::cerr, "imsd-media: AUDIO_USER '{}' not found; no PipeWire session for uid 0", user);
|
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/{}", user, uid);
|
std::println(std::cerr, "imsd-media: audio session: {} -> /run/user/{}", s.name.empty() ? std::string("(none)") : s.name, s.uid);
|
||||||
});
|
});
|
||||||
return uid;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn pw-record/pw-play in the desktop user's PipeWire session (sudo -u
|
// Spawn pw-record/pw-play in the desktop session's PipeWire instance (sudo -u
|
||||||
// when run as root). AUDIO_USER names the session owner; the default "user"
|
// when run as root; as ourselves when there is no session to aim at).
|
||||||
// is postmarketOS's standard account. `toChild` true = we write the child's
|
// `toChild` true = we write the child's stdin (pw-play); false = we read its
|
||||||
// stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}.
|
// stdout (pw-record). Returns {pid, fd}.
|
||||||
struct Child { pid_t pid = -1; int fd = -1; };
|
struct Child { pid_t pid = -1; int fd = -1; };
|
||||||
Child SpawnPw(bool play, bool toChild, int rate) {
|
Child SpawnPw(bool play, bool toChild, int rate) {
|
||||||
int pipefd[2];
|
int pipefd[2];
|
||||||
if (pipe(pipefd) != 0) return {};
|
if (pipe(pipefd) != 0) return {};
|
||||||
std::vector<std::string> argv;
|
std::vector<std::string> argv;
|
||||||
if (geteuid() == 0) {
|
if (geteuid() == 0) {
|
||||||
std::string user = EnvOr("AUDIO_USER", "user");
|
const AudioSession& session = Session();
|
||||||
argv = {"sudo", "-u", user, "env",
|
if (!session.name.empty())
|
||||||
std::format("XDG_RUNTIME_DIR=/run/user/{}", AudioUid())};
|
argv = {"sudo", "-u", session.name, "env",
|
||||||
|
std::format("XDG_RUNTIME_DIR=/run/user/{}", session.uid)};
|
||||||
}
|
}
|
||||||
const char* tool = play ? "pw-play" : "pw-record";
|
const char* tool = play ? "pw-play" : "pw-record";
|
||||||
const char* lat = play ? "40ms" : "20ms";
|
const char* lat = play ? "40ms" : "20ms";
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,12 @@ 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
|
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
|
packaging/org.ofono.conf). It does no I/O beyond D-Bus: every call decision
|
||||||
stays in imsd. Everything happens on the GLib main loop, so there is no
|
stays in imsd. Its calls reach imsd as root, so it asks polkit about its own
|
||||||
locking.
|
callers (ImsdBus): call control is net.catcrafts.IMS1.Voice, as on 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
|
--session own org.ofono on the session bus and talk to imsd there
|
||||||
(development: no root, no policy file)
|
(development: no root, no policy file, no polkit)
|
||||||
IMSD_BUS=session|system override only the imsd side of that choice
|
IMSD_BUS=session|system override only the imsd side of that choice
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ locking.
|
||||||
#include <glib-unix.h>
|
#include <glib-unix.h>
|
||||||
|
|
||||||
import std;
|
import std;
|
||||||
|
import ImsdBus;
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
|
@ -315,7 +317,9 @@ 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");
|
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
|
||||||
}
|
}
|
||||||
|
|
||||||
const GDBusInterfaceVTable CallVtable = { HandleCallMethod, nullptr, nullptr, {} };
|
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, {} };
|
||||||
|
|
||||||
guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) {
|
guint RegisterObject(const char* xml, const char* path, const GDBusInterfaceVTable* vtable) {
|
||||||
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr);
|
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr);
|
||||||
|
|
@ -596,9 +600,9 @@ void HandleSsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*,
|
||||||
}
|
}
|
||||||
|
|
||||||
const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} };
|
const GDBusInterfaceVTable ManagerVtable = { HandleManagerMethod, nullptr, nullptr, {} };
|
||||||
const GDBusInterfaceVTable ModemVtable = { HandleModemMethod, nullptr, nullptr, {} };
|
const GDBusInterfaceVTable ModemVtable = { imsd::bus::Gated<HandleModemMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
|
||||||
const GDBusInterfaceVTable VcmVtable = { HandleVcmMethod, nullptr, nullptr, {} };
|
const GDBusInterfaceVTable VcmVtable = { imsd::bus::Gated<HandleVcmMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
|
||||||
const GDBusInterfaceVTable SsVtable = { HandleSsMethod, nullptr, nullptr, {} };
|
const GDBusInterfaceVTable SsVtable = { imsd::bus::Gated<HandleSsMethod, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
|
||||||
|
|
||||||
// ---- imsd events ------------------------------------------------------------
|
// ---- imsd events ------------------------------------------------------------
|
||||||
void PublishVoiceState() {
|
void PublishVoiceState() {
|
||||||
|
|
@ -727,6 +731,7 @@ int main(int argc, char** argv) {
|
||||||
if (std::string_view(argv[i]) == "--session") session = true;
|
if (std::string_view(argv[i]) == "--session") session = true;
|
||||||
|
|
||||||
GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;
|
GBusType ofonoBus = session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;
|
||||||
|
imsd::bus::AuthorizeCalls = !session;
|
||||||
std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system");
|
std::string imsdBusName = EnvOr("IMSD_BUS", session ? "session" : "system");
|
||||||
GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;
|
GBusType imsdBus = imsdBusName == "session" ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
|
|
||||||
// lint-disable-file fixed-width-types
|
// lint-disable-file fixed-width-types
|
||||||
/*
|
/*
|
||||||
Imsd:Engine — the call state machine as a pure reducer.
|
Imsd:Engine — the daemon's decisions as pure reducers: the call state
|
||||||
|
machine (CallMachine) and the registration recovery policy (RegRecovery,
|
||||||
|
its own section below).
|
||||||
|
|
||||||
A CallMachine owns one call's SIP dialog, outgoing or incoming. It never
|
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
|
touches a socket, a clock, or a subprocess: each event (the INVITE's
|
||||||
|
|
@ -52,6 +54,215 @@ export namespace imsd::engine {
|
||||||
return d == Direction::Incoming ? "incoming" : "outgoing";
|
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
|
// 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).
|
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
|
||||||
struct MediaLeg {
|
struct MediaLeg {
|
||||||
|
|
|
||||||
|
|
@ -400,4 +400,17 @@ 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);
|
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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,93 @@ export namespace imsd::sip {
|
||||||
return v;
|
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
|
// 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
|
// 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
|
// the caller should reply to the request's source port instead
|
||||||
|
|
|
||||||
106
interfaces/ImsdBus.cppm
Normal file
106
interfaces/ImsdBus.cppm
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// 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
858
lint-rules.h
|
|
@ -1,858 +0,0 @@
|
||||||
// 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
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,12 @@
|
||||||
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
|
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
|
||||||
# Binary packaging: wraps a crafter-build binary cross-compiled per the
|
# Binary packaging: wraps a crafter-build binary cross-compiled per the
|
||||||
# README's "Cross-compiling" section into a proper apk — used by this repo's
|
# cross-compiling flow of AGENTS.md §4.3 into a proper apk — used by this repo's
|
||||||
# package CI (packaging/build-package.sh, which also seds pkgver from
|
# package CI (packaging/build-package.sh, which also seds pkgver from
|
||||||
# implementations/main.cpp) and runnable by hand. The source tarball is
|
# implementations/main.cpp) and runnable by hand. The source tarball is
|
||||||
# produced by packaging/make-bin-tarball.sh; the desktop/preset files live
|
# produced by packaging/make-bin-tarball.sh; the desktop/preset files and
|
||||||
# next to it in packaging/ (copy them beside this file). Since 2026-09-02
|
# the install scripts (*.post-upgrade, *.post-install) 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
|
# this is the only imsd packaging: fp6-img images install the apk this
|
||||||
# builds from the registry rather than building their own.
|
# builds from the registry rather than building their own.
|
||||||
pkgname=imsd
|
pkgname=imsd
|
||||||
|
|
@ -16,8 +17,9 @@ pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
|
||||||
url="https://forgejo.catcrafts.net/Catcrafts/imsd"
|
url="https://forgejo.catcrafts.net/Catcrafts/imsd"
|
||||||
arch="aarch64"
|
arch="aarch64"
|
||||||
license="GPL-3.0-only"
|
license="GPL-3.0-only"
|
||||||
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire
|
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire;
|
||||||
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools"
|
# polkit answers the daemons' call-control check (ImsdBus)
|
||||||
|
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools polkit"
|
||||||
# versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND
|
# versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND
|
||||||
# excludes the real package: 81voltd serves the modem's own ims-PDN requests,
|
# 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
|
# which races imsd for the PDN and flaps it with a new prefix every ~2.5 min
|
||||||
|
|
@ -29,6 +31,9 @@ provides="81voltd=$pkgver-r$pkgrel"
|
||||||
# (ofono before systemd: systemd() amoves all of usr/lib/systemd/system,
|
# (ofono before systemd: systemd() amoves all of usr/lib/systemd/system,
|
||||||
# so ofono() must take imsd-ofonod.service first)
|
# so ofono() must take imsd-ofonod.service first)
|
||||||
subpackages="$pkgname-ofono:ofono $pkgname-systemd"
|
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"
|
options="!check !tracedeps"
|
||||||
source="
|
source="
|
||||||
imsd-$pkgver.tar.gz
|
imsd-$pkgver.tar.gz
|
||||||
|
|
@ -42,11 +47,14 @@ package() {
|
||||||
install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media
|
install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media
|
||||||
install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
|
install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
|
||||||
install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh
|
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.service "$pkgdir"/usr/lib/systemd/system/imsd.service
|
||||||
install -Dm644 imsd-dialerd.desktop \
|
install -Dm644 imsd-dialerd.desktop \
|
||||||
"$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop
|
"$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop
|
||||||
install -Dm644 net.catcrafts.IMS1.conf \
|
install -Dm644 net.catcrafts.IMS1.conf \
|
||||||
"$pkgdir"/usr/share/dbus-1/system.d/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
|
# the skel override hides kde-telephony's modem daemon autostart for the
|
||||||
# account created at install — imsd-dialerd owns those session bus names
|
# account created at install — imsd-dialerd owns those session bus names
|
||||||
install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \
|
install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \
|
||||||
|
|
@ -64,6 +72,8 @@ package() {
|
||||||
printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \
|
printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \
|
||||||
> "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf
|
> "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf
|
||||||
install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod
|
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 \
|
install -Dm644 imsd-ofonod.service \
|
||||||
"$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service
|
"$pkgdir"/usr/lib/systemd/system/imsd-ofonod.service
|
||||||
install -Dm644 org.ofono.conf \
|
install -Dm644 org.ofono.conf \
|
||||||
|
|
@ -78,6 +88,7 @@ ofono() {
|
||||||
pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd"
|
pkgdesc="GNOME Calls (Phosh, GNOME Mobile) backend for imsd"
|
||||||
depends="$pkgname=$pkgver-r$pkgrel calls"
|
depends="$pkgname=$pkgver-r$pkgrel calls"
|
||||||
amove usr/bin/imsd-ofonod
|
amove usr/bin/imsd-ofonod
|
||||||
|
amove usr/libexec/imsd-ofono-restart-calls.sh
|
||||||
amove usr/lib/systemd/system/imsd-ofonod.service
|
amove usr/lib/systemd/system/imsd-ofonod.service
|
||||||
amove usr/share/dbus-1/system.d/imsd-ofono.conf
|
amove usr/share/dbus-1/system.d/imsd-ofono.conf
|
||||||
amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override
|
amove usr/share/glib-2.0/schemas/90_imsd-ofono.gschema.override
|
||||||
|
|
|
||||||
|
|
@ -2,47 +2,31 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-only
|
# SPDX-License-Identifier: GPL-3.0-only
|
||||||
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
# CI package build: cross-compile imsd for aarch64 with crafter-build (the
|
# CI package build: cross-compile imsd for aarch64 with crafter-build (the
|
||||||
# README's "Cross-compiling" flow), run the test suite natively, and package
|
# cross-compiling flow of AGENTS.md §4.3), run the test suite natively, and package
|
||||||
# the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine
|
# the result via packaging/APKBUILD.binary. Expects an x86_64 Alpine
|
||||||
# environment with root — the workflow runs it in an alpine:edge job
|
# environment with root — the workflow runs it in an alpine:edge job
|
||||||
# container on an ordinary runner. Root only installs packages and hands off
|
# container on the privileged `flatpak` runner: crafter-build is the
|
||||||
# to a scratch user: the sysroot is built with apk.static --usermode (which
|
# Crafter.Build Flatpak, and bubblewrap inside the container needs the
|
||||||
# refuses root) and abuild wants a user too.
|
# 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.
|
||||||
#
|
#
|
||||||
# Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk;
|
# Built packages land in /home/build/.local/share/abuild/*/aarch64/imsd*.apk;
|
||||||
# the workflow's publish step uploads them to the Forgejo Alpine registry.
|
# the workflow's publish step uploads them to the Forgejo Alpine registry.
|
||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# The musl build of crafter-build (Crafter.Build CI's release-musl job):
|
# crafter-build is the Crafter.Build Flatpak: it carries its own clang, lld
|
||||||
# this container is Alpine, and the glibc launcher cannot run on musl. v2 =
|
# and libc++, and its SDK has glib, so nothing that compiles is installed
|
||||||
# SSE4.2 baseline: the CI box is an Intel N5105 (no AVX). Overridable for
|
# here. `stable` is the branch Crafter.Build publishes from its release tags
|
||||||
# local rehearsals (file:// works).
|
# (`beta` follows every push to its master), so the toolchain of a release
|
||||||
CRAFTER_URL=${CRAFTER_URL:-https://forgejo.catcrafts.net/Catcrafts/Crafter.Build/releases/download/latest/crafter-build-linux-x86_64-musl-v2.tar.gz}
|
# 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}
|
||||||
|
|
||||||
SRC=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
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...>
|
retry() { # retry <description> <cmd...>
|
||||||
_desc=$1; shift
|
_desc=$1; shift
|
||||||
for _i in 1 2 3; do
|
for _i in 1 2 3; do
|
||||||
|
|
@ -54,26 +38,49 @@ retry() { # retry <description> <cmd...>
|
||||||
return 1
|
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
|
# implementations/main.cpp is the version's single source of truth (same
|
||||||
# derivation as make-bin-tarball.sh)
|
# derivation as make-bin-tarball.sh)
|
||||||
VER=$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' "$SRC/implementations/main.cpp")
|
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; }
|
[ -n "$VER" ] || { echo "cannot read Version from implementations/main.cpp" >&2; exit 1; }
|
||||||
echo ">> packaging imsd $VER"
|
echo ">> packaging imsd $VER"
|
||||||
|
|
||||||
# --- crafter-build: static launcher from the rolling release
|
# --- crafter-build: the Flatpak, under the name the rest of this file uses.
|
||||||
mkdir -p "$HOME/crafter-build"
|
# The build log names the build it ran with.
|
||||||
retry "fetch crafter-build" \
|
mkdir -p "$HOME/bin"
|
||||||
sh -c "curl -fsSL '$CRAFTER_URL' | tar -xz -C '$HOME/crafter-build'"
|
printf '#!/bin/sh\nexec flatpak run %s "$@"\n' "$CRAFTER_REF" > "$HOME/bin/crafter-build"
|
||||||
PATH="$HOME/crafter-build/bin:$PATH"
|
chmod +x "$HOME/bin/crafter-build"
|
||||||
export CRAFTER_BUILD_HOME="$HOME/crafter-build/share/crafter-build"
|
PATH="$HOME/bin:$PATH"
|
||||||
|
flatpak info "$CRAFTER_REF" | sed -n 's/^ *\(Version\|Commit\|Date\): */crafter-build \1: /p'
|
||||||
|
crafter-build --version
|
||||||
|
|
||||||
# --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode)
|
# --- aarch64 Alpine sysroot (unprivileged: apk.static --usermode)
|
||||||
SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine"
|
SYSROOT="$HOME/.cache/imsd/sysroot-aarch64-alpine"
|
||||||
retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT"
|
retry "make sysroot" "$SRC/packaging/make-sysroot.sh" "$SYSROOT"
|
||||||
|
|
||||||
# --- cross-compile the products; run the suites natively (same sources,
|
# --- cross-compile the products; run the suites natively, inside the Flatpak
|
||||||
# host arch — the aarch64 binaries are additionally exercised by fp6-img's
|
# (same sources, host arch: no suite runs on the aarch64 binaries)
|
||||||
# chroot `make check` whenever an image builds)
|
|
||||||
cd "$SRC"
|
cd "$SRC"
|
||||||
# The phone is one SoC, 4x Cortex-A520 + 4x Cortex-A720, so build for it.
|
# 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
|
# armv8.6-a is the highest level whose mandatory set it exposes (8.7 would
|
||||||
|
|
@ -94,7 +101,7 @@ rm -rf "$PKG"
|
||||||
mkdir -p "$PKG"
|
mkdir -p "$PKG"
|
||||||
cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD"
|
cp "$SRC/packaging/APKBUILD.binary" "$PKG/APKBUILD"
|
||||||
cp "$SRC/packaging/org.kde.modem.daemon.desktop" \
|
cp "$SRC/packaging/org.kde.modem.daemon.desktop" \
|
||||||
"$SRC/packaging/80-imsd.preset" "$PKG/"
|
"$SRC/packaging/80-imsd.preset" "$SRC"/packaging/*.post-upgrade "$SRC"/packaging/*.post-install "$PKG/"
|
||||||
mv "imsd-$VER.tar.gz" "$PKG/"
|
mv "imsd-$VER.tar.gz" "$PKG/"
|
||||||
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
|
sed -i "s/^pkgver=.*/pkgver=$VER/" "$PKG/APKBUILD"
|
||||||
# a throwaway signing key: phones trust the registry-signed APKINDEX, not
|
# a throwaway signing key: phones trust the registry-signed APKINDEX, not
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ bearer_paths() {
|
||||||
kv -m "$MODEM" | sed -n 's/^modem\.generic\.bearers\.value\[[0-9]*\] *: *//p'
|
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
|
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 '"'
|
sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"'
|
||||||
}
|
}
|
||||||
|
|
@ -65,34 +71,49 @@ log "modem $MODEM"
|
||||||
# lookup itself can fail while ModemManager is still enabling the modem; a
|
# 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
|
# 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
|
# back to is the one the modem refuses under an IPv4 attach), so it is
|
||||||
# retried after the registration wait and only a list without the APN
|
# retried after the registration wait; a list without the APN is final —
|
||||||
# falls back to the APN string.
|
# the APN string is used — only once the modem is registered.
|
||||||
LOOKUP=pending
|
LOOKUP=pending
|
||||||
if [ -n "$PROFILE_ID" ]; then
|
if [ -n "$PROFILE_ID" ]; then
|
||||||
[ "$PROFILE_ID" = none ] && PROFILE_ID=
|
[ "$PROFILE_ID" = none ] && PROFILE_ID=
|
||||||
LOOKUP=done
|
LOOKUP=done
|
||||||
fi
|
fi
|
||||||
lookup_profile() { # $1 = log suffix; sets PROFILE_ID, LOOKUP=done on a usable list
|
lookup_profile() { # $1 = modem state, $2 = log suffix; sets PROFILE_ID, LOOKUP=done on a final answer
|
||||||
[ "$LOOKUP" = done ] && return 0
|
[ "$LOOKUP" = done ] && return 0
|
||||||
if OUT=$(qmicli -d qrtr://0 --wds-get-profile-list=3gpp 2>&1); then
|
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 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
|
# 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)
|
# 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
|
if ! printf '%s\n' "$OUT" | grep -q -E '^[[:space:]]*\[[0-9]+\] 3gpp'; then
|
||||||
log "ims profile lookup returned no profiles$1"
|
log "ims profile lookup returned no profiles$2"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" '
|
PROFILE_ID=$(printf '%s\n' "$OUT" | awk -v apn="$IMS_APN" '
|
||||||
/^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) }
|
/^[ \t]*\[[0-9]+\] 3gpp/ { idx = $1; gsub(/[^0-9]/, "", idx) }
|
||||||
/APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a);
|
/APN:/ { a = $0; sub(/.*APN: '"'"'/, "", a); sub(/'"'"'.*/, "", a);
|
||||||
if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }')
|
if (tolower(a) == tolower(apn) && idx != "") { print idx; exit } }')
|
||||||
|
if [ -n "$PROFILE_ID" ]; then
|
||||||
LOOKUP=done
|
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"
|
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
|
||||||
else
|
else
|
||||||
log "ims profile lookup failed$1: $(squash "$OUT")"
|
log "ims profile lookup failed$2: $(squash "$OUT")"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
lookup_profile " (modem state: $(modem_state))"
|
STATE=$(modem_state)
|
||||||
|
lookup_profile "$STATE" " (modem state: $STATE)"
|
||||||
|
|
||||||
# ---- find a connected ims bearer; else find-or-create one and connect it.
|
# ---- 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
|
# APN names are case-insensitive. A DISCONNECTED bearer is reused only when
|
||||||
|
|
@ -150,6 +171,10 @@ if [ -z "$BEARER" ]; then
|
||||||
[ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})"
|
[ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})"
|
||||||
last=$STATE
|
last=$STATE
|
||||||
if [ "$waited" -ge "$REG_TIMEOUT" ]; then
|
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"
|
log "not registered after $REG_TIMEOUT s — attempting anyway"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
@ -157,12 +182,14 @@ if [ -z "$BEARER" ]; then
|
||||||
waited=$((waited + 5))
|
waited=$((waited + 5))
|
||||||
done
|
done
|
||||||
log "modem state: $(modem_state), packet service: $(packet_state)"
|
log "modem state: $(modem_state), packet service: $(packet_state)"
|
||||||
# a lookup that failed while the modem was still coming up
|
# a lookup with no final answer yet: it failed, or its list lacked the
|
||||||
|
# APN while the modem was still coming up
|
||||||
n=0
|
n=0
|
||||||
while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do
|
while [ "$LOOKUP" != done ] && [ "$n" -lt 3 ]; do
|
||||||
n=$((n + 1))
|
n=$((n + 1))
|
||||||
sleep 5
|
sleep 5
|
||||||
lookup_profile " (retry $n)"
|
STATE=$(modem_state)
|
||||||
|
lookup_profile "$STATE" " (retry $n, modem state: $STATE)"
|
||||||
done
|
done
|
||||||
if [ "$LOOKUP" != done ]; then
|
if [ "$LOOKUP" != done ]; then
|
||||||
PROFILE_ID=
|
PROFILE_ID=
|
||||||
|
|
@ -176,6 +203,7 @@ n=0
|
||||||
while [ -z "$BEARER" ]; do
|
while [ -z "$BEARER" ]; do
|
||||||
n=$((n + 1))
|
n=$((n + 1))
|
||||||
[ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 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
|
B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape
|
||||||
if [ -z "$B" ]; then
|
if [ -z "$B" ]; then
|
||||||
if [ -n "$PROFILE_ID" ]; then
|
if [ -n "$PROFILE_ID" ]; then
|
||||||
|
|
@ -280,9 +308,13 @@ case "$PCSCF" in
|
||||||
*) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;;
|
*) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# ---- hand the connected ims netdev to imsd (imsd.service reads
|
# ---- hand the connected ims netdev and the PDN's own address to imsd
|
||||||
# /run/imsd.env) so the daemon carries no baked-in interface name
|
# (imsd.service reads /run/imsd.env): the daemon carries no baked-in
|
||||||
echo "DEV=$IFACE" > /run/imsd.env
|
# 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
|
||||||
|
|
||||||
# ---- open the IMS protected ports in the firewall (rung 5b root cause,
|
# ---- 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
|
# journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and
|
||||||
|
|
|
||||||
83
packaging/imsd-handover.sh
Executable file
83
packaging/imsd-handover.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/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
|
||||||
24
packaging/imsd-ofono-restart-calls.sh
Executable file
24
packaging/imsd-ofono-restart-calls.sh
Executable file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#!/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
|
||||||
1
packaging/imsd-ofono.post-install
Symbolic link
1
packaging/imsd-ofono.post-install
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
imsd-ofono.post-upgrade
|
||||||
21
packaging/imsd-ofono.post-upgrade
Executable file
21
packaging/imsd-ofono.post-upgrade
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -3,8 +3,11 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=oFono-compatible GNOME Calls backend for imsd
|
Description=oFono-compatible GNOME Calls backend for imsd
|
||||||
Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd
|
Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd
|
||||||
# org.ofono is a system-bus name, so this is a system service; it needs no
|
# org.ofono is a system-bus name only root may own (org.ofono.conf), so this
|
||||||
# privileges beyond owning that name. Only enable it on a GNOME/Phosh session
|
# 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
|
||||||
# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different
|
# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different
|
||||||
# buses, different consumers), but a real ofonod must not.
|
# buses, different consumers), but a real ofonod must not.
|
||||||
Conflicts=ofono.service
|
Conflicts=ofono.service
|
||||||
|
|
@ -17,6 +20,9 @@ Type=simple
|
||||||
# matches imsd's configuration; imsd itself owns emergency classification.
|
# matches imsd's configuration; imsd itself owns emergency classification.
|
||||||
EnvironmentFile=-/etc/imsd.env
|
EnvironmentFile=-/etc/imsd.env
|
||||||
ExecStart=/usr/bin/imsd-ofonod
|
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
|
# it holds no call state of its own — imsd is re-queried on every reconnect
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
|
||||||
1
packaging/imsd-systemd.post-install
Symbolic link
1
packaging/imsd-systemd.post-install
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
imsd-systemd.post-upgrade
|
||||||
23
packaging/imsd-systemd.post-upgrade
Executable file
23
packaging/imsd-systemd.post-upgrade
Executable file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#!/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
|
||||||
1
packaging/imsd.post-install
Symbolic link
1
packaging/imsd.post-install
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
imsd.post-upgrade
|
||||||
48
packaging/imsd.post-upgrade
Executable file
48
packaging/imsd.post-upgrade
Executable file
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -13,10 +13,16 @@ Type=simple
|
||||||
# idempotent. worst case ~14 min (2 min modem wait + 5 min registration
|
# idempotent. worst case ~14 min (2 min modem wait + 5 min registration
|
||||||
# gate + connect retries), hence TimeoutStartSec
|
# gate + connect retries), hence TimeoutStartSec
|
||||||
ExecStartPre=/usr/libexec/ims-pdn-up.sh
|
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
|
TimeoutStartSec=900
|
||||||
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev).
|
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev,
|
||||||
|
# LOCAL= the PDN address ModemManager assigned to it).
|
||||||
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's
|
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's
|
||||||
# P-CSCF address; see the README's Configuration section) and wins over the
|
# P-CSCF address; see AGENTS.md §10, or the README) and wins over the
|
||||||
# runtime-derived values.
|
# runtime-derived values.
|
||||||
EnvironmentFile=-/run/imsd.env
|
EnvironmentFile=-/run/imsd.env
|
||||||
EnvironmentFile=-/etc/imsd.env
|
EnvironmentFile=-/etc/imsd.env
|
||||||
|
|
@ -24,7 +30,8 @@ EnvironmentFile=-/etc/imsd.env
|
||||||
StateDirectory=imsd
|
StateDirectory=imsd
|
||||||
ExecStart=/usr/bin/imsd
|
ExecStart=/usr/bin/imsd
|
||||||
# registration: resume an existing SA when possible (avoids fresh-SA
|
# registration: resume an existing SA when possible (avoids fresh-SA
|
||||||
# throttles); a failed bring-up exits nonzero and systemd retries
|
# throttles); a failed bring-up exits nonzero and systemd retries, as does
|
||||||
|
# a registered imsd whose PDN address left the netdev
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=120
|
RestartSec=120
|
||||||
# SIGTERM: engine keeps the SA + registration state for the next resume
|
# SIGTERM: engine keeps the SA + registration state for the next resume
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@ cp "$BIN" "$stage/imsd-$VER/imsd"
|
||||||
cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
|
cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
|
||||||
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
|
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
|
||||||
cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod"
|
cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod"
|
||||||
cp packaging/ims-pdn-up.sh packaging/imsd.service \
|
cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \
|
||||||
packaging/imsd-dialerd.desktop packaging/imsd-ofonod.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/org.ofono.conf packaging/90_imsd-ofono.gschema.override \
|
||||||
packaging/80-imsd-ofono.preset \
|
packaging/80-imsd-ofono.preset \
|
||||||
packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/"
|
packaging/net.catcrafts.IMS1.conf packaging/net.catcrafts.IMS1.policy \
|
||||||
|
"$stage/imsd-$VER/"
|
||||||
tar -C "$stage" -czf "imsd-$VER.tar.gz" "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))"
|
echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
# clang can use via --sysroot. No root, no qemu, no device needed:
|
# clang can use via --sysroot. No root, no qemu, no device needed:
|
||||||
#
|
#
|
||||||
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
|
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
|
||||||
# crafter-build --target=aarch64-alpine-linux-musl --sysroot=<dir>
|
# crafter-build -- --target=aarch64-alpine-linux-musl --sysroot=<dir> \
|
||||||
|
# --march=… --mtune=… # the phone's values: AGENTS.md §4.3
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}"
|
SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}"
|
||||||
|
|
@ -16,8 +17,13 @@ MIRROR="${MIRROR:-https://dl-cdn.alpinelinux.org/alpine/edge}"
|
||||||
ARCH=aarch64
|
ARCH=aarch64
|
||||||
# gcc is in the list for its crtbeginS.o/libgcc: clang's driver for
|
# 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
|
# *-alpine-linux-musl links against the GCC runtime it detects under
|
||||||
# <sysroot>/usr/lib/gcc/<triple>/
|
# <sysroot>/usr/lib/gcc/<triple>/. llvm-runtimes is in it for the std module
|
||||||
PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc"
|
# 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"
|
||||||
|
|
||||||
work=$(mktemp -d)
|
work=$(mktemp -d)
|
||||||
trap 'rm -rf "$work"' EXIT
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
@ -40,6 +46,6 @@ mkdir -p "$SYSROOT"
|
||||||
--arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \
|
--arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \
|
||||||
--usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \
|
--usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \
|
||||||
-X "$MIRROR/main" -X "$MIRROR/community" \
|
-X "$MIRROR/main" -X "$MIRROR/community" \
|
||||||
add $PKGS
|
add --upgrade $PKGS
|
||||||
|
|
||||||
echo ">> done: $SYSROOT"
|
echo ">> done: $SYSROOT"
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@
|
||||||
<allow send_destination="net.catcrafts.IMS1"/>
|
<allow send_destination="net.catcrafts.IMS1"/>
|
||||||
<allow receive_sender="net.catcrafts.IMS1"/>
|
<allow receive_sender="net.catcrafts.IMS1"/>
|
||||||
</policy>
|
</policy>
|
||||||
<!-- the phone session user drives calls via the imsdialerd shim -->
|
<!-- Any local account may reach imsd: the dialer shim runs as the session
|
||||||
<policy user="user">
|
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">
|
||||||
<allow send_destination="net.catcrafts.IMS1"/>
|
<allow send_destination="net.catcrafts.IMS1"/>
|
||||||
<allow receive_sender="net.catcrafts.IMS1"/>
|
<allow receive_sender="net.catcrafts.IMS1"/>
|
||||||
</policy>
|
</policy>
|
||||||
<policy context="default">
|
|
||||||
<deny send_destination="net.catcrafts.IMS1"/>
|
|
||||||
</policy>
|
|
||||||
</busconfig>
|
</busconfig>
|
||||||
|
|
|
||||||
26
packaging/net.catcrafts.IMS1.policy
Normal file
26
packaging/net.catcrafts.IMS1.policy
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?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>
|
||||||
|
|
@ -15,20 +15,20 @@
|
||||||
<allow own="org.ofono"/>
|
<allow own="org.ofono"/>
|
||||||
<!-- imsd (root) reaches this daemon only by broadcast signals, and
|
<!-- imsd (root) reaches this daemon only by broadcast signals, and
|
||||||
dbus-broker checks a broadcast against the sender's send policy for
|
dbus-broker checks a broadcast against the sender's send policy for
|
||||||
every name the receiver owns (dbus-daemon does not): without this
|
every name the receiver owns (dbus-daemon does not). The default
|
||||||
line the default deny below silently drops every CallAdded /
|
below allows sending too since 0.3.10; this line stays so that a
|
||||||
CallStateChanged from imsd on the way to imsd-ofonod (found on the
|
tighter default can never again silently drop every CallAdded /
|
||||||
FP6, 2026-09-20). -->
|
CallStateChanged from imsd on the way to imsd-ofonod, as the old
|
||||||
|
default deny did (found on the FP6, 2026-09-20). -->
|
||||||
<allow send_destination="org.ofono"/>
|
<allow send_destination="org.ofono"/>
|
||||||
</policy>
|
</policy>
|
||||||
<!-- the session user's GNOME Calls drives the modem; call control is
|
<!-- Any local account's GNOME Calls may reach imsd-ofonod, whatever the
|
||||||
exactly what net.catcrafts.IMS1 already grants it through the dialerd -->
|
session account is called; imsd-ofonod asks polkit before call control
|
||||||
<policy user="user">
|
(net.catcrafts.IMS1.Voice, as imsd does). Owning the name stays
|
||||||
|
root's. -->
|
||||||
|
<policy context="default">
|
||||||
|
<deny own="org.ofono"/>
|
||||||
<allow send_destination="org.ofono"/>
|
<allow send_destination="org.ofono"/>
|
||||||
<allow receive_sender="org.ofono"/>
|
<allow receive_sender="org.ofono"/>
|
||||||
</policy>
|
</policy>
|
||||||
<policy context="default">
|
|
||||||
<deny own="org.ofono"/>
|
|
||||||
<deny send_destination="org.ofono"/>
|
|
||||||
</policy>
|
|
||||||
</busconfig>
|
</busconfig>
|
||||||
|
|
|
||||||
37
project.cpp
37
project.cpp
|
|
@ -4,7 +4,8 @@
|
||||||
// lint-disable-file no-char-pointer
|
// lint-disable-file no-char-pointer
|
||||||
import std;
|
import std;
|
||||||
import Crafter.Build;
|
import Crafter.Build;
|
||||||
#include "lint-rules.h"
|
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" commit "e906f797da2cd0b1776dd1b420f9b04d3cd24baf"
|
||||||
|
import Crafter.LintRules;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
using namespace Crafter;
|
using namespace Crafter;
|
||||||
|
|
||||||
|
|
@ -57,6 +58,24 @@ 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) {
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
// Four executables come out of this repo; which one a build produces is
|
// Four executables come out of this repo; which one a build produces is
|
||||||
// chosen by `--product=`:
|
// chosen by `--product=`:
|
||||||
|
|
@ -70,7 +89,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
// (pure GDBus translation, no core).
|
// (pure GDBus translation, no core).
|
||||||
// ofonod — imsd-ofonod, the system-bus GNOME Calls backend
|
// ofonod — imsd-ofonod, the system-bus GNOME Calls backend
|
||||||
// (org.ofono.*) bridging to imsd, for Phosh and
|
// (org.ofono.*) bridging to imsd, for Phosh and
|
||||||
// GNOME Mobile (pure GDBus translation, no core).
|
// GNOME Mobile (GDBus translation plus the imsd-bus
|
||||||
|
// polkit gate on its callers, no core).
|
||||||
bool wantMedia = false;
|
bool wantMedia = false;
|
||||||
bool wantDialerd = false;
|
bool wantDialerd = false;
|
||||||
bool wantOfonod = false;
|
bool wantOfonod = false;
|
||||||
|
|
@ -86,13 +106,15 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
ofonod.outputName = "imsd-ofonod";
|
ofonod.outputName = "imsd-ofonod";
|
||||||
ApplyStandardArgs(ofonod, args);
|
ApplyStandardArgs(ofonod, args);
|
||||||
ofonod.type = ConfigurationType::Executable;
|
ofonod.type = ConfigurationType::Executable;
|
||||||
|
static auto Bus = BusLibrary(args);
|
||||||
|
ofonod.dependencies = { Bus.get() };
|
||||||
{
|
{
|
||||||
std::array<fs::path, 0> ifaces = {};
|
std::array<fs::path, 0> ifaces = {};
|
||||||
std::array<fs::path, 1> impls = { "implementations/ofonod" };
|
std::array<fs::path, 1> impls = { "implementations/ofonod" };
|
||||||
ofonod.GetInterfacesAndImplementations(ifaces, impls);
|
ofonod.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
}
|
}
|
||||||
ApplyGioFlags(ofonod);
|
ApplyGioFlags(ofonod);
|
||||||
ProjectLint::AddProjectLintRules(ofonod);
|
LintRules::AddRules(ofonod);
|
||||||
return ofonod;
|
return ofonod;
|
||||||
}
|
}
|
||||||
if (wantDialerd) {
|
if (wantDialerd) {
|
||||||
|
|
@ -108,7 +130,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
dialerd.GetInterfacesAndImplementations(ifaces, impls);
|
dialerd.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
}
|
}
|
||||||
ApplyGioFlags(dialerd);
|
ApplyGioFlags(dialerd);
|
||||||
ProjectLint::AddProjectLintRules(dialerd);
|
LintRules::AddRules(dialerd);
|
||||||
return dialerd;
|
return dialerd;
|
||||||
}
|
}
|
||||||
if (wantMedia) {
|
if (wantMedia) {
|
||||||
|
|
@ -125,7 +147,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("-ldl"); // dlopen the AMR-WB codecs
|
||||||
media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic
|
media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic
|
||||||
ProjectLint::AddProjectLintRules(media);
|
LintRules::AddRules(media);
|
||||||
return media;
|
return media;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +183,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.outputName = "imsd";
|
cfg.outputName = "imsd";
|
||||||
ApplyStandardArgs(cfg, args);
|
ApplyStandardArgs(cfg, args);
|
||||||
cfg.type = ConfigurationType::Executable;
|
cfg.type = ConfigurationType::Executable;
|
||||||
cfg.dependencies = { Core.get() };
|
static auto Bus = BusLibrary(args);
|
||||||
|
cfg.dependencies = { Core.get(), Bus.get() };
|
||||||
{
|
{
|
||||||
std::array<fs::path, 0> ifaces = {};
|
std::array<fs::path, 0> ifaces = {};
|
||||||
std::array<fs::path, 1> impls = { "implementations/main" };
|
std::array<fs::path, 1> impls = { "implementations/main" };
|
||||||
|
|
@ -179,7 +202,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.AddTest("Sip").Dependencies({ Core.get() });
|
cfg.AddTest("Sip").Dependencies({ Core.get() });
|
||||||
cfg.AddTest("Sdp").Dependencies({ Core.get() });
|
cfg.AddTest("Sdp").Dependencies({ Core.get() });
|
||||||
|
|
||||||
ProjectLint::AddProjectLintRules(cfg);
|
LintRules::AddRules(cfg);
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@
|
||||||
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
|
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
|
||||||
// what was offered), a landline gateway's G.711-only and AMR-NB offers
|
// 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 ->
|
// ring and are answered at the offer's own payload types, ring timeout ->
|
||||||
// 480, BYE both ways. Pure reducer, no I/O.
|
// 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.
|
||||||
import std;
|
import std;
|
||||||
import Imsd;
|
import Imsd;
|
||||||
|
|
||||||
|
|
@ -854,6 +859,248 @@ int main() {
|
||||||
Check(can && !can->text.contains("001010123456789@ims"), "CANCEL carries no IMSI");
|
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");
|
if (Failures == 0) std::println("Engine: all tests passed");
|
||||||
return Failures;
|
return Failures != 0; // not the count: an exit status keeps 8 bits, and this suite has more checks than that
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,14 @@ int main() {
|
||||||
}
|
}
|
||||||
// incomplete input -> nullopt
|
// incomplete input -> nullopt
|
||||||
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> 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
|
// ---- fresh setup command sequence
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,98 @@ int main() {
|
||||||
Check(!m4.has_value(), "absurd Content-Length: discarded, no overflow");
|
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\">"<urn:gsma:imei:35901961-000000-0>" </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");
|
if (Failures == 0) std::println("Sip: all tests passed");
|
||||||
return Failures;
|
return Failures;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue