Files
Interlap ffd3a13d34 refactor: split into an importable library and a cmd/simslim CLI (#14)
The repo root becomes `package simslim`, an importable library holding every
piece of slimming logic; the CLI moves to `cmd/simslim`. Previously everything
was `package main`, so nothing here could be imported at all.

The library carries no external dependencies — urfave/cli stays on the CLI side,
along with all terminal-facing code: the interactive profile wizard, writeJSON,
truncate/humanBytes, fatal and usage. The library never writes to stdout; it
returns values and reports progress through the Reporter callback.

Renames forced by collisions with existing types: status() -> ReadStatus,
statusForDevice() -> ReadStatusForDevice, diskCleanupPlan() -> PlanDiskCleanup.
The shutdown/boot timeouts move to simctl.go as ShutdownTimeout/BootTimeout.

`go install` gains a path element: github.com/mobai-app/simslim/cmd/simslim.
The installed binary is still named simslim, and -X main.version still applies.
packaging/homebrew/simslim.rb.in is updated to match, since std_go_args passes
no package path.

Verified live against a real simulator: list, profiles, status, doctor, size,
disk-plan, measure, boot, shutdown, on, off, on --except, on --profile, clone,
rename, delete and the interactive wizard all behave identically to the previous
build. JSON output, human output, help text and exit codes are unchanged.
2026-07-23 12:39:20 +02:00

172 lines
4.5 KiB
Go

package simslim
import (
"fmt"
"os"
"path/filepath"
"syscall"
)
// DiskStorageMeasurement is a read-only breakdown of durable per-simulator
// storage. These rows are deliberately separate from cleanup categories so
// documents, app bundles, and user data can never become selected for deletion.
type DiskStorageMeasurement struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Bytes int64 `json:"bytes"`
}
var diskStorageCategories = []DiskStorageMeasurement{
{
ID: "installed-apps",
Name: "Installed Apps",
Description: "Developer-installed app and test-runner bundles. Built-in apps live in the shared runtime.",
},
{
ID: "documents",
Name: "Documents",
Description: "Files in app and app-group Documents directories.",
},
{
ID: "app-data",
Name: "App Data",
Description: "Preferences, databases, and support files outside Documents and cleanup categories.",
},
{
ID: "user-media",
Name: "User Media",
Description: "Photos, videos, downloads, and other files in the simulator media library.",
},
}
func diskStorageMeasurements(dataDirectory string) ([]DiskStorageMeasurement, error) {
measurements := make([]DiskStorageMeasurement, 0, len(diskStorageCategories))
for _, category := range diskStorageCategories {
measurement := category
var err error
switch category.ID {
case "installed-apps":
measurement.Bytes, err = allocatedSizeOfExistingDirectories(
dataDirectory,
[]string{filepath.Join(dataDirectory, "Containers", "Bundle", "Application")},
)
case "documents":
var targets []string
targets, err = appDocumentTargets(dataDirectory)
if err == nil {
measurement.Bytes, err = allocatedSizeOfExistingDirectories(dataDirectory, targets)
}
case "app-data":
measurement.Bytes, err = appDataSize(dataDirectory)
case "user-media":
measurement.Bytes, err = allocatedSizeOfExistingDirectories(
dataDirectory,
[]string{filepath.Join(dataDirectory, "Media")},
)
default:
err = fmt.Errorf("unknown disk storage category %q", category.ID)
}
if err != nil {
return nil, fmt.Errorf("measure %s: %w", category.Name, err)
}
measurements = append(measurements, measurement)
}
return measurements, nil
}
func appDocumentTargets(dataDirectory string) ([]string, error) {
var candidates []string
for _, root := range []string{
filepath.Join(dataDirectory, "Containers", "Data", "Application"),
filepath.Join(dataDirectory, "Containers", "Shared", "AppGroup"),
} {
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
candidates = append(candidates, filepath.Join(root, entry.Name(), "Documents"))
}
}
}
return compactExistingTargets(dataDirectory, candidates)
}
func appDataSize(dataDirectory string) (int64, error) {
excluded := map[string]bool{
"Caches": true,
"Documents": true,
"Logs": true,
"tmp": true,
}
var total int64
for _, root := range []string{
filepath.Join(dataDirectory, "Containers", "Data", "Application"),
filepath.Join(dataDirectory, "Containers", "Shared", "AppGroup"),
} {
info, err := os.Lstat(root)
if os.IsNotExist(err) {
continue
}
if err != nil {
return 0, err
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
continue
}
if err := requireResolvedDescendant(dataDirectory, root); err != nil {
return 0, err
}
bytes, err := allocatedSizeExcludingNames(root, excluded)
if err != nil {
return 0, err
}
total += bytes
}
return total, nil
}
func allocatedSizeOfExistingDirectories(root string, candidates []string) (int64, error) {
targets, err := compactExistingTargets(root, candidates)
if err != nil {
return 0, err
}
var total int64
for _, target := range targets {
bytes, err := allocatedSize(target)
if err != nil {
return 0, err
}
total += bytes
}
return total, nil
}
func allocatedSizeExcludingNames(root string, excluded map[string]bool) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if current != root && entry.IsDir() && excluded[entry.Name()] {
return filepath.SkipDir
}
info, err := entry.Info()
if err != nil {
return err
}
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
total += stat.Blocks * 512
} else {
total += info.Size()
}
return nil
})
return total, err
}