mirror of
https://github.com/blacktop/ipsw.git
synced 2026-05-08 12:22:26 +00:00
docs: update entitlement browser UI + add support for macOS IPSWs
This commit is contained in:
+37
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
-19900
File diff suppressed because it is too large
Load Diff
+35
-2
@@ -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": [
|
||||
|
||||
Generated
+1796
-57
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+682
-1742
File diff suppressed because it is too large
Load Diff
@@ -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")],
|
||||
}
|
||||
Reference in New Issue
Block a user