Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ jobs:
- name: Test
run: go test -v ./...

# Pinned: golang.org/x/tools v0.50.0 and later require Go 1.26, which
# breaks the Go 1.25 job. v0.49.0 is the newest release on Go 1.25.
- name: Install goimports
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install golang.org/x/tools/cmd/goimports@v0.49.0
export PATH="$HOME/go/bin:$PATH"

- name: Run pre-commit
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ All notable HyperMeow changes are documented here. HyperMeow uses commit pseudo-

## [Unreleased]

### Added

- Added connection lifecycle observers with the original `ConnectContext` for Connected, Disconnected, QR, LoggedOut and StreamReplaced. Existing generic event delivery remains unchanged; delayed lifecycle events retain their original context.

- Added remote Signal keys: `NewRemoteSignalClient` runs a linked device whose Signal private keys, sessions and message plaintext live in an external store reached through `RemoteSignal`. The client keeps its socket, receipts, retry receipts, prekey uploads and QR pairing, hands each received payload to the store, receives only delivery metadata (`events.RemoteMessage`), and transmits stanzas the store built with `SendPrebuiltStanza`. The store also handles identity changes (`ForgetIdentity`) and phone-number-to-LID session moves (`MigratePNToLID`). Pair-code pairing, local `SendMessage` and `SendFBMessage` return `ErrRemoteSignalUnsupported` for such clients, and `PreRetryCallback` receives a nil message.

### Changed

- Synchronized native disconnect callback ownership during concurrent socket stop/close.

- `RemoteSignal` implementations must provide `PreparePreKeyUpload` and `AcknowledgePreKeyUpload`. Remote prekey uploads recover the full durable pending batch across restarts and count changes, acknowledge exact ordered snapshots across ID wrap, and retry lost ACK responses without generating replacement keys. Fresh retry-receipt generation remains separate. See [remote Signal stores](README.md#remote-signal-stores).

### Documentation

- Added an evidence-backed comparison with upstream WhatsMeow.
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,42 @@ HyperMeow retains upstream's core support for:

New exported functionality is additive unless called out in the [changelog](CHANGELOG.md).

### Remote Signal stores

`NewRemoteSignalClient` uses a customer-held Signal store through `RemoteSignal`.
The store supplies public prekeys and builds encrypted message stanzas; private
keys and message plaintext stay outside HyperMeow. Applications implementing the
interface must implement both durable upload operations:

- `PreparePreKeyUpload(ctx, count)` returns a `RemotePreKeyUploadBatch` with an
opaque 32-character lowercase hexadecimal `BatchID` and 1–812 public `PreKeys`.
`count` selects a fresh batch size. An outstanding batch returns all surviving
keys in its original order, even when its size differs from `count`. Persist
that batch across service restarts; do not truncate it or generate a top-up.
- `AcknowledgePreKeyUpload(ctx, batchID, ids)` commits the complete upload after
the WhatsApp upload IQ succeeds. IDs can cross the 24-bit wrap boundary. The
acknowledgement covers every surviving key; consumed original members can
remain in the list and must not be recreated. Replaying the last exact ACK
after a lost response succeeds without changing a newer batch.

The adapter captures independent public-key and ID snapshots. It checks the
literal last uploaded ID and acknowledges the entire captured list. A failed IQ
does not acknowledge the batch. After an attempted ACK fails, the adapter retains
it and resolves that exact ACK before preparing another batch. Unsupported remote
operations fail; there is no fresh-generation fallback. Upload serialization and
the adapter mutex keep upload state separate from retry-receipt generation.

`GeneratePreKeys` and `MarkPreKeysUploaded` remain separate operations for fresh
retry-receipt keys. `GenOnePreKey` generates one fresh key and marks it published;
it never borrows from a prepared upload batch. Remote implementations must reject
legacy marking of prepared-batch members. Returned one-time prekeys must have
unique nonzero 24-bit IDs, a nonzero 32-byte public key, and no private key or
signed-prekey signature.

This interface change requires matching remote-store implementations. It does
not establish that a service is deployed or a feature is available to customers.
Production consumers continue to pin reviewed `main` pseudo-versions.

## Discord

Join the [WhatsApp Web ecosystem Discord](https://whiskey.so/discord) and visit
Expand Down Expand Up @@ -134,3 +170,22 @@ Polymorfa-added files: Copyright (c) 2026 Rajeh Taher, licensed under the
[MIT License](LICENSE-MIT).

See [LICENSING.md](LICENSING.md) for the file-level boundary.

### Connection lifecycle observers

`AddConnectionEventHandler(func(context.Context, any))` observes `Connected`,
`Disconnected`, `QR`, `LoggedOut` and `StreamReplaced`. The context is the original
connection's `ConnectContext` argument, captured before asynchronous delivery.
A delayed event can arrive after that context is cancelled. Compare it with the
connection authority your application owns before updating state; do not label an
old event using the newest connection. `RemoveConnectionEventHandler(id)` removes
a registration; an already captured callback can still arrive. Callbacks run
without the observer registry lock and can remove themselves.

Generic `AddEventHandler` consumers still receive these events. Other event types
retain their existing API. Connection observers do not authorize a send, undo
bytes already sent or prove that a cancelled socket has finished closing.
Disconnect and wait for owned work to settle before accepting replacement
connection authority. `FrameSocket.SetDisconnectHandler` synchronizes callback
ownership with socket shutdown; assign the legacy field only before connecting.
These additions are unreleased until their reviewed main commit is adopted.
30 changes: 25 additions & 5 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,11 @@ type Client struct {
// closed switch) lets a headless shadow Client dispatch injected nodes
// synchronously and lets embedders that reach for it via reflection keep
// working.
nodeHandlers map[string]nodeHandler
eventHandlers []wrappedEventHandler
eventHandlersLock sync.RWMutex
nodeHandlers map[string]nodeHandler
eventHandlers []wrappedEventHandler
eventHandlersLock sync.RWMutex
connectionEventHandlers []wrappedConnectionEventHandler
connectionEventHandlersLock sync.Mutex

messageRetries map[string]int
messageRetriesLock sync.Mutex
Expand Down Expand Up @@ -208,6 +210,8 @@ type Client struct {
GetMessageForRetry func(requester, to types.JID, id types.MessageID) *waE2E.Message
// PreRetryCallback is called before a retry receipt is accepted.
// If it returns false, the accepting will be cancelled and the retry receipt will be ignored.
// On a client from NewRemoteSignalClient, msg is always nil: the remote
// Signal store keeps sent messages and this client never has their content.
PreRetryCallback func(receipt *events.Receipt, id types.MessageID, retryCount int, msg *waE2E.Message) bool
// Should whatsmeow store recently sent messages in the database so that retry receipts can be accepted
// even if the process is restarted? If false, only the in-memory cache and GetMessageForRetry will be used.
Expand Down Expand Up @@ -236,6 +240,15 @@ type Client struct {
// client (see [NewShadowClient]): it has no socket, Connect is guarded,
// and outbound nodes plus Signal/keying oracle ops are delegated here.
shadowRelay ShadowRelay
// remoteSignal holds this device's Signal keys when they live outside
// the client; see NewRemoteSignalClient.
remoteSignal RemoteSignal
// remoteMigratedPN records phone-number users whose Signal state the
// remote store has already moved to their LID.
remoteMigratedPN sync.Map
// testSendHook captures marshaled outbound nodes when there is no
// socket. Only tests set it.
testSendHook func(payload []byte) error

// DisabledFeatures controls which built-in processing paths are
// skipped. See [DisabledFeatures].
Expand Down Expand Up @@ -582,7 +595,7 @@ func (cli *Client) ConnectContext(ctx context.Context) error {
err := cli.unlockedConnect(ctx)
if isRetryableConnectError(err) && cli.InitialAutoReconnect && cli.EnableAutoReconnect {
cli.Log.Errorf("Initial connection failed but reconnecting in background (%v)", err)
go cli.dispatchEvent(&events.Disconnected{})
go cli.dispatchConnectionEvent(ctx, &events.Disconnected{})
go cli.autoReconnect(ctx)
return nil
}
Expand Down Expand Up @@ -662,7 +675,7 @@ func (cli *Client) onDisconnect(ctx context.Context, ns *socket.NoiseSocket, rem
cli.clearResponseWaiters(xmlStreamEndNode)
if !cli.isExpectedDisconnect() && (cli.forceAutoReconnect.Swap(false) || remote) {
cli.Log.Debugf("Emitting Disconnected event")
go cli.dispatchEvent(&events.Disconnected{})
go cli.dispatchConnectionEvent(ctx, &events.Disconnected{})
go cli.autoReconnect(ctx)
} else if remote {
cli.Log.Debugf("OnDisconnect() called, but it was expected, so not emitting event")
Expand Down Expand Up @@ -1064,6 +1077,13 @@ func (cli *Client) sendNodeAndGetData(ctx context.Context, node waBinary.Node) (
cli.sendLog.Debugf("%s", &node)
return payload, cli.shadowRelay.SendNode(ctx, payload)
}
if cli.testSendHook != nil {
payload, err := waBinary.Marshal(node)
if err != nil {
return nil, fmt.Errorf("failed to marshal node: %w", err)
}
return payload, cli.testSendHook(payload)
}
return nil, ErrNotConnected
}

Expand Down
73 changes: 73 additions & 0 deletions connection_lifecycle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) 2026 Rajeh Taher
// Licensed under the MIT License. See LICENSE-MIT for details.

package whatsmeow

import (
"context"
"sync/atomic"

"github.com/polymorfa/hypermeow/types/events"
)

// ConnectionEventHandler receives Connected, Disconnected, QR, LoggedOut and
// StreamReplaced with the original connection's ConnectContext. The context may
// already be cancelled when a delayed event arrives. Consumers must check their
// own current connection authority; an event does not establish ownership.
// Other events continue through the existing generic event API only.
type ConnectionEventHandler func(context.Context, any)

type wrappedConnectionEventHandler struct {
fn ConnectionEventHandler
id uint32
}

// AddConnectionEventHandler adds a lifecycle observer without replacing generic
// event handlers. A handler may reenter this registry without holding its lock.
func (cli *Client) AddConnectionEventHandler(handler ConnectionEventHandler) uint32 {
id := atomic.AddUint32(&nextHandlerID, 1)
cli.connectionEventHandlersLock.Lock()
cli.connectionEventHandlers = append(cli.connectionEventHandlers, wrappedConnectionEventHandler{handler, id})
cli.connectionEventHandlersLock.Unlock()
return id
}

// RemoveConnectionEventHandler removes a lifecycle observer. A callback already
// captured by an in-flight dispatch can still arrive with its original context.
func (cli *Client) RemoveConnectionEventHandler(id uint32) bool {
cli.connectionEventHandlersLock.Lock()
defer cli.connectionEventHandlersLock.Unlock()
for i, handler := range cli.connectionEventHandlers {
if handler.id == id {
copy(cli.connectionEventHandlers[i:], cli.connectionEventHandlers[i+1:])
cli.connectionEventHandlers[len(cli.connectionEventHandlers)-1] = wrappedConnectionEventHandler{}
cli.connectionEventHandlers = cli.connectionEventHandlers[:len(cli.connectionEventHandlers)-1]
return true
}
}
return false
}

// ctx is supplied by the original connection producer before any asynchronous
// scheduling. Never replace it with client.BackgroundEventCtx or current state.
func (cli *Client) dispatchConnectionEvent(ctx context.Context, evt any) (handlerFailed bool) {
switch evt.(type) {
case *events.Connected, *events.Disconnected, *events.QR, *events.LoggedOut, *events.StreamReplaced:
cli.connectionEventHandlersLock.Lock()
handlers := append([]wrappedConnectionEventHandler(nil), cli.connectionEventHandlers...)
cli.connectionEventHandlersLock.Unlock()
for _, handler := range handlers {
invokeConnectionEventHandler(cli, handler.fn, ctx, evt)
}
}
return cli.dispatchEvent(evt)
}

func invokeConnectionEventHandler(cli *Client, handler ConnectionEventHandler, ctx context.Context, evt any) {
defer func() {
if recover() != nil {
cli.Log.Errorf("Connection lifecycle event handler panicked")
}
}()
handler(ctx, evt)
}
74 changes: 74 additions & 0 deletions connection_lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Rajeh Taher
// Licensed under the MIT License. See LICENSE-MIT for details.

package whatsmeow

import (
"context"
"sync/atomic"
"testing"
"time"

waBinary "github.com/polymorfa/hypermeow/binary"
"github.com/polymorfa/hypermeow/types/events"
)

func TestConnectionLifecycleRetainsOriginalContextAfterDelayedDispatch(t *testing.T) {
cli, _, _ := newTestRemote(t)
type sourceKey struct{}
original, cancel := context.WithCancel(context.WithValue(context.Background(), sourceKey{}, "old"))
defer cancel()
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
generic := atomic.Int32{}
cli.AddEventHandler(func(evt any) {
if _, ok := evt.(*events.StreamReplaced); ok {
generic.Add(1)
close(done)
}
})
var got context.Context
id := cli.AddConnectionEventHandler(func(ctx context.Context, evt any) {
if _, ok := evt.(*events.StreamReplaced); !ok {
t.Error("wrong event")
}
got = ctx
close(entered)
<-release
})
cli.handleStreamError(original, &waBinary.Node{Tag: "stream:error", Content: []waBinary.Node{{Tag: "conflict", Attrs: waBinary.Attrs{"type": "replaced"}}}})
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("actual lifecycle producer did not dispatch")
}
cancel()
cli.BackgroundEventCtx = context.WithValue(context.Background(), sourceKey{}, "new")
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("generic callback lost")
}
if got != original || got.Value(sourceKey{}) != "old" || got.Err() != context.Canceled {
t.Fatal("late lifecycle event relabelled with current context")
}
if generic.Load() != 1 {
t.Fatal("generic consumers changed")
}
if !cli.RemoveConnectionEventHandler(id) || cli.RemoveConnectionEventHandler(id) {
t.Fatal("scoped callback removal failed")
}
}

func TestConnectionCallbacksCanReenterRegistryAndGenericDeliveryRemains(t *testing.T) {
cli, _, _ := newTestRemote(t)
var scoped, generic int
var id uint32
id = cli.AddConnectionEventHandler(func(context.Context, any) { scoped++; cli.RemoveConnectionEventHandler(id) })
cli.AddEventHandler(func(any) { generic++ })
cli.dispatchConnectionEvent(context.Background(), &events.Connected{})
cli.dispatchConnectionEvent(context.Background(), &events.Connected{})
if scoped != 1 || generic != 2 {
t.Fatal("scoped handler reentry or generic delivery failed")
}
}
Loading
Loading