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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ systray/systray
result
.pkg-config-wrapped
.wails-wrapped
*.AppImage
*.AppImage
*.exe
133 changes: 121 additions & 12 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import (
"errors"
"fmt"
"log"
"log/slog"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/gen2brain/beeep"

Expand Down Expand Up @@ -39,6 +41,7 @@ type Api struct {
imageCache *cache.ImageCache
us *socket.UnixSocket
waContainer *sqlstore.Container
sessionDB *sql.DB
eventHandlerID uint32
eventHandlerSet bool
startupErr error
Expand Down Expand Up @@ -120,14 +123,104 @@ func (a *Api) resyncAppState() {
// re-applies the collection from a server snapshot; with
// EmitAppStateEventsOnFullSync set, every mutation is dispatched to
// mainEventHandler (FromFullSync=true) and lands in our tables.
//
// critical_block and critical_unblock_low are NOT full-synced here —
// those collections are huge and fullSync destroys the version before the
// network round-trip completes, so a failure wipes the version entirely.
// Instead, critical_unblock_low is fetched without fullSync (preserving
// any existing mutation MACs); if its version was never stored (0), the
// internal fullSync code path still fetches the full snapshot.
//
// This is necessary because whatsmeow's auto-sync in
// handleAppStateSyncKeyShare only fires during initial pairing or key
// renewal — NOT on every connect.
for _, name := range []appstate.WAPatchName{appstate.WAPatchRegularLow, appstate.WAPatchRegularHigh} {
slog.Info(fmt.Sprintf("Starting app state full sync for %s", name), "source", "appstate")
if err := a.waClient.FetchAppState(a.ctx, name, true, false); err != nil {
log.Printf("App state full sync failed for %s: %v", name, err)
slog.Error(fmt.Sprintf("App state full sync failed for %s", name), "source", "appstate", "err", err)
a.emitError(fmt.Sprintf("App state sync failed: %s — %v", name, err))
continue
}
log.Println("App state fully synced:", name)
slog.Info(fmt.Sprintf("App state fully synced: %s", name), "source", "appstate")
}
// Sync critical_unblock_low (phone contacts — first_name/full_name)
// without fullSync to avoid destroying existing mutation MACs.
slog.Info(fmt.Sprintf("Starting app state sync for %s", appstate.WAPatchCriticalUnblockLow), "source", "appstate")
if err := a.waClient.FetchAppState(a.ctx, appstate.WAPatchCriticalUnblockLow, false, false); err != nil {
slog.Error("Contact sync failed", "source", "appstate", "err", err)
a.emitError(fmt.Sprintf("Contact sync failed: %v", err))
}
// Log contact sync status for diagnostics
if versions, _, err := a.waClient.Store.AppState.GetAppStateVersion(a.ctx, string(appstate.WAPatchCriticalUnblockLow)); err == nil && versions > 0 {
contacts, _ := a.waClient.Store.Contacts.GetAllContacts(a.ctx)
slog.Info(fmt.Sprintf("Synced %d entries (version %d)", len(contacts), versions), "source", "contacts")
} else if err != nil {
slog.Error(fmt.Sprintf("Version check failed: %v", err), "source", "contacts", "err", err)
} else {
slog.Warn("Sync has never completed (version 0)", "source", "contacts")
}
slog.Info("Resync complete", "source", "appstate")
runtime.EventsEmit(a.ctx, "wa:chat_list_refresh")
// Purge empty contact stubs that have no data at all (no push_name,
// no first_name, no full_name, no business_name). These accumulate
// when app state sync runs but receives ContactAction mutations with
// empty name fields.
if a.sessionDB != nil {
res, err := a.sessionDB.ExecContext(a.ctx,
`DELETE FROM whatsmeow_contacts
WHERE (first_name IS NULL OR first_name = '')
AND (full_name IS NULL OR full_name = '')
AND (push_name IS NULL OR push_name = '')
AND (business_name IS NULL OR business_name = '')
AND (nick_name IS NULL OR nick_name = '')`)
if err != nil {
slog.Error(fmt.Sprintf("Failed to purge empty stubs: %v", err), "source", "contacts", "err", err)
} else if n, _ := res.RowsAffected(); n > 0 {
slog.Info(fmt.Sprintf("Purged %d empty stubs", n), "source", "contacts")
}
}
}

// reconnectLoop is started as a background goroutine after an unexpected
// disconnect. It waits a few seconds, then tries to reconnect with
// exponential backoff. Successful reconnect triggers the Connected event,
// which re-runs group init, app-state resync, LID migration, etc.
// Stops when the app shuts down (isShuttingDown returns true) or after
// maxAttempts failed tries.
func (a *Api) reconnectLoop() {
runtime.EventsEmit(a.ctx, "wa:status", "reconnecting")
backoff := 2 * time.Second
maxBackoff := 30 * time.Second
for i := 0; i < 8; i++ {
select {
case <-a.ctx.Done():
return
case <-time.After(backoff):
}
if a.isShuttingDown() {
return
}
slog.Info(fmt.Sprintf("Attempt %d/8...", i+1), "source", "reconnect")
if err := a.waClient.Connect(); err != nil {
slog.Warn(fmt.Sprintf("Attempt %d failed", i+1), "source", "reconnect", "err", err)
if backoff < maxBackoff {
backoff = time.Duration(float64(backoff) * 1.5)
}
continue
}
slog.Info("Reconnect successful", "source", "reconnect")
return
}
slog.Error("All attempts exhausted — staying disconnected", "source", "reconnect")
runtime.EventsEmit(a.ctx, "wa:status", "disconnected")
runtime.EventsEmit(a.ctx, "wa:error", "Could not reconnect to WhatsApp after 8 attempts.")
}

// emitError sends an error toast to the frontend and prints to stderr.
func (a *Api) emitError(msg string) {
log.Println("ERROR:", msg)
slog.Error(msg, "source", "app")
runtime.EventsEmit(a.ctx, "wa:error", msg)
}

// htmlTagRE strips HTML tags from message previews so desktop notifications
Expand Down Expand Up @@ -248,6 +341,10 @@ func (a *Api) closeResources() error {
closeErr = errors.Join(closeErr, a.waContainer.Close())
a.waContainer = nil
}
if a.sessionDB != nil {
closeErr = errors.Join(closeErr, a.sessionDB.Close())
a.sessionDB = nil
}
return closeErr
}

Expand Down Expand Up @@ -296,12 +393,13 @@ func (a *Api) Startup(ctx context.Context) {
a.failStartup(fmt.Errorf("open application database: %w", err))
return
}
db, err := sql.Open("sqlite3", misc.GetSQLiteAddress("session.wa"))
db, err := sql.Open("sqlite", misc.GetSQLiteAddress("session.wa"))
if err != nil {
a.failStartup(fmt.Errorf("open WhatsApp session database: %w", err))
return
}
container := sqlstore.NewWithDB(db, "sqlite3", dbLog)
a.sessionDB = db
container := sqlstore.NewWithDB(db, "sqlite", dbLog)
a.waContainer = container
err = container.Upgrade(ctx)
if err != nil {
Expand Down Expand Up @@ -493,28 +591,31 @@ func (a *Api) mainEventHandler(evt any) {
// groups in the app until a manual reinitialize is done). To avoid that,
// wait here until logged in.
if err := a.cw.Initialise(a.waClient); err != nil {
log.Println("group database initialization failed:", err)
slog.Error(fmt.Sprintf("Database init failed: %v", err), "source", "groups", "err", err)
a.emitError(fmt.Sprintf("Group init failed: %v", err))
}
// Heal group rows with missing/empty names in the background now
// that the client can reach the server.
a.startBackground(a.repairGroupNames)
// Recover archive/pin/mute sync if the local app state is corrupted.
a.startBackground(a.resyncAppState)
if err := a.waClient.SendPresence(a.ctx, types.PresenceAvailable); err != nil {
log.Println("failed to send available presence:", err)
slog.Warn(fmt.Sprintf("Failed to send available: %v", err), "source", "presence", "err", err)
}
// Run migration for messages.db
err := a.messageStore.MigrateLIDToPN(a.ctx, a.waClient.Store.LIDs)
if err != nil {
log.Println("Messages DB migration failed:", err)
slog.Error(fmt.Sprintf("LID migration failed: %v", err), "source", "migration", "err", err)
a.emitError(fmt.Sprintf("LID migration failed: %v", err))
} else {
log.Println("Messages DB migration completed successfully")
slog.Info("LID migration completed", "source", "migration")
runtime.EventsEmit(a.ctx, "wa:chat_list_refresh")
}
case *events.HistorySync:
// whatsmeow delivers past conversations here after linking. Reuse the
// same storage path as live messages so chats/history populate the UI.
a.processHistorySync(v)
// whatsmeow delivers past conversations here after linking. Process
// in a background goroutine so thousands of messages don't block the
// event loop (which would delay live messages, QR rendering, etc.).
a.startBackground(func() { a.processHistorySync(v) })
case *events.Archive:
// Chat archived/unarchived from another device (or app state sync).
if err := a.messageStore.SetChatArchived(v.JID.String(), v.Action.GetArchived(), v.Timestamp.Unix()); err != nil {
Expand All @@ -529,11 +630,19 @@ func (a *Api) mainEventHandler(evt any) {
runtime.EventsEmit(a.ctx, "wa:chat_list_refresh")
case *events.Disconnected:
a.waClient.SendPresence(a.ctx, types.PresenceUnavailable)
if !a.isShuttingDown() {
a.emitError("Disconnected from WhatsApp — reconnecting...")
a.startBackground(a.reconnectLoop)
}
case *events.Receipt:
runtime.EventsEmit(a.ctx, "wa:message_receipt", map[string]any{
"chatId": v.Chat.String(),
"status": v.Type.GoString(),
})
case *events.Contact:
runtime.EventsEmit(a.ctx, "wa:chat_list_refresh")
case *events.PushName, *events.BusinessName:
runtime.EventsEmit(a.ctx, "wa:chat_list_refresh")
default:
// Ignore other events for now
}
Expand Down Expand Up @@ -578,6 +687,6 @@ func (a *Api) processHistorySync(v *events.HistorySync) {
}
}
}
log.Printf("History sync: stored %d messages from %d conversations", stored, len(conversations))
slog.Info(fmt.Sprintf("Stored %d messages from %d conversations", stored, len(conversations)), "source", "history")
runtime.EventsEmit(a.ctx, "wa:chat_list_refresh")
}
10 changes: 10 additions & 0 deletions api/contact.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ func (a *Api) GetContact(jid types.JID) (*Contact, error) {
return nil, fmt.Errorf("invalid phone number")
}

// If this is an empty stub that survived pruning, return nil so the
// caller falls through to push_name or phone-number display.
if contact.FirstName == "" && contact.FullName == "" && contact.PushName == "" && contact.BusinessName == "" {
return nil, fmt.Errorf("contact is empty stub")
}

return &Contact{
Phno: phonenumbers.Format(num, phonenumbers.INTERNATIONAL),
JID: jid.String(),
Expand All @@ -64,6 +70,10 @@ func (a *Api) FetchContacts() ([]Contact, error) {
if err != nil && !phonenumbers.IsValidNumber(num) {
continue
}
// Skip empty stubs that have no name data at all
if c.FirstName == "" && c.FullName == "" && c.PushName == "" && c.BusinessName == "" {
continue
}

contacts = append(contacts, Contact{
Phno: phonenumbers.Format(num, phonenumbers.INTERNATIONAL),
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ function App() {
if (status === "logged_in" || status === "success") {
setScreen("chats")
void initSelf()
const nid = (window as any).__reconnectNotifId
if (nid !== undefined) {
removeNotification(nid)
delete (window as any).__reconnectNotifId
}
} else if (status === "reconnecting") {
const nid = addNotification("Reconnecting to WhatsApp...")
// The "logged_in" or "disconnected" events will remove this.
;(window as any).__reconnectNotifId = nid
} else if (status === "disconnected") {
const nid = (window as any).__reconnectNotifId
if (nid !== undefined) removeNotification(nid)
delete (window as any).__reconnectNotifId
addNotification("Disconnected from WhatsApp")
}
})

Expand All @@ -108,6 +122,12 @@ function App() {
}, 3000)
})

// Surface backend errors to the user as toast notifications.
const unsubError = EventsOn("wa:error", (msg: string) => {
const nid = addNotification(msg)
setTimeout(() => removeNotification(nid), 6000)
})

// Keep the muted-chats store fresh so chat rows/info panels stay in sync
// with mute changes made anywhere (this app, the phone, another device).
const unsubMuteUpdate = EventsOn(
Expand All @@ -129,6 +149,7 @@ function App() {
unsubQR()
unsubStatus()
unsubDownload()
unsubError()
unsubMuteUpdate()
}
}, [addNotification, removeNotification])
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/assets/svgs/settings_icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export const SearchIcon = ({ className = "w-5 h-5" }) => (
</svg>
)

export const LogIcon = ({ className = "w-6 h-6" }) => (
<svg viewBox="0 0 24 24" className={className} fill="currentColor">
<path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4c0-1.1-.9-2-2-2zm0 14H4V4h16v12zM6 10h4V8H6v2zm0 4h4v-2H6v2zm8-8v2h4V6h-4zm0 4h4v-2h-4v2zm0 4h4v-2h-4v2z" />
</svg>
)

export const UserIcon = () => (
<svg viewBox="0 0 48 48" className="w-full h-full text-white" fill="currentColor">
<path d="M24 23q-1.857 0-3.178-1.322Q19.5 20.357 19.5 18.5t1.322-3.178T24 14t3.178 1.322Q28.5 16.643 28.5 18.5t-1.322 3.178T24 23m-6.75 10q-.928 0-1.59-.66-.66-.662-.66-1.59v-.9q0-.956.492-1.758A3.3 3.3 0 0 1 16.8 26.87a16.7 16.7 0 0 1 3.544-1.308q1.8-.435 3.656-.436 1.856 0 3.656.436T31.2 26.87q.816.422 1.308 1.223T33 29.85v.9q0 .928-.66 1.59-.662.66-1.59.66z" />
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
HelpIcon,
KeyboardIcon,
LockIcon,
LogIcon,
LogoutIcon,
SearchIcon,
SettingsIcon,
Expand All @@ -36,6 +37,7 @@ type SettingsCategory =
| "notifications"
| "shortcuts"
| "help"

| "logout"
| "advanced"

Expand Down