feat(notif): add hidden cmd to read macOS Notification Center DB

Notifications persist on disk in a SQLite store after dismissal —
  including "disappearing" message previews from Signal/iMessage/etc.
  Inspired by objective-see/AuRevoir.

    ipsw notif --apps               # bundle IDs with record counts
    ipsw notif -a com.apple.mail    # one app's notifications
    ipsw notif --json               # all records as JSON
    ipsw notif --raw                # full bplist dump per record

  Decoder pulls req.{titl,subt,body} via map walk (go-plist silently
  no-ops nested struct fields). DB located at the macOS 26 group
  container with fallback to the legacy DARWIN_USER_DIR path; surfaces
  a clean FDA hint when TCC denies the read. Reuses glebarez/go-sqlite
  already in tree, cgo-free, darwin-only.
This commit is contained in:
blacktop
2026-04-09 18:25:53 -06:00
parent f47c9414b0
commit d932173948
3 changed files with 315 additions and 1 deletions
+140
View File
@@ -0,0 +1,140 @@
//go:build darwin
/*
Copyright © 2026 blacktop
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd
import (
"encoding/json"
"fmt"
"maps"
"os"
"slices"
"strings"
"github.com/apex/log"
"github.com/blacktop/ipsw/pkg/notif"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func init() {
rootCmd.AddCommand(notifCmd)
notifCmd.Flags().StringP("app", "a", "", "Filter by bundle identifier (e.g. com.apple.Messages)")
notifCmd.Flags().Bool("apps", false, "List bundle identifiers with record counts and exit")
notifCmd.Flags().String("db", "", "Path to notification database (auto-detected if omitted)")
notifCmd.Flags().BoolP("json", "j", false, "Output as JSON")
notifCmd.Flags().Bool("raw", false, "Dump raw bplist for each record")
viper.BindPFlag("notif.app", notifCmd.Flags().Lookup("app"))
viper.BindPFlag("notif.apps", notifCmd.Flags().Lookup("apps"))
viper.BindPFlag("notif.db", notifCmd.Flags().Lookup("db"))
viper.BindPFlag("notif.json", notifCmd.Flags().Lookup("json"))
viper.BindPFlag("notif.raw", notifCmd.Flags().Lookup("raw"))
}
// notifCmd reads the macOS Notification Center SQLite store.
var notifCmd = &cobra.Command{
Use: "notif",
Short: "🚧 Read macOS Notification Center database",
Args: cobra.NoArgs,
Hidden: true,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
if Verbose {
log.SetLevel(log.DebugLevel)
}
color.NoColor = viper.GetBool("no-color")
dbPath := viper.GetString("notif.db")
bundleID := viper.GetString("notif.app")
listApps := viper.GetBool("notif.apps")
asJSON := viper.GetBool("notif.json")
raw := viper.GetBool("notif.raw")
db, err := notif.Open(dbPath)
if err != nil {
return err
}
defer db.Close()
if listApps {
apps, err := notif.Apps(db)
if err != nil {
return err
}
if asJSON {
return json.NewEncoder(os.Stdout).Encode(apps)
}
for _, id := range slices.Sorted(maps.Keys(apps)) {
if apps[id] > 0 {
fmt.Printf("%5d %s\n", apps[id], id)
}
}
return nil
}
recs, err := notif.List(db, bundleID)
if err != nil {
return err
}
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(recs)
}
titleC := color.New(color.Bold, color.FgHiBlue).SprintFunc()
bundleC := color.New(color.FgHiCyan).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
for _, r := range recs {
if r.Delivered.IsZero() {
fmt.Printf("%s %s\n", dim("(scheduled) "), bundleC(r.BundleID))
} else {
fmt.Printf("%s %s\n", dim(r.Delivered.Local().Format("2006-01-02 15:04:05")), bundleC(r.BundleID))
}
if r.Title != "" {
fmt.Printf(" %s\n", titleC(r.Title))
}
if r.Subtitle != "" {
fmt.Printf(" %s\n", r.Subtitle)
}
if r.Body != "" {
for line := range strings.SplitSeq(r.Body, "\n") {
fmt.Printf(" %s\n", line)
}
}
if raw {
if v, err := notif.DumpRaw(r.Raw); err == nil {
b, _ := json.MarshalIndent(v, " ", " ")
fmt.Printf(" %s\n", string(b))
}
}
fmt.Println()
}
log.Infof("Found %d notification record(s)", len(recs))
return nil
},
}
+1 -1
View File
@@ -44,6 +44,7 @@ require (
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa
github.com/gen2brain/beeep v0.11.2
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-git/go-git/v5 v5.17.2
github.com/go-viper/mapstructure/v2 v2.5.0
@@ -150,7 +151,6 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
+174
View File
@@ -0,0 +1,174 @@
//go:build darwin
// Package notif reads macOS Notification Center's SQLite store.
//
// Records persist on disk after dismissal — including "disappearing" message
// previews from Signal/WhatsApp/etc. See objective-see.com/blog/blog_0x2E.html.
//
// macOS 26+ moved the store under a group container; reading it requires that
// the calling process (terminal/IDE) has Full Disk Access.
package notif
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
"time"
"github.com/blacktop/go-plist"
_ "github.com/glebarez/go-sqlite" // registers "sqlite" driver (cgo-free)
)
// dbPaths returns candidate notification DB locations, newest first.
func dbPaths() []string {
home, _ := os.UserHomeDir()
tmp := os.TempDir() // .../T/ — strip last component to reach .../0/
return []string{
filepath.Join(home, "Library/Group Containers/group.com.apple.usernoted/db2/db"), // macOS 26+
filepath.Join(filepath.Dir(filepath.Clean(tmp)), "0/com.apple.notificationcenter/db2/db"),
}
}
// Open finds and opens the notification database read-only.
// If path is empty, the standard locations are probed.
func Open(path string) (*sql.DB, error) {
candidates := []string{path}
if path == "" {
candidates = dbPaths()
}
var permErr error
for _, p := range candidates {
if p == "" {
continue
}
// TCC permits stat() but denies open(); probe the read path so we
// surface a clean EPERM instead of SQLite's "out of memory" (CANTOPEN).
f, err := os.Open(p)
if err != nil {
if permErr == nil && (errors.Is(err, syscall.EPERM) || errors.Is(err, syscall.EACCES)) {
permErr = fmt.Errorf("cannot read %s: %w\n grant Full Disk Access to your terminal in System Settings → Privacy & Security → Full Disk Access", p, err)
}
continue
}
f.Close()
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping %s: %w", p, err)
}
return db, nil
}
if permErr != nil {
return nil, permErr
}
return nil, fmt.Errorf("notification database not found (tried: %v)", candidates)
}
// Record is one notification entry from the `record` table with its
// associated app identifier and the decoded request payload.
type Record struct {
BundleID string `json:"bundle_id"`
Delivered time.Time `json:"delivered,omitzero"`
Title string `json:"title,omitempty"`
Subtitle string `json:"subtitle,omitempty"`
Body string `json:"body,omitempty"`
Raw []byte `json:"-"` // unparsed bplist blob
}
// List returns notification records, optionally filtered by bundle identifier.
func List(db *sql.DB, bundleID string) ([]Record, error) {
q := `SELECT app.identifier, record.delivered_date, record.data
FROM record LEFT JOIN app ON record.app_id = app.app_id`
args := []any{}
if bundleID != "" {
q += ` WHERE app.identifier = ?`
args = append(args, bundleID)
}
q += ` ORDER BY record.delivered_date DESC`
rows, err := db.Query(q, args...)
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var id sql.NullString
var ts sql.NullFloat64
var data []byte
if err := rows.Scan(&id, &ts, &data); err != nil {
return nil, err
}
r := Record{BundleID: id.String, Raw: data}
if ts.Valid && ts.Float64 > 0 {
r.Delivered = cfAbsoluteToTime(ts.Float64)
}
r.Title, r.Subtitle, r.Body = decodeRequest(data)
out = append(out, r)
}
return out, rows.Err()
}
// Apps returns the bundle identifiers known to Notification Center along with
// their persisted record counts.
func Apps(db *sql.DB) (map[string]int, error) {
rows, err := db.Query(`SELECT app.identifier, COUNT(record.rec_id)
FROM app LEFT JOIN record ON app.app_id = record.app_id
GROUP BY app.identifier`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]int)
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return nil, err
}
out[id] = n
}
return out, rows.Err()
}
// cfAbsoluteToTime converts a Core Foundation absolute time (seconds since
// 2001-01-01 00:00:00 UTC) to a time.Time.
func cfAbsoluteToTime(cf float64) time.Time {
const cfEpoch = 978307200 // 2001-01-01 in Unix seconds
sec, frac := int64(cf), cf-float64(int64(cf))
return time.Unix(cfEpoch+sec, int64(frac*1e9)).UTC()
}
// decodeRequest extracts the user-visible title/subtitle/body strings from a
// notification record blob. On modern macOS the blob is a plain bplist dict
// with the request under "req"; keys are 4-char shortenings (titl/subt/body).
// go-plist silently no-ops nested struct fields, so walk the map manually.
func decodeRequest(data []byte) (title, subtitle, body string) {
var top map[string]any
if _, err := plist.Unmarshal(data, &top); err != nil {
return
}
req, _ := top["req"].(map[string]any)
if req == nil {
return
}
title, _ = req["titl"].(string)
subtitle, _ = req["subt"].(string)
body, _ = req["body"].(string)
return
}
// DumpRaw returns the bplist blob decoded as a generic plist tree — useful for
// `--raw` inspection when the schema-aware path misses fields.
func DumpRaw(data []byte) (any, error) {
var v any
_, err := plist.Unmarshal(data, &v)
return v, err
}