diff --git a/cmd/ipsw/cmd/notif.go b/cmd/ipsw/cmd/notif.go new file mode 100644 index 000000000..d9d25522a --- /dev/null +++ b/cmd/ipsw/cmd/notif.go @@ -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 + }, +} diff --git a/go.mod b/go.mod index a14ef4f4a..3f1adb2a6 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/pkg/notif/notif.go b/pkg/notif/notif.go new file mode 100644 index 000000000..beefabda9 --- /dev/null +++ b/pkg/notif/notif.go @@ -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 +}