docs: update entitlement browser UI + add support for macOS IPSWs

This commit is contained in:
blacktop
2025-08-15 17:58:59 -06:00
parent 7459ee5408
commit fd96d0e006
25 changed files with 4465 additions and 21758 deletions
+37 -1
View File
@@ -63,6 +63,11 @@ func init() {
entCmd.Flags().Bool("stats", false, "Show database statistics")
entCmd.Flags().Int("limit", 100, "Limit number of results")
// Replacement flags
entCmd.Flags().Bool("replace", false, "Replace older builds of the same iOS version with newer builds")
entCmd.Flags().String("replace-strategy", "auto", "Replacement strategy: auto, prompt, force")
entCmd.Flags().Bool("dry-run", false, "Show what would be replaced without making changes")
// Viper bindings
viper.BindPFlag("ent.ipsw", entCmd.Flags().Lookup("ipsw"))
viper.BindPFlag("ent.input", entCmd.Flags().Lookup("input"))
@@ -80,6 +85,9 @@ func init() {
viper.BindPFlag("ent.file-only", entCmd.Flags().Lookup("file-only"))
viper.BindPFlag("ent.stats", entCmd.Flags().Lookup("stats"))
viper.BindPFlag("ent.limit", entCmd.Flags().Lookup("limit"))
viper.BindPFlag("ent.replace", entCmd.Flags().Lookup("replace"))
viper.BindPFlag("ent.replace-strategy", entCmd.Flags().Lookup("replace-strategy"))
viper.BindPFlag("ent.dry-run", entCmd.Flags().Lookup("dry-run"))
}
@@ -113,7 +121,13 @@ var entCmd = &cobra.Command{
ipsw ent --sqlite entitlements.db --stats
# Search PostgreSQL database (Supabase)
ipsw ent --pg-host db.xyz.supabase.co --pg-user postgres --pg-password your-password --pg-database postgres --key sandbox`),
ipsw ent --pg-host db.xyz.supabase.co --pg-user postgres --pg-password your-password --pg-database postgres --key sandbox
# Replace older iOS builds with newer ones
ipsw ent --sqlite entitlements.db --ipsw iPhone16,1_26.0_22G87_Restore.ipsw --replace
# Preview what would be replaced
ipsw ent --sqlite entitlements.db --ipsw iPhone16,1_26.0_22G87_Restore.ipsw --replace --dry-run`),
Args: cobra.NoArgs,
SilenceErrors: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -138,6 +152,9 @@ var entCmd = &cobra.Command{
fileOnly := viper.GetBool("ent.file-only")
showStats := viper.GetBool("ent.stats")
limit := viper.GetInt("ent.limit")
replace := viper.GetBool("ent.replace")
replaceStrategy := viper.GetString("ent.replace-strategy")
dryRun := viper.GetBool("ent.dry-run")
// Validate required flags
if sqliteDB == "" && pgHost == "" {
@@ -171,6 +188,19 @@ var entCmd = &cobra.Command{
return fmt.Errorf("--key, --value, --file, and --stats are mutually exclusive")
}
// Validate replacement flags
if replace && (keyPattern != "" || valuePattern != "" || filePattern != "" || showStats) {
return fmt.Errorf("--replace cannot be used with search operations")
}
if (replaceStrategy != "auto" && replaceStrategy != "prompt" && replaceStrategy != "force") {
return fmt.Errorf("--replace-strategy must be one of: auto, prompt, force")
}
if dryRun && !replace {
return fmt.Errorf("--dry-run can only be used with --replace")
}
// Validate PostgreSQL flags if using PostgreSQL
if pgHost != "" {
if pgUser == "" || pgDatabase == "" {
@@ -190,8 +220,14 @@ var entCmd = &cobra.Command{
// Handle database creation
if len(ipsws) > 0 || len(inputs) > 0 {
if pgHost != "" {
if replace {
return ent.CreatePostgreSQLDatabaseWithReplacement(pgHost, pgPort, pgUser, pgPassword, pgDatabase, pgSSLMode, ipsws, inputs, replaceStrategy, dryRun)
}
return ent.CreatePostgreSQLDatabase(pgHost, pgPort, pgUser, pgPassword, pgDatabase, pgSSLMode, ipsws, inputs)
}
if replace {
return ent.CreateSQLiteDatabaseWithReplacement(sqliteDB, ipsws, inputs, replaceStrategy, dryRun)
}
return ent.CreateSQLiteDatabase(sqliteDB, ipsws, inputs)
}
+18 -8
View File
@@ -49,11 +49,15 @@ func (ds *DatabaseService) StoreEntitlements(ipswPath string, entDB map[string]s
return fmt.Errorf("failed to parse IPSW info: %v", err)
}
// Detect platform from IPSW
platform := DetectPlatformFromIPSW(ipswPath, ipswInfo)
ipswRecord = &model.Ipsw{
ID: generateIPSWID(ipswInfo.Plists.BuildManifest.ProductVersion, ipswInfo.Plists.BuildManifest.ProductBuildVersion),
Name: filepath.Base(ipswPath),
Version: ipswInfo.Plists.BuildManifest.ProductVersion,
BuildID: ipswInfo.Plists.BuildManifest.ProductBuildVersion,
ID: generateIPSWIDWithPlatform(platform, ipswInfo.Plists.BuildManifest.ProductVersion, ipswInfo.Plists.BuildManifest.ProductBuildVersion),
Name: filepath.Base(ipswPath),
Version: ipswInfo.Plists.BuildManifest.ProductVersion,
BuildID: ipswInfo.Plists.BuildManifest.ProductBuildVersion,
Platform: platform,
}
// Add devices
@@ -94,9 +98,10 @@ func (ds *DatabaseService) StoreEntitlements(ipswPath string, entDB map[string]s
} else {
// Create a minimal IPSW record for standalone usage
ipswRecord = &model.Ipsw{
ID: generateIPSWID("unknown", "unknown"),
Version: "unknown",
BuildID: "unknown",
ID: generateIPSWIDWithPlatform(model.PlatformIOS, "unknown", "unknown"),
Version: "unknown",
BuildID: "unknown",
Platform: model.PlatformIOS,
}
}
@@ -459,11 +464,16 @@ func (ds *DatabaseService) storeEntitlement(ipswRecord *model.Ipsw, filePath, en
return nil
}
// generateIPSWID creates a unique ID for an IPSW based on version and build
// generateIPSWID creates a unique ID for an IPSW based on version and build (legacy)
func generateIPSWID(version, build string) string {
return fmt.Sprintf("%s_%s", version, build)
}
// generateIPSWIDWithPlatform creates a unique ID for an IPSW based on platform, version and build
func generateIPSWIDWithPlatform(platform model.Platform, version, build string) string {
return fmt.Sprintf("%s_%s_%s", string(platform), version, build)
}
// createValueHash creates a hash for a value to ensure uniqueness
func createValueHash(valueType, value string) string {
hashInput := fmt.Sprintf("%s:%s", valueType, value)
+130
View File
@@ -9,6 +9,7 @@ import (
"github.com/apex/log"
"github.com/blacktop/ipsw/internal/db"
"github.com/blacktop/ipsw/pkg/info"
"github.com/dustin/go-humanize"
"github.com/fatih/color"
)
@@ -353,3 +354,132 @@ func contains(haystack, needle string) bool {
}
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
}
// CreateSQLiteDatabaseWithReplacement creates or updates SQLite database with replacement support
func CreateSQLiteDatabaseWithReplacement(dbPath string, ipsws, inputs []string, replaceStrategy string, dryRun bool) error {
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return fmt.Errorf("failed to create database directory: %v", err)
}
// Create SQLite database connection
dbConn, err := db.NewSqlite(dbPath, 1000)
if err != nil {
return fmt.Errorf("failed to create SQLite database: %v", err)
}
if err := dbConn.Connect(); err != nil {
return fmt.Errorf("failed to connect to SQLite database: %v", err)
}
defer dbConn.Close()
dbService := NewDatabaseService(dbConn)
strategy := NewSQLiteReplacementStrategy(dbService)
return processIPSWsWithReplacement(strategy, ipsws, inputs, replaceStrategy, dryRun)
}
// CreatePostgreSQLDatabaseWithReplacement creates or updates PostgreSQL database with replacement support
func CreatePostgreSQLDatabaseWithReplacement(host, port, user, password, database, sslMode string, ipsws, inputs []string, replaceStrategy string, dryRun bool) error {
// Create PostgreSQL database connection
dbConn, err := db.NewPostgresWithSSL(host, port, user, password, database, sslMode, 1000)
if err != nil {
return fmt.Errorf("failed to create PostgreSQL database connection: %v", err)
}
if err := dbConn.Connect(); err != nil {
return fmt.Errorf("failed to connect to PostgreSQL database: %v", err)
}
defer dbConn.Close()
dbService := NewDatabaseService(dbConn)
strategy := NewPostgreSQLReplacementStrategy(dbService)
return processIPSWsWithReplacement(strategy, ipsws, inputs, replaceStrategy, dryRun)
}
// processIPSWsWithReplacement processes IPSWs with replacement logic
func processIPSWsWithReplacement(strategy ReplacementStrategy, ipsws, inputs []string, replaceStrategy string, dryRun bool) error {
config := &ReplacementConfig{
Strategy: replaceStrategy,
DryRun: dryRun,
}
// Process IPSWs
for _, ipswPath := range ipsws {
log.WithField("ipsw", filepath.Base(ipswPath)).Info("Processing IPSW")
// Parse IPSW info for version comparison
ipswInfo, err := info.Parse(ipswPath)
if err != nil {
return fmt.Errorf("failed to parse IPSW info from %s: %v", ipswPath, err)
}
// Detect platform for replacement logic
platform := DetectPlatformFromIPSW(ipswPath, ipswInfo)
newIPSW := IPSWInfo{
ID: generateIPSWIDWithPlatform(platform, ipswInfo.Plists.BuildManifest.ProductVersion, ipswInfo.Plists.BuildManifest.ProductBuildVersion),
Name: filepath.Base(ipswPath),
Version: ipswInfo.Plists.BuildManifest.ProductVersion,
BuildID: ipswInfo.Plists.BuildManifest.ProductBuildVersion,
Platform: string(platform),
}
// Get existing IPSWs for comparison (platform-aware)
existingIPSWs, err := strategy.GetExistingIPSWs(string(platform), newIPSW.Version)
if err != nil {
return fmt.Errorf("failed to get existing IPSWs: %v", err)
}
// Create replacement plan
plan, err := strategy.CreateReplacementPlan(newIPSW, existingIPSWs, config)
if err != nil {
return fmt.Errorf("failed to create replacement plan: %v", err)
}
// Execute replacement if needed
if len(plan.ToReplace) > 0 || config.DryRun {
if err := strategy.ExecuteReplacement(plan); err != nil {
return fmt.Errorf("failed to execute replacement: %v", err)
}
}
// Extract and store entitlements (only if not dry run and plan was executed)
if !config.DryRun {
// Extract entitlements from IPSW
entDB, err := GetDatabase(&Config{
IPSW: ipswPath,
Database: "", // Don't create blob file
})
if err != nil {
return fmt.Errorf("failed to extract entitlements from %s: %v", ipswPath, err)
}
// Store entitlements using the strategy's database service
sqliteStrat, ok := strategy.(*SQLiteReplacementStrategy)
if ok {
if err := sqliteStrat.service.StoreEntitlements(ipswPath, entDB); err != nil {
return fmt.Errorf("failed to store entitlements: %v", err)
}
} else if pgStrat, ok := strategy.(*PostgreSQLReplacementStrategy); ok {
if err := pgStrat.service.StoreEntitlements(ipswPath, entDB); err != nil {
return fmt.Errorf("failed to store entitlements: %v", err)
}
}
}
}
// Process input folders if specified
for _, inputPath := range inputs {
log.WithField("input", inputPath).Info("Processing input folder")
// For inputs, we don't have version info so we use legacy replacement
// This would need to be implemented based on your input processing logic
return fmt.Errorf("input folder processing with replacement not yet implemented")
}
if !dryRun {
fmt.Printf("✓ Database creation/update completed successfully\n")
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
/*
Copyright © 2018-2025 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 ent
import (
"path/filepath"
"strings"
"github.com/blacktop/ipsw/internal/model"
"github.com/blacktop/ipsw/pkg/info"
)
// DetectPlatformFromIPSW detects the platform from IPSW filename and metadata
func DetectPlatformFromIPSW(ipswPath string, ipswInfo *info.Info) model.Platform {
filename := strings.ToLower(filepath.Base(ipswPath))
// First, try to detect from filename patterns
if platform := detectPlatformFromFilename(filename); platform != "" {
return platform
}
// If filename detection fails, try from device types in BuildManifest
if ipswInfo != nil && ipswInfo.Plists.BuildManifest != nil {
if platform := detectPlatformFromDevices(ipswInfo.Plists.BuildManifest.SupportedProductTypes); platform != "" {
return platform
}
}
// Default fallback to iOS
return model.PlatformIOS
}
// detectPlatformFromFilename detects platform from IPSW filename patterns
func detectPlatformFromFilename(filename string) model.Platform {
// macOS patterns (most specific first)
macOSPatterns := []string{
"universalmac", "macos", "mac_os", "macbook", "imac", "macmini", "macpro", "macstudio",
}
// visionOS patterns (check before general patterns to avoid conflicts)
visionOSPatterns := []string{
"apple_vision_pro", "vision_pro", "visionos", "vision_os", "applevision", "realityos", "realitydevice",
}
// watchOS patterns
watchOSPatterns := []string{
"watchos", "watch_os", "watch7", "watch6", "watch5", "applewatch",
}
// tvOS patterns
tvOSPatterns := []string{
"tvos", "tv_os", "appletv", "apple_tv",
}
// Check each platform pattern (order matters - most specific first)
for _, pattern := range visionOSPatterns {
if strings.Contains(filename, pattern) {
return model.PlatformVisionOS
}
}
for _, pattern := range macOSPatterns {
if strings.Contains(filename, pattern) {
return model.PlatformMacOS
}
}
for _, pattern := range watchOSPatterns {
if strings.Contains(filename, pattern) {
return model.PlatformWatchOS
}
}
for _, pattern := range tvOSPatterns {
if strings.Contains(filename, pattern) {
return model.PlatformTvOS
}
}
// If no patterns match, return empty (caller will try other methods)
return ""
}
// detectPlatformFromDevices detects platform from device identifiers
func detectPlatformFromDevices(devices []string) model.Platform {
if len(devices) == 0 {
return ""
}
// Count device types to determine platform
iosCount := 0
macOSCount := 0
watchOSCount := 0
tvOSCount := 0
visionOSCount := 0
for _, device := range devices {
deviceLower := strings.ToLower(device)
// macOS device patterns
if strings.Contains(deviceLower, "mac") ||
strings.Contains(deviceLower, "vmware") ||
strings.Contains(deviceLower, "parallels") {
macOSCount++
} else if strings.Contains(deviceLower, "watch") {
watchOSCount++
} else if strings.Contains(deviceLower, "appletv") ||
strings.Contains(deviceLower, "atv") {
tvOSCount++
} else if strings.Contains(deviceLower, "realitydevice") ||
strings.Contains(deviceLower, "vision") {
visionOSCount++
} else if strings.Contains(deviceLower, "iphone") ||
strings.Contains(deviceLower, "ipad") ||
strings.Contains(deviceLower, "ipod") ||
strings.Contains(deviceLower, "simulator") {
iosCount++
}
}
// Return platform with highest count
maxCount := iosCount
platform := model.PlatformIOS
if macOSCount > maxCount {
maxCount = macOSCount
platform = model.PlatformMacOS
}
if watchOSCount > maxCount {
maxCount = watchOSCount
platform = model.PlatformWatchOS
}
if tvOSCount > maxCount {
maxCount = tvOSCount
platform = model.PlatformTvOS
}
if visionOSCount > maxCount {
platform = model.PlatformVisionOS
}
return platform
}
// GetAllPlatforms returns all supported platforms
func GetAllPlatforms() []model.Platform {
return []model.Platform{
model.PlatformIOS,
model.PlatformMacOS,
model.PlatformWatchOS,
model.PlatformTvOS,
model.PlatformVisionOS,
}
}
// FormatPlatformForDisplay formats platform name for user display
func FormatPlatformForDisplay(platform model.Platform) string {
switch platform {
case model.PlatformIOS:
return "iOS"
case model.PlatformMacOS:
return "macOS"
case model.PlatformWatchOS:
return "watchOS"
case model.PlatformTvOS:
return "tvOS"
case model.PlatformVisionOS:
return "visionOS"
default:
return string(platform)
}
}
+555
View File
@@ -0,0 +1,555 @@
/*
Copyright © 2018-2025 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 ent
import (
"bufio"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"gorm.io/gorm"
)
// VersionComparator represents an iOS version for comparison purposes
type VersionComparator struct {
MajorMinor string // e.g., "26.0", "18.5"
Build string // e.g., "22G87", "beta6", "22F76"
Raw string // Original version string
}
// ReplacementConfig holds configuration for replacement operations
type ReplacementConfig struct {
Strategy string // auto, prompt, force
DryRun bool
}
// ReplacementPlan represents a planned replacement operation
type ReplacementPlan struct {
ToReplace []IPSWInfo
NewIPSW IPSWInfo
Config *ReplacementConfig
ReasonMsg string
ConflictType string
}
// IPSWInfo represents basic information about an IPSW
type IPSWInfo struct {
Version string
BuildID string
ID string // Database ID
Name string // Filename
Platform string // Platform (iOS, macOS, etc.)
}
// ParseVersionComparator creates a VersionComparator from a version string
func ParseVersionComparator(version, build string) VersionComparator {
majorMinor := extractMajorMinorVersion(version)
return VersionComparator{
MajorMinor: majorMinor,
Build: build,
Raw: version,
}
}
// extractMajorMinorVersion extracts major.minor from version string
// Examples: "26.0.1" -> "26.0", "18.5" -> "18.5"
func extractMajorMinorVersion(version string) string {
parts := strings.Split(version, ".")
if len(parts) >= 2 {
return parts[0] + "." + parts[1]
}
return version
}
// ShouldReplace determines if an existing version should be replaced by a new version
func ShouldReplace(existing, new VersionComparator) bool {
// Only replace if same major.minor version
if existing.MajorMinor != new.MajorMinor {
return false
}
// If versions are identical, compare builds
if existing.Raw == new.Raw {
return CompareBuildVersions(existing.Build, new.Build) < 0
}
// Compare full versions (e.g., 26.0.1 vs 26.0.2)
return CompareVersionStrings(existing.Raw, new.Raw) < 0
}
// ShouldReplaceWithPlatform determines if an existing IPSW should be replaced by a new one
// considering both platform and version
func ShouldReplaceWithPlatform(existingIPSW, newIPSW IPSWInfo) bool {
// Only replace if same platform
if existingIPSW.Platform != newIPSW.Platform {
return false
}
// Use existing version comparison logic
existingComparator := ParseVersionComparator(existingIPSW.Version, existingIPSW.BuildID)
newComparator := ParseVersionComparator(newIPSW.Version, newIPSW.BuildID)
return ShouldReplace(existingComparator, newComparator)
}
// CompareBuildVersions compares two build version strings
// Returns: -1 if a < b, 0 if a == b, 1 if a > b
func CompareBuildVersions(a, b string) int {
// Handle beta versions
if strings.Contains(a, "beta") || strings.Contains(b, "beta") {
return compareBetaBuilds(a, b)
}
// Handle numeric build IDs (e.g., 22G87 vs 22G89)
if isNumericBuild(a) && isNumericBuild(b) {
return compareNumericBuilds(a, b)
}
// Fallback to string comparison
if a < b {
return -1
} else if a > b {
return 1
}
return 0
}
// CompareVersionStrings compares two version strings (e.g., "26.0.1" vs "26.0.2")
func CompareVersionStrings(a, b string) int {
aParts := parseVersionParts(a)
bParts := parseVersionParts(b)
maxLen := len(aParts)
if len(bParts) > maxLen {
maxLen = len(bParts)
}
for i := 0; i < maxLen; i++ {
aVal := 0
bVal := 0
if i < len(aParts) {
aVal = aParts[i]
}
if i < len(bParts) {
bVal = bParts[i]
}
if aVal < bVal {
return -1
} else if aVal > bVal {
return 1
}
}
return 0
}
// parseVersionParts parses a version string into numeric parts
func parseVersionParts(version string) []int {
parts := strings.Split(version, ".")
result := make([]int, len(parts))
for i, part := range parts {
if val, err := strconv.Atoi(part); err == nil {
result[i] = val
}
}
return result
}
// compareBetaBuilds compares beta build versions
func compareBetaBuilds(a, b string) int {
betaRegex := regexp.MustCompile(`beta(\d+)`)
aMatches := betaRegex.FindStringSubmatch(a)
bMatches := betaRegex.FindStringSubmatch(b)
// If both are beta versions
if len(aMatches) > 1 && len(bMatches) > 1 {
aNum, _ := strconv.Atoi(aMatches[1])
bNum, _ := strconv.Atoi(bMatches[1])
if aNum < bNum {
return -1
} else if aNum > bNum {
return 1
}
return 0
}
// Beta versions are considered "less than" release versions
if len(aMatches) > 1 && len(bMatches) == 0 {
return -1
}
if len(aMatches) == 0 && len(bMatches) > 1 {
return 1
}
// Fallback to string comparison
return strings.Compare(a, b)
}
// isNumericBuild checks if a build ID follows numeric pattern (e.g., 22G87)
func isNumericBuild(build string) bool {
match, _ := regexp.MatchString(`^\d+[A-Z]+\d+$`, build)
return match
}
// compareNumericBuilds compares numeric build IDs
func compareNumericBuilds(a, b string) int {
// Extract prefix numbers and suffix numbers
aPrefix, aSuffix := extractBuildParts(a)
bPrefix, bSuffix := extractBuildParts(b)
// Compare prefixes first
if aPrefix != bPrefix {
if aPrefix < bPrefix {
return -1
}
return 1
}
// Compare suffixes
if aSuffix < bSuffix {
return -1
} else if aSuffix > bSuffix {
return 1
}
return 0
}
// extractBuildParts extracts numeric parts from build ID (e.g., "22G87" -> 22, 87)
func extractBuildParts(build string) (int, int) {
regex := regexp.MustCompile(`^(\d+)[A-Z]+(\d+)$`)
matches := regex.FindStringSubmatch(build)
if len(matches) < 3 {
return 0, 0
}
prefix, _ := strconv.Atoi(matches[1])
suffix, _ := strconv.Atoi(matches[2])
return prefix, suffix
}
// CreateReplacementPlan analyzes what would be replaced and creates a plan
func CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) *ReplacementPlan {
var toReplace []IPSWInfo
var reasonMsg string
for _, existing := range existingIPSWs {
if ShouldReplaceWithPlatform(existing, newIPSW) {
toReplace = append(toReplace, existing)
}
}
if len(toReplace) > 0 {
if len(toReplace) == 1 {
reasonMsg = fmt.Sprintf("Replacing %s %s (build %s) with %s %s (build %s)",
toReplace[0].Platform, toReplace[0].Version, toReplace[0].BuildID,
newIPSW.Platform, newIPSW.Version, newIPSW.BuildID)
} else {
newComparator := ParseVersionComparator(newIPSW.Version, newIPSW.BuildID)
reasonMsg = fmt.Sprintf("Replacing %d older builds of %s %s with newer build %s",
len(toReplace), newIPSW.Platform, newComparator.MajorMinor, newIPSW.BuildID)
}
} else {
newComparator := ParseVersionComparator(newIPSW.Version, newIPSW.BuildID)
reasonMsg = fmt.Sprintf("No older builds found for %s %s - will add as new entry", newIPSW.Platform, newComparator.MajorMinor)
}
return &ReplacementPlan{
ToReplace: toReplace,
NewIPSW: newIPSW,
Config: config,
ReasonMsg: reasonMsg,
ConflictType: determineConflictType(toReplace, newIPSW),
}
}
// determineConflictType categorizes the type of replacement
func determineConflictType(toReplace []IPSWInfo, newIPSW IPSWInfo) string {
if len(toReplace) == 0 {
return "new"
}
if len(toReplace) == 1 {
existing := toReplace[0]
if existing.Version == newIPSW.Version {
return "build_update"
}
return "version_update"
}
return "multiple_replace"
}
// ReplacementStrategy interface for different replacement implementations
type ReplacementStrategy interface {
SupportsVersionBasedReplacement() bool
CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error)
ExecuteReplacement(plan *ReplacementPlan) error
GetExistingIPSWs(platform, version string) ([]IPSWInfo, error)
}
// SQLiteReplacementStrategy handles replacements for SQLite databases
type SQLiteReplacementStrategy struct {
service *DatabaseService
}
// NewSQLiteReplacementStrategy creates a new SQLite replacement strategy
func NewSQLiteReplacementStrategy(service *DatabaseService) *SQLiteReplacementStrategy {
return &SQLiteReplacementStrategy{service: service}
}
// SupportsVersionBasedReplacement returns true for SQLite strategy
func (s *SQLiteReplacementStrategy) SupportsVersionBasedReplacement() bool {
return true
}
// CreateReplacementPlan creates a replacement plan for SQLite
func (s *SQLiteReplacementStrategy) CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error) {
return CreateReplacementPlan(newIPSW, existingIPSWs, config), nil
}
// ExecuteReplacement executes the replacement plan atomically
func (s *SQLiteReplacementStrategy) ExecuteReplacement(plan *ReplacementPlan) error {
if s.service.gormDB == nil {
return fmt.Errorf("GORM database required for replacement operations")
}
// If dry run, just log what would happen
if plan.Config.DryRun {
fmt.Printf("DRY RUN: %s\n", plan.ReasonMsg)
return nil
}
// Prompt user if strategy is prompt
if plan.Config.Strategy == "prompt" && len(plan.ToReplace) > 0 {
if !promptUserForReplacement(plan) {
fmt.Printf("Replacement cancelled by user\n")
return nil
}
}
// Execute atomic replacement transaction
return s.service.gormDB.Transaction(func(tx *gorm.DB) error {
// Step 1: Delete old IPSW data
for _, oldIPSW := range plan.ToReplace {
if err := s.deleteIPSWData(tx, oldIPSW.ID); err != nil {
return fmt.Errorf("failed to delete old IPSW %s: %w", oldIPSW.ID, err)
}
}
fmt.Printf("✓ %s\n", plan.ReasonMsg)
return nil
})
}
// GetExistingIPSWs retrieves existing IPSWs for version comparison
func (s *SQLiteReplacementStrategy) GetExistingIPSWs(platform, version string) ([]IPSWInfo, error) {
if s.service.gormDB == nil {
return nil, fmt.Errorf("GORM database required")
}
majorMinor := extractMajorMinorVersion(version)
// Query IPSWs with same platform and major.minor version
var ipsws []struct {
ID string
Name string
Version string
BuildID string
Platform string
}
err := s.service.gormDB.Table("ipsws").
Select("id, name, version, buildid as build_id, platform").
Where("platform = ? AND version LIKE ?", platform, majorMinor+"%").
Find(&ipsws).Error
if err != nil {
return nil, fmt.Errorf("failed to query existing IPSWs: %w", err)
}
result := make([]IPSWInfo, len(ipsws))
for i, ipsw := range ipsws {
result[i] = IPSWInfo{
ID: ipsw.ID,
Name: ipsw.Name,
Version: ipsw.Version,
BuildID: ipsw.BuildID,
Platform: ipsw.Platform,
}
}
return result, nil
}
// deleteIPSWData deletes all data associated with an IPSW
func (s *SQLiteReplacementStrategy) deleteIPSWData(tx *gorm.DB, ipswID string) error {
// Delete entitlements (cascading delete handles references)
if err := tx.Exec("DELETE FROM entitlements WHERE ipsw_id = ?", ipswID).Error; err != nil {
return fmt.Errorf("failed to delete entitlements: %w", err)
}
// Delete IPSW record
if err := tx.Exec("DELETE FROM ipsws WHERE id = ?", ipswID).Error; err != nil {
return fmt.Errorf("failed to delete IPSW record: %w", err)
}
// Clean up orphaned references
return s.cleanupOrphanedReferences(tx)
}
// cleanupOrphanedReferences removes unused paths, keys, and values
func (s *SQLiteReplacementStrategy) cleanupOrphanedReferences(tx *gorm.DB) error {
// Remove paths not referenced by any entitlements
if err := tx.Exec(`
DELETE FROM paths
WHERE id NOT IN (SELECT DISTINCT path_id FROM entitlements WHERE path_id IS NOT NULL)
`).Error; err != nil {
return fmt.Errorf("failed to cleanup orphaned paths: %w", err)
}
// Remove keys not referenced by any entitlements
if err := tx.Exec(`
DELETE FROM entitlement_keys
WHERE id NOT IN (SELECT DISTINCT key_id FROM entitlements WHERE key_id IS NOT NULL)
`).Error; err != nil {
return fmt.Errorf("failed to cleanup orphaned keys: %w", err)
}
// Remove values not referenced by any entitlements
if err := tx.Exec(`
DELETE FROM entitlement_values
WHERE id NOT IN (SELECT DISTINCT value_id FROM entitlements WHERE value_id IS NOT NULL)
`).Error; err != nil {
return fmt.Errorf("failed to cleanup orphaned values: %w", err)
}
return nil
}
// PostgreSQLReplacementStrategy handles replacements for PostgreSQL databases
type PostgreSQLReplacementStrategy struct {
service *DatabaseService
}
// NewPostgreSQLReplacementStrategy creates a new PostgreSQL replacement strategy
func NewPostgreSQLReplacementStrategy(service *DatabaseService) *PostgreSQLReplacementStrategy {
return &PostgreSQLReplacementStrategy{service: service}
}
// SupportsVersionBasedReplacement returns true for PostgreSQL strategy
func (p *PostgreSQLReplacementStrategy) SupportsVersionBasedReplacement() bool {
return true
}
// CreateReplacementPlan creates a replacement plan for PostgreSQL
func (p *PostgreSQLReplacementStrategy) CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error) {
return CreateReplacementPlan(newIPSW, existingIPSWs, config), nil
}
// ExecuteReplacement executes the replacement plan atomically
func (p *PostgreSQLReplacementStrategy) ExecuteReplacement(plan *ReplacementPlan) error {
// Same implementation as SQLite for now since both use GORM
sqliteStrategy := &SQLiteReplacementStrategy{service: p.service}
return sqliteStrategy.ExecuteReplacement(plan)
}
// GetExistingIPSWs retrieves existing IPSWs for version comparison
func (p *PostgreSQLReplacementStrategy) GetExistingIPSWs(platform, version string) ([]IPSWInfo, error) {
// Same implementation as SQLite for now since both use GORM
sqliteStrategy := &SQLiteReplacementStrategy{service: p.service}
return sqliteStrategy.GetExistingIPSWs(platform, version)
}
// LegacyReplacementStrategy handles replacements for legacy database formats
type LegacyReplacementStrategy struct{}
// NewLegacyReplacementStrategy creates a new legacy replacement strategy
func NewLegacyReplacementStrategy() *LegacyReplacementStrategy {
return &LegacyReplacementStrategy{}
}
// SupportsVersionBasedReplacement returns false for legacy strategy (simple overwrite)
func (l *LegacyReplacementStrategy) SupportsVersionBasedReplacement() bool {
return false
}
// CreateReplacementPlan creates a simple replacement plan for legacy format
func (l *LegacyReplacementStrategy) CreateReplacementPlan(newIPSW IPSWInfo, existingIPSWs []IPSWInfo, config *ReplacementConfig) (*ReplacementPlan, error) {
return &ReplacementPlan{
ToReplace: existingIPSWs, // Replace all existing data
NewIPSW: newIPSW,
Config: config,
ReasonMsg: "Legacy format: replacing entire database with new data",
ConflictType: "legacy_overwrite",
}, nil
}
// ExecuteReplacement executes simple overwrite for legacy format
func (l *LegacyReplacementStrategy) ExecuteReplacement(plan *ReplacementPlan) error {
if plan.Config.DryRun {
fmt.Printf("DRY RUN: %s\n", plan.ReasonMsg)
return nil
}
fmt.Printf("✓ %s\n", plan.ReasonMsg)
// For legacy format, the actual overwrite is handled by the calling code
return nil
}
// GetExistingIPSWs returns empty list for legacy format
func (l *LegacyReplacementStrategy) GetExistingIPSWs(platform, version string) ([]IPSWInfo, error) {
return []IPSWInfo{}, nil
}
// promptUserForReplacement prompts the user to confirm replacement
func promptUserForReplacement(plan *ReplacementPlan) bool {
fmt.Printf("\n%s\n", plan.ReasonMsg)
if len(plan.ToReplace) > 0 {
fmt.Printf("This will delete the following existing data:\n")
for _, ipsw := range plan.ToReplace {
fmt.Printf(" - %s (iOS %s, build %s)\n", ipsw.Name, ipsw.Version, ipsw.BuildID)
}
}
fmt.Printf("\nDo you want to continue? (y/N): ")
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
response := strings.ToLower(strings.TrimSpace(scanner.Text()))
return response == "y" || response == "yes"
}
return false
}
+13 -1
View File
@@ -14,12 +14,24 @@ var (
ErrSymExists = errors.New("symbol exists")
)
// Platform represents supported Apple platforms
type Platform string
const (
PlatformIOS Platform = "iOS"
PlatformMacOS Platform = "macOS"
PlatformWatchOS Platform = "watchOS"
PlatformTvOS Platform = "tvOS"
PlatformVisionOS Platform = "visionOS"
)
// Ipsw is the model for an Ipsw file.
type Ipsw struct {
ID string `gorm:"primaryKey" json:"id"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Version string `gorm:"index:idx_platform_version,priority:2" json:"version,omitempty"`
BuildID string `gorm:"column:buildid" json:"buildid,omitempty"`
Platform Platform `gorm:"type:varchar(20);index:idx_platform_version,priority:1" json:"platform,omitempty"`
Devices []*Device `gorm:"many2many:ipsw_devices;" json:"devices,omitempty"`
Kernels []*Kernelcache `gorm:"many2many:ipsw_kernels;" json:"kernels,omitempty"`
DSCs []*DyldSharedCache `gorm:"many2many:ipsw_dscs;" json:"dscs,omitempty"`
BIN
View File
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/css/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/components",
"utils": "src/lib/utils"
}
}
-19900
View File
File diff suppressed because it is too large Load Diff
+35 -2
View File
@@ -23,18 +23,51 @@
"@docusaurus/theme-mermaid": "^3.8.1",
"@docusaurus/theme-search-algolia": "^3.8.1",
"@mdx-js/react": "^3.1.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-hover-card": "^1.0.7",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@supabase/supabase-js": "^2.50.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.263.1",
"prism-react-renderer": "^2.4.1",
"prism-themes": "^1.9.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"redocusaurus": "^2.5.0"
"redocusaurus": "^2.5.0",
"tailwind-merge": "^1.14.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.8.1",
"@docusaurus/tsconfig": "^3.8.1",
"@docusaurus/types": "^3.8.1"
"@docusaurus/types": "^3.8.1",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^20.5.2",
"autoprefixer": "^10.4.15",
"postcss": "^8.4.29",
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.1.6"
},
"browserslist": {
"production": [
+1796 -57
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+12
View File
@@ -0,0 +1,12 @@
"use client"
import * as React from "react"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+231
View File
@@ -0,0 +1,231 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles for entitlements page */
.entitlements-page {
min-height: 100vh;
}
/* Fix dropdown backgrounds for readability - multiple selectors to ensure coverage */
[data-radix-select-content],
[data-radix-popper-content-wrapper] > div,
.select-content,
[role="listbox"] {
background-color: var(--ifm-background-surface-color) !important;
border: 1px solid var(--ifm-color-emphasis-300) !important;
border-radius: 6px !important;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1) !important;
z-index: 50 !important;
padding: 4px !important;
}
/* Ensure items have proper styling */
[data-radix-select-item] {
background-color: transparent !important;
padding: 6px 8px !important;
border-radius: 4px !important;
cursor: pointer !important;
color: var(--ifm-color-content) !important;
}
[data-radix-select-item]:hover,
[data-radix-select-item][data-highlighted],
[data-radix-select-item][data-state="checked"] {
background-color: var(--ifm-color-emphasis-200) !important;
color: var(--ifm-color-content) !important;
}
/* Dark mode support using Docusaurus variables */
[data-theme='dark'] [data-radix-select-content],
[data-theme='dark'] [data-radix-popper-content-wrapper] > div,
[data-theme='dark'] .select-content,
[data-theme='dark'] [role="listbox"] {
background-color: var(--ifm-background-surface-color) !important;
border-color: var(--ifm-color-emphasis-300) !important;
box-shadow: 0 10px 15px -3px rgba(255, 255, 255, 0.1), 0 4px 6px -4px rgba(255, 255, 255, 0.1) !important;
}
[data-theme='dark'] [data-radix-select-item] {
color: var(--ifm-color-content) !important;
}
[data-theme='dark'] [data-radix-select-item]:hover,
[data-theme='dark'] [data-radix-select-item][data-highlighted],
[data-theme='dark'] [data-radix-select-item][data-state="checked"] {
background-color: var(--ifm-color-emphasis-200) !important;
color: var(--ifm-color-content) !important;
}
/* Custom scrollbar for results */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground)) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground));
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--primary));
}
/* Animation for search results */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out;
}
/* Syntax highlighting for code values */
.syntax-key {
color: hsl(var(--primary));
}
.syntax-string {
color: hsl(142, 76%, 36%);
}
.syntax-number {
color: hsl(32, 95%, 44%);
}
.syntax-boolean {
color: hsl(346, 87%, 43%);
}
.syntax-null {
color: hsl(var(--muted-foreground));
}
/* Value display styles */
.bool-badge {
@apply inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold font-mono uppercase tracking-wider;
}
.bool-true {
@apply bg-green-100 text-green-800 border border-green-200;
}
.bool-false {
@apply bg-red-100 text-red-800 border border-red-200;
}
.array-container {
@apply bg-blue-50 border border-blue-200 rounded-md p-3;
}
.array-item {
@apply flex items-center gap-2 mb-1 last:mb-0;
}
.array-bullet {
@apply w-1 h-1 bg-blue-500 rounded-full flex-shrink-0;
}
.dict-container {
@apply bg-card border rounded-md p-3;
}
.dict-entry {
@apply flex items-start gap-2 mb-1 last:mb-0 text-sm;
}
.dict-key {
@apply font-medium text-primary;
}
.dict-value {
@apply flex-1 text-green-700;
}
.code-value {
@apply bg-card border rounded-md p-2 font-mono text-sm break-all;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.bool-true {
@apply bg-green-900/20 text-green-400 border-green-800;
}
.bool-false {
@apply bg-red-900/20 text-red-400 border-red-800;
}
.array-container {
@apply bg-blue-900/20 border-blue-800;
}
.array-bullet {
@apply bg-blue-400;
}
.dict-value {
@apply text-green-400;
}
.syntax-string {
color: hsl(142, 76%, 56%);
}
.syntax-number {
color: hsl(32, 95%, 64%);
}
.syntax-boolean {
color: hsl(346, 87%, 63%);
}
}
/* Docusaurus dark mode support */
[data-theme='dark'] .bool-true {
@apply bg-green-900/20 text-green-400 border-green-800;
}
[data-theme='dark'] .bool-false {
@apply bg-red-900/20 text-red-400 border-red-800;
}
[data-theme='dark'] .array-container {
@apply bg-blue-900/20 border-blue-800;
}
[data-theme='dark'] .array-bullet {
@apply bg-blue-400;
}
[data-theme='dark'] .dict-value {
@apply text-green-400;
}
[data-theme='dark'] .syntax-string {
color: hsl(142, 76%, 56%);
}
[data-theme='dark'] .syntax-number {
color: hsl(32, 95%, 64%);
}
[data-theme='dark'] .syntax-boolean {
color: hsl(346, 87%, 63%);
}
+65
View File
@@ -0,0 +1,65 @@
/* Custom scrollbar for results */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: var(--ifm-color-content-secondary) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--ifm-color-content-secondary);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--ifm-color-primary);
}
/* Animation for search results */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.3s ease-out;
}
/* Syntax highlighting for code values - using Docusaurus colors */
.syntax-key {
color: var(--ifm-color-primary);
}
.syntax-string {
color: var(--ifm-color-success);
}
.syntax-number {
color: var(--ifm-color-warning);
}
.syntax-boolean {
color: var(--ifm-color-danger);
}
.syntax-null {
color: var(--ifm-color-content-secondary);
}
/* Ensure entitlements page uses proper theme colors */
.entitlements-page {
background-color: var(--ifm-background-color);
color: var(--ifm-color-content);
}
+72 -47
View File
@@ -3,7 +3,8 @@ import { createClient } from '@supabase/supabase-js';
// Define the result type for the optimized schema
export interface EntitlementResult {
id: number;
ios_version: string;
platform: string;
version: string;
build_id: string;
device_list: string[] | string; // Array from materialized view, string for compatibility
file_path: string;
@@ -59,20 +60,10 @@ export class EntitlementsService {
}
try {
// First try to connect to the materialized view
const { data: viewData, error: viewError } = await supabase
.from('entitlements_search')
.select('*', { count: 'exact', head: true })
.limit(1);
if (!viewError) {
return true;
}
// If materialized view fails, try base table
// Use the most lightweight query possible for connection test
const { data, error } = await supabase
.from('entitlement_keys')
.select('*', { count: 'exact', head: true })
.from('ipsws')
.select('id', { count: 'exact', head: true })
.limit(1);
return !error;
@@ -87,22 +78,23 @@ export class EntitlementsService {
*/
static async searchByKey(
keyPattern: string,
iosVersion?: string,
version?: string,
executablePath?: string,
limit: number = 50, // Reduced default limit for better performance
cursor?: number
limit: number = 50,
cursor?: number,
platform: string = 'iOS'
): Promise<EntitlementResult[]> {
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Please set REACT_APP_SUPABASE_URL and REACT_APP_SUPABASE_ANON_KEY environment variables.');
}
// Create cache key
const cacheKey = `key:${keyPattern}:${iosVersion}:${executablePath}:${limit}:${cursor}`;
const cacheKey = `key:${keyPattern}:${version}:${executablePath}:${limit}:${cursor}:${platform}`;
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
// Type guard to ensure we return EntitlementResult[]
if (Array.isArray(cached.data) && cached.data.length > 0 && typeof cached.data[0] === 'object') {
if (Array.isArray(cached.data) && (cached.data.length === 0 || typeof cached.data[0] === 'object')) {
return cached.data as EntitlementResult[];
}
}
@@ -151,18 +143,19 @@ export class EntitlementsService {
query = query.in('key_id', keyIds);
}
// Filter by iOS version - we'll need to get IPSW IDs first
if (iosVersion) {
const { data: ipswData } = await supabase
// Filter by version if specified
if (version) {
let ipswQuery = supabase
.from('ipsws')
.select('id')
.eq('version', iosVersion);
.eq('version', version);
const { data: ipswData } = await ipswQuery;
const ipswIds = ipswData?.map(i => i.id) || [];
if (ipswIds.length > 0) {
query = query.in('ipsw_id', ipswIds);
} else {
return []; // No matching iOS version
return []; // No matching version
}
}
@@ -205,17 +198,18 @@ export class EntitlementsService {
*/
static async searchByFile(
filePattern: string,
iosVersion?: string,
version?: string,
executablePath?: string,
limit: number = 50,
cursor?: number
cursor?: number,
platform: string = 'iOS'
): Promise<EntitlementResult[]> {
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Please set REACT_APP_SUPABASE_URL and REACT_APP_SUPABASE_ANON_KEY environment variables.');
}
// Create cache key
const cacheKey = `file:${filePattern}:${iosVersion}:${executablePath}:${limit}:${cursor}`;
const cacheKey = `file:${filePattern}:${version}:${executablePath}:${limit}:${cursor}:${platform}`;
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
@@ -276,18 +270,19 @@ export class EntitlementsService {
query = query.in('path_id', pathIds);
}
// Filter by iOS version - we'll need to get IPSW IDs first
if (iosVersion) {
const { data: ipswData } = await supabase
// Filter by version if specified
if (version) {
let ipswQuery = supabase
.from('ipsws')
.select('id')
.eq('version', iosVersion);
.eq('version', version);
const { data: ipswData } = await ipswQuery;
const ipswIds = ipswData?.map(i => i.id) || [];
if (ipswIds.length > 0) {
query = query.in('ipsw_id', ipswIds);
} else {
return []; // No matching iOS version
return []; // No matching version
}
}
@@ -311,25 +306,28 @@ export class EntitlementsService {
}
/**
* Get unique iOS versions for filter dropdown
* Get unique versions for filter dropdown
*/
static async getUniqueVersions(): Promise<string[]> {
static async getUniqueVersions(platform: string = 'iOS'): Promise<string[]> {
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured.');
}
const cacheKey = 'versions';
const cacheKey = `versions:${platform}`;
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL * 6) { // Cache versions longer
return cached.data as string[];
}
const { data, error } = await supabase
// Simple query without platform filtering for backward compatibility
let query = supabase
.from('ipsws')
.select('version')
.order('version', { ascending: false });
const { data, error } = await query;
if (error) {
throw new Error(`Failed to get versions: ${error.message}`);
}
@@ -344,15 +342,29 @@ export class EntitlementsService {
* Alias for getUniqueVersions for backward compatibility
*/
static async getIosVersions(): Promise<string[]> {
return this.getUniqueVersions();
return this.getUniqueVersions('iOS');
}
/**
* Get unique versions for macOS
*/
static async getMacOSVersions(): Promise<string[]> {
return this.getUniqueVersions('macOS');
}
/**
* Get versions for any platform
*/
static async getVersions(platform: string): Promise<string[]> {
return this.getUniqueVersions(platform);
}
/**
* Transform search results with separate lookups for related data
* Includes deduplication logic to fix duplicate entries issue
*
*
* Note: This approach uses separate lookups instead of joins to avoid Supabase
* relationship schema issues. For large result sets, consider using the
* relationship schema issues. For large result sets, consider using the
* materialized view (entitlements_search) for better performance.
*/
private static async transformSearchResultsWithLookups(data: any[]): Promise<EntitlementResult[]> {
@@ -389,7 +401,8 @@ export class EntitlementsService {
return {
id: row.id as number,
ios_version: (ipsw?.version as string) || '',
platform: 'iOS',
version: (ipsw?.version as string) || '',
build_id: (ipsw?.buildid as string) || '',
device_list: '', // Would need separate device query
file_path: (path?.path as string) || '',
@@ -411,7 +424,7 @@ export class EntitlementsService {
for (const result of results) {
// Create a unique key based on the combination of fields that define uniqueness
const valueStr = result.string_value || result.bool_value || result.number_value || result.array_value || result.dict_value || '';
const uniqueKey = `${result.ios_version}|${result.file_path}|${result.key}|${result.value_type}|${valueStr}`;
const uniqueKey = `${result.platform}|${result.version}|${result.file_path}|${result.key}|${result.value_type}|${valueStr}`;
if (!seen.has(uniqueKey)) {
seen.add(uniqueKey);
@@ -452,19 +465,26 @@ export class EntitlementsService {
}
/**
* Get database statistics (optimized)
* Get database statistics (optimized for free tier)
*/
static async getStats(): Promise<{
totalEntitlements: number;
uniqueKeys: number;
uniquePaths: number;
iosVersions: number;
totalVersions: number;
}> {
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured.');
}
// Use more efficient count queries
const cacheKey = 'stats:database';
const cached = searchCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL * 6) { // Cache stats for 1 hour
return cached.data as any;
}
// Use more efficient count queries with minimal network overhead
const [entitlementsCount, keysCount, pathsCount, versionsCount] = await Promise.all([
supabase.from('entitlements').select('*', { count: 'exact', head: true }),
supabase.from('entitlement_keys').select('*', { count: 'exact', head: true }),
@@ -472,12 +492,17 @@ export class EntitlementsService {
supabase.from('ipsws').select('*', { count: 'exact', head: true })
]);
return {
const stats = {
totalEntitlements: entitlementsCount.count || 0,
uniqueKeys: keysCount.count || 0,
uniquePaths: pathsCount.count || 0,
iosVersions: versionsCount.count || 0
totalVersions: versionsCount.count || 0
};
// Cache the results
searchCache.set(cacheKey, { data: stats, timestamp: Date.now() });
return stats;
}
/**
@@ -497,4 +522,4 @@ export class EntitlementsService {
// Clear cache when view is refreshed
searchCache.clear();
}
}
}
+124
View File
@@ -0,0 +1,124 @@
import { type ClassValue, clsx } from "clsx"
export function cn(...inputs: ClassValue[]) {
return clsx(inputs)
}
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
immediate?: boolean
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
let timeout: NodeJS.Timeout | null = null
const debouncedFunction = function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null
if (!immediate) func(...args)
}
const callNow = immediate && !timeout
if (timeout) clearTimeout(timeout)
timeout = setTimeout(later, wait)
if (callNow) func(...args)
}
debouncedFunction.cancel = () => {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
}
return debouncedFunction
}
export function truncateText(text: string, maxLength: number) {
if (text.length <= maxLength) return text
return text.slice(0, maxLength) + '...'
}
export function highlightSearchTerm(text: string, searchTerm: string) {
if (!searchTerm) return text
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
return text.replace(regex, '<mark class="bg-primary/20 text-primary">$1</mark>')
}
export function formatEntitlementValue(value: any, type: string): string {
switch (type) {
case 'bool':
return value ? 'true' : 'false'
case 'number':
return value?.toString() || '0'
case 'string':
return value || ''
case 'array':
try {
const arrayValue = typeof value === 'string' ? JSON.parse(value) : value
return Array.isArray(arrayValue) ? arrayValue.join(', ') : value
} catch {
return value || ''
}
case 'dict':
case 'object':
try {
const dictValue = typeof value === 'string' ? JSON.parse(value) : value
return typeof dictValue === 'object' ? JSON.stringify(dictValue, null, 2) : value
} catch {
return value || ''
}
default:
return value?.toString() || ''
}
}
export function getValueTypeColor(type: string): string {
switch (type) {
case 'bool':
return 'var(--ifm-color-danger)'
case 'number':
return 'var(--ifm-color-warning)'
case 'string':
return 'var(--ifm-color-success)'
case 'array':
return 'var(--ifm-color-info)'
case 'dict':
case 'object':
return 'var(--ifm-color-primary)'
default:
return 'var(--ifm-color-content-secondary)'
}
}
export function sortVersions(versions: string[]): string[] {
return versions.sort((a, b) => {
const aParts = a.split('.').map(Number)
const bParts = b.split('.').map(Number)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const aPart = aParts[i] || 0
const bPart = bParts[i] || 0
if (aPart !== bPart) {
return bPart - aPart // Descending order
}
}
return 0
})
}
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
}