mirror of
https://github.com/blacktop/ipsw.git
synced 2026-05-08 12:22:26 +00:00
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:
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user