Add iOS 27 hook source, browser integration and build scripts
This commit is contained in:
1 parent
f5a5603375
commit
c1d0293404
43 files changed
+17046
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,193 @@
|
||||
//
|
||||
// WQAccess.h
|
||||
// WQ Files
|
||||
//
|
||||
// File access outside the sandbox through the worse_query technique by
|
||||
// Taj C (forcequit), https://github.com/forcequitOS/worse_query,
|
||||
// GPL-3.0-or-later. This reimplementation is GPL-3.0-or-later as well.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "WQLaunchServices.h"
|
||||
#import <sys/stat.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Log shared with the UI.
|
||||
void wq_log(NSString *format, ...) NS_FORMAT_FUNCTION(1, 2);
|
||||
NSString *wq_log_text(void);
|
||||
|
||||
// Picks the app group the signer actually gave us. Nil when there is none.
|
||||
NSString *_Nullable wq_app_group(void);
|
||||
|
||||
// Asks for a sandbox extension covering path (once per path). Returns YES
|
||||
// when one is held for it.
|
||||
BOOL wq_grant(NSString *path);
|
||||
|
||||
// Directory listing; asks for access when the plain call is refused.
|
||||
NSArray<NSString *> *_Nullable wq_list(NSString *dir, NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// Reads a whole file (up to maxBytes; 0 = no limit).
|
||||
NSData *_Nullable wq_read(NSString *path, NSUInteger maxBytes, NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// A file's identity, size and change timestamps, for optimistic concurrency.
|
||||
NSDictionary<NSString *, NSNumber *> *_Nullable wq_file_version(NSString *path);
|
||||
|
||||
// Overwrites a file in place (same inode, owner and xattrs). The previous
|
||||
// contents are backed up first, and a failed write attempts to roll back.
|
||||
// Mutation/backup/rollback use one verified regular-file descriptor and an
|
||||
// advisory lock where supported. The _if_unchanged forms reject a stale expected
|
||||
// version before mutation; nil keeps the legacy unconditional behavior. This is
|
||||
// not an atomic transaction against uncooperative external writers or crashes.
|
||||
// Backup-storage files cannot be overwritten/deleted through these generic APIs.
|
||||
BOOL wq_write(NSString *path, NSData *data, NSString *_Nullable *_Nullable errorText);
|
||||
BOOL wq_write_if_unchanged(NSString *path, NSData *data, NSDictionary<NSString *, NSNumber *> *_Nullable expected,
|
||||
NSString *_Nullable *_Nullable errorText);
|
||||
// Verifies saved bytes against the intended contents while the descriptor stays
|
||||
// locked, returning that descriptor's version (never a later pathname sample).
|
||||
// The returned snapshot still detects later external changes on the next save.
|
||||
BOOL wq_write_if_unchanged_get_version(NSString *path, NSData *data,
|
||||
NSDictionary<NSString *, NSNumber *> *_Nullable expected,
|
||||
NSDictionary<NSString *, NSNumber *> *_Nullable *_Nullable savedVersion,
|
||||
NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// Same as wq_write / wq_create_file, but streamed from a local file (for large
|
||||
// files; nothing is held in memory). A failed create leaves no partial file.
|
||||
BOOL wq_write_from_file(NSString *path, NSString *source, NSString *_Nullable *_Nullable errorText);
|
||||
// As wq_write_from_file; *backupUnit gets the backup folder (Backups/<stamp>)
|
||||
// holding the previous contents, so a caller may remove it after success.
|
||||
BOOL wq_write_from_file_with_backup(NSString *path, NSString *source, NSString *_Nullable *_Nullable backupUnit,
|
||||
NSString *_Nullable *_Nullable errorText);
|
||||
BOOL wq_create_file_from(NSString *path, NSString *source, NSString *_Nullable *_Nullable errorText);
|
||||
// Streams a regular file into a staged local path, then replaces the destination.
|
||||
// Same-file aliases are refused; failed streaming leaves the destination intact.
|
||||
BOOL wq_copy_to_local(NSString *path, NSString *dest, NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// When Unix permissions (not the sandbox) keep this app out of `path`: an
|
||||
// explanation for the user; nil if the owner and mode allow it.
|
||||
NSString *_Nullable wq_permission_hint(NSString *path, BOOL forWriting);
|
||||
|
||||
// Reads `length` bytes at `offset` (fewer at the end of the file).
|
||||
NSData *_Nullable wq_read_range(NSString *path, unsigned long long offset, NSUInteger length,
|
||||
NSString *_Nullable *_Nullable errorText);
|
||||
// Overwrites bytes in place: offset -> replacement bytes. The file keeps its
|
||||
// size; it is backed up first, and flush/close errors are reported.
|
||||
BOOL wq_patch(NSString *path, NSDictionary<NSNumber *, NSData *> *runs, NSString *_Nullable *_Nullable errorText);
|
||||
BOOL wq_patch_if_unchanged(NSString *path, NSDictionary<NSNumber *, NSData *> *runs,
|
||||
NSDictionary<NSString *, NSNumber *> *_Nullable expected, NSString *_Nullable *_Nullable errorText);
|
||||
BOOL wq_patch_if_unchanged_get_version(NSString *path, NSDictionary<NSNumber *, NSData *> *runs,
|
||||
NSDictionary<NSString *, NSNumber *> *_Nullable expected,
|
||||
NSDictionary<NSString *, NSNumber *> *_Nullable *_Nullable savedVersion,
|
||||
NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// Creates a new file or folder inside dir.
|
||||
BOOL wq_create_file(NSString *path, NSData *data, NSString *_Nullable *_Nullable errorText);
|
||||
BOOL wq_create_dir(NSString *path, NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// Deletes a file (backed up first) or an empty folder.
|
||||
BOOL wq_delete(NSString *path, NSString *_Nullable *_Nullable errorText);
|
||||
// YES for a folder that represents one backup unit: a stamp directly under
|
||||
// the file backup root, or a bundle/date folder directly under the app backup
|
||||
// root. Used by the backup browser's multi-selection.
|
||||
BOOL wq_backup_unit_deletable(NSString *path);
|
||||
// Explicit, user-confirmed removal of one backup unit. Unlike wq_delete, this
|
||||
// is allowed inside a backup store and removes the whole subtree. No safety
|
||||
// copy is made.
|
||||
BOOL wq_delete_backup(NSString *path, NSString *_Nullable *_Nullable errorText);
|
||||
BOOL wq_rename(NSString *path, NSString *newName, NSString *_Nullable *_Nullable errorText);
|
||||
// Moves a file or folder into destDir (same volume), keeping its name. Never
|
||||
// replaces an existing item; refuses moves into itself and into backups.
|
||||
BOOL wq_move(NSString *path, NSString *destDir, NSString *_Nullable *_Nullable errorText);
|
||||
// Read-only descriptor for a regular file (asks for access when refused), or -1.
|
||||
int wq_open_for_reading(NSString *path);
|
||||
|
||||
// lstat with access retry. Returns NO if the item does not exist.
|
||||
BOOL wq_stat(NSString *path, struct stat *st);
|
||||
|
||||
// Thread-safe cancellation for expensive filesystem scans.
|
||||
@interface WQScanCancellation : NSObject
|
||||
@property (atomic, readonly, getter=isCancelled) BOOL cancelled;
|
||||
- (void)cancel;
|
||||
@end
|
||||
|
||||
// Discovers direct children of dir by brute-forcing inode numbers with
|
||||
// fsgetpath (not sandbox-gated), the way KAMUI/bad_query_list do. Needs no
|
||||
// token on dir. maxInode bounds the scan (0 = up to the newest id on the
|
||||
// volume); progress is called off the main thread, from several threads.
|
||||
// Returns absolute child paths, sorted, or nil on failure/cancellation, including
|
||||
// when the dynamic bound cannot be discovered. No implicit fixed-range fallback.
|
||||
// With maxInode 0, the App Data, Extensions and Internal Daemons parents are
|
||||
// scanned together in one pass, reused for 10 minutes (or until
|
||||
// wq_enumerate_invalidate) and saved: later scans only check the ids created
|
||||
// since the saved one (a full scan runs weekly). The scan keeps running when
|
||||
// its caller cancels; concurrent callers wait for that one scan.
|
||||
// Forget the shared container scan (App Data, Extensions, Internal Daemons),
|
||||
// so the next wq_enumerate of those scans the ids created since. Call on a
|
||||
// user's refresh.
|
||||
void wq_enumerate_invalidate(void);
|
||||
// NO on iOS 26: there worse_query can't reach System Data, the system groups
|
||||
// or the App Groups parent folder (only the individual containers).
|
||||
BOOL wq_system_areas_reachable(void);
|
||||
NSArray<NSString *> *_Nullable wq_enumerate(NSString *dir, int64_t maxInode,
|
||||
WQScanCancellation *_Nullable cancellation,
|
||||
void (^_Nullable progress)(int64_t done, int64_t total, NSUInteger found));
|
||||
|
||||
typedef NS_ENUM(NSInteger, WQMeasureResult) {
|
||||
WQMeasureResultMissing,
|
||||
WQMeasureResultKnown,
|
||||
WQMeasureResultLimited,
|
||||
WQMeasureResultCancelled,
|
||||
};
|
||||
|
||||
// Total byte size of regular files below dir. A missing directory is Missing
|
||||
// (size 0); an unreadable subtree makes the result Limited instead of silently
|
||||
// undercounting. Grants access when the root cannot be listed.
|
||||
WQMeasureResult wq_measure_tree(NSString *dir, WQScanCancellation *_Nullable cancellation,
|
||||
unsigned long long *_Nullable bytes);
|
||||
|
||||
// Deletes every item *inside* dir (dir itself stays). No backup — for caches.
|
||||
// Adds freed bytes to *freed. A missing dir counts as done; NO if dir cannot
|
||||
// be read or listed, ancestry contains links (except canonical /var), the target
|
||||
// changes, or some items could not be removed. Child links are not followed.
|
||||
BOOL wq_purge_contents(NSString *dir, unsigned long long *freed);
|
||||
// Cancellable variant for long cache cleans; returns NO when cancelled.
|
||||
BOOL wq_purge_contents_cancellable(NSString *dir, unsigned long long *freed,
|
||||
WQScanCancellation *_Nullable cancellation);
|
||||
|
||||
// Copies every item inside srcDir into dstDir (replacing same-named items).
|
||||
// Each child is staged/flushed before installation; old children survive failed
|
||||
// copies. This is not an all-children transaction: callers still own tree rollback.
|
||||
// Either side may be another app's container. Returns NO if anything failed.
|
||||
BOOL wq_copy_contents(NSString *srcDir, NSString *dstDir, NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// MCM container identifier (bundle or group id) of a container folder.
|
||||
NSString *_Nullable wq_container_label(NSString *dir);
|
||||
// Extra source for wq_container_label, tried after the identifier attribute and
|
||||
// the container-manager table: path (/private/var/...) -> identifier or nil.
|
||||
// iOS 26: where a full scan takes far too long, the App Data and Extensions
|
||||
// lists come from this provider (the LaunchServices database). storeHint is a
|
||||
// database path the scan found, or nil for the remembered one.
|
||||
// Full scans (every file id; minutes on a well-used device) only start after
|
||||
// `confirm` returns YES; it's called on a background thread with what is being
|
||||
// listed, the number of ids and a rough duration. `progress` reports a running
|
||||
// full scan and its end (finished = YES). Without these hooks no full scan runs.
|
||||
void wq_set_full_scan_ui(BOOL (*_Nullable confirm)(NSString *what, int64_t ids, double seconds),
|
||||
void (*_Nullable progress)(int64_t done, int64_t total, BOOL finished));
|
||||
// Stops the running full scan (the scan window's Stop button).
|
||||
void wq_full_scan_stop(void);
|
||||
// Whether the last listing failed because a full scan wasn't confirmed.
|
||||
BOOL wq_full_scan_was_declined(void);
|
||||
void wq_set_container_list_provider(NSArray<NSString *> *_Nullable (*_Nullable provider)(NSString *_Nullable storeHint));
|
||||
void wq_set_container_label_resolver(NSString *_Nullable (*_Nullable resolver)(NSString *path));
|
||||
|
||||
// Backups of overwritten or deleted files, mirrored by original path.
|
||||
NSString *wq_backup_root(void);
|
||||
// App backups live in Documents/AppBackups, a sibling of file backups.
|
||||
NSString *wq_app_backup_root(void);
|
||||
// YES inside either backup store. Generic create/write/delete/rename must be
|
||||
// refused there so a backup cannot be recursively backed up or silently
|
||||
// turned into a different restore source.
|
||||
BOOL wq_is_backup_storage_path(NSString *path);
|
||||
NSString *_Nullable wq_backup_original_path(NSString *backupPath);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,323 @@
|
||||
// Portable implementation helpers for WQAccess; no privileged access is performed here.
|
||||
#ifndef WQ_ACCESS_SAFETY_H
|
||||
#define WQ_ACCESS_SAFETY_H
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static inline BOOL WQSameObject(const struct stat *a, const struct stat *b)
|
||||
{
|
||||
return a->st_dev == b->st_dev && a->st_ino == b->st_ino;
|
||||
}
|
||||
|
||||
// Only Apple's documented /var alias is allowed. Do not standardize away '..'
|
||||
// before walking: every actual ancestor must be opened without following links.
|
||||
static inline NSString *WQCanonicalVarPath(NSString *path)
|
||||
{
|
||||
if ([path isEqualToString:@"/var"] || [path hasPrefix:@"/var/"])
|
||||
return [@"/private" stringByAppendingString:path];
|
||||
return path;
|
||||
}
|
||||
|
||||
static inline int WQOpenDirectoryNoLinksFlags(NSString *path, int flags)
|
||||
{
|
||||
path = WQCanonicalVarPath(path);
|
||||
if (![path hasPrefix:@"/"] || [path rangeOfString:@"\0"].location != NSNotFound) { errno = EINVAL; return -1; }
|
||||
// Ancestors need traversal, not enumeration. A file/folder-specific sandbox
|
||||
// extension need not grant read-directory rights on all container parents.
|
||||
int fd = open("/", O_SEARCH | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
for (NSString *part in [path componentsSeparatedByString:@"/"]) {
|
||||
if (!part.length) continue;
|
||||
if ([part isEqualToString:@"."] || [part isEqualToString:@".."]) { close(fd); errno = EINVAL; return -1; }
|
||||
int next = openat(fd, part.fileSystemRepresentation, O_SEARCH | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||
int err = errno;
|
||||
close(fd);
|
||||
if (next < 0) { errno = err; return -1; }
|
||||
fd = next;
|
||||
}
|
||||
if (flags == O_SEARCH) return fd;
|
||||
int result = openat(fd, ".", flags | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||
int err = errno;
|
||||
close(fd);
|
||||
if (result < 0) errno = err;
|
||||
return result;
|
||||
}
|
||||
|
||||
static inline int WQOpenDirectoryNoLinks(NSString *path)
|
||||
{
|
||||
return WQOpenDirectoryNoLinksFlags(path, O_RDONLY);
|
||||
}
|
||||
|
||||
static inline BOOL WQSyncDirectory(int fd)
|
||||
{
|
||||
int readable = openat(fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (readable < 0) return NO;
|
||||
BOOL ok = fsync(readable) == 0;
|
||||
int err = errno;
|
||||
if (close(readable) != 0 && ok) { ok = NO; err = errno; }
|
||||
if (!ok) errno = err;
|
||||
return ok;
|
||||
}
|
||||
|
||||
static inline int WQOpenRegularAt(int parent, const char *name, int flags, mode_t mode)
|
||||
{
|
||||
// Never allow truncation before the file type has been verified.
|
||||
if (flags & O_TRUNC) { errno = EINVAL; return -1; }
|
||||
struct stat entry;
|
||||
if (fstatat(parent, name, &entry, AT_SYMLINK_NOFOLLOW) == 0) {
|
||||
if (!S_ISREG(entry.st_mode)) { errno = EINVAL; return -1; }
|
||||
} else if (errno != ENOENT || !(flags & O_CREAT)) return -1;
|
||||
int fd = openat(parent, name, flags | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC, mode);
|
||||
if (fd < 0) return -1;
|
||||
struct stat st = {0};
|
||||
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
|
||||
int err = errno;
|
||||
if (!S_ISREG(st.st_mode)) err = EINVAL;
|
||||
close(fd); errno = err; return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
static inline BOOL WQWriteBytes(int fd, NSData *data)
|
||||
{
|
||||
const uint8_t *bytes = data.bytes;
|
||||
size_t done = 0;
|
||||
while (done < data.length) {
|
||||
ssize_t n = write(fd, bytes + done, data.length - done);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) { if (n == 0) errno = EIO; return NO; }
|
||||
done += (size_t)n;
|
||||
}
|
||||
return fsync(fd) == 0;
|
||||
}
|
||||
|
||||
// A durable intent file precedes the rename. The preserved directory itself is
|
||||
// never removed. Recovery accepts only our exact symlink or the original inode.
|
||||
//
|
||||
// Durability: this runs for every access grant (a Cache Cleaner scan makes
|
||||
// over a thousand), so only the marker's contents are flushed. That flush is
|
||||
// what recovery relies on: a marker that can't be read while the preserved
|
||||
// directory exists must fail closed, so its contents have to reach the disk
|
||||
// before Caches is renamed. The directory operations themselves need no flush:
|
||||
// after an app crash or kill they are already in place, and APFS commits
|
||||
// changes to one directory in order, so a power loss leaves one of the states
|
||||
// WQCacheSwapRecover handles.
|
||||
static const char *const WQCacheMarker = ".wq-caches-swap.plist";
|
||||
static const char *const WQCacheMarkerTemp = ".wq-caches-swap.plist.tmp";
|
||||
static const char *const WQCacheSaved = ".wq-caches-preserved";
|
||||
|
||||
// An invalid marker means Begin never completed the marker write, so no swap
|
||||
// should have started. It is safe to discard only when Caches is still a real
|
||||
// directory and no preserved directory exists. Otherwise fail closed.
|
||||
static inline BOOL WQCacheInvalidMarkerCanBeDiscarded(int library)
|
||||
{
|
||||
struct stat saved, caches;
|
||||
if (fstatat(library, WQCacheSaved, &saved, AT_SYMLINK_NOFOLLOW) == 0) { errno = EEXIST; return NO; }
|
||||
if (errno != ENOENT) return NO;
|
||||
if (fstatat(library, "Caches", &caches, AT_SYMLINK_NOFOLLOW) != 0) return NO;
|
||||
if (!S_ISDIR(caches.st_mode)) { errno = EINVAL; return NO; }
|
||||
if (unlinkat(library, WQCacheMarker, 0) != 0) return NO;
|
||||
return fsync(library) == 0;
|
||||
}
|
||||
|
||||
static inline BOOL WQCacheSwapRecover(int library)
|
||||
{
|
||||
int marker = WQOpenRegularAt(library, WQCacheMarker, O_RDONLY, 0);
|
||||
if (marker < 0) {
|
||||
if (errno != ENOENT) return NO;
|
||||
struct stat saved;
|
||||
if (fstatat(library, WQCacheSaved, &saved, AT_SYMLINK_NOFOLLOW) == 0) { errno = EEXIST; return NO; }
|
||||
return errno == ENOENT;
|
||||
}
|
||||
struct stat ms;
|
||||
if (fstat(marker, &ms) != 0 || ms.st_size <= 0 || ms.st_size > 65536) {
|
||||
close(marker);
|
||||
errno = EINVAL;
|
||||
return WQCacheInvalidMarkerCanBeDiscarded(library);
|
||||
}
|
||||
NSMutableData *data = [NSMutableData dataWithLength:(NSUInteger)ms.st_size];
|
||||
size_t done = 0;
|
||||
while (done < data.length) {
|
||||
ssize_t n = read(marker, (uint8_t *)data.mutableBytes + done, data.length - done);
|
||||
if (n < 0 && errno == EINTR) continue;
|
||||
if (n <= 0) {
|
||||
close(marker);
|
||||
errno = EIO;
|
||||
return WQCacheInvalidMarkerCanBeDiscarded(library);
|
||||
}
|
||||
done += (size_t)n;
|
||||
}
|
||||
close(marker);
|
||||
id record = [NSPropertyListSerialization propertyListWithData:data options:0 format:NULL error:nil];
|
||||
if (![record isKindOfClass:NSDictionary.class] || ![record[@"Format"] isEqual:@1] ||
|
||||
![record[@"Device"] isKindOfClass:NSNumber.class] || ![record[@"Inode"] isKindOfClass:NSNumber.class] ||
|
||||
![record[@"Target"] isKindOfClass:NSString.class]) {
|
||||
errno = EINVAL;
|
||||
return WQCacheInvalidMarkerCanBeDiscarded(library);
|
||||
}
|
||||
struct stat original = {0}, current = {0};
|
||||
original.st_dev = [record[@"Device"] intValue];
|
||||
original.st_ino = [record[@"Inode"] unsignedLongLongValue];
|
||||
BOOL savedExists = fstatat(library, WQCacheSaved, &original, AT_SYMLINK_NOFOLLOW) == 0;
|
||||
if (!savedExists && errno != ENOENT) return NO;
|
||||
struct stat expected = {0};
|
||||
expected.st_dev = [record[@"Device"] intValue];
|
||||
expected.st_ino = [record[@"Inode"] unsignedLongLongValue];
|
||||
if (savedExists && (!S_ISDIR(original.st_mode) || !WQSameObject(&original, &expected))) { errno = EINVAL; return NO; }
|
||||
BOOL cachesExists = fstatat(library, "Caches", ¤t, AT_SYMLINK_NOFOLLOW) == 0;
|
||||
if (!cachesExists && errno != ENOENT) return NO;
|
||||
if (savedExists) {
|
||||
if (cachesExists) {
|
||||
char link[65536];
|
||||
ssize_t n = S_ISLNK(current.st_mode) ? readlinkat(library, "Caches", link, sizeof(link) - 1) : -1;
|
||||
if (n < 0) { errno = EEXIST; return NO; }
|
||||
link[n] = 0;
|
||||
if (![record[@"Target"] isEqualToString:[NSString stringWithUTF8String:link]]) { errno = EEXIST; return NO; }
|
||||
if (unlinkat(library, "Caches", 0) != 0) return NO;
|
||||
}
|
||||
if (renameatx_np(library, WQCacheSaved, library, "Caches", RENAME_EXCL) != 0) return NO;
|
||||
} else if (!cachesExists || !S_ISDIR(current.st_mode) || !WQSameObject(¤t, &expected)) {
|
||||
errno = EINVAL; return NO;
|
||||
}
|
||||
return unlinkat(library, WQCacheMarker, 0) == 0;
|
||||
}
|
||||
|
||||
// Normal startup performs no writes. Only an existing recovery marker or saved
|
||||
// directory opens the same cross-process lock used by token issuance.
|
||||
static inline BOOL WQRecoverCacheSwapAtPath(NSString *libraryPath)
|
||||
{
|
||||
int library = WQOpenDirectoryNoLinks(libraryPath);
|
||||
if (library < 0) return errno == ENOENT;
|
||||
struct stat st;
|
||||
BOOL marker = fstatat(library, WQCacheMarker, &st, AT_SYMLINK_NOFOLLOW) == 0;
|
||||
int markerError = marker ? 0 : errno;
|
||||
BOOL saved = fstatat(library, WQCacheSaved, &st, AT_SYMLINK_NOFOLLOW) == 0;
|
||||
int savedError = saved ? 0 : errno;
|
||||
if ((!marker && markerError != ENOENT) || (!saved && savedError != ENOENT)) { close(library); return NO; }
|
||||
if (!marker && !saved) { close(library); return YES; }
|
||||
int lock = WQOpenRegularAt(library, ".wq-caches-lock", O_RDWR | O_CREAT, 0600);
|
||||
BOOL ok = lock >= 0 && flock(lock, LOCK_EX | LOCK_NB) == 0 && WQCacheSwapRecover(library);
|
||||
int error = errno;
|
||||
if (lock >= 0) close(lock);
|
||||
close(library);
|
||||
if (!ok) errno = error;
|
||||
return ok;
|
||||
}
|
||||
|
||||
static inline BOOL WQCacheSwapBegin(int library, NSString *target)
|
||||
{
|
||||
if (!WQCacheSwapRecover(library)) return NO;
|
||||
struct stat st;
|
||||
if (fstatat(library, "Caches", &st, AT_SYMLINK_NOFOLLOW) != 0) {
|
||||
if (errno != ENOENT || mkdirat(library, "Caches", 0755) != 0 ||
|
||||
fstatat(library, "Caches", &st, AT_SYMLINK_NOFOLLOW) != 0) return NO;
|
||||
}
|
||||
if (!S_ISDIR(st.st_mode)) { errno = EINVAL; return NO; }
|
||||
NSDictionary *record = @{ @"Format": @1, @"Device": @(st.st_dev), @"Inode": @(st.st_ino), @"Target": target };
|
||||
NSData *data = [NSPropertyListSerialization dataWithPropertyList:record format:NSPropertyListBinaryFormat_v1_0 options:0 error:nil];
|
||||
if (!data) { errno = EINVAL; return NO; }
|
||||
// A failed marker write must never leave a partial marker behind: write a
|
||||
// temporary sibling, flush it, then atomically rename it into place.
|
||||
unlinkat(library, WQCacheMarkerTemp, 0);
|
||||
int marker = WQOpenRegularAt(library, WQCacheMarkerTemp, O_WRONLY | O_CREAT | O_EXCL, 0600);
|
||||
if (marker < 0) return NO;
|
||||
BOOL durable = WQWriteBytes(marker, data); // flushes the marker's contents
|
||||
if (close(marker) != 0) durable = NO;
|
||||
if (!durable) {
|
||||
unlinkat(library, WQCacheMarkerTemp, 0);
|
||||
return NO;
|
||||
}
|
||||
if (renameatx_np(library, WQCacheMarkerTemp, library, WQCacheMarker, RENAME_EXCL) != 0) {
|
||||
unlinkat(library, WQCacheMarkerTemp, 0);
|
||||
return NO;
|
||||
}
|
||||
if (renameatx_np(library, "Caches", library, WQCacheSaved, RENAME_EXCL) != 0) return NO;
|
||||
return symlinkat(target.fileSystemRepresentation, library, "Caches") == 0;
|
||||
}
|
||||
|
||||
static inline BOOL WQAbsolutePathGrammar(NSString *path)
|
||||
{
|
||||
if (![path hasPrefix:@"/"] || path.length < 2 || [path rangeOfString:@"\0"].location != NSNotFound) return NO;
|
||||
NSArray *parts = [path componentsSeparatedByString:@"/"];
|
||||
for (NSUInteger i = 1; i < parts.count; i++) {
|
||||
NSString *part = parts[i];
|
||||
if (!part.length || [part isEqualToString:@"."] || [part isEqualToString:@".."]) return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static inline int WQCreateDirectoriesNoLinksMode(NSString *path, mode_t mode)
|
||||
{
|
||||
path = WQCanonicalVarPath(path);
|
||||
if (!WQAbsolutePathGrammar(path)) { errno = EINVAL; return -1; }
|
||||
int fd = open("/", O_SEARCH | O_CLOEXEC);
|
||||
if (fd < 0) return -1;
|
||||
for (NSString *part in [path componentsSeparatedByString:@"/"]) {
|
||||
if (!part.length) continue;
|
||||
int next = openat(fd, part.fileSystemRepresentation, O_SEARCH | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||
if (next < 0 && errno == ENOENT) {
|
||||
if (mkdirat(fd, part.fileSystemRepresentation, mode) != 0 && errno != EEXIST) { int err = errno; close(fd); errno = err; return -1; }
|
||||
if (!WQSyncDirectory(fd)) { int err = errno; close(fd); errno = err; return -1; }
|
||||
next = openat(fd, part.fileSystemRepresentation, O_SEARCH | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||
}
|
||||
int err = errno;
|
||||
close(fd);
|
||||
if (next < 0) { errno = err; return -1; }
|
||||
fd = next;
|
||||
}
|
||||
int result = openat(fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
|
||||
int err = errno;
|
||||
close(fd);
|
||||
if (result < 0) errno = err;
|
||||
return result;
|
||||
}
|
||||
|
||||
static inline int WQCreateDirectoriesNoLinks(NSString *path)
|
||||
{
|
||||
return WQCreateDirectoriesNoLinksMode(path, 0700);
|
||||
}
|
||||
|
||||
static inline BOOL WQBackupStampValid(NSString *stamp)
|
||||
{
|
||||
if (stamp.length < 15) return NO;
|
||||
static NSCache<NSString *, NSNumber *> *cache;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
cache = [NSCache new];
|
||||
cache.countLimit = 1024;
|
||||
});
|
||||
NSNumber *known = [cache objectForKey:stamp];
|
||||
if (known) return known.boolValue;
|
||||
|
||||
// Recognize the historical second/millisecond stamps and the current
|
||||
// millisecond-counter format. No arbitrary directory can become a restore.
|
||||
NSString *pattern = @"^[0-9]{8}-[0-9]{6}(-[0-9]{3}(-[1-9][0-9]*)?)?$";
|
||||
NSRange match = [stamp rangeOfString:pattern options:NSRegularExpressionSearch];
|
||||
if (match.location != 0 || match.length != stamp.length) return NO;
|
||||
NSArray *parts = [stamp componentsSeparatedByString:@"-"];
|
||||
if (parts.count == 4) {
|
||||
uint64_t number = 0;
|
||||
for (NSUInteger i = 0; i < [parts[3] length]; i++) {
|
||||
unsigned digit = [parts[3] characterAtIndex:i] - '0';
|
||||
if (number > (UINT64_MAX - digit) / 10) return NO;
|
||||
number = number * 10 + digit;
|
||||
}
|
||||
}
|
||||
NSDateFormatter *fmt = [NSDateFormatter new];
|
||||
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
|
||||
fmt.calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
|
||||
fmt.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
|
||||
fmt.lenient = NO;
|
||||
fmt.dateFormat = @"yyyyMMdd-HHmmss";
|
||||
NSString *datePart = [stamp substringToIndex:15];
|
||||
NSDate *date = [fmt dateFromString:datePart];
|
||||
BOOL valid = date && [[fmt stringFromDate:date] isEqualToString:datePart];
|
||||
[cache setObject:@(valid) forKey:stamp];
|
||||
return valid;
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
// Reapplies the pre-restore safety state after an interrupted restore.
|
||||
// Call on the serial I/O queue before presenting the result to the user.
|
||||
NSString *_Nullable wq_recover_interrupted_restore(void);
|
||||
|
||||
@interface WQAppsViewController : UITableViewController <UISearchResultsUpdating>
|
||||
// Opens Apps and, once the list is read, offers to restore this app's backup
|
||||
// (from the App Backups long-press menu).
|
||||
- (nonnull instancetype)initRestoringBundleID:(nonnull NSString *)bundleID;
|
||||
@end
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,431 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <CoreFoundation/CoreFoundation.h>
|
||||
#import <CommonCrypto/CommonDigest.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// Portable, side-effect-free validation shared by the Apps implementation and
|
||||
// its macOS fixture tests. Filesystem agreement is checked by WQApps.m.
|
||||
static inline BOOL WQAppsSupportIsBool(id value)
|
||||
{
|
||||
return [value isKindOfClass:NSNumber.class] &&
|
||||
CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID();
|
||||
}
|
||||
|
||||
static inline BOOL WQAppsSupportBundleIDValid(NSString *bundleID)
|
||||
{
|
||||
if (![bundleID isKindOfClass:NSString.class] || bundleID.length < 3 || bundleID.length > 255) return NO;
|
||||
if ([bundleID hasPrefix:@"."] || [bundleID hasSuffix:@"."] || [bundleID containsString:@".."]) return NO;
|
||||
static NSCharacterSet *bad;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
bad = [[NSCharacterSet characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"] invertedSet];
|
||||
});
|
||||
return [bundleID containsString:@"."] && [bundleID rangeOfCharacterFromSet:bad].location == NSNotFound;
|
||||
}
|
||||
|
||||
static inline BOOL WQAppsSupportIsInteger(id value)
|
||||
{
|
||||
if (![value isKindOfClass:NSNumber.class]) return NO;
|
||||
if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) return NO;
|
||||
const char *type = [(NSNumber *)value objCType];
|
||||
return strcmp(type, @encode(char)) == 0 || strcmp(type, @encode(unsigned char)) == 0 ||
|
||||
strcmp(type, @encode(short)) == 0 || strcmp(type, @encode(unsigned short)) == 0 ||
|
||||
strcmp(type, @encode(int)) == 0 || strcmp(type, @encode(unsigned int)) == 0 ||
|
||||
strcmp(type, @encode(long)) == 0 || strcmp(type, @encode(unsigned long)) == 0 ||
|
||||
strcmp(type, @encode(long long)) == 0 || strcmp(type, @encode(unsigned long long)) == 0;
|
||||
}
|
||||
|
||||
static inline BOOL WQAppsSupportManifestFailure(NSString **errorOut, NSString *message)
|
||||
{
|
||||
if (errorOut) *errorOut = message;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static inline BOOL WQAppsSupportManifestFields(NSDictionary *manifest, NSString *bundleID,
|
||||
NSArray<NSString *> **partsOut, NSString **errorOut)
|
||||
{
|
||||
if (![manifest isKindOfClass:NSDictionary.class]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest is not a dictionary");
|
||||
}
|
||||
if (!WQAppsSupportIsBool(manifest[@"Complete"]) || ![manifest[@"Complete"] boolValue]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest is not marked complete");
|
||||
}
|
||||
if (!WQAppsSupportIsInteger(manifest[@"Format"]) || [manifest[@"Format"] unsignedIntegerValue] != 1) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest format is unsupported");
|
||||
}
|
||||
if (![manifest[@"BundleID"] isKindOfClass:NSString.class] ||
|
||||
![(NSString *)manifest[@"BundleID"] length] ||
|
||||
![(NSString *)manifest[@"BundleID"] isEqualToString:bundleID]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest belongs to another app");
|
||||
}
|
||||
if (![manifest[@"Created"] isKindOfClass:NSDate.class]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest has no valid creation timestamp");
|
||||
}
|
||||
id reason = manifest[@"Reason"];
|
||||
if (reason && ![reason isKindOfClass:NSString.class]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest reason is malformed");
|
||||
}
|
||||
id sequence = manifest[@"Sequence"];
|
||||
if (sequence && (!WQAppsSupportIsInteger(sequence) || [sequence compare:@1] == NSOrderedAscending)) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest sequence is malformed");
|
||||
}
|
||||
NSArray *parts = manifest[@"Parts"];
|
||||
if (![parts isKindOfClass:NSArray.class]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest parts are malformed");
|
||||
}
|
||||
NSSet *allowed = [NSSet setWithObjects:@"Documents", @"Library", nil];
|
||||
NSMutableSet *seen = [NSMutableSet setWithCapacity:parts.count];
|
||||
for (id part in parts) {
|
||||
if (![part isKindOfClass:NSString.class] || ![allowed containsObject:part] || [seen containsObject:part]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest contains an invalid or duplicate part");
|
||||
}
|
||||
[seen addObject:part];
|
||||
}
|
||||
NSMutableArray *canonical = [NSMutableArray arrayWithCapacity:seen.count];
|
||||
for (NSString *part in @[@"Documents", @"Library"])
|
||||
if ([seen containsObject:part]) [canonical addObject:part];
|
||||
id hashes = manifest[@"PartHashes"];
|
||||
if (hashes) {
|
||||
if (![hashes isKindOfClass:NSDictionary.class]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest hashes are malformed");
|
||||
}
|
||||
NSDictionary *partHashes = hashes;
|
||||
if (![[NSSet setWithArray:partHashes.allKeys] isEqualToSet:seen]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest hashes do not match its parts");
|
||||
}
|
||||
for (id key in partHashes) {
|
||||
id hash = partHashes[key];
|
||||
if (![key isKindOfClass:NSString.class] || ![allowed containsObject:key] ||
|
||||
![hash isKindOfClass:NSString.class] || ![hash length]) {
|
||||
return WQAppsSupportManifestFailure(errorOut, @"The backup manifest has an invalid part hash");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (partsOut) *partsOut = canonical;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static inline void WQAppsSupportHashU64(CC_SHA256_CTX *ctx, uint64_t value)
|
||||
{
|
||||
uint8_t bytes[8];
|
||||
for (NSUInteger i = 0; i < 8; i++) bytes[i] = (uint8_t)(value >> (56 - 8 * i));
|
||||
CC_SHA256_Update(ctx, bytes, (CC_LONG)sizeof(bytes));
|
||||
}
|
||||
|
||||
static inline void WQAppsSupportHashBlob(CC_SHA256_CTX *ctx, const void *bytes, NSUInteger length)
|
||||
{
|
||||
WQAppsSupportHashU64(ctx, (uint64_t)length);
|
||||
if (length) CC_SHA256_Update(ctx, bytes, (CC_LONG)length);
|
||||
}
|
||||
|
||||
// Deterministic SHA-256 over length-prefixed tree metadata. `includeIdentity`
|
||||
// adds inode/mtime/ctime for source-stability checks; the shape variant omits
|
||||
// them and is suitable for comparing a source with its copy. A digest keeps
|
||||
// memory bounded and makes filenames containing delimiters unambiguous.
|
||||
static inline NSString *WQAppsSupportTreeSignature(NSString *path, BOOL includeIdentity,
|
||||
BOOL *exists, NSString **errorOut)
|
||||
{
|
||||
struct stat rootStat;
|
||||
if (lstat(path.fileSystemRepresentation, &rootStat) != 0) {
|
||||
if (errno == ENOENT) {
|
||||
if (exists) *exists = NO;
|
||||
return @"<missing>";
|
||||
}
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", path.lastPathComponent];
|
||||
return nil;
|
||||
}
|
||||
if (!S_ISDIR(rootStat.st_mode)) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"%@ is not a directory", path.lastPathComponent];
|
||||
return nil;
|
||||
}
|
||||
NSError *listError = nil;
|
||||
if (![NSFileManager.defaultManager contentsOfDirectoryAtPath:path error:&listError]) {
|
||||
if (errorOut) *errorOut = listError.localizedDescription ?: [NSString stringWithFormat:@"Cannot read %@", path.lastPathComponent];
|
||||
return nil;
|
||||
}
|
||||
if (exists) *exists = YES;
|
||||
|
||||
NSMutableArray<NSString *> *relativePaths = [NSMutableArray new];
|
||||
for (NSString *relative in [NSFileManager.defaultManager enumeratorAtPath:path])
|
||||
[relativePaths addObject:relative];
|
||||
[relativePaths sortUsingSelector:@selector(compare:)];
|
||||
|
||||
CC_SHA256_CTX ctx;
|
||||
CC_SHA256_Init(&ctx);
|
||||
for (NSString *relative in relativePaths) {
|
||||
NSString *item = [path stringByAppendingPathComponent:relative];
|
||||
struct stat itemStat;
|
||||
if (lstat(item.fileSystemRepresentation, &itemStat) != 0) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", relative];
|
||||
return nil;
|
||||
}
|
||||
unsigned type = (unsigned)(itemStat.st_mode & S_IFMT);
|
||||
const char *name = relative.UTF8String;
|
||||
NSUInteger nameLength = name ? strlen(name) : 0;
|
||||
if (!name) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot encode %@", relative];
|
||||
return nil;
|
||||
}
|
||||
uint8_t tag = includeIdentity ? 'I' : 'S';
|
||||
CC_SHA256_Update(&ctx, &tag, 1);
|
||||
WQAppsSupportHashBlob(&ctx, name, nameLength);
|
||||
WQAppsSupportHashU64(&ctx, type);
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)(itemStat.st_mode & 07777));
|
||||
if (includeIdentity) {
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_ino);
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_size);
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_mtimespec.tv_sec);
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_mtimespec.tv_nsec);
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_ctimespec.tv_sec);
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_ctimespec.tv_nsec);
|
||||
} else if (type == S_IFREG) {
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_size);
|
||||
} else if (type == S_IFLNK) {
|
||||
char target[PATH_MAX];
|
||||
ssize_t length = readlink(item.fileSystemRepresentation, target, sizeof(target) - 1);
|
||||
if (length < 0 || length >= (ssize_t)sizeof(target) - 1) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect link %@", relative];
|
||||
return nil;
|
||||
}
|
||||
WQAppsSupportHashBlob(&ctx, target, (NSUInteger)length);
|
||||
}
|
||||
}
|
||||
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
||||
CC_SHA256_Final(digest, &ctx);
|
||||
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||
for (NSUInteger i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
|
||||
return hex;
|
||||
}
|
||||
|
||||
// Content-level digest for backup verification: metadata plus every regular
|
||||
// file's bytes. Reading is streamed; a file that changes while it is hashed
|
||||
// fails with an error rather than producing a torn digest.
|
||||
static inline NSString *WQAppsSupportTreeContentSignature(NSString *path, BOOL *exists, NSString **errorOut)
|
||||
{
|
||||
struct stat rootStat;
|
||||
if (lstat(path.fileSystemRepresentation, &rootStat) != 0) {
|
||||
if (errno == ENOENT) {
|
||||
if (exists) *exists = NO;
|
||||
return @"<missing>";
|
||||
}
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", path.lastPathComponent];
|
||||
return nil;
|
||||
}
|
||||
if (!S_ISDIR(rootStat.st_mode)) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"%@ is not a directory", path.lastPathComponent];
|
||||
return nil;
|
||||
}
|
||||
NSError *listError = nil;
|
||||
if (![NSFileManager.defaultManager contentsOfDirectoryAtPath:path error:&listError]) {
|
||||
if (errorOut) *errorOut = listError.localizedDescription ?: [NSString stringWithFormat:@"Cannot read %@", path.lastPathComponent];
|
||||
return nil;
|
||||
}
|
||||
if (exists) *exists = YES;
|
||||
|
||||
NSMutableArray<NSString *> *relativePaths = [NSMutableArray new];
|
||||
for (NSString *relative in [NSFileManager.defaultManager enumeratorAtPath:path])
|
||||
[relativePaths addObject:relative];
|
||||
[relativePaths sortUsingSelector:@selector(compare:)];
|
||||
|
||||
CC_SHA256_CTX ctx;
|
||||
CC_SHA256_Init(&ctx);
|
||||
uint8_t buffer[65536];
|
||||
for (NSString *relative in relativePaths) {
|
||||
NSString *item = [path stringByAppendingPathComponent:relative];
|
||||
struct stat itemStat;
|
||||
if (lstat(item.fileSystemRepresentation, &itemStat) != 0) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect %@", relative];
|
||||
return nil;
|
||||
}
|
||||
const char *name = relative.UTF8String;
|
||||
if (!name) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot encode %@", relative];
|
||||
return nil;
|
||||
}
|
||||
uint8_t tag = 'C';
|
||||
CC_SHA256_Update(&ctx, &tag, 1);
|
||||
WQAppsSupportHashBlob(&ctx, name, strlen(name));
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)(itemStat.st_mode & S_IFMT));
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)(itemStat.st_mode & 07777));
|
||||
WQAppsSupportHashU64(&ctx, (uint64_t)itemStat.st_size);
|
||||
if (S_ISREG(itemStat.st_mode)) {
|
||||
int fd = open(item.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot read %@", relative];
|
||||
return nil;
|
||||
}
|
||||
struct stat after;
|
||||
BOOL ok = fstat(fd, &after) == 0 && S_ISREG(after.st_mode) &&
|
||||
after.st_dev == itemStat.st_dev && after.st_ino == itemStat.st_ino;
|
||||
for (;;) {
|
||||
if (!ok) break;
|
||||
ssize_t n = read(fd, buffer, sizeof(buffer));
|
||||
if (n < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
ok = NO;
|
||||
break;
|
||||
}
|
||||
if (n == 0) break;
|
||||
CC_SHA256_Update(&ctx, buffer, (CC_LONG)n);
|
||||
}
|
||||
struct stat finalStat;
|
||||
if (ok && (fstat(fd, &finalStat) != 0 ||
|
||||
finalStat.st_size != itemStat.st_size ||
|
||||
finalStat.st_mtimespec.tv_sec != itemStat.st_mtimespec.tv_sec ||
|
||||
finalStat.st_mtimespec.tv_nsec != itemStat.st_mtimespec.tv_nsec ||
|
||||
finalStat.st_ctimespec.tv_sec != itemStat.st_ctimespec.tv_sec ||
|
||||
finalStat.st_ctimespec.tv_nsec != itemStat.st_ctimespec.tv_nsec)) ok = NO;
|
||||
int err = errno;
|
||||
close(fd);
|
||||
if (!ok) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"%@ changed while it was read", relative];
|
||||
errno = err;
|
||||
return nil;
|
||||
}
|
||||
} else if (S_ISLNK(itemStat.st_mode)) {
|
||||
char target[PATH_MAX];
|
||||
ssize_t length = readlink(item.fileSystemRepresentation, target, sizeof(target) - 1);
|
||||
if (length < 0 || length >= (ssize_t)sizeof(target) - 1) {
|
||||
if (errorOut) *errorOut = [NSString stringWithFormat:@"Cannot inspect link %@", relative];
|
||||
return nil;
|
||||
}
|
||||
WQAppsSupportHashBlob(&ctx, target, (NSUInteger)length);
|
||||
}
|
||||
}
|
||||
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
||||
CC_SHA256_Final(digest, &ctx);
|
||||
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
||||
for (NSUInteger i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
|
||||
return hex;
|
||||
}
|
||||
|
||||
static inline NSUInteger WQAppsSupportBackupSequence(NSString *name)
|
||||
{
|
||||
if (![name isKindOfClass:NSString.class] || !name.length) return 0;
|
||||
if ([name hasSuffix:@")"]) {
|
||||
NSRange open = [name rangeOfString:@" (" options:NSBackwardsSearch];
|
||||
if (open.location != NSNotFound && open.location + 3 < name.length) {
|
||||
NSString *digits = [name substringWithRange:NSMakeRange(open.location + 2, name.length - open.location - 3)];
|
||||
NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
|
||||
if (digits.length && [digits rangeOfCharacterFromSet:nonDigits].location == NSNotFound) {
|
||||
NSUInteger sequence = digits.integerValue;
|
||||
if (sequence > 0) return sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline NSComparisonResult WQAppsSupportCompareBackupMetadata(NSDate *dateA, NSUInteger sequenceA, NSString *nameA,
|
||||
NSDate *dateB, NSUInteger sequenceB, NSString *nameB)
|
||||
{
|
||||
NSComparisonResult dateOrder = [dateA compare:dateB];
|
||||
if (dateOrder != NSOrderedSame) return dateOrder;
|
||||
if (sequenceA < sequenceB) return NSOrderedAscending;
|
||||
if (sequenceA > sequenceB) return NSOrderedDescending;
|
||||
return [nameA compare:nameB options:NSNumericSearch];
|
||||
}
|
||||
|
||||
typedef NS_ENUM(NSInteger, WQAppsSupportJournalState) {
|
||||
WQAppsSupportJournalMissing,
|
||||
WQAppsSupportJournalActive,
|
||||
WQAppsSupportJournalCommitted,
|
||||
WQAppsSupportJournalUnknown,
|
||||
};
|
||||
|
||||
static inline WQAppsSupportJournalState WQAppsSupportJournalStateForDictionary(NSDictionary *journal)
|
||||
{
|
||||
if (![journal isKindOfClass:NSDictionary.class]) return WQAppsSupportJournalMissing;
|
||||
id state = journal[@"State"];
|
||||
if (![state isKindOfClass:NSString.class]) return WQAppsSupportJournalUnknown;
|
||||
if ([state isEqualToString:@"Active"]) return WQAppsSupportJournalActive;
|
||||
if ([state isEqualToString:@"Committed"]) return WQAppsSupportJournalCommitted;
|
||||
return WQAppsSupportJournalUnknown;
|
||||
}
|
||||
|
||||
// A new restore may start only when no recovery is pending. A committed
|
||||
// journal is safe to clean up and replace; an Active or unknown journal must
|
||||
// be recovered or explicitly discarded first.
|
||||
static inline BOOL WQAppsSupportJournalAllowsNewRestore(WQAppsSupportJournalState state, NSString **error)
|
||||
{
|
||||
if (state == WQAppsSupportJournalActive) {
|
||||
if (error) *error = @"An interrupted restore is still active. Recover or discard it before starting another restore";
|
||||
return NO;
|
||||
}
|
||||
if (state == WQAppsSupportJournalUnknown) {
|
||||
if (error) *error = @"The existing restore recovery journal has an unknown state";
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
#pragma mark - Import as Backup
|
||||
|
||||
// The folder inside an import that holds Documents and/or Library: the
|
||||
// picked folder itself, or the shallowest one below it (a shared backup ZIP
|
||||
// or another tool's export usually wraps them in a folder). Several at the
|
||||
// same depth are ambiguous.
|
||||
static inline NSString *WQAppsSupportImportRoot(NSString *dir, NSString **error)
|
||||
{
|
||||
NSFileManager *fm = NSFileManager.defaultManager;
|
||||
NSArray<NSString *> *level = @[dir];
|
||||
for (int depth = 0; depth <= 3 && level.count; depth++) {
|
||||
NSMutableArray<NSString *> *found = [NSMutableArray new], *next = [NSMutableArray new];
|
||||
for (NSString *candidate in level) {
|
||||
BOOL has = NO;
|
||||
for (NSString *part in @[@"Documents", @"Library"]) {
|
||||
struct stat st;
|
||||
if (lstat([candidate stringByAppendingPathComponent:part].fileSystemRepresentation, &st) == 0 && S_ISDIR(st.st_mode))
|
||||
has = YES;
|
||||
}
|
||||
if (has) { [found addObject:candidate]; continue; }
|
||||
for (NSString *name in [[fm contentsOfDirectoryAtPath:candidate error:nil] sortedArrayUsingSelector:@selector(compare:)]) {
|
||||
if ([name hasPrefix:@"."] || [name isEqualToString:@"__MACOSX"]) continue;
|
||||
NSString *child = [candidate stringByAppendingPathComponent:name];
|
||||
struct stat st;
|
||||
if (lstat(child.fileSystemRepresentation, &st) == 0 && S_ISDIR(st.st_mode)) [next addObject:child];
|
||||
}
|
||||
}
|
||||
if (found.count == 1) return found.firstObject;
|
||||
if (found.count > 1) {
|
||||
NSMutableArray *names = [NSMutableArray new];
|
||||
for (NSString *f in found) [names addObject:[f substringFromIndex:MIN(dir.length + 1, f.length)]];
|
||||
if (error) *error = [NSString stringWithFormat:NSLocalizedString(@"It holds data of several apps (%@). Pick one of these folders instead.", nil),
|
||||
[names componentsJoinedByString:@", "]];
|
||||
return nil;
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
if (error) *error = NSLocalizedString(@"No Documents or Library folder was found in it.", nil);
|
||||
return nil;
|
||||
}
|
||||
|
||||
// The bundle ID the imported data says it belongs to, if it says: a WQ Files
|
||||
// backup's checklist or a container's metadata file.
|
||||
static inline NSString *WQAppsSupportImportDeclaredBundle(NSString *root)
|
||||
{
|
||||
NSDictionary *manifest = [NSDictionary dictionaryWithContentsOfFile:[root stringByAppendingPathComponent:@".wqbackup.plist"]];
|
||||
if ([manifest[@"BundleID"] isKindOfClass:NSString.class]) return manifest[@"BundleID"];
|
||||
NSDictionary *metadata = [NSDictionary dictionaryWithContentsOfFile:
|
||||
[root stringByAppendingPathComponent:@".com.apple.mobile_container_manager.metadata.plist"]];
|
||||
if ([metadata[@"MCMMetadataIdentifier"] isKindOfClass:NSString.class]) return metadata[@"MCMMetadataIdentifier"];
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Items next to Documents/Library that an import leaves out.
|
||||
static inline NSArray<NSString *> *WQAppsSupportImportIgnored(NSString *root)
|
||||
{
|
||||
NSMutableArray *ignored = [NSMutableArray new];
|
||||
for (NSString *name in [[NSFileManager.defaultManager contentsOfDirectoryAtPath:root error:nil] sortedArrayUsingSelector:@selector(compare:)]) {
|
||||
if ([@[@"Documents", @"Library", @".wqbackup.plist", @".com.apple.mobile_container_manager.metadata.plist", @".DS_Store"] containsObject:name]) continue;
|
||||
[ignored addObject:name];
|
||||
}
|
||||
return ignored;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// WQArchive.h
|
||||
// WQ Files
|
||||
//
|
||||
// Minimal ZIP writer and reader (stored and deflate, via zlib). Files are
|
||||
// read through a small I/O table, so the same code works on other apps'
|
||||
// containers (through WQAccess) and on plain files in host tests.
|
||||
//
|
||||
// Writing: ZIP files up to 4 GB with at most 65535 entries. Reading also
|
||||
// understands ZIP64. Extraction never creates symbolic links, refuses entry
|
||||
// names that would leave the target folder, checks every entry's size and
|
||||
// CRC, and writes only into the (local, empty) folder it is given.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <sys/stat.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface WQArchiveIO : NSObject
|
||||
// lstat-like: does not follow a final symbolic link. NO if missing.
|
||||
@property (nonatomic, copy) BOOL (^stat)(NSString *path, struct stat *st);
|
||||
// Names of the entries in a folder, or nil.
|
||||
@property (nonatomic, copy) NSArray<NSString *> *_Nullable (^list)(NSString *dir);
|
||||
// A read-only descriptor for a regular file (not following links), or -1.
|
||||
@property (nonatomic, copy) int (^openRead)(NSString *path);
|
||||
@end
|
||||
|
||||
// Plain POSIX calls; for local files and tests.
|
||||
WQArchiveIO *wq_archive_posix_io(void);
|
||||
|
||||
typedef BOOL (^WQArchiveCancelled)(void);
|
||||
// Bytes read (writing) or written (extracting) so far.
|
||||
typedef void (^WQArchiveProgress)(unsigned long long bytes);
|
||||
|
||||
// Writes a ZIP of `paths` (files and folders, recursively; each one appears
|
||||
// under its own name at the top of the archive) to the new local file
|
||||
// `zipPath`. Symbolic links and special files are left out and counted in
|
||||
// *skipped. A failed or cancelled write removes zipPath.
|
||||
BOOL wq_zip_write(NSArray<NSString *> *paths, NSString *zipPath, WQArchiveIO *io,
|
||||
WQArchiveCancelled _Nullable cancelled, WQArchiveProgress _Nullable progress,
|
||||
NSUInteger *_Nullable skipped, NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// Extracts the ZIP at zipPath (read through io) into the existing local folder
|
||||
// outDir. Refuses archives that would unpack to more than maxBytes, encrypted
|
||||
// entries and unsupported compression. Symbolic links in the archive are left
|
||||
// out (counted in *skipped); macOS "__MACOSX" metadata is ignored. On failure
|
||||
// outDir may hold a partial result; the caller removes it.
|
||||
BOOL wq_zip_extract(NSString *zipPath, NSString *outDir, WQArchiveIO *io, unsigned long long maxBytes,
|
||||
WQArchiveCancelled _Nullable cancelled, WQArchiveProgress _Nullable progress,
|
||||
NSUInteger *_Nullable files, NSUInteger *_Nullable skipped,
|
||||
NSString *_Nullable *_Nullable errorText);
|
||||
|
||||
// A safe relative path for an entry name ("a/b/c"; backslashes count as
|
||||
// separators), or nil for absolute names, "." / ".." components or empty names.
|
||||
NSString *_Nullable wq_zip_safe_entry_path(NSString *name);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,575 @@
|
||||
//
|
||||
// WQArchive.m
|
||||
// WQ Files
|
||||
//
|
||||
|
||||
#import "WQArchive.h"
|
||||
#import "WQLocalization.h"
|
||||
#import <dirent.h>
|
||||
#import <fcntl.h>
|
||||
#import <sys/time.h>
|
||||
#import <time.h>
|
||||
#import <unistd.h>
|
||||
#import <zlib.h>
|
||||
|
||||
static const size_t kWQZipChunk = 256 * 1024;
|
||||
static const uint32_t kWQZipMax32 = 0xFFFFFFFFu;
|
||||
|
||||
@implementation WQArchiveIO
|
||||
@end
|
||||
|
||||
WQArchiveIO *wq_archive_posix_io(void)
|
||||
{
|
||||
WQArchiveIO *io = [WQArchiveIO new];
|
||||
io.stat = ^BOOL(NSString *path, struct stat *st) { return lstat(path.fileSystemRepresentation, st) == 0; };
|
||||
io.list = ^NSArray<NSString *> *(NSString *dir) {
|
||||
return [NSFileManager.defaultManager contentsOfDirectoryAtPath:dir error:nil];
|
||||
};
|
||||
io.openRead = ^int(NSString *path) {
|
||||
return open(path.fileSystemRepresentation, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
|
||||
};
|
||||
return io;
|
||||
}
|
||||
|
||||
#pragma mark Little-endian helpers
|
||||
|
||||
static void wq_put16(NSMutableData *d, uint16_t v) { uint8_t b[2] = { (uint8_t)v, (uint8_t)(v >> 8) }; [d appendBytes:b length:2]; }
|
||||
static void wq_put32(NSMutableData *d, uint32_t v)
|
||||
{
|
||||
uint8_t b[4] = { (uint8_t)v, (uint8_t)(v >> 8), (uint8_t)(v >> 16), (uint8_t)(v >> 24) };
|
||||
[d appendBytes:b length:4];
|
||||
}
|
||||
static uint16_t wq_rd16(const uint8_t *p) { return (uint16_t)(p[0] | (p[1] << 8)); }
|
||||
static uint32_t wq_rd32(const uint8_t *p) { return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); }
|
||||
static uint64_t wq_rd64(const uint8_t *p) { return (uint64_t)wq_rd32(p) | ((uint64_t)wq_rd32(p + 4) << 32); }
|
||||
|
||||
static BOOL wq_write_fd(int fd, const void *buf, size_t n)
|
||||
{
|
||||
const uint8_t *p = buf;
|
||||
while (n > 0) {
|
||||
ssize_t w = write(fd, p, n);
|
||||
if (w < 0 && errno == EINTR) continue;
|
||||
if (w <= 0) return NO;
|
||||
p += w; n -= (size_t)w;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL wq_pread_all(int fd, void *buf, size_t n, off_t offset)
|
||||
{
|
||||
uint8_t *p = buf;
|
||||
while (n > 0) {
|
||||
ssize_t r = pread(fd, p, n, offset);
|
||||
if (r < 0 && errno == EINTR) continue;
|
||||
if (r <= 0) return NO;
|
||||
p += r; n -= (size_t)r; offset += r;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static ssize_t wq_read_some(int fd, void *buf, size_t n)
|
||||
{
|
||||
for (;;) {
|
||||
ssize_t r = read(fd, buf, n);
|
||||
if (r < 0 && errno == EINTR) continue;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
static void wq_dos_time(time_t t, uint16_t *dosTime, uint16_t *dosDate)
|
||||
{
|
||||
struct tm tm;
|
||||
localtime_r(&t, &tm);
|
||||
if (tm.tm_year < 80) { *dosTime = 0; *dosDate = (1 << 5) | 1; return; } // 1980-01-01
|
||||
if (tm.tm_year > 207) tm.tm_year = 207;
|
||||
*dosTime = (uint16_t)((tm.tm_hour << 11) | (tm.tm_min << 5) | (tm.tm_sec / 2));
|
||||
*dosDate = (uint16_t)(((tm.tm_year - 80) << 9) | ((tm.tm_mon + 1) << 5) | tm.tm_mday);
|
||||
}
|
||||
|
||||
static time_t wq_from_dos_time(uint16_t dosTime, uint16_t dosDate)
|
||||
{
|
||||
struct tm tm = {0};
|
||||
tm.tm_year = ((dosDate >> 9) & 0x7f) + 80;
|
||||
tm.tm_mon = ((dosDate >> 5) & 0x0f) - 1;
|
||||
tm.tm_mday = dosDate & 0x1f;
|
||||
tm.tm_hour = (dosTime >> 11) & 0x1f;
|
||||
tm.tm_min = (dosTime >> 5) & 0x3f;
|
||||
tm.tm_sec = (dosTime & 0x1f) * 2;
|
||||
tm.tm_isdst = -1;
|
||||
if (tm.tm_mon < 0 || tm.tm_mday == 0) return 0;
|
||||
return mktime(&tm);
|
||||
}
|
||||
|
||||
#pragma mark Writing
|
||||
|
||||
@interface WQZipEntry : NSObject
|
||||
@property (nonatomic) NSData *name;
|
||||
@property (nonatomic) BOOL dir;
|
||||
@property (nonatomic) uint16_t method, time, date;
|
||||
@property (nonatomic) uint32_t crc, mode;
|
||||
@property (nonatomic) uint64_t csize, usize, offset;
|
||||
@end
|
||||
@implementation WQZipEntry
|
||||
@end
|
||||
|
||||
typedef struct {
|
||||
int out;
|
||||
uint64_t offset;
|
||||
unsigned long long bytesRead;
|
||||
NSUInteger skipped;
|
||||
NSMutableArray<WQZipEntry *> *entries;
|
||||
WQArchiveIO *io;
|
||||
WQArchiveCancelled cancelled;
|
||||
WQArchiveProgress progress;
|
||||
NSString *error;
|
||||
} WQZipWriter;
|
||||
|
||||
static BOOL wq_zip_fail(WQZipWriter *w, NSString *message)
|
||||
{
|
||||
if (!w->error) w->error = message;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL wq_zip_too_big(WQZipWriter *w)
|
||||
{
|
||||
return wq_zip_fail(w, WQLocalized(@"The archive would be larger than 4 GB, which isn't supported yet."));
|
||||
}
|
||||
|
||||
static BOOL wq_zip_local_header(WQZipWriter *w, WQZipEntry *e)
|
||||
{
|
||||
if (w->offset >= kWQZipMax32) return wq_zip_too_big(w);
|
||||
if (w->entries.count >= 0xFFFF) return wq_zip_fail(w, WQLocalized(@"Too many items for one ZIP (more than 65535)."));
|
||||
e.offset = w->offset;
|
||||
NSMutableData *h = [NSMutableData dataWithCapacity:30 + e.name.length];
|
||||
wq_put32(h, 0x04034b50);
|
||||
wq_put16(h, 20); // version needed
|
||||
wq_put16(h, 0x0800); // UTF-8 names
|
||||
wq_put16(h, e.method);
|
||||
wq_put16(h, e.time);
|
||||
wq_put16(h, e.date);
|
||||
wq_put32(h, 0); wq_put32(h, 0); wq_put32(h, 0); // crc and sizes, patched later
|
||||
wq_put16(h, (uint16_t)e.name.length);
|
||||
wq_put16(h, 0);
|
||||
[h appendData:e.name];
|
||||
if (!wq_write_fd(w->out, h.bytes, h.length)) return wq_zip_fail(w, @(strerror(errno)));
|
||||
w->offset += h.length;
|
||||
[w->entries addObject:e];
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL wq_zip_add_file(WQZipWriter *w, NSString *path, NSString *name, const struct stat *st)
|
||||
{
|
||||
int in = w->io.openRead(path);
|
||||
if (in < 0) return wq_zip_fail(w, WQLocalizedFormat(@"Can't read %@ (%s)", name, strerror(errno)));
|
||||
WQZipEntry *e = [WQZipEntry new];
|
||||
e.name = [name dataUsingEncoding:NSUTF8StringEncoding];
|
||||
e.method = 8;
|
||||
e.mode = st->st_mode & 0777;
|
||||
uint16_t t, d; wq_dos_time(st->st_mtimespec.tv_sec, &t, &d); e.time = t; e.date = d;
|
||||
if (!wq_zip_local_header(w, e)) { close(in); return NO; }
|
||||
|
||||
z_stream z = {0};
|
||||
if (deflateInit2(&z, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
|
||||
close(in); return wq_zip_fail(w, WQLocalized(@"Can't start compression"));
|
||||
}
|
||||
uint8_t *inBuf = malloc(kWQZipChunk), *outBuf = malloc(kWQZipChunk);
|
||||
BOOL ok = inBuf && outBuf;
|
||||
if (!ok) wq_zip_fail(w, WQLocalized(@"Out of memory"));
|
||||
uLong crc = crc32(0, NULL, 0);
|
||||
uint64_t usize = 0, csize = 0;
|
||||
BOOL eof = NO;
|
||||
while (ok && !eof) {
|
||||
if (w->cancelled && w->cancelled()) { ok = wq_zip_fail(w, @"Cancelled"); break; }
|
||||
ssize_t n = wq_read_some(in, inBuf, kWQZipChunk);
|
||||
if (n < 0) { ok = wq_zip_fail(w, WQLocalizedFormat(@"Can't read %@ (%s)", name, strerror(errno))); break; }
|
||||
eof = n == 0;
|
||||
crc = crc32(crc, inBuf, (uInt)n);
|
||||
usize += (uint64_t)n;
|
||||
w->bytesRead += (unsigned long long)n;
|
||||
if (usize >= kWQZipMax32) { ok = wq_zip_too_big(w); break; }
|
||||
z.next_in = inBuf; z.avail_in = (uInt)n;
|
||||
int flush = eof ? Z_FINISH : Z_NO_FLUSH;
|
||||
int rc;
|
||||
do {
|
||||
z.next_out = outBuf; z.avail_out = (uInt)kWQZipChunk;
|
||||
rc = deflate(&z, flush);
|
||||
if (rc == Z_STREAM_ERROR) { ok = wq_zip_fail(w, WQLocalized(@"Compression failed")); break; }
|
||||
size_t have = kWQZipChunk - z.avail_out;
|
||||
if (have && !wq_write_fd(w->out, outBuf, have)) { ok = wq_zip_fail(w, @(strerror(errno))); break; }
|
||||
csize += have;
|
||||
} while (z.avail_out == 0);
|
||||
if (ok && w->progress) w->progress(w->bytesRead);
|
||||
}
|
||||
deflateEnd(&z);
|
||||
free(inBuf); free(outBuf);
|
||||
close(in);
|
||||
if (!ok) return NO;
|
||||
w->offset += csize;
|
||||
if (w->offset >= kWQZipMax32) return wq_zip_too_big(w);
|
||||
e.crc = (uint32_t)crc; e.csize = csize; e.usize = usize;
|
||||
NSMutableData *patch = [NSMutableData dataWithCapacity:12];
|
||||
wq_put32(patch, e.crc); wq_put32(patch, (uint32_t)csize); wq_put32(patch, (uint32_t)usize);
|
||||
if (pwrite(w->out, patch.bytes, 12, (off_t)e.offset + 14) != 12) return wq_zip_fail(w, @(strerror(errno)));
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL wq_zip_add(WQZipWriter *w, NSString *path, NSString *name, NSUInteger depth)
|
||||
{
|
||||
if (w->cancelled && w->cancelled()) return wq_zip_fail(w, @"Cancelled");
|
||||
if (depth > 64) return wq_zip_fail(w, WQLocalized(@"Folders are nested too deeply"));
|
||||
struct stat st;
|
||||
if (!w->io.stat(path, &st)) return wq_zip_fail(w, WQLocalizedFormat(@"Can't read %@ (%s)", name, strerror(errno)));
|
||||
if (S_ISREG(st.st_mode)) return wq_zip_add_file(w, path, name, &st);
|
||||
if (!S_ISDIR(st.st_mode)) { w->skipped++; return YES; } // links and special files
|
||||
WQZipEntry *e = [WQZipEntry new];
|
||||
e.name = [[name stringByAppendingString:@"/"] dataUsingEncoding:NSUTF8StringEncoding];
|
||||
e.dir = YES;
|
||||
e.mode = st.st_mode & 0777;
|
||||
uint16_t t, d; wq_dos_time(st.st_mtimespec.tv_sec, &t, &d); e.time = t; e.date = d;
|
||||
e.crc = 0;
|
||||
if (!wq_zip_local_header(w, e)) return NO;
|
||||
NSArray<NSString *> *children = w->io.list(path);
|
||||
if (!children) return wq_zip_fail(w, WQLocalizedFormat(@"Can't list %@", name));
|
||||
for (NSString *child in [children sortedArrayUsingSelector:@selector(localizedStandardCompare:)]) {
|
||||
if (!wq_zip_add(w, [path stringByAppendingPathComponent:child], [name stringByAppendingFormat:@"/%@", child], depth + 1))
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL wq_zip_central_directory(WQZipWriter *w)
|
||||
{
|
||||
uint64_t cdStart = w->offset;
|
||||
NSMutableData *cd = [NSMutableData new];
|
||||
for (WQZipEntry *e in w->entries) {
|
||||
wq_put32(cd, 0x02014b50);
|
||||
wq_put16(cd, (3 << 8) | 20); // made by Unix, ZIP 2.0
|
||||
wq_put16(cd, 20);
|
||||
wq_put16(cd, 0x0800);
|
||||
wq_put16(cd, e.method);
|
||||
wq_put16(cd, e.time);
|
||||
wq_put16(cd, e.date);
|
||||
wq_put32(cd, e.crc);
|
||||
wq_put32(cd, (uint32_t)e.csize);
|
||||
wq_put32(cd, (uint32_t)e.usize);
|
||||
wq_put16(cd, (uint16_t)e.name.length);
|
||||
wq_put16(cd, 0); wq_put16(cd, 0); wq_put16(cd, 0); wq_put16(cd, 0);
|
||||
uint32_t type = e.dir ? S_IFDIR : S_IFREG;
|
||||
wq_put32(cd, ((type | e.mode) << 16) | (e.dir ? 0x10 : 0));
|
||||
wq_put32(cd, (uint32_t)e.offset);
|
||||
[cd appendData:e.name];
|
||||
}
|
||||
uint32_t cdLength = (uint32_t)cd.length;
|
||||
if (cdStart + cd.length >= kWQZipMax32) return wq_zip_too_big(w);
|
||||
wq_put32(cd, 0x06054b50);
|
||||
wq_put16(cd, 0); wq_put16(cd, 0);
|
||||
wq_put16(cd, (uint16_t)w->entries.count);
|
||||
wq_put16(cd, (uint16_t)w->entries.count);
|
||||
wq_put32(cd, cdLength);
|
||||
wq_put32(cd, (uint32_t)cdStart);
|
||||
wq_put16(cd, 0);
|
||||
if (!wq_write_fd(w->out, cd.bytes, cd.length)) return wq_zip_fail(w, @(strerror(errno)));
|
||||
return YES;
|
||||
}
|
||||
|
||||
BOOL wq_zip_write(NSArray<NSString *> *paths, NSString *zipPath, WQArchiveIO *io,
|
||||
WQArchiveCancelled cancelled, WQArchiveProgress progress,
|
||||
NSUInteger *skipped, NSString **errorText)
|
||||
{
|
||||
if (paths.count == 0) { if (errorText) *errorText = WQLocalized(@"Nothing to compress"); return NO; }
|
||||
int out = open(zipPath.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644);
|
||||
if (out < 0) { if (errorText) *errorText = @(strerror(errno)); return NO; }
|
||||
NSMutableArray *entries = [NSMutableArray new];
|
||||
NSString *error = nil;
|
||||
WQZipWriter w = { .out = out, .entries = entries, .io = io, .cancelled = cancelled, .progress = progress };
|
||||
BOOL ok = YES;
|
||||
NSMutableSet<NSString *> *topNames = [NSMutableSet new];
|
||||
for (NSString *path in paths) {
|
||||
NSString *name = path.lastPathComponent;
|
||||
if (!name.length || [topNames containsObject:name]) { ok = wq_zip_fail(&w, WQLocalized(@"Two selected items have the same name")); break; }
|
||||
[topNames addObject:name];
|
||||
if (!(ok = wq_zip_add(&w, path, name, 0))) break;
|
||||
}
|
||||
if (ok) ok = wq_zip_central_directory(&w);
|
||||
if (ok && fsync(out) != 0) ok = wq_zip_fail(&w, @(strerror(errno)));
|
||||
error = w.error;
|
||||
if (close(out) != 0 && ok) { ok = NO; error = @(strerror(errno)); }
|
||||
if (!ok) unlink(zipPath.fileSystemRepresentation);
|
||||
if (skipped) *skipped = w.skipped;
|
||||
if (!ok && errorText) *errorText = error ?: WQLocalized(@"Could not create the ZIP");
|
||||
return ok;
|
||||
}
|
||||
|
||||
#pragma mark Reading
|
||||
|
||||
NSString *wq_zip_safe_entry_path(NSString *name)
|
||||
{
|
||||
if (!name.length || [name rangeOfString:@"\0"].location != NSNotFound) return nil;
|
||||
NSString *slashed = [name stringByReplacingOccurrencesOfString:@"\\" withString:@"/"];
|
||||
if ([slashed hasPrefix:@"/"]) return nil;
|
||||
if (slashed.length >= 2 && [slashed characterAtIndex:1] == ':') return nil; // "C:/…"
|
||||
NSMutableArray<NSString *> *parts = [NSMutableArray new];
|
||||
for (NSString *part in [slashed componentsSeparatedByString:@"/"]) {
|
||||
if (!part.length) continue;
|
||||
if ([part isEqualToString:@"."] || [part isEqualToString:@".."]) return nil;
|
||||
if (strlen(part.fileSystemRepresentation ?: "") > 255) return nil;
|
||||
[parts addObject:part];
|
||||
}
|
||||
return parts.count ? [parts componentsJoinedByString:@"/"] : nil;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
NSString *name;
|
||||
uint16_t flags, method, time, date, madeBy;
|
||||
uint32_t crc, external;
|
||||
uint64_t csize, usize, offset;
|
||||
} WQZipItem;
|
||||
|
||||
static NSString *wq_zip_decode_name(const uint8_t *p, uint16_t len, uint16_t flags)
|
||||
{
|
||||
NSData *raw = [NSData dataWithBytes:p length:len];
|
||||
NSString *s = [[NSString alloc] initWithData:raw encoding:NSUTF8StringEncoding];
|
||||
if (!s && !(flags & 0x0800))
|
||||
s = [[NSString alloc] initWithData:raw encoding:CFStringConvertEncodingToNSStringEncoding(kCFStringEncodingDOSLatinUS)];
|
||||
return s;
|
||||
}
|
||||
|
||||
static BOOL wq_zip_read_directory(int fd, uint64_t fileSize, WQZipItem **itemsOut, NSUInteger *countOut, NSString **error)
|
||||
{
|
||||
size_t tailLen = (size_t)MIN(fileSize, (uint64_t)(22 + 65535));
|
||||
if (tailLen < 22) { *error = WQLocalized(@"Not a ZIP file"); return NO; }
|
||||
uint8_t *tail = malloc(tailLen);
|
||||
if (!tail || !wq_pread_all(fd, tail, tailLen, (off_t)(fileSize - tailLen))) { free(tail); *error = WQLocalized(@"Can't read the ZIP file"); return NO; }
|
||||
ssize_t eocd = -1;
|
||||
for (ssize_t i = (ssize_t)tailLen - 22; i >= 0; i--) {
|
||||
if (wq_rd32(tail + i) == 0x06054b50) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) { free(tail); *error = WQLocalized(@"Not a ZIP file (no directory found)"); return NO; }
|
||||
uint64_t eocdPos = fileSize - tailLen + (uint64_t)eocd;
|
||||
uint64_t count = wq_rd16(tail + eocd + 10);
|
||||
uint64_t cdSize = wq_rd32(tail + eocd + 12);
|
||||
uint64_t cdOffset = wq_rd32(tail + eocd + 16);
|
||||
free(tail);
|
||||
if ((count == 0xFFFF || cdSize == kWQZipMax32 || cdOffset == kWQZipMax32) && eocdPos >= 20) {
|
||||
uint8_t loc[20], rec[56];
|
||||
if (wq_pread_all(fd, loc, 20, (off_t)(eocdPos - 20)) && wq_rd32(loc) == 0x07064b50) {
|
||||
uint64_t recPos = wq_rd64(loc + 8);
|
||||
if (recPos + 56 <= fileSize && wq_pread_all(fd, rec, 56, (off_t)recPos) && wq_rd32(rec) == 0x06064b50) {
|
||||
count = wq_rd64(rec + 32);
|
||||
cdSize = wq_rd64(rec + 40);
|
||||
cdOffset = wq_rd64(rec + 48);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cdOffset > fileSize || cdSize > fileSize - cdOffset || cdSize > 256ull * 1024 * 1024 || count > cdSize / 46 + 1) {
|
||||
*error = WQLocalized(@"The ZIP file is damaged (bad directory)");
|
||||
return NO;
|
||||
}
|
||||
uint8_t *cd = malloc(cdSize ? (size_t)cdSize : 1);
|
||||
if (!cd || (cdSize && !wq_pread_all(fd, cd, (size_t)cdSize, (off_t)cdOffset))) { free(cd); *error = WQLocalized(@"Can't read the ZIP directory"); return NO; }
|
||||
WQZipItem *items = calloc((size_t)MAX(count, 1), sizeof(WQZipItem));
|
||||
if (!items) { free(cd); *error = WQLocalized(@"Out of memory"); return NO; }
|
||||
uint64_t pos = 0;
|
||||
NSUInteger n = 0;
|
||||
BOOL ok = YES;
|
||||
for (uint64_t i = 0; i < count; i++) {
|
||||
if (pos + 46 > cdSize || wq_rd32(cd + pos) != 0x02014b50) { ok = NO; break; }
|
||||
const uint8_t *p = cd + pos;
|
||||
uint16_t nameLen = wq_rd16(p + 28), extraLen = wq_rd16(p + 30), commentLen = wq_rd16(p + 32);
|
||||
if (pos + 46 + nameLen + extraLen + commentLen > cdSize) { ok = NO; break; }
|
||||
WQZipItem it = {0};
|
||||
it.madeBy = wq_rd16(p + 4);
|
||||
it.flags = wq_rd16(p + 8);
|
||||
it.method = wq_rd16(p + 10);
|
||||
it.time = wq_rd16(p + 12);
|
||||
it.date = wq_rd16(p + 14);
|
||||
it.crc = wq_rd32(p + 16);
|
||||
it.csize = wq_rd32(p + 20);
|
||||
it.usize = wq_rd32(p + 24);
|
||||
it.external = wq_rd32(p + 38);
|
||||
it.offset = wq_rd32(p + 42);
|
||||
// ZIP64 extra field: the 64-bit values for the fields marked 0xFFFFFFFF, in order.
|
||||
const uint8_t *x = p + 46 + nameLen, *xEnd = x + extraLen;
|
||||
while (x + 4 <= xEnd) {
|
||||
uint16_t xid = wq_rd16(x), xlen = wq_rd16(x + 2);
|
||||
if (x + 4 + xlen > xEnd) break;
|
||||
if (xid == 0x0001) {
|
||||
const uint8_t *v = x + 4, *vEnd = v + xlen;
|
||||
if (it.usize == kWQZipMax32 && v + 8 <= vEnd) { it.usize = wq_rd64(v); v += 8; }
|
||||
if (it.csize == kWQZipMax32 && v + 8 <= vEnd) { it.csize = wq_rd64(v); v += 8; }
|
||||
if (it.offset == kWQZipMax32 && v + 8 <= vEnd) { it.offset = wq_rd64(v); v += 8; }
|
||||
}
|
||||
x += 4 + xlen;
|
||||
}
|
||||
it.name = wq_zip_decode_name(p + 46, nameLen, it.flags);
|
||||
if (!it.name) { ok = NO; break; }
|
||||
items[n] = it;
|
||||
// Keep the name alive inside the C array.
|
||||
CFBridgingRetain(it.name);
|
||||
n++;
|
||||
pos += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
free(cd);
|
||||
if (!ok) {
|
||||
for (NSUInteger i = 0; i < n; i++) CFBridgingRelease((__bridge CFTypeRef)items[i].name);
|
||||
free(items);
|
||||
*error = WQLocalized(@"The ZIP file is damaged (bad entry)");
|
||||
return NO;
|
||||
}
|
||||
*itemsOut = items;
|
||||
*countOut = n;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static void wq_zip_free_items(WQZipItem *items, NSUInteger n)
|
||||
{
|
||||
for (NSUInteger i = 0; i < n; i++) CFBridgingRelease((__bridge CFTypeRef)items[i].name);
|
||||
free(items);
|
||||
}
|
||||
|
||||
static BOOL wq_zip_extract_entry(int fd, uint64_t fileSize, const WQZipItem *it, int out,
|
||||
unsigned long long *written, unsigned long long maxBytes,
|
||||
WQArchiveCancelled cancelled, WQArchiveProgress progress, NSString **error)
|
||||
{
|
||||
uint8_t lh[30];
|
||||
if (it->offset + 30 > fileSize || !wq_pread_all(fd, lh, 30, (off_t)it->offset) || wq_rd32(lh) != 0x04034b50) {
|
||||
*error = WQLocalized(@"The ZIP file is damaged (bad entry header)"); return NO;
|
||||
}
|
||||
uint64_t dataPos = it->offset + 30 + wq_rd16(lh + 26) + wq_rd16(lh + 28);
|
||||
if (dataPos > fileSize || it->csize > fileSize - dataPos) { *error = WQLocalized(@"The ZIP file is damaged (entry out of range)"); return NO; }
|
||||
uint8_t *inBuf = malloc(kWQZipChunk), *outBuf = malloc(kWQZipChunk);
|
||||
if (!inBuf || !outBuf) { free(inBuf); free(outBuf); *error = WQLocalized(@"Out of memory"); return NO; }
|
||||
z_stream z = {0};
|
||||
BOOL inflating = it->method == 8;
|
||||
if (inflating && inflateInit2(&z, -MAX_WBITS) != Z_OK) { free(inBuf); free(outBuf); *error = WQLocalized(@"Can't start decompression"); return NO; }
|
||||
uLong crc = crc32(0, NULL, 0);
|
||||
uint64_t remaining = it->csize, produced = 0;
|
||||
off_t readPos = (off_t)dataPos;
|
||||
BOOL ok = YES, done = it->usize == 0 && it->csize == 0 && !inflating;
|
||||
while (ok && !done) {
|
||||
if (cancelled && cancelled()) { *error = @"Cancelled"; ok = NO; break; }
|
||||
if (z.avail_in == 0 && remaining > 0) {
|
||||
size_t chunk = (size_t)MIN(remaining, (uint64_t)kWQZipChunk);
|
||||
if (!wq_pread_all(fd, inBuf, chunk, readPos)) { *error = WQLocalized(@"Can't read the ZIP file"); ok = NO; break; }
|
||||
readPos += (off_t)chunk; remaining -= chunk;
|
||||
z.next_in = inBuf; z.avail_in = (uInt)chunk;
|
||||
}
|
||||
const uint8_t *data;
|
||||
size_t have;
|
||||
if (inflating) {
|
||||
z.next_out = outBuf; z.avail_out = (uInt)kWQZipChunk;
|
||||
int rc = inflate(&z, Z_NO_FLUSH);
|
||||
if (rc == Z_STREAM_END) done = YES;
|
||||
else if (rc == Z_BUF_ERROR && z.avail_in == 0 && remaining == 0) {
|
||||
*error = WQLocalized(@"The ZIP file is damaged (data ends early)"); ok = NO; break;
|
||||
} else if (rc != Z_OK && rc != Z_BUF_ERROR) {
|
||||
*error = WQLocalized(@"The ZIP file is damaged (bad compressed data)"); ok = NO; break;
|
||||
}
|
||||
data = outBuf; have = kWQZipChunk - z.avail_out;
|
||||
} else {
|
||||
data = z.next_in; have = z.avail_in;
|
||||
z.avail_in = 0;
|
||||
done = remaining == 0;
|
||||
}
|
||||
produced += have;
|
||||
if (produced > it->usize) { *error = WQLocalized(@"The ZIP file is damaged (entry larger than stated)"); ok = NO; break; }
|
||||
if (*written + have > maxBytes) { *error = WQLocalized(@"Not enough free space to extract this ZIP"); ok = NO; break; }
|
||||
if (have && !wq_write_fd(out, data, have)) { *error = @(strerror(errno)); ok = NO; break; }
|
||||
crc = crc32(crc, data, (uInt)have);
|
||||
*written += have;
|
||||
if (progress && have) progress(*written);
|
||||
}
|
||||
if (inflating) inflateEnd(&z);
|
||||
free(inBuf); free(outBuf);
|
||||
if (ok && (produced != it->usize || (uint32_t)crc != it->crc)) {
|
||||
*error = WQLocalized(@"The ZIP file is damaged (checksum mismatch)"); ok = NO;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
BOOL wq_zip_extract(NSString *zipPath, NSString *outDir, WQArchiveIO *io, unsigned long long maxBytes,
|
||||
WQArchiveCancelled cancelled, WQArchiveProgress progress,
|
||||
NSUInteger *filesOut, NSUInteger *skippedOut, NSString **errorText)
|
||||
{
|
||||
NSString *error = nil;
|
||||
int fd = io.openRead(zipPath);
|
||||
if (fd < 0) { if (errorText) *errorText = @(strerror(errno)); return NO; }
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) { close(fd); if (errorText) *errorText = WQLocalized(@"Not a regular file"); return NO; }
|
||||
uint64_t fileSize = (uint64_t)st.st_size;
|
||||
WQZipItem *items = NULL;
|
||||
NSUInteger count = 0;
|
||||
if (!wq_zip_read_directory(fd, fileSize, &items, &count, &error)) {
|
||||
close(fd); if (errorText) *errorText = error; return NO;
|
||||
}
|
||||
// Check everything before writing anything.
|
||||
unsigned long long declared = 0;
|
||||
NSMutableArray<NSString *> *safeNames = [NSMutableArray arrayWithCapacity:count];
|
||||
BOOL ok = YES;
|
||||
for (NSUInteger i = 0; i < count && ok; i++) {
|
||||
WQZipItem *it = &items[i];
|
||||
NSString *safe = wq_zip_safe_entry_path(it->name);
|
||||
if (!safe) {
|
||||
ok = NO; error = WQLocalizedFormat(@"The ZIP contains an unsafe name (%@); nothing was extracted.", it->name);
|
||||
break;
|
||||
}
|
||||
[safeNames addObject:safe];
|
||||
if (it->flags & 0x0001) { ok = NO; error = WQLocalized(@"This ZIP is encrypted, which isn't supported."); break; }
|
||||
BOOL isDir = [it->name hasSuffix:@"/"] || [it->name hasSuffix:@"\\"];
|
||||
if (!isDir && it->method != 0 && it->method != 8) {
|
||||
ok = NO; error = WQLocalizedFormat(@"This ZIP uses a compression method that isn't supported (%u).", it->method);
|
||||
break;
|
||||
}
|
||||
if (it->method == 0 && it->csize != it->usize) { ok = NO; error = WQLocalized(@"The ZIP file is damaged (bad sizes)"); break; }
|
||||
declared += it->usize;
|
||||
}
|
||||
if (ok && declared > maxBytes) { ok = NO; error = WQLocalized(@"Not enough free space to extract this ZIP"); }
|
||||
NSUInteger files = 0, skipped = 0;
|
||||
unsigned long long written = 0;
|
||||
NSFileManager *fm = NSFileManager.defaultManager;
|
||||
for (NSUInteger i = 0; i < count && ok; i++) {
|
||||
WQZipItem *it = &items[i];
|
||||
NSString *rel = safeNames[i];
|
||||
if ([rel isEqualToString:@"__MACOSX"] || [rel hasPrefix:@"__MACOSX/"]) continue;
|
||||
BOOL unixMade = (it->madeBy >> 8) == 3;
|
||||
mode_t mode = unixMade ? (mode_t)(it->external >> 16) : 0;
|
||||
if (unixMade && S_ISLNK(mode)) { skipped++; continue; }
|
||||
BOOL isDir = [it->name hasSuffix:@"/"] || [it->name hasSuffix:@"\\"] || (unixMade && S_ISDIR(mode)) || (it->external & 0x10);
|
||||
NSString *target = [outDir stringByAppendingPathComponent:rel];
|
||||
if (isDir) {
|
||||
if (![fm createDirectoryAtPath:target withIntermediateDirectories:YES attributes:nil error:nil]) {
|
||||
ok = NO; error = WQLocalizedFormat(@"Can't create the folder %@", rel);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
NSString *parent = target.stringByDeletingLastPathComponent;
|
||||
if (![fm createDirectoryAtPath:parent withIntermediateDirectories:YES attributes:nil error:nil]) {
|
||||
ok = NO; error = WQLocalizedFormat(@"Can't create the folder for %@", rel); break;
|
||||
}
|
||||
int out = open(target.fileSystemRepresentation, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644);
|
||||
if (out < 0) {
|
||||
ok = NO;
|
||||
error = errno == EEXIST ? WQLocalizedFormat(@"The ZIP contains %@ twice", rel)
|
||||
: WQLocalizedFormat(@"Can't create %@ (%s)", rel, strerror(errno));
|
||||
break;
|
||||
}
|
||||
NSString *entryError = nil;
|
||||
ok = wq_zip_extract_entry(fd, fileSize, it, out, &written, maxBytes, cancelled, progress, &entryError);
|
||||
if (ok) {
|
||||
time_t when = wq_from_dos_time(it->time, it->date);
|
||||
if (when > 0) {
|
||||
struct timeval tv[2] = { { when, 0 }, { when, 0 } };
|
||||
futimes(out, tv);
|
||||
}
|
||||
}
|
||||
if (close(out) != 0 && ok) { ok = NO; entryError = @(strerror(errno)); }
|
||||
if (!ok) error = entryError ?: WQLocalized(@"Extraction failed");
|
||||
else files++;
|
||||
}
|
||||
close(fd);
|
||||
wq_zip_free_items(items, count);
|
||||
if (filesOut) *filesOut = files;
|
||||
if (skippedOut) *skippedOut = skipped;
|
||||
if (!ok && errorText) *errorText = error ?: WQLocalized(@"Extraction failed");
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface WQCacheCleanerViewController : UITableViewController <UISearchResultsUpdating>
|
||||
@end
|
||||
@@ -0,0 +1,933 @@
|
||||
//
|
||||
// WQCache.m
|
||||
// WQ Files cache cleaner
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQCache.h"
|
||||
#import "WQDir.h"
|
||||
#import "WQUI.h"
|
||||
#import "WQAccess.h"
|
||||
#import "WQLaunchServices.h"
|
||||
#import "WQCacheSafety.h"
|
||||
|
||||
#import <errno.h>
|
||||
#import <sys/stat.h>
|
||||
|
||||
#pragma mark - Cache cleaner
|
||||
|
||||
static NSString *const kCrashLogsPath =
|
||||
@"/private/var/containers/Shared/SystemGroup/systemgroup.com.apple.osanalytics/Library/Logs/CrashReporter";
|
||||
|
||||
@interface WQCacheTargetRecord : NSObject
|
||||
@property (nonatomic, copy) NSString *path;
|
||||
@property (nonatomic, copy) NSString *relativePath;
|
||||
@property (nonatomic) BOOL existed;
|
||||
@property (nonatomic) BOOL identityKnown;
|
||||
@property (nonatomic) dev_t device;
|
||||
@property (nonatomic) ino_t inode;
|
||||
@property (nonatomic) mode_t mode;
|
||||
@end
|
||||
@implementation WQCacheTargetRecord
|
||||
@end
|
||||
|
||||
@interface WQCacheItem : NSObject
|
||||
@property (nonatomic, copy) NSString *name; // app name or "Crash Logs"
|
||||
@property (nonatomic, copy) NSString *bundleID; // for the icon; nil for logs
|
||||
@property (nonatomic, copy) NSArray<NSString *> *targets; // dirs to purge
|
||||
@property (nonatomic) unsigned long long cacheBytes;
|
||||
@property (nonatomic) unsigned long long tempBytes;
|
||||
@property (nonatomic) BOOL cacheKnown;
|
||||
@property (nonatomic) BOOL tempKnown;
|
||||
@property (nonatomic) BOOL limited;
|
||||
@property (nonatomic) BOOL crashLogs;
|
||||
@property (nonatomic, copy) NSString *containerPath;
|
||||
@property (nonatomic) BOOL containerIdentityKnown;
|
||||
@property (nonatomic) dev_t containerDevice;
|
||||
@property (nonatomic) ino_t containerInode;
|
||||
@property (nonatomic) mode_t containerMode;
|
||||
@property (nonatomic, copy) NSArray<WQCacheTargetRecord *> *targetRecords;
|
||||
@property (nonatomic) BOOL selected;
|
||||
@property (nonatomic, readonly) unsigned long long bytes;
|
||||
@end
|
||||
@implementation WQCacheItem
|
||||
- (unsigned long long)bytes { return self.cacheBytes + self.tempBytes; }
|
||||
@end
|
||||
|
||||
static void WQCacheSelectionCategories(NSArray<WQCacheItem *> *items,
|
||||
BOOL *hasApps,
|
||||
BOOL *hasCrashLogs,
|
||||
NSUInteger *appCount)
|
||||
{
|
||||
BOOL apps = NO, crashLogs = NO;
|
||||
NSUInteger count = 0;
|
||||
for (WQCacheItem *item in items) {
|
||||
if (item.crashLogs) {
|
||||
crashLogs = YES;
|
||||
} else {
|
||||
apps = YES;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (hasApps) *hasApps = apps;
|
||||
if (hasCrashLogs) *hasCrashLogs = crashLogs;
|
||||
if (appCount) *appCount = count;
|
||||
}
|
||||
|
||||
static NSString *WQCacheConfirmationScope(NSArray<WQCacheItem *> *items)
|
||||
{
|
||||
BOOL hasApps = NO, hasCrashLogs = NO;
|
||||
NSUInteger appCount = 0;
|
||||
WQCacheSelectionCategories(items, &hasApps, &hasCrashLogs, &appCount);
|
||||
if (hasApps && hasCrashLogs) {
|
||||
return WQLocalizedFormat(appCount == 1
|
||||
? @"Delete Library/Caches and tmp for %lu app, plus Crash Logs"
|
||||
: @"Delete Library/Caches and tmp for %lu apps, plus Crash Logs",
|
||||
(unsigned long)appCount);
|
||||
}
|
||||
if (hasApps) {
|
||||
return WQLocalizedFormat(appCount == 1
|
||||
? @"Delete Library/Caches and tmp for %lu app"
|
||||
: @"Delete Library/Caches and tmp for %lu apps",
|
||||
(unsigned long)appCount);
|
||||
}
|
||||
if (hasCrashLogs) return WQLocalized(@"Delete Crash Logs");
|
||||
return WQLocalized(@"Delete the selected cache targets");
|
||||
}
|
||||
|
||||
static NSString *WQCacheFooterScope(NSArray<WQCacheItem *> *items)
|
||||
{
|
||||
BOOL hasApps = NO, hasCrashLogs = NO;
|
||||
WQCacheSelectionCategories(items, &hasApps, &hasCrashLogs, NULL);
|
||||
if (hasApps && hasCrashLogs) {
|
||||
return WQLocalized(@"Clears each selected app's Library/Caches and tmp, and deletes selected Crash Logs. Apps rebuild their caches as you use them; some keep offline content (music, maps, downloads) there and will download it again. No backup is kept.");
|
||||
}
|
||||
if (hasApps) {
|
||||
return WQLocalized(@"Clears each selected app's Library/Caches and tmp. Apps rebuild their caches as you use them; some keep offline content (music, maps, downloads) there and will download it again. No backup is kept.");
|
||||
}
|
||||
if (hasCrashLogs) return WQLocalized(@"Deletes selected Crash Logs. This cannot be undone. No backup is kept.");
|
||||
return WQLocalized(@"Select app caches or Crash Logs to see the deletion scope. No backup is kept.");
|
||||
}
|
||||
|
||||
typedef NS_ENUM(NSInteger, WQCacheMeasurementState) {
|
||||
WQCacheMeasurementMissing,
|
||||
WQCacheMeasurementKnown,
|
||||
WQCacheMeasurementLimited,
|
||||
WQCacheMeasurementCancelled,
|
||||
};
|
||||
|
||||
static WQCacheMeasurementState WQCacheMeasureDirectory(NSString *path,
|
||||
WQScanCancellation *cancellation,
|
||||
unsigned long long *bytes)
|
||||
{
|
||||
unsigned long long total = 0;
|
||||
WQMeasureResult result = wq_measure_tree(path, cancellation, &total);
|
||||
switch (result) {
|
||||
case WQMeasureResultMissing:
|
||||
return WQCacheMeasurementMissing;
|
||||
case WQMeasureResultKnown:
|
||||
if (bytes) *bytes = total;
|
||||
return WQCacheMeasurementKnown;
|
||||
case WQMeasureResultCancelled:
|
||||
return WQCacheMeasurementCancelled;
|
||||
case WQMeasureResultLimited:
|
||||
wq_log(@"cache scan: limited tree measurement for %@", path);
|
||||
return WQCacheMeasurementLimited;
|
||||
}
|
||||
return WQCacheMeasurementLimited;
|
||||
}
|
||||
|
||||
typedef NS_ENUM(NSInteger, WQCachePathState) {
|
||||
WQCachePathMissing,
|
||||
WQCachePathExisting,
|
||||
WQCachePathInaccessible,
|
||||
};
|
||||
|
||||
static WQCachePathState WQCacheInspectPath(NSString *path, struct stat *st)
|
||||
{
|
||||
if (wq_stat(path, st)) return WQCachePathExisting;
|
||||
struct stat direct = {0};
|
||||
if (lstat(path.fileSystemRepresentation, &direct) == 0) {
|
||||
if (st) *st = direct;
|
||||
return WQCachePathExisting;
|
||||
}
|
||||
if (errno == ENOENT || errno == ENOTDIR) return WQCachePathMissing;
|
||||
return WQCachePathInaccessible;
|
||||
}
|
||||
|
||||
static WQCacheTargetRecord *WQCacheTargetRecordForPath(NSString *path, NSString *relativePath)
|
||||
{
|
||||
WQCacheTargetRecord *record = [WQCacheTargetRecord new];
|
||||
record.path = path;
|
||||
record.relativePath = relativePath;
|
||||
struct stat st = {0};
|
||||
WQCachePathState state = WQCacheInspectPath(path, &st);
|
||||
if (state == WQCachePathExisting) {
|
||||
record.existed = YES;
|
||||
record.identityKnown = YES;
|
||||
record.device = st.st_dev;
|
||||
record.inode = st.st_ino;
|
||||
record.mode = st.st_mode & S_IFMT;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
static void WQCacheCaptureContainerIdentity(WQCacheItem *item)
|
||||
{
|
||||
struct stat st = {0};
|
||||
if (item.containerPath.length && wq_stat(item.containerPath, &st) && S_ISDIR(st.st_mode)) {
|
||||
item.containerIdentityKnown = YES;
|
||||
item.containerDevice = st.st_dev;
|
||||
item.containerInode = st.st_ino;
|
||||
item.containerMode = st.st_mode & S_IFMT;
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL WQCacheRecordIdentityMatches(WQCacheTargetRecord *record, NSString **reason)
|
||||
{
|
||||
struct stat st = {0};
|
||||
WQCachePathState state = WQCacheInspectPath(record.path, &st);
|
||||
if (!record.existed) {
|
||||
if (state == WQCachePathMissing) return YES;
|
||||
if (reason) *reason = [NSString stringWithFormat:@"%@ appeared after the scan", record.path];
|
||||
return NO;
|
||||
}
|
||||
if (state != WQCachePathExisting || !record.identityKnown ||
|
||||
st.st_dev != record.device || st.st_ino != record.inode ||
|
||||
(st.st_mode & S_IFMT) != record.mode) {
|
||||
if (reason) *reason = [NSString stringWithFormat:@"%@ changed after the scan", record.path];
|
||||
return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL WQCacheCrashPathIsSafe(NSString *path)
|
||||
{
|
||||
NSString *expected = [kCrashLogsPath stringByStandardizingPath];
|
||||
return [[path stringByStandardizingPath] isEqualToString:expected] &&
|
||||
WQCacheSafetyHasNoSymlinkAncestry(path, YES) &&
|
||||
[WQCacheSafetyCanonicalPath(path) isEqualToString:expected];
|
||||
}
|
||||
|
||||
static BOOL WQCacheValidateItem(WQCacheItem *item, NSString **reason)
|
||||
{
|
||||
if (item.limited) {
|
||||
if (reason) *reason = WQLocalized(@"the scan had limited access");
|
||||
return NO;
|
||||
}
|
||||
if (item.crashLogs) {
|
||||
if (item.targetRecords.count != 1 || !WQCacheCrashPathIsSafe(item.targets.firstObject)) {
|
||||
if (reason) *reason = WQLocalized(@"the crash-log target is not the expected canonical directory");
|
||||
return NO;
|
||||
}
|
||||
return WQCacheRecordIdentityMatches(item.targetRecords.firstObject, reason);
|
||||
}
|
||||
if (item.targetRecords.count != 2 || item.targets.count != 2 ||
|
||||
![item.targets[0] isEqualToString:item.targetRecords[0].path] ||
|
||||
![item.targets[1] isEqualToString:item.targetRecords[1].path]) {
|
||||
if (reason) *reason = WQLocalized(@"the scanned cache targets are incomplete");
|
||||
return NO;
|
||||
}
|
||||
if (!item.containerIdentityKnown || !WQCacheSafetyHasNoSymlinkAncestry(item.containerPath, NO)) {
|
||||
if (reason) *reason = WQLocalized(@"the app container identity or ancestry is unavailable");
|
||||
return NO;
|
||||
}
|
||||
struct stat container = {0};
|
||||
if (WQCacheInspectPath(item.containerPath, &container) != WQCachePathExisting ||
|
||||
!S_ISDIR(container.st_mode) || container.st_dev != item.containerDevice ||
|
||||
container.st_ino != item.containerInode || (container.st_mode & S_IFMT) != item.containerMode) {
|
||||
if (reason) *reason = [NSString stringWithFormat:@"%@ changed after the scan", item.name];
|
||||
return NO;
|
||||
}
|
||||
NSString *label = wq_container_label(item.containerPath);
|
||||
if (!item.bundleID.length || ![label isEqualToString:item.bundleID]) {
|
||||
if (reason) *reason = [NSString stringWithFormat:@"%@ no longer has the scanned container identity", item.name];
|
||||
return NO;
|
||||
}
|
||||
for (WQCacheTargetRecord *record in item.targetRecords) {
|
||||
if (!WQCacheSafetyTargetMatchesContainer(record.path, item.containerPath, record.relativePath)) {
|
||||
if (reason) *reason = [NSString stringWithFormat:@"%@ has unsafe path ancestry", record.path];
|
||||
return NO;
|
||||
}
|
||||
if (!WQCacheRecordIdentityMatches(record, reason)) return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
// Row: selection circle, icon, name with "cache · temp" below, size in a
|
||||
// fixed right-hand column so long names never push it out.
|
||||
@interface WQCleanerCell : UITableViewCell
|
||||
@property (nonatomic, strong) UIImageView *checkView, *iconView;
|
||||
@property (nonatomic, strong) UILabel *nameLabel, *detailLabel, *sizeLabel;
|
||||
@end
|
||||
|
||||
@implementation WQCleanerCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
|
||||
{
|
||||
if ((self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])) {
|
||||
_checkView = [UIImageView new];
|
||||
_checkView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_iconView = [UIImageView new];
|
||||
_iconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_iconView.layer.cornerRadius = 8;
|
||||
_iconView.layer.cornerCurve = kCACornerCurveContinuous;
|
||||
_iconView.clipsToBounds = YES;
|
||||
_nameLabel = [UILabel new];
|
||||
_nameLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
|
||||
_nameLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
||||
_detailLabel = [UILabel new];
|
||||
_detailLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
|
||||
_detailLabel.textColor = UIColor.secondaryLabelColor;
|
||||
_detailLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
||||
_sizeLabel = [UILabel new];
|
||||
_sizeLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
// Trailing edge in both layout directions.
|
||||
_sizeLabel.textAlignment = [UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.semanticContentAttribute]
|
||||
== UIUserInterfaceLayoutDirectionRightToLeft ? NSTextAlignmentLeft : NSTextAlignmentRight;
|
||||
for (UIView *v in @[_checkView, _iconView, _nameLabel, _detailLabel, _sizeLabel]) {
|
||||
v.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.contentView addSubview:v];
|
||||
}
|
||||
[_sizeLabel setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
|
||||
[_sizeLabel setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
|
||||
UILayoutGuide *m = self.contentView.layoutMarginsGuide;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_checkView.leadingAnchor constraintEqualToAnchor:m.leadingAnchor],
|
||||
[_checkView.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
[_checkView.widthAnchor constraintEqualToConstant:24],
|
||||
[_checkView.heightAnchor constraintEqualToConstant:24],
|
||||
[_iconView.leadingAnchor constraintEqualToAnchor:_checkView.trailingAnchor constant:12],
|
||||
[_iconView.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
[_iconView.widthAnchor constraintEqualToConstant:36],
|
||||
[_iconView.heightAnchor constraintEqualToConstant:36],
|
||||
[_sizeLabel.trailingAnchor constraintEqualToAnchor:m.trailingAnchor],
|
||||
[_sizeLabel.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
[_sizeLabel.widthAnchor constraintGreaterThanOrEqualToConstant:76],
|
||||
[_nameLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:12],
|
||||
[_nameLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_sizeLabel.leadingAnchor constant:-8],
|
||||
[_nameLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:11],
|
||||
[_detailLabel.leadingAnchor constraintEqualToAnchor:_nameLabel.leadingAnchor],
|
||||
[_detailLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_sizeLabel.leadingAnchor constant:-8],
|
||||
[_detailLabel.topAnchor constraintEqualToAnchor:_nameLabel.bottomAnchor constant:2],
|
||||
[_detailLabel.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor constant:-11],
|
||||
]];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// Summary card: reclaimable total, a bar for the selected share, status.
|
||||
@interface WQSummaryCell : UITableViewCell
|
||||
@property (nonatomic, strong) UILabel *captionLabel, *totalLabel, *statusLabel;
|
||||
@property (nonatomic, strong) UIProgressView *bar;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
|
||||
@end
|
||||
|
||||
@implementation WQSummaryCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier
|
||||
{
|
||||
if ((self = [super initWithStyle:style reuseIdentifier:reuseIdentifier])) {
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
_captionLabel = [UILabel new];
|
||||
_captionLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleSubheadline];
|
||||
_captionLabel.textColor = UIColor.secondaryLabelColor;
|
||||
_totalLabel = [UILabel new];
|
||||
_totalLabel.font = [[UIFontMetrics metricsForTextStyle:UIFontTextStyleLargeTitle]
|
||||
scaledFontForFont:[UIFont systemFontOfSize:34 weight:UIFontWeightBold]];
|
||||
_totalLabel.adjustsFontForContentSizeCategory = YES;
|
||||
_bar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
|
||||
_bar.progressTintColor = UIColor.systemBlueColor;
|
||||
_statusLabel = [UILabel new];
|
||||
_statusLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
|
||||
_statusLabel.textColor = UIColor.secondaryLabelColor;
|
||||
_statusLabel.numberOfLines = 0;
|
||||
_spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
_spinner.hidesWhenStopped = YES;
|
||||
UIStackView *captionRow = [[UIStackView alloc] initWithArrangedSubviews:@[_captionLabel, _spinner]];
|
||||
captionRow.spacing = 8;
|
||||
captionRow.alignment = UIStackViewAlignmentCenter;
|
||||
UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[captionRow, _totalLabel, _bar, _statusLabel]];
|
||||
stack.axis = UILayoutConstraintAxisVertical;
|
||||
stack.spacing = 6;
|
||||
[stack setCustomSpacing:10 afterView:_totalLabel];
|
||||
stack.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.contentView addSubview:stack];
|
||||
UILayoutGuide *m = self.contentView.layoutMarginsGuide;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[stack.leadingAnchor constraintEqualToAnchor:m.leadingAnchor],
|
||||
[stack.trailingAnchor constraintEqualToAnchor:m.trailingAnchor],
|
||||
[stack.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:14],
|
||||
[stack.bottomAnchor constraintEqualToAnchor:self.contentView.bottomAnchor constant:-14],
|
||||
]];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation WQCacheCleanerViewController {
|
||||
NSArray<WQCacheItem *> *_items;
|
||||
NSArray<WQCacheItem *> *_visibleItems;
|
||||
NSString *_query;
|
||||
NSString *_status; // scan / clean progress, nil when idle
|
||||
NSString *_result; // last clean result
|
||||
BOOL _scanning, _cleaning, _scanLimited, _cleanRequiresRescan;
|
||||
WQScanCancellation *_scanCancellation;
|
||||
WQScanCancellation *_cleanCancellation;
|
||||
NSUInteger _scanGeneration;
|
||||
NSTimeInterval _lastScanFinished;
|
||||
}
|
||||
|
||||
- (instancetype)init { return [super initWithStyle:UITableViewStyleInsetGrouped]; }
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
self.title = WQLocalizedNullable(@"Cache Cleaner");
|
||||
self.tableView.tableHeaderView = wq_device_header(self.view.bounds.size.width);
|
||||
UISearchController *search = [[UISearchController alloc] initWithSearchResultsController:nil];
|
||||
search.searchResultsUpdater = self;
|
||||
search.obscuresBackgroundDuringPresentation = NO;
|
||||
search.searchBar.placeholder = WQLocalizedNullable(@"Search apps");
|
||||
self.navigationItem.searchController = search;
|
||||
self.navigationItem.hidesSearchBarWhenScrolling = NO;
|
||||
self.definesPresentationContext = YES;
|
||||
self.refreshControl = [UIRefreshControl new];
|
||||
[self.refreshControl addTarget:self action:@selector(userRefresh) forControlEvents:UIControlEventValueChanged];
|
||||
[self scan];
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated
|
||||
{
|
||||
[super viewWillAppear:animated];
|
||||
if (_scanning && _scanCancellation.isCancelled) {
|
||||
[self scan];
|
||||
return;
|
||||
}
|
||||
// The screen is kept between visits: rescan on its own only when the
|
||||
// list is old; pull to refresh rescans any time.
|
||||
if (!_scanning && !_cleaning && _lastScanFinished > 0 &&
|
||||
NSDate.date.timeIntervalSinceReferenceDate - _lastScanFinished > 600.0) {
|
||||
[self scan];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated
|
||||
{
|
||||
[super viewWillDisappear:animated];
|
||||
if (_scanning) [_scanCancellation cancel];
|
||||
if (_cleaning) [_cleanCancellation cancel];
|
||||
[self.refreshControl endRefreshing];
|
||||
}
|
||||
|
||||
- (NSArray<WQCacheItem *> *)visibleItems
|
||||
{
|
||||
return _visibleItems ?: @[];
|
||||
}
|
||||
|
||||
- (void)rebuildVisibleItems
|
||||
{
|
||||
if (!_query.length) {
|
||||
_visibleItems = _items;
|
||||
return;
|
||||
}
|
||||
_visibleItems = [_items filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(WQCacheItem *it, NSDictionary *__unused b) {
|
||||
return [it.name localizedCaseInsensitiveContainsString:self->_query] ||
|
||||
(it.bundleID && [it.bundleID localizedCaseInsensitiveContainsString:self->_query]);
|
||||
}]];
|
||||
}
|
||||
|
||||
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController
|
||||
{
|
||||
_query = [searchController.searchBar.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
||||
[self rebuildVisibleItems];
|
||||
[self.tableView reloadData];
|
||||
[self updateButtons];
|
||||
}
|
||||
|
||||
- (void)setStatus:(NSString *)status
|
||||
{
|
||||
_status = status;
|
||||
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone];
|
||||
}
|
||||
|
||||
- (void)updateButtons
|
||||
{
|
||||
if (_scanning) {
|
||||
self.navigationItem.rightBarButtonItems = nil;
|
||||
return;
|
||||
}
|
||||
if (_cleaning) {
|
||||
UIBarButtonItem *stop = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Stop") style:UIBarButtonItemStylePlain
|
||||
target:self action:@selector(stopClean)];
|
||||
self.navigationItem.rightBarButtonItems = @[stop];
|
||||
return;
|
||||
}
|
||||
NSUInteger n = 0;
|
||||
for (WQCacheItem *it in _items) if (it.selected) n++;
|
||||
UIBarButtonItem *clean = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Clean") style:wq_primary_button_style()
|
||||
target:self action:@selector(confirmClean)];
|
||||
clean.enabled = n > 0 && !_cleanRequiresRescan;
|
||||
NSArray *visible = self.visibleItems;
|
||||
BOOL allVisible = visible.count > 0;
|
||||
for (WQCacheItem *it in visible) if (it.limited || !it.selected) { allVisible = NO; break; }
|
||||
__weak typeof(self) weakSelf = self;
|
||||
UIAction *__unused all = [UIAction actionWithTitle:WQLocalizedNullable(@"Select All") image:[UIImage systemImageNamed:@"checkmark.circle"]
|
||||
identifier:nil handler:^(UIAction *__unused a) { [weakSelf selectWhere:^BOOL(WQCacheItem *__unused it) { return YES; }]; }];
|
||||
UIAction *none = [UIAction actionWithTitle:WQLocalizedNullable(@"Select None") image:[UIImage systemImageNamed:@"circle"]
|
||||
identifier:nil handler:^(UIAction *__unused a) { [weakSelf selectWhere:^BOOL(WQCacheItem *__unused it) { return NO; }]; }];
|
||||
UIAction *large = [UIAction actionWithTitle:WQLocalizedNullable(@"Select Large Caches (50 MB+)") image:[UIImage systemImageNamed:@"arrow.up.circle"]
|
||||
identifier:nil handler:^(UIAction *__unused a) {
|
||||
[weakSelf selectWhere:^BOOL(WQCacheItem *__unused it) { return it.bytes >= 50ull * 1000 * 1000; }];
|
||||
}];
|
||||
all.state = allVisible ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
UIBarButtonItem *select = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Select")
|
||||
menu:[UIMenu menuWithChildren:@[all, none, large]]];
|
||||
select.enabled = visible.count > 0;
|
||||
self.navigationItem.rightBarButtonItems = @[clean, select];
|
||||
}
|
||||
|
||||
// Pull to refresh: rescan the containers instead of reusing the shared scan,
|
||||
// and look for the app name database again if it wasn't found before.
|
||||
- (void)userRefresh
|
||||
{
|
||||
wq_enumerate_invalidate();
|
||||
wq_display_names_reset_miss();
|
||||
[self scan];
|
||||
}
|
||||
|
||||
- (void)scan
|
||||
{
|
||||
[_scanCancellation cancel];
|
||||
WQScanCancellation *cancellation = [WQScanCancellation new];
|
||||
_scanCancellation = cancellation;
|
||||
NSUInteger generation = ++_scanGeneration;
|
||||
_scanning = YES;
|
||||
_scanLimited = NO;
|
||||
_cleanRequiresRescan = NO;
|
||||
_result = nil;
|
||||
[self updateButtons];
|
||||
[self setStatus:WQLocalizedNullable(@"Finding apps…")];
|
||||
dispatch_async(wq_io(), ^{
|
||||
NSMutableArray<WQCacheItem *> *items = [NSMutableArray new];
|
||||
NSMutableArray<NSString *> *emptyRoots = [NSMutableArray new];
|
||||
BOOL scanLimited = NO;
|
||||
NSUInteger failedEnumerations = 0;
|
||||
NSUInteger limitedContainers = 0;
|
||||
NSUInteger limitedMeasurements = 0;
|
||||
NSUInteger missingIdentities = 0;
|
||||
NSArray *roots = @[@"/private/var/mobile/Containers/Data/Application",
|
||||
@"/private/var/mobile/Containers/Data/PluginKitPlugin"];
|
||||
for (NSString *root in roots) {
|
||||
if (cancellation.isCancelled) return;
|
||||
NSString *kind = [root.lastPathComponent isEqualToString:@"Application"] ? @"apps" : @"extensions";
|
||||
NSArray *containers = wq_enumerate(root, 0, cancellation, ^(int64_t done, int64_t total, NSUInteger __unused f) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
|
||||
[self setStatus:WQLocalizedFormat(@"Finding %@… %lld%%", kind, (long long)(done * 100 / total))];
|
||||
});
|
||||
});
|
||||
if (!containers) {
|
||||
if (cancellation.isCancelled) return;
|
||||
scanLimited = YES;
|
||||
failedEnumerations++;
|
||||
wq_log(@"cache scan: %@ container enumeration failed", kind);
|
||||
continue;
|
||||
}
|
||||
if (!cancellation.isCancelled && !wq_display_names_loaded()) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
|
||||
[self setStatus:WQLocalizedNullable(@"Reading app names…")];
|
||||
});
|
||||
wq_load_display_names();
|
||||
}
|
||||
if (!containers.count) [emptyRoots addObject:kind];
|
||||
NSUInteger idx = 0;
|
||||
for (NSString *c in containers) {
|
||||
if (cancellation.isCancelled) return;
|
||||
idx++;
|
||||
if (idx % 10 == 0 || idx == containers.count) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
|
||||
[self setStatus:WQLocalizedFormat(@"Measuring %@ %lu of %lu…", kind,
|
||||
(unsigned long)idx, (unsigned long)containers.count)];
|
||||
});
|
||||
}
|
||||
BOOL safeContainer = WQCacheSafetyHasNoSymlinkAncestry(c, NO);
|
||||
BOOL granted = safeContainer && wq_grant(c); // one token for the whole container
|
||||
NSString *caches = [c stringByAppendingPathComponent:@"Library/Caches"];
|
||||
NSString *tmp = [c stringByAppendingPathComponent:@"tmp"];
|
||||
unsigned long long cacheBytes = 0, tempBytes = 0;
|
||||
BOOL safeCaches = safeContainer &&
|
||||
WQCacheSafetyTargetMatchesContainer(caches, c, @"Library/Caches");
|
||||
BOOL safeTmp = safeContainer &&
|
||||
WQCacheSafetyTargetMatchesContainer(tmp, c, @"tmp");
|
||||
WQCacheMeasurementState cacheState = granted && safeCaches
|
||||
? WQCacheMeasureDirectory(caches, cancellation, &cacheBytes)
|
||||
: WQCacheMeasurementLimited;
|
||||
if (cacheState == WQCacheMeasurementCancelled) return;
|
||||
WQCacheMeasurementState tempState = granted && safeTmp
|
||||
? WQCacheMeasureDirectory(tmp, cancellation, &tempBytes)
|
||||
: WQCacheMeasurementLimited;
|
||||
if (tempState == WQCacheMeasurementCancelled) return;
|
||||
if (!granted || !safeContainer) {
|
||||
scanLimited = YES;
|
||||
limitedContainers++;
|
||||
wq_log(@"cache scan: %@ (%@) %@", c.lastPathComponent, wq_bundle_id_for_container(c) ?: @"unknown app",
|
||||
safeContainer ? @"refused access" : @"failed the path safety check");
|
||||
}
|
||||
if (cacheState == WQCacheMeasurementLimited || tempState == WQCacheMeasurementLimited) {
|
||||
scanLimited = YES;
|
||||
limitedMeasurements++;
|
||||
}
|
||||
BOOL cacheKnown = cacheState != WQCacheMeasurementLimited;
|
||||
BOOL tempKnown = tempState != WQCacheMeasurementLimited;
|
||||
if (cacheKnown && tempKnown && cacheBytes + tempBytes < 64 * 1024) continue;
|
||||
WQCacheItem *item = [WQCacheItem new];
|
||||
NSString *bid = granted ? wq_container_label(c) : nil;
|
||||
item.bundleID = bid;
|
||||
if (bid) (void)wq_app_icon(bid); // warm the icon off the main thread
|
||||
item.name = wq_display_name(bid) ?: bid ?: c.lastPathComponent;
|
||||
item.targets = @[caches, tmp];
|
||||
item.cacheBytes = cacheBytes;
|
||||
item.tempBytes = tempBytes;
|
||||
item.cacheKnown = cacheKnown;
|
||||
item.tempKnown = tempKnown;
|
||||
item.limited = !cacheKnown || !tempKnown;
|
||||
item.containerPath = c;
|
||||
item.targetRecords = @[
|
||||
WQCacheTargetRecordForPath(caches, @"Library/Caches"),
|
||||
WQCacheTargetRecordForPath(tmp, @"tmp"),
|
||||
];
|
||||
WQCacheCaptureContainerIdentity(item);
|
||||
if (!bid.length || !item.containerIdentityKnown) {
|
||||
item.limited = YES;
|
||||
scanLimited = YES;
|
||||
missingIdentities++;
|
||||
}
|
||||
item.selected = !item.limited;
|
||||
[items addObject:item];
|
||||
}
|
||||
}
|
||||
if (cancellation.isCancelled) return;
|
||||
unsigned long long crash = 0;
|
||||
// Crash logs are in a system group, which worse_query reaches on iOS 27
|
||||
// only. On iOS 26 the section is left out instead of reported as limited.
|
||||
BOOL crashReachable = wq_system_areas_reachable();
|
||||
BOOL safeCrashPath = crashReachable && WQCacheCrashPathIsSafe(kCrashLogsPath);
|
||||
WQCacheMeasurementState crashState = !crashReachable ? WQCacheMeasurementMissing
|
||||
: safeCrashPath ? WQCacheMeasureDirectory(kCrashLogsPath, cancellation, &crash)
|
||||
: WQCacheMeasurementLimited;
|
||||
if (crashState == WQCacheMeasurementCancelled) return;
|
||||
if (crashState == WQCacheMeasurementLimited) {
|
||||
scanLimited = YES;
|
||||
limitedMeasurements++;
|
||||
}
|
||||
if (crashState == WQCacheMeasurementLimited || crash > 0) {
|
||||
WQCacheItem *logs = [WQCacheItem new];
|
||||
logs.name = WQLocalized(@"Crash Logs");
|
||||
logs.targets = @[kCrashLogsPath];
|
||||
logs.cacheBytes = crash;
|
||||
logs.cacheKnown = crashState != WQCacheMeasurementLimited;
|
||||
logs.crashLogs = YES;
|
||||
logs.limited = crashState == WQCacheMeasurementLimited;
|
||||
logs.targetRecords = @[WQCacheTargetRecordForPath(kCrashLogsPath, nil)];
|
||||
logs.selected = !logs.limited;
|
||||
[items addObject:logs];
|
||||
}
|
||||
[items sortUsingComparator:^NSComparisonResult(WQCacheItem *a, WQCacheItem *b) {
|
||||
return a.bytes > b.bytes ? NSOrderedAscending : (a.bytes < b.bytes ? NSOrderedDescending : NSOrderedSame);
|
||||
}];
|
||||
NSMutableArray<NSString *> *notes = [NSMutableArray new];
|
||||
if (failedEnumerations) {
|
||||
[notes addObject:WQLocalizedFormat(failedEnumerations == 1
|
||||
? @"%lu container enumeration failed" : @"%lu container enumerations failed",
|
||||
(unsigned long)failedEnumerations)];
|
||||
}
|
||||
if (limitedContainers) {
|
||||
[notes addObject:WQLocalizedFormat(limitedContainers == 1
|
||||
? @"%lu container had limited access" : @"%lu containers had limited access",
|
||||
(unsigned long)limitedContainers)];
|
||||
}
|
||||
if (limitedMeasurements) {
|
||||
[notes addObject:WQLocalizedFormat(limitedMeasurements == 1
|
||||
? @"%lu cache tree measurement was limited" : @"%lu cache tree measurements were limited",
|
||||
(unsigned long)limitedMeasurements)];
|
||||
}
|
||||
if (missingIdentities) {
|
||||
[notes addObject:WQLocalizedFormat(missingIdentities == 1
|
||||
? @"%lu container had no identifier or identity" : @"%lu containers had no identifier or identity",
|
||||
(unsigned long)missingIdentities)];
|
||||
}
|
||||
NSString *note = nil;
|
||||
if (scanLimited) {
|
||||
[notes addObject:WQLocalized(@"limited rows are excluded; the remaining rows can still be cleaned")];
|
||||
note = WQLocalizedFormat(@"Scan incomplete: %@. See Log.", [notes componentsJoinedByString:@"; "]);
|
||||
} else if (emptyRoots.count) {
|
||||
note = WQLocalizedFormat(emptyRoots.count == 1 ? @"No %@ container found." : @"No %@ containers found.",
|
||||
[emptyRoots componentsJoinedByString:@" or "]);
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (generation != self->_scanGeneration || cancellation.isCancelled) return;
|
||||
self->_items = items;
|
||||
[self rebuildVisibleItems];
|
||||
self->_scanning = NO;
|
||||
self->_scanLimited = scanLimited;
|
||||
self->_status = note;
|
||||
self->_lastScanFinished = NSDate.date.timeIntervalSinceReferenceDate;
|
||||
[self.refreshControl endRefreshing];
|
||||
[self.tableView reloadData];
|
||||
[self updateButtons];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark Table
|
||||
|
||||
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 2; }
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||
{
|
||||
return section == 0 ? 1 : (NSInteger)self.visibleItems.count;
|
||||
}
|
||||
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
|
||||
{
|
||||
if (section == 0 || !_items.count) return nil;
|
||||
NSUInteger shown = self.visibleItems.count;
|
||||
NSUInteger appCount = 0;
|
||||
for (WQCacheItem *item in _items) if (!item.crashLogs) appCount++;
|
||||
if (!appCount) return WQLocalized(@"Crash Logs");
|
||||
return _query.length ? WQLocalizedFormat(@"%lu of %lu", (unsigned long)shown, (unsigned long)_items.count)
|
||||
: WQLocalizedFormat(@"%lu apps and extensions", (unsigned long)appCount);
|
||||
}
|
||||
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
|
||||
{
|
||||
if (section == 0 || !_items.count) return nil;
|
||||
NSArray<WQCacheItem *> *selected = [_items filteredArrayUsingPredicate:
|
||||
[NSPredicate predicateWithFormat:@"selected == YES"]];
|
||||
return [WQCacheFooterScope(selected) stringByAppendingString:WQLocalized(@" Close running apps first.")];
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
if (indexPath.section == 0) {
|
||||
WQSummaryCell *cell = [tableView dequeueReusableCellWithIdentifier:@"summary"]
|
||||
?: [[WQSummaryCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"summary"];
|
||||
unsigned long long total = 0, selected = 0;
|
||||
NSUInteger n = 0;
|
||||
for (WQCacheItem *it in _items) { total += it.bytes; if (it.selected) { selected += it.bytes; n++; } }
|
||||
BOOL busy = _scanning || _cleaning;
|
||||
cell.captionLabel.text = busy ? (_cleaning ? WQLocalized(@"Cleaning") : WQLocalized(@"Scanning")) : WQLocalized(@"Reclaimable");
|
||||
if (busy) [cell.spinner startAnimating]; else [cell.spinner stopAnimating];
|
||||
cell.totalLabel.text = (_scanning && !_items.count) ? @"–" : wq_size_text(total);
|
||||
cell.bar.progress = total ? (float)((double)selected / (double)total) : 0;
|
||||
cell.bar.hidden = _scanning && !_items.count;
|
||||
cell.statusLabel.text = _status ?: _result
|
||||
?: WQLocalizedFormat(n == 1 ? @"%@ selected in %lu item" : @"%@ selected in %lu items",
|
||||
wq_size_text(selected), (unsigned long)n);
|
||||
return cell;
|
||||
}
|
||||
WQCleanerCell *cell = [tableView dequeueReusableCellWithIdentifier:@"item"]
|
||||
?: [[WQCleanerCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"item"];
|
||||
WQCacheItem *item = self.visibleItems[(NSUInteger)indexPath.row];
|
||||
cell.nameLabel.text = item.name;
|
||||
if (item.crashLogs) {
|
||||
cell.detailLabel.text = item.cacheKnown ? WQLocalized(@"Crash Logs · osanalytics") : WQLocalized(@"Crash Logs · access limited");
|
||||
} else {
|
||||
NSString *cache = item.cacheKnown ? wq_size_text(item.cacheBytes) : WQLocalized(@"unknown");
|
||||
NSString *temp = item.tempKnown ? wq_size_text(item.tempBytes) : WQLocalized(@"unknown");
|
||||
cell.detailLabel.text = WQLocalizedFormat(@"cache %@ · temp %@%@", cache, temp,
|
||||
item.limited ? WQLocalized(@" · access limited") : @"");
|
||||
}
|
||||
cell.sizeLabel.text = wq_size_text(item.bytes);
|
||||
cell.iconView.image = wq_app_icon(item.bundleID)
|
||||
?: [UIImage systemImageNamed:item.bundleID ? @"app.dashed" : @"exclamationmark.triangle"];
|
||||
cell.iconView.tintColor = UIColor.secondaryLabelColor;
|
||||
cell.checkView.image = [UIImage systemImageNamed:item.selected ? @"checkmark.circle.fill" : @"circle"];
|
||||
cell.checkView.tintColor = item.selected ? UIColor.systemBlueColor : UIColor.tertiaryLabelColor;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
[tableView deselectRowAtIndexPath:indexPath animated:YES];
|
||||
if (indexPath.section == 0 || _scanning || _cleaning) return;
|
||||
WQCacheItem *item = self.visibleItems[(NSUInteger)indexPath.row];
|
||||
if (item.limited) {
|
||||
wq_alert(self, @"Not cleanable",
|
||||
@"This row has incomplete access or identity information. Scan again while the app is closed, or use its file browser entry to inspect it.");
|
||||
return;
|
||||
}
|
||||
item.selected = !item.selected;
|
||||
[tableView reloadRowsAtIndexPaths:@[indexPath, [NSIndexPath indexPathForRow:0 inSection:0]]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
[self updateButtons];
|
||||
}
|
||||
|
||||
- (void)openFolder:(NSString *)path title:(NSString *)title
|
||||
{
|
||||
[self.navigationController pushViewController:[[WQDirViewController alloc] initWithPath:path title:title] animated:YES];
|
||||
}
|
||||
|
||||
- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
|
||||
contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point
|
||||
{
|
||||
// Not during a scan or clean: the browser would show a tree that changes underneath it.
|
||||
if (indexPath.section == 0 || _scanning || _cleaning) return nil;
|
||||
WQCacheItem *item = self.visibleItems[(NSUInteger)indexPath.row];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil
|
||||
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *__unused suggested) {
|
||||
NSMutableArray *actions = [NSMutableArray new];
|
||||
if (item.crashLogs) {
|
||||
NSString *root = item.targets.firstObject;
|
||||
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show Crash Logs in File Browser") image:[UIImage systemImageNamed:@"folder"]
|
||||
identifier:nil handler:^(UIAction *__unused a) { [weakSelf openFolder:root title:WQLocalizedNullable(@"Crash Logs")]; }]];
|
||||
} else {
|
||||
NSString *caches = item.targets[0], *tmp = item.targets[1];
|
||||
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show Caches in File Browser") image:[UIImage systemImageNamed:@"folder"]
|
||||
identifier:nil handler:^(UIAction *__unused a) {
|
||||
[weakSelf openFolder:caches title:[item.name stringByAppendingString:WQLocalized(@" · Caches")]];
|
||||
}]];
|
||||
if (item.tempBytes > 0)
|
||||
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show tmp in File Browser") image:[UIImage systemImageNamed:@"folder"]
|
||||
identifier:nil handler:^(UIAction *__unused a) {
|
||||
[weakSelf openFolder:tmp title:[item.name stringByAppendingString:WQLocalized(@" · tmp")]];
|
||||
}]];
|
||||
[actions addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Show App Data in File Browser") image:[UIImage systemImageNamed:@"folder.badge.gearshape"]
|
||||
identifier:nil handler:^(UIAction *__unused a) {
|
||||
[weakSelf openFolder:caches.stringByDeletingLastPathComponent.stringByDeletingLastPathComponent title:item.name];
|
||||
}]];
|
||||
}
|
||||
return [UIMenu menuWithTitle:item.name children:actions];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark Actions
|
||||
|
||||
// Applies to the rows the current search shows.
|
||||
- (void)selectWhere:(BOOL (^)(WQCacheItem *item))rule
|
||||
{
|
||||
for (WQCacheItem *it in self.visibleItems) it.selected = !it.limited && rule(it);
|
||||
[self.tableView reloadData];
|
||||
[self updateButtons];
|
||||
}
|
||||
|
||||
- (void)stopClean
|
||||
{
|
||||
[_cleanCancellation cancel];
|
||||
[self setStatus:WQLocalizedNullable(@"Stopping after the current item…")];
|
||||
}
|
||||
|
||||
- (void)confirmClean
|
||||
{
|
||||
if (wq_refuse_if_deleting_off(self)) return;
|
||||
NSMutableArray<WQCacheItem *> *selected = [NSMutableArray new];
|
||||
unsigned long long sel = 0;
|
||||
for (WQCacheItem *item in _items) {
|
||||
if (!item.selected) continue;
|
||||
[selected addObject:item];
|
||||
sel += item.bytes;
|
||||
}
|
||||
if (!selected.count) return;
|
||||
NSString *msg = WQLocalizedFormat(@"%@, about %@? This cannot be undone. Close the selected apps first; you can stop between items.", WQCacheConfirmationScope(selected), wq_size_text(sel));
|
||||
UIAlertController *alert = wq_alert_controller(@"Clean caches?", msg, UIAlertControllerStyleAlert);
|
||||
[alert addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[alert addAction:wq_alert_action(@"Clean", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused a) {
|
||||
[self clean];
|
||||
})];
|
||||
[self presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)clean
|
||||
{
|
||||
NSArray<WQCacheItem *> *selected = [_items filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"selected == YES"]];
|
||||
WQScanCancellation *cancellation = [WQScanCancellation new];
|
||||
_cleanCancellation = cancellation;
|
||||
_cleaning = YES;
|
||||
[self updateButtons];
|
||||
[self setStatus:WQLocalizedNullable(@"Cleaning…")];
|
||||
dispatch_async(wq_io(), ^{
|
||||
unsigned long long freed = 0;
|
||||
BOOL allOk = YES;
|
||||
BOOL cancelled = NO;
|
||||
NSString *validationFailure = nil;
|
||||
for (WQCacheItem *item in selected) {
|
||||
if (cancellation.isCancelled) { cancelled = YES; break; }
|
||||
NSString *reason = nil;
|
||||
if (!WQCacheValidateItem(item, &reason)) {
|
||||
allOk = NO;
|
||||
validationFailure = validationFailure ?: [NSString stringWithFormat:@"%@ (%@)", item.name, reason ?: @"identity changed"];
|
||||
wq_log(@"clean: refusing %@ because %@", item.name, reason ?: @"identity validation failed");
|
||||
}
|
||||
}
|
||||
if (validationFailure) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self->_cleaning = NO;
|
||||
self->_cleanCancellation = nil;
|
||||
self->_scanLimited = YES;
|
||||
self->_cleanRequiresRescan = YES;
|
||||
self->_status = nil;
|
||||
self->_result = WQLocalizedFormat(@"Clean cancelled: %@. Scan again before retrying.", validationFailure);
|
||||
[self.tableView reloadData];
|
||||
[self updateButtons];
|
||||
});
|
||||
return;
|
||||
}
|
||||
NSUInteger i = 0;
|
||||
for (WQCacheItem *item in selected) {
|
||||
if (cancellation.isCancelled) { cancelled = YES; break; }
|
||||
i++;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self setStatus:WQLocalizedFormat(@"Cleaning %lu of %lu: %@", (unsigned long)i, (unsigned long)selected.count, item.name)];
|
||||
});
|
||||
for (WQCacheTargetRecord *record in item.targetRecords) {
|
||||
if (cancellation.isCancelled) { cancelled = YES; break; }
|
||||
NSString *reason = nil;
|
||||
if (!WQCacheValidateItem(item, &reason)) {
|
||||
allOk = NO;
|
||||
validationFailure = [NSString stringWithFormat:@"%@ (%@)", item.name, reason ?: @"identity changed"];
|
||||
wq_log(@"clean: stopped before %@ because %@", record.path, reason ?: @"identity validation failed");
|
||||
break;
|
||||
}
|
||||
if (!wq_purge_contents_cancellable(record.path, &freed, cancellation)) {
|
||||
allOk = NO;
|
||||
if (cancellation.isCancelled) { cancelled = YES; break; }
|
||||
}
|
||||
}
|
||||
if (validationFailure || cancelled) break;
|
||||
}
|
||||
wq_log(@"clean: freed %llu bytes, allOk=%d cancelled=%d", freed, allOk, cancelled);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self->_cleaning = NO;
|
||||
self->_cleanCancellation = nil;
|
||||
if (cancelled) {
|
||||
self->_status = nil;
|
||||
[self scan];
|
||||
self->_result = WQLocalizedFormat(@"Stopped. Freed %@.%@", wq_size_text(freed),
|
||||
allOk ? @"" : WQLocalized(@" Some items could not be removed (see Log)."));
|
||||
return;
|
||||
}
|
||||
if (validationFailure) {
|
||||
self->_scanLimited = YES;
|
||||
self->_cleanRequiresRescan = YES;
|
||||
self->_status = nil;
|
||||
self->_result = WQLocalizedFormat(@"Clean stopped: %@. Scan again before retrying.", validationFailure);
|
||||
[self.tableView reloadData];
|
||||
[self updateButtons];
|
||||
} else {
|
||||
self->_status = nil;
|
||||
[self scan];
|
||||
self->_result = WQLocalizedFormat(@"Freed %@.%@", wq_size_text(freed),
|
||||
allOk ? @"" : WQLocalized(@" Some items could not be removed (see Log)."));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,78 @@
|
||||
#ifndef WQ_CACHE_SAFETY_H
|
||||
#define WQ_CACHE_SAFETY_H
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <errno.h>
|
||||
#import <limits.h>
|
||||
#import <stdlib.h>
|
||||
#import <sys/stat.h>
|
||||
|
||||
// Checks every existing component, including the final component when present,
|
||||
// without following links. A missing final component is allowed for scan-time
|
||||
// snapshots, but a missing ancestor is never accepted.
|
||||
static inline BOOL WQCacheSafetyHasNoSymlinkAncestry(NSString *path, BOOL allowMissingFinal)
|
||||
{
|
||||
if (!path.isAbsolutePath || !path.length) return NO;
|
||||
NSArray<NSString *> *components = path.pathComponents;
|
||||
if (components.count < 2 || ![components.firstObject isEqualToString:@"/"]) return NO;
|
||||
|
||||
NSString *prefix = @"/";
|
||||
for (NSUInteger i = 1; i < components.count; i++) {
|
||||
if ([components[i] isEqualToString:@"."] || [components[i] isEqualToString:@".."]) return NO;
|
||||
prefix = [prefix stringByAppendingPathComponent:components[i]];
|
||||
struct stat st = {0};
|
||||
if (lstat(prefix.fileSystemRepresentation, &st) != 0) {
|
||||
if (allowMissingFinal && i == components.count - 1 && errno == ENOENT) return YES;
|
||||
return NO;
|
||||
}
|
||||
if (S_ISLNK(st.st_mode)) return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
// Resolve an existing path with the filesystem's canonical spelling. For a
|
||||
// missing final component, resolve its existing parent and append the final
|
||||
// component without following it. realpath() is intentional here: on macOS,
|
||||
// Foundation may retain /var while the filesystem's no-follow spelling is
|
||||
// /private/var.
|
||||
static inline NSString *WQCacheSafetyCanonicalPath(NSString *path)
|
||||
{
|
||||
NSString *standard = [path stringByStandardizingPath];
|
||||
char resolved[PATH_MAX];
|
||||
if (realpath(standard.fileSystemRepresentation, resolved)) {
|
||||
return [NSString stringWithUTF8String:resolved] ?: standard;
|
||||
}
|
||||
|
||||
NSString *parent = [standard stringByDeletingLastPathComponent];
|
||||
NSString *last = standard.lastPathComponent;
|
||||
if (realpath(parent.fileSystemRepresentation, resolved)) {
|
||||
NSString *resolvedParent = [NSString stringWithUTF8String:resolved];
|
||||
return [resolvedParent stringByAppendingPathComponent:last];
|
||||
}
|
||||
return standard;
|
||||
}
|
||||
|
||||
// The lexical shape, no-follow ancestry, and canonical shape must all agree.
|
||||
// `relativePath` is the expected target below the canonical container.
|
||||
static inline BOOL WQCacheSafetyTargetMatchesContainer(NSString *target,
|
||||
NSString *container,
|
||||
NSString *relativePath)
|
||||
{
|
||||
if (!relativePath.length || relativePath.isAbsolutePath) return NO;
|
||||
for (NSString *component in relativePath.pathComponents) {
|
||||
if ([component isEqualToString:@"."] || [component isEqualToString:@".."]) return NO;
|
||||
}
|
||||
NSString *lexicalTarget = [target stringByStandardizingPath];
|
||||
NSString *lexicalExpected = [[container stringByAppendingPathComponent:relativePath]
|
||||
stringByStandardizingPath];
|
||||
if (![lexicalTarget isEqualToString:lexicalExpected]) return NO;
|
||||
if (!WQCacheSafetyHasNoSymlinkAncestry(target, YES)) return NO;
|
||||
if (!WQCacheSafetyHasNoSymlinkAncestry(container, NO)) return NO;
|
||||
|
||||
NSString *canonicalContainer = WQCacheSafetyCanonicalPath(container);
|
||||
NSString *canonicalTarget = WQCacheSafetyCanonicalPath(target);
|
||||
NSString *canonicalExpected = [canonicalContainer stringByAppendingPathComponent:relativePath];
|
||||
return [canonicalTarget isEqualToString:canonicalExpected];
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// WQCore.h
|
||||
// WQ Files
|
||||
//
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Main-thread edit state shared by all three editors. A successful completion
|
||||
// clears dirty only when no newer revision was made after beginSave.
|
||||
@interface WQEditState : NSObject
|
||||
@property (nonatomic, readonly) NSUInteger revision;
|
||||
@property (nonatomic, readonly, getter=isDirty) BOOL dirty;
|
||||
@property (nonatomic, readonly, getter=isSaving) BOOL saving;
|
||||
- (void)noteChange;
|
||||
- (NSUInteger)beginSave;
|
||||
- (void)finishSave:(BOOL)success revision:(NSUInteger)savedRevision;
|
||||
- (void)discard;
|
||||
@end
|
||||
|
||||
// Parses pairs of ASCII hex digits separated by whitespace. Property-list data
|
||||
// may also contain angle brackets; the hex editor does not accept them.
|
||||
NSData *_Nullable WQParseHex(NSString *text, BOOL allowAngleBrackets);
|
||||
|
||||
// Overflow-safe validation for an in-place patch range.
|
||||
BOOL WQPatchRangeValid(unsigned long long offset, NSUInteger length, unsigned long long fileSize);
|
||||
|
||||
// Documents and Library are always represented. YES means copy from the
|
||||
// backup; NO means deliberately purge the current part during restore.
|
||||
NSDictionary<NSString *, NSNumber *> *WQRestorePlan(BOOL hasDocuments, BOOL hasLibrary);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// WQCore.m
|
||||
// WQ Files
|
||||
//
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQCore.h"
|
||||
|
||||
@implementation WQEditState
|
||||
|
||||
- (void)noteChange
|
||||
{
|
||||
_revision++;
|
||||
_dirty = YES;
|
||||
}
|
||||
|
||||
- (NSUInteger)beginSave
|
||||
{
|
||||
_saving = YES;
|
||||
return _revision;
|
||||
}
|
||||
|
||||
- (void)finishSave:(BOOL)success revision:(NSUInteger)savedRevision
|
||||
{
|
||||
_saving = NO;
|
||||
if (success && _revision == savedRevision) _dirty = NO;
|
||||
}
|
||||
|
||||
- (void)discard
|
||||
{
|
||||
_revision++;
|
||||
_dirty = NO;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
static BOOL WQHexValue(unichar c, uint8_t *value)
|
||||
{
|
||||
if (c >= '0' && c <= '9') *value = (uint8_t)(c - '0');
|
||||
else if (c >= 'a' && c <= 'f') *value = (uint8_t)(c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') *value = (uint8_t)(c - 'A' + 10);
|
||||
else return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
NSData *WQParseHex(NSString *text, BOOL allowAngleBrackets)
|
||||
{
|
||||
NSMutableData *out = [NSMutableData new];
|
||||
int high = -1;
|
||||
for (NSUInteger i = 0; i < text.length; i++) {
|
||||
unichar c = [text characterAtIndex:i];
|
||||
if ([NSCharacterSet.whitespaceAndNewlineCharacterSet characterIsMember:c] ||
|
||||
(allowAngleBrackets && (c == '<' || c == '>'))) continue;
|
||||
uint8_t value = 0;
|
||||
if (!WQHexValue(c, &value)) return nil;
|
||||
if (high < 0) high = value;
|
||||
else {
|
||||
uint8_t byte = (uint8_t)((high << 4) | value);
|
||||
[out appendBytes:&byte length:1];
|
||||
high = -1;
|
||||
}
|
||||
}
|
||||
return high < 0 ? out : nil;
|
||||
}
|
||||
|
||||
BOOL WQPatchRangeValid(unsigned long long offset, NSUInteger length, unsigned long long fileSize)
|
||||
{
|
||||
return offset <= fileSize && length <= fileSize - offset;
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, NSNumber *> *WQRestorePlan(BOOL hasDocuments, BOOL hasLibrary)
|
||||
{
|
||||
return @{ @"Documents": @(hasDocuments), @"Library": @(hasLibrary) };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// WQDir.h
|
||||
// WQ Files folder browser interface
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface WQDirViewController : UITableViewController <UIDocumentPickerDelegate, UISearchResultsUpdating>
|
||||
@property (nonatomic) BOOL readOnly;
|
||||
- (instancetype)initWithPath:(NSString *)path title:(NSString *_Nullable)title;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// WQEditing.h
|
||||
// WQ Files
|
||||
//
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Editors implement this so Back, Home and the interactive edge swipe protect
|
||||
// unsaved changes. Root plist editors prompt for a dirty document; child
|
||||
// plist editors intentionally keep the shared document's edits.
|
||||
@protocol WQUnsavedEditing <NSObject>
|
||||
- (BOOL)wq_needsLeaveConfirmation;
|
||||
- (void)confirmLeaveThen:(void (^)(void))proceed;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// WQHexViewer.h
|
||||
// WQ Files
|
||||
//
|
||||
// Hex viewer and editor for files of any size: reads in pages on demand,
|
||||
// go to offset, find hex or text, overwrite bytes in place (wq_patch, with a
|
||||
// backup first). Never changes the file's size.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "WQEditing.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface WQHexViewController : UITableViewController <WQUnsavedEditing>
|
||||
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,526 @@
|
||||
//
|
||||
// WQHexViewer.m
|
||||
// WQ Files
|
||||
//
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQHexViewer.h"
|
||||
#import "WQHexViewerHelpers.h"
|
||||
#import "WQAccess.h"
|
||||
#import "WQCore.h"
|
||||
#import "WQUI.h"
|
||||
#import <sys/stat.h>
|
||||
|
||||
static const NSUInteger kPageSize = 64 * 1024;
|
||||
static const NSUInteger kSearchChunk = 1024 * 1024;
|
||||
|
||||
static BOOL wqh_is_visible(UIViewController *host)
|
||||
{
|
||||
if (!host.isViewLoaded || !host.view.window) return NO;
|
||||
UINavigationController *navigation = host.navigationController;
|
||||
return !navigation || navigation.visibleViewController == host;
|
||||
}
|
||||
|
||||
static void wqh_alert(UIViewController *host, NSString *title, NSString *message)
|
||||
{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (!wqh_is_visible(host)) return;
|
||||
UIViewController *presented = host.presentedViewController;
|
||||
if (presented && ![presented isKindOfClass:UIAlertController.class]) return;
|
||||
void (^present)(void) = ^{
|
||||
if (!wqh_is_visible(host) || host.presentedViewController) return;
|
||||
UIAlertController *a = wq_alert_controller(title, message, UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
|
||||
[host presentViewController:a animated:YES completion:nil];
|
||||
};
|
||||
if (presented) [presented dismissViewControllerAnimated:YES completion:present];
|
||||
else present();
|
||||
});
|
||||
}
|
||||
|
||||
@implementation WQHexViewController {
|
||||
NSString *_path;
|
||||
BOOL _readOnly;
|
||||
unsigned long long _size;
|
||||
NSUInteger _perRow;
|
||||
int _offsetDigits; // hex digits in the offset column
|
||||
NSCache<NSNumber *, NSData *> *_pages;
|
||||
NSMutableSet<NSNumber *> *_loading;
|
||||
NSMutableDictionary<NSNumber *, NSNumber *> *_edits; // offset -> new byte
|
||||
WQEditState *_editState;
|
||||
NSDictionary<NSString *, NSNumber *> *_version;
|
||||
unsigned long long _matchOffset;
|
||||
NSUInteger _matchLength;
|
||||
NSData *_lastSearchNeedle;
|
||||
NSUInteger _searchGeneration;
|
||||
NSString *_status;
|
||||
dispatch_queue_t _queue;
|
||||
}
|
||||
|
||||
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly
|
||||
{
|
||||
if ((self = [super initWithStyle:UITableViewStylePlain])) {
|
||||
_path = [path copy];
|
||||
_readOnly = readOnly;
|
||||
_perRow = 8;
|
||||
_offsetDigits = 8;
|
||||
_pages = [NSCache new];
|
||||
_pages.countLimit = 64; // 4 MB
|
||||
_loading = [NSMutableSet new];
|
||||
_edits = [NSMutableDictionary new];
|
||||
_editState = [WQEditState new];
|
||||
_queue = dispatch_queue_create("wqfiles.hex", DISPATCH_QUEUE_SERIAL);
|
||||
self.title = path.lastPathComponent;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever;
|
||||
// Offsets, hex and ASCII columns read left to right in every language
|
||||
// (the rows are forced too, in cellForRow).
|
||||
self.tableView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
|
||||
self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
self.tableView.rowHeight = 20;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { [weakSelf leave]; }];
|
||||
NSString *path = _path;
|
||||
dispatch_async(_queue, ^{
|
||||
struct stat st;
|
||||
BOOL ok = wq_stat(path, &st) && S_ISREG(st.st_mode);
|
||||
NSDictionary *version = ok ? wq_file_version(path) : nil;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self->_version = version;
|
||||
self->_size = ok ? (unsigned long long)st.st_size : 0;
|
||||
[self layoutColumns];
|
||||
if (wq_permission_hint(path, YES)) self->_readOnly = YES;
|
||||
self->_status = ok ? nil : WQLocalized(@"Cannot open this file.");
|
||||
[self updateBar];
|
||||
[self.tableView reloadData];
|
||||
});
|
||||
});
|
||||
[self updateBar];
|
||||
}
|
||||
|
||||
- (void)viewDidDisappear:(BOOL)animated
|
||||
{
|
||||
[super viewDidDisappear:animated];
|
||||
_searchGeneration++;
|
||||
_lastSearchNeedle = nil;
|
||||
_matchLength = 0;
|
||||
_status = nil;
|
||||
}
|
||||
|
||||
- (UIFont *)rowFont
|
||||
{
|
||||
return [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular];
|
||||
}
|
||||
|
||||
// As many bytes per row as fit (in steps of 4), and an offset column only as
|
||||
// wide as the file needs.
|
||||
- (void)layoutColumns
|
||||
{
|
||||
int digits = 4;
|
||||
for (unsigned long long last = _size ? _size - 1 : 0; digits < 16 && (last >> (4 * digits)); digits++) {}
|
||||
CGFloat charWidth = [@"0" sizeWithAttributes:@{ NSFontAttributeName: self.rowFont }].width;
|
||||
CGFloat avail = self.tableView.bounds.size.width - 16; // 8 pt margins on each side
|
||||
if (charWidth <= 0 || avail <= 0) return;
|
||||
NSInteger chars = (NSInteger)floor(avail / charWidth);
|
||||
// A row is: offset, 2 spaces, "xx " per byte, 1 space, 1 character per byte.
|
||||
NSInteger fit = (chars - digits - 3) / 4;
|
||||
NSUInteger per = (NSUInteger)MAX(4, MIN(32, fit - fit % 4));
|
||||
if (per != _perRow || digits != _offsetDigits) {
|
||||
_perRow = per;
|
||||
_offsetDigits = digits;
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewWillLayoutSubviews
|
||||
{
|
||||
[super viewWillLayoutSubviews];
|
||||
[self layoutColumns];
|
||||
}
|
||||
|
||||
- (void)updateBar
|
||||
{
|
||||
NSString *size = [NSByteCountFormatter stringFromByteCount:(long long)_size countStyle:NSByteCountFormatterCountStyleFile];
|
||||
NSString *prompt = _status ?: WQLocalizedFormat(@"%@ · %llu bytes%@%@", size, _size,
|
||||
_edits.count ? WQLocalizedFormat(@" · %lu changed", (unsigned long)_edits.count) : @"",
|
||||
_editState.saving ? WQLocalized(@" · Saving…") : @"");
|
||||
self.navigationItem.prompt = prompt;
|
||||
NSMutableArray *items = [NSMutableArray array];
|
||||
if (!_readOnly) {
|
||||
UIBarButtonItem *save = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Save") style:wq_primary_button_style()
|
||||
target:self action:@selector(save)];
|
||||
save.enabled = _edits.count > 0 && !_editState.saving;
|
||||
[items addObject:save];
|
||||
}
|
||||
__weak typeof(self) weakSelf = self;
|
||||
UIAction *discard = [UIAction actionWithTitle:WQLocalizedNullable(@"Discard Changes") image:[UIImage systemImageNamed:@"arrow.uturn.backward"] identifier:nil
|
||||
handler:^(UIAction *__unused a) { [weakSelf discard]; }];
|
||||
if (_editState.saving) discard.attributes = UIMenuElementAttributesDisabled;
|
||||
UIMenu *menu = [UIMenu menuWithChildren:@[
|
||||
[UIAction actionWithTitle:WQLocalizedNullable(@"Go to Offset…") image:[UIImage systemImageNamed:@"arrow.right.to.line"] identifier:nil
|
||||
handler:^(UIAction *__unused a) { [weakSelf goToOffset]; }],
|
||||
[UIAction actionWithTitle:WQLocalizedNullable(@"Find Hex Bytes…") image:[UIImage systemImageNamed:@"magnifyingglass"] identifier:nil
|
||||
handler:^(UIAction *__unused a) { [weakSelf promptFindHex:YES]; }],
|
||||
[UIAction actionWithTitle:WQLocalizedNullable(@"Find Text…") image:[UIImage systemImageNamed:@"text.magnifyingglass"] identifier:nil
|
||||
handler:^(UIAction *__unused a) { [weakSelf promptFindHex:NO]; }],
|
||||
discard,
|
||||
]];
|
||||
[items addObject:[[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] menu:menu]];
|
||||
self.navigationItem.rightBarButtonItems = items;
|
||||
}
|
||||
|
||||
#pragma mark Data
|
||||
|
||||
// The file byte at `offset` (an edit if there is one), or -1 while its page
|
||||
// is still being read.
|
||||
- (int)byteAt:(unsigned long long)offset
|
||||
{
|
||||
NSNumber *edit = _edits[@(offset)];
|
||||
if (edit) return edit.intValue;
|
||||
return [self originalByteAt:offset];
|
||||
}
|
||||
|
||||
- (int)originalByteAt:(unsigned long long)offset
|
||||
{
|
||||
NSNumber *page = @(offset / kPageSize);
|
||||
NSData *data = [_pages objectForKey:page];
|
||||
if (!data) { [self loadPage:page]; return -1; }
|
||||
NSUInteger i = (NSUInteger)(offset % kPageSize);
|
||||
return i < data.length ? ((const uint8_t *)data.bytes)[i] : -1;
|
||||
}
|
||||
|
||||
- (void)loadPage:(NSNumber *)page
|
||||
{
|
||||
if ([_loading containsObject:page]) return;
|
||||
[_loading addObject:page];
|
||||
NSString *path = _path;
|
||||
dispatch_async(_queue, ^{
|
||||
NSString *error = nil;
|
||||
NSData *data = wq_read_range(path, page.unsignedLongLongValue * kPageSize, kPageSize, &error);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self->_loading removeObject:page];
|
||||
if (!data) {
|
||||
self->_status = wq_permission_hint(path, NO) ? WQLocalized(@"Only the owner may read this file.") : WQLocalizedFormat(@"Read failed: %@", error);
|
||||
[self updateBar];
|
||||
return;
|
||||
}
|
||||
[self->_pages setObject:data forKey:page];
|
||||
[self.tableView reloadRowsAtIndexPaths:self.tableView.indexPathsForVisibleRows
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#pragma mark Table
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||
{
|
||||
return (NSInteger)((_size + _perRow - 1) / _perRow);
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"h"]
|
||||
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"h"];
|
||||
cell.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
|
||||
cell.contentView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
|
||||
UIFont *font = self.rowFont;
|
||||
unsigned long long start = (unsigned long long)indexPath.row * _perRow;
|
||||
NSMutableAttributedString *line = [[NSMutableAttributedString alloc]
|
||||
initWithString:[NSString stringWithFormat:@"%0*llx ", _offsetDigits, start]
|
||||
attributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: UIColor.secondaryLabelColor }];
|
||||
NSMutableString *ascii = [NSMutableString new];
|
||||
NSMutableArray<NSValue *> *asciiMarks = [NSMutableArray new];
|
||||
for (NSUInteger i = 0; i < _perRow; i++) {
|
||||
unsigned long long off = start + i;
|
||||
if (off >= _size) { [line appendAttributedString:[[NSAttributedString alloc] initWithString:@" " attributes:@{ NSFontAttributeName: font }]]; continue; }
|
||||
int b = [self byteAt:off];
|
||||
UIColor *color = UIColor.labelColor;
|
||||
BOOL edited = _edits[@(off)] != nil;
|
||||
BOOL match = _matchLength && off >= _matchOffset && off < _matchOffset + _matchLength;
|
||||
if (edited) color = UIColor.systemOrangeColor;
|
||||
NSMutableDictionary *attrs = [@{ NSFontAttributeName: font, NSForegroundColorAttributeName: color } mutableCopy];
|
||||
if (match) attrs[NSBackgroundColorAttributeName] = [UIColor.systemYellowColor colorWithAlphaComponent:0.45];
|
||||
NSString *hex = b < 0 ? @"·· " : [NSString stringWithFormat:@"%02x ", b];
|
||||
[line appendAttributedString:[[NSAttributedString alloc] initWithString:hex attributes:attrs]];
|
||||
if (edited || match) [asciiMarks addObject:[NSValue valueWithRange:NSMakeRange(ascii.length, 1)]];
|
||||
[ascii appendFormat:@"%c", (b >= 0x20 && b < 0x7f) ? b : '.'];
|
||||
}
|
||||
NSMutableAttributedString *asc = [[NSMutableAttributedString alloc] initWithString:[@" " stringByAppendingString:ascii]
|
||||
attributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: UIColor.secondaryLabelColor }];
|
||||
for (NSValue *v in asciiMarks) {
|
||||
NSRange r = v.rangeValue;
|
||||
[asc addAttribute:NSForegroundColorAttributeName value:UIColor.systemOrangeColor range:NSMakeRange(r.location + 1, 1)];
|
||||
}
|
||||
[line appendAttributedString:asc];
|
||||
UIListContentConfiguration *c = [UIListContentConfiguration cellConfiguration];
|
||||
c.attributedText = line;
|
||||
c.textProperties.numberOfLines = 1;
|
||||
c.directionalLayoutMargins = NSDirectionalEdgeInsetsMake(0, 8, 0, 8);
|
||||
cell.contentConfiguration = c;
|
||||
cell.selectionStyle = _readOnly ? UITableViewCellSelectionStyleNone : UITableViewCellSelectionStyleDefault;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
[tableView deselectRowAtIndexPath:indexPath animated:YES];
|
||||
if (_readOnly || _editState.saving) return;
|
||||
unsigned long long start = (unsigned long long)indexPath.row * _perRow;
|
||||
NSUInteger count = (NSUInteger)MIN((unsigned long long)_perRow, _size - start);
|
||||
NSMutableString *current = [NSMutableString new];
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
int b = [self byteAt:start + i];
|
||||
if (b < 0) return; // page not loaded yet
|
||||
[current appendFormat:i ? @" %02x" : @"%02x", b];
|
||||
}
|
||||
UIAlertController *a = wq_alert_controller(WQLocalizedFormat(@"Bytes at 0x%llx", start), WQLocalizedFormat(@"Overwrite these %lu bytes (hex). The file size stays the same.", (unsigned long)count), UIAlertControllerStyleAlert);
|
||||
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
|
||||
f.text = current;
|
||||
f.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular];
|
||||
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
f.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
}];
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Set", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
|
||||
NSData *bytes = WQParseHex(a.textFields.firstObject.text ?: @"", NO);
|
||||
if (bytes.length != count) {
|
||||
wqh_alert(self, @"Not changed", WQLocalizedFormat(@"Enter exactly %lu bytes in hex.", (unsigned long)count));
|
||||
return;
|
||||
}
|
||||
const uint8_t *nb = bytes.bytes;
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
unsigned long long off = start + i;
|
||||
if ([self originalByteAt:off] == nb[i]) [self->_edits removeObjectForKey:@(off)];
|
||||
else self->_edits[@(off)] = @(nb[i]);
|
||||
}
|
||||
[self->_editState noteChange];
|
||||
[self updateBar];
|
||||
[self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark Save, discard, leave
|
||||
|
||||
- (BOOL)wq_needsLeaveConfirmation
|
||||
{
|
||||
return _edits.count > 0 || _editState.saving;
|
||||
}
|
||||
|
||||
- (NSDictionary<NSNumber *, NSData *> *)runs
|
||||
{
|
||||
NSMutableDictionary *runs = [NSMutableDictionary new];
|
||||
NSArray *offsets = [_edits.allKeys sortedArrayUsingSelector:@selector(compare:)];
|
||||
unsigned long long runStart = 0;
|
||||
NSMutableData *run = nil;
|
||||
for (NSNumber *o in offsets) {
|
||||
unsigned long long off = o.unsignedLongLongValue;
|
||||
uint8_t b = (uint8_t)_edits[o].intValue;
|
||||
if (run && off == runStart + run.length) { [run appendBytes:&b length:1]; continue; }
|
||||
if (run) runs[@(runStart)] = run;
|
||||
runStart = off;
|
||||
run = [NSMutableData dataWithBytes:&b length:1];
|
||||
}
|
||||
if (run) runs[@(runStart)] = run;
|
||||
return runs;
|
||||
}
|
||||
|
||||
- (BOOL)blockWhileSaving
|
||||
{
|
||||
if (!_editState.saving) return NO;
|
||||
wqh_alert(self, @"Save in progress", @"Wait for the save to finish before leaving or changing these edits.");
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)save
|
||||
{
|
||||
if (!_edits.count || [self blockWhileSaving]) return;
|
||||
if (!_version) {
|
||||
wqh_alert(self, @"Save unavailable", @"The file version could not be verified. Close and reopen it before saving.");
|
||||
return;
|
||||
}
|
||||
NSDictionary *runs = [self runs];
|
||||
UIAlertController *a = wq_alert_controller(@"Save changes?", WQLocalizedFormat(@"%lu bytes in %@ are overwritten in place. The current file is backed up first.", (unsigned long)_edits.count, _path), UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Save", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
|
||||
NSDictionary *expectedVersion = [self->_version copy];
|
||||
if (!expectedVersion) {
|
||||
wqh_alert(self, @"Save unavailable", @"The file version could not be verified. Close and reopen it before saving.");
|
||||
return;
|
||||
}
|
||||
NSUInteger savedRevision = [self->_editState beginSave];
|
||||
[self updateBar];
|
||||
NSString *path = self->_path;
|
||||
dispatch_async(self->_queue, ^{
|
||||
NSString *error = nil;
|
||||
NSDictionary *savedVersion = nil;
|
||||
BOOL ok = wq_patch_if_unchanged_get_version(path, runs, expectedVersion, &savedVersion, &error);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self->_editState finishSave:ok revision:savedRevision];
|
||||
if (!ok) {
|
||||
[self updateBar];
|
||||
wqh_alert(self, @"Save failed", error ?: wq_permission_hint(path, YES) ?: @"Unknown error");
|
||||
return;
|
||||
}
|
||||
self->_version = savedVersion;
|
||||
self->_status = nil;
|
||||
if (self->_editState.revision == savedRevision) [self->_edits removeAllObjects];
|
||||
[self->_pages removeAllObjects];
|
||||
[self updateBar];
|
||||
[self.tableView reloadData];
|
||||
});
|
||||
});
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)discard
|
||||
{
|
||||
if ([self blockWhileSaving]) return;
|
||||
[_edits removeAllObjects];
|
||||
[_editState discard];
|
||||
[self updateBar];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
// Home button: ask before throwing away unsaved changes.
|
||||
- (void)confirmLeaveThen:(void (^)(void))proceed
|
||||
{
|
||||
if ([self blockWhileSaving]) return;
|
||||
if (!_edits.count) { proceed(); return; }
|
||||
UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your byte changes have not been saved.", UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
|
||||
[self->_edits removeAllObjects];
|
||||
[self->_editState discard];
|
||||
proceed();
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)leave
|
||||
{
|
||||
[self confirmLeaveThen:^{
|
||||
[self.navigationController popViewControllerAnimated:YES];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark Navigation and search
|
||||
|
||||
- (void)scrollToOffset:(unsigned long long)offset
|
||||
{
|
||||
if (_size == 0) return;
|
||||
if (offset >= _size) offset = _size - 1;
|
||||
NSInteger row = (NSInteger)(offset / _perRow);
|
||||
[self.tableView scrollToRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:0]
|
||||
atScrollPosition:UITableViewScrollPositionMiddle animated:YES];
|
||||
}
|
||||
|
||||
- (void)goToOffset
|
||||
{
|
||||
UIAlertController *a = wq_alert_controller(@"Go to Offset", @"Hex (0x1a2b) or decimal", UIAlertControllerStyleAlert);
|
||||
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
|
||||
f.placeholder = @"0x0";
|
||||
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
f.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
}];
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Go", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
|
||||
NSString *t = [a.textFields.firstObject.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet];
|
||||
unsigned long long off = 0;
|
||||
if (!WQHexParseOffset(t, &off)) {
|
||||
wqh_alert(self, @"Invalid offset", @"Enter a complete non-negative decimal value or a 0x-prefixed hexadecimal value.");
|
||||
return;
|
||||
}
|
||||
if (_size == 0) {
|
||||
wqh_alert(self, @"Offset unavailable", @"The file is empty or its size is still loading.");
|
||||
return;
|
||||
}
|
||||
if (off >= _size) {
|
||||
wqh_alert(self, @"Offset out of range", WQLocalizedFormat(@"Enter an offset from 0x0 through 0x%llx.", _size - 1));
|
||||
return;
|
||||
}
|
||||
[self scrollToOffset:off];
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)promptFindHex:(BOOL)hex
|
||||
{
|
||||
UIAlertController *a = wq_alert_controller(hex ? @"Find Hex Bytes" : @"Find Text", @"Searches the saved file forward from the top of the screen.", UIAlertControllerStyleAlert);
|
||||
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
|
||||
f.placeholder = hex ? @"de ad be ef" : WQLocalized(@"text");
|
||||
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
f.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
}];
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Find", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
|
||||
NSString *t = a.textFields.firstObject.text ?: @"";
|
||||
if (!WQHexSearchInputWithinLimit(t)) {
|
||||
wqh_alert(self, @"Search too large", @"Use at most 1 MiB of UTF-8 search input.");
|
||||
return;
|
||||
}
|
||||
NSData *needle = hex ? WQParseHex(t, NO) : [t dataUsingEncoding:NSUTF8StringEncoding];
|
||||
if (needle.length == 0) { wqh_alert(self, @"Nothing to find", hex ? @"Enter hex bytes, e.g. de ad be ef." : @"Enter some text."); return; }
|
||||
BOOL sameNeedle = self->_lastSearchNeedle && [self->_lastSearchNeedle isEqualToData:needle];
|
||||
NSInteger topRow = NSIntegerMax;
|
||||
for (NSIndexPath *indexPath in self.tableView.indexPathsForVisibleRows) {
|
||||
if (indexPath.section == 0) topRow = MIN(topRow, indexPath.row);
|
||||
}
|
||||
if (topRow == NSIntegerMax) topRow = 0;
|
||||
unsigned long long from = (sameNeedle && self->_matchLength && self->_matchOffset < ULLONG_MAX)
|
||||
? self->_matchOffset + 1 : (unsigned long long)topRow * self->_perRow;
|
||||
if (!sameNeedle) self->_matchLength = 0;
|
||||
self->_lastSearchNeedle = [needle copy];
|
||||
[self search:needle from:from];
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)search:(NSData *)needle from:(unsigned long long)from
|
||||
{
|
||||
NSUInteger generation = ++_searchGeneration;
|
||||
_status = WQLocalized(@"Searching…");
|
||||
[self updateBar];
|
||||
NSString *path = _path;
|
||||
unsigned long long size = _size;
|
||||
NSUInteger requestLength = kSearchChunk + needle.length - 1;
|
||||
dispatch_async(_queue, ^{
|
||||
unsigned long long found = ULLONG_MAX;
|
||||
NSString *error = nil;
|
||||
for (unsigned long long pos = from; pos < size && found == ULLONG_MAX; ) {
|
||||
NSUInteger length = (NSUInteger)MIN((unsigned long long)requestLength, size - pos);
|
||||
NSData *chunk = wq_read_range(path, pos, length, &error);
|
||||
if (!chunk || chunk.length < needle.length) break;
|
||||
NSRange r = [chunk rangeOfData:needle options:0 range:NSMakeRange(0, chunk.length)];
|
||||
if (r.location != NSNotFound) found = pos + r.location;
|
||||
if (size - pos <= kSearchChunk) break;
|
||||
pos += kSearchChunk;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (generation != self->_searchGeneration || !wqh_is_visible(self)) return;
|
||||
self->_status = nil;
|
||||
[self updateBar];
|
||||
if (found == ULLONG_MAX) {
|
||||
self->_matchLength = 0;
|
||||
[self.tableView reloadData];
|
||||
wqh_alert(self, @"Not found", error ?: WQLocalizedFormat(@"No match after offset 0x%llx.", from));
|
||||
return;
|
||||
}
|
||||
self->_matchOffset = found;
|
||||
self->_matchLength = needle.length;
|
||||
[self.tableView reloadData];
|
||||
[self scrollToOffset:found];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// WQHexViewerHelpers.h
|
||||
// WQ Files
|
||||
//
|
||||
// Small Foundation-portable validation helpers for the hex viewer.
|
||||
//
|
||||
|
||||
#ifndef WQHexViewerHelpers_h
|
||||
#define WQHexViewerHelpers_h
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <errno.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// Keep both the search needle and the range-read overlap bounded before either
|
||||
// can be materialized. This is a UTF-8 input limit, not a UI character limit.
|
||||
static const NSUInteger WQHexSearchMaximumInputBytes = 1024UL * 1024UL;
|
||||
|
||||
static inline BOOL WQHexSearchInputWithinLimit(NSString *text)
|
||||
{
|
||||
return text && [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding] <= WQHexSearchMaximumInputBytes;
|
||||
}
|
||||
|
||||
// Parses the complete trimmed input as an unsigned decimal or 0x-prefixed
|
||||
// hexadecimal offset. The caller remains responsible for checking file range.
|
||||
static inline BOOL WQHexParseOffset(NSString *text, unsigned long long *offset)
|
||||
{
|
||||
if (!text || !offset) return NO;
|
||||
NSString *trimmed = [text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
if (!trimmed.length) return NO;
|
||||
|
||||
BOOL hex = [trimmed hasPrefix:@"0x"] || [trimmed hasPrefix:@"0X"];
|
||||
NSString *digits = hex ? [trimmed substringFromIndex:2] : trimmed;
|
||||
if (!digits.length) return NO;
|
||||
|
||||
unichar first = [digits characterAtIndex:0];
|
||||
if (first == '-' || first == '+') return NO;
|
||||
for (NSUInteger i = 0; i < digits.length; i++) {
|
||||
unichar c = [digits characterAtIndex:i];
|
||||
BOOL valid = c >= '0' && c <= '9';
|
||||
if (hex) valid = valid || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
if (!valid) return NO;
|
||||
}
|
||||
|
||||
const char *input = digits.UTF8String;
|
||||
NSUInteger inputBytes = [digits lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
|
||||
if (!input || strlen(input) != inputBytes) return NO;
|
||||
|
||||
errno = 0;
|
||||
char *end = NULL;
|
||||
unsigned long long parsed = strtoull(input, &end, hex ? 16 : 10);
|
||||
if (errno == ERANGE || end == input || !end || *end != '\0') return NO;
|
||||
*offset = parsed;
|
||||
return YES;
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// WQLaunchServices.h
|
||||
// WQ Files
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Human app name for a bundle id, from LaunchServices' database (read from
|
||||
// lsd's container), a built-in table of Apple services, or the parent app.
|
||||
NSString *_Nullable wq_display_name(NSString *_Nullable bundleID);
|
||||
|
||||
// Bundle id of an app or extension data container (/private/var/mobile/
|
||||
// Containers/Data/...) or an app bundle container, from the same
|
||||
// LaunchServices database. For iOS 27.2, where the containers' identifier
|
||||
// attribute can't be read. Nil if unknown.
|
||||
NSString *_Nullable wq_bundle_id_for_container(NSString *path);
|
||||
|
||||
// Whether the name database is loaded; loading it (blocking, off the main
|
||||
// thread) the first time can take a while, so screens show a status for it.
|
||||
BOOL wq_display_names_loaded(void);
|
||||
void wq_load_display_names(void);
|
||||
// After a user's refresh: search for a database that wasn't found before
|
||||
// right away instead of waiting a day.
|
||||
void wq_display_names_reset_miss(void);
|
||||
// Every app and extension data container the LaunchServices database lists,
|
||||
// read from storeHint (remembered when it parses) or the remembered location.
|
||||
// Never scans. Nil when there's no usable database.
|
||||
NSArray<NSString *> *_Nullable wq_ls_container_paths(NSString *_Nullable storeHint);
|
||||
// The .app bundle of an installed app, from the same database (for icons
|
||||
// where iOS won't hand them out). Nil if unknown.
|
||||
NSString *_Nullable wq_bundle_path_for_id(NSString *bundleID);
|
||||
// The app bundle containers (/private/var/containers/Bundle/Application/
|
||||
// <UUID>) of installed apps, from the same database; empty if unknown.
|
||||
// wq_bundle_id_for_container also labels these.
|
||||
NSArray<NSString *> *wq_ls_bundle_containers(void);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,897 @@
|
||||
//
|
||||
// WQLaunchServices.m
|
||||
// WQ Files
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQLaunchServices.h"
|
||||
#import "WQLocalization.h"
|
||||
#import "WQAccess.h"
|
||||
#import <stdlib.h>
|
||||
#import <string.h>
|
||||
#import <sys/stat.h>
|
||||
|
||||
#pragma mark - App names from the LaunchServices database
|
||||
|
||||
// LaunchServices' own API refuses this app on 27.x (OSStatus -54) and the app
|
||||
// bundles cannot be opened, but lsd keeps its database (CSStore2, magic
|
||||
// "bdsl") in an Internal Daemon container that we can read. In it, Bundle
|
||||
// records are 0x250 bytes long, preceded by [u32][u32 0x250]: +0x0c bundle
|
||||
// id, +0x6c bundle name, +0x74 display name, each a string reference
|
||||
// (string id << 2). Strings are stored as [u32 id][u32 length][UTF-8][NUL].
|
||||
// Names that exist only in localized form (most Apple apps) are not decoded;
|
||||
// the built-in table below covers those.
|
||||
|
||||
static NSString *const kWQLSStoreKey = @"WQLaunchServicesStore";
|
||||
// When the database wasn't found: don't search again for a day (each search
|
||||
// lists every daemon container), unless the user refreshes.
|
||||
static NSString *const kWQLSMissKey = @"WQLaunchServicesStoreMissedAt";
|
||||
static const NSTimeInterval kWQLSMissRetry = 24 * 60 * 60;
|
||||
|
||||
// These limits keep a malformed or unexpectedly large database from turning a
|
||||
// synchronous display-name lookup into an unbounded read/parse operation.
|
||||
static const size_t kWQLSMinStoreBytes = 0x1000;
|
||||
static const size_t kWQLSMaxStoreBytes = 64u * 1024u * 1024u;
|
||||
static const uint32_t kWQLSMaxStringID = 0x01000000u;
|
||||
static const NSUInteger kWQLSMaxBundleRecords = 100000;
|
||||
static const NSUInteger kWQLSMaxDecodedStrings = 200000;
|
||||
static const NSUInteger kWQLSMaxDecodedStringBytes = 8u * 1024u * 1024u;
|
||||
|
||||
static NSObject *wq_ls_lock(void)
|
||||
{
|
||||
static NSObject *lock;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ lock = [NSObject new]; });
|
||||
return lock;
|
||||
}
|
||||
|
||||
// The regression tests still exercise discovery through the daemon list.
|
||||
#ifdef WQ_LS_TESTING
|
||||
static BOOL wq_ls_test_daemon_search = YES;
|
||||
#else
|
||||
static const BOOL wq_ls_test_daemon_search = NO;
|
||||
#endif
|
||||
|
||||
static NSString *wq_find_ls_store(BOOL *searched);
|
||||
static BOOL wq_ls_candidate_info(NSString *path, struct stat *st);
|
||||
|
||||
static BOOL wq_ls_stat_equal(const struct stat *a, const struct stat *b)
|
||||
{
|
||||
return a->st_dev == b->st_dev && a->st_ino == b->st_ino &&
|
||||
a->st_size == b->st_size &&
|
||||
a->st_mtimespec.tv_sec == b->st_mtimespec.tv_sec &&
|
||||
a->st_mtimespec.tv_nsec == b->st_mtimespec.tv_nsec;
|
||||
}
|
||||
|
||||
static NSDictionary *wq_ls_signature(const struct stat *st)
|
||||
{
|
||||
return @{
|
||||
@"device": @(st->st_dev), @"inode": @(st->st_ino), @"size": @(st->st_size),
|
||||
@"modifiedSeconds": @(st->st_mtimespec.tv_sec),
|
||||
@"modifiedNanoseconds": @(st->st_mtimespec.tv_nsec)
|
||||
};
|
||||
}
|
||||
|
||||
static BOOL wq_ls_signature_equal(NSDictionary *a, NSDictionary *b)
|
||||
{
|
||||
return a && b && [a isEqualToDictionary:b];
|
||||
}
|
||||
|
||||
static NSString *wq_ls_error(NSString *path, NSString *reason)
|
||||
{
|
||||
return [NSString stringWithFormat:@"%@ (%@)", path ?: @"<none>", reason ?: @"unknown error"];
|
||||
}
|
||||
|
||||
static BOOL wq_ls_candidate_info(NSString *path, struct stat *st)
|
||||
{
|
||||
if (!path.length || !wq_stat(path, st) || !S_ISREG(st->st_mode) ||
|
||||
st->st_size < (off_t)kWQLSMinStoreBytes || st->st_size > (off_t)kWQLSMaxStoreBytes) {
|
||||
return NO;
|
||||
}
|
||||
NSString *error = nil;
|
||||
NSData *header = wq_read(path, 4, &error);
|
||||
if (header.length != 4 || memcmp(header.bytes, "bdsl", 4) != 0) return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL wq_ls_is_newer(NSString *path, const struct stat *st,
|
||||
NSString *bestPath, const struct stat *best)
|
||||
{
|
||||
if (!bestPath) return YES;
|
||||
if (st->st_mtimespec.tv_sec != best->st_mtimespec.tv_sec)
|
||||
return st->st_mtimespec.tv_sec > best->st_mtimespec.tv_sec;
|
||||
if (st->st_mtimespec.tv_nsec != best->st_mtimespec.tv_nsec)
|
||||
return st->st_mtimespec.tv_nsec > best->st_mtimespec.tv_nsec;
|
||||
if (st->st_size != best->st_size) return st->st_size > best->st_size;
|
||||
return [path compare:bestPath] == NSOrderedAscending;
|
||||
}
|
||||
|
||||
static void wq_ls_consider_candidate(NSString *path, NSString **bestPath,
|
||||
struct stat *best, NSMutableSet<NSString *> *seen)
|
||||
{
|
||||
if (!path.length || [seen containsObject:path]) return;
|
||||
[seen addObject:path];
|
||||
|
||||
struct stat st;
|
||||
if (!wq_ls_candidate_info(path, &st)) return;
|
||||
if (wq_ls_is_newer(path, &st, *bestPath, best)) {
|
||||
*bestPath = path;
|
||||
*best = st;
|
||||
}
|
||||
}
|
||||
|
||||
// *searched: the daemon containers were actually listed (a failed or cancelled
|
||||
// scan says nothing about whether the database exists).
|
||||
static NSString *wq_find_ls_store(BOOL *searched)
|
||||
{
|
||||
if (searched) *searched = NO;
|
||||
NSString *cached = [NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey];
|
||||
NSString *bestPath = nil;
|
||||
struct stat best = {0};
|
||||
struct stat cachedStat;
|
||||
BOOL cachedValid = cached.length && wq_ls_candidate_info(cached, &cachedStat);
|
||||
NSMutableSet<NSString *> *seen = [NSMutableSet set];
|
||||
if (cachedValid) wq_ls_consider_candidate(cached, &bestPath, &best, seen);
|
||||
|
||||
// Listing the daemon containers is a full id scan (minutes, and it asks
|
||||
// first); the database is found by the container scan among the newest
|
||||
// ids and remembered, so names only use the remembered location.
|
||||
if (!wq_ls_test_daemon_search) {
|
||||
if (searched) *searched = cachedValid;
|
||||
return cachedValid ? cached : nil;
|
||||
}
|
||||
NSArray *daemons = wq_enumerate(@"/private/var/mobile/Containers/Data/InternalDaemon", 0, nil, nil);
|
||||
if (daemons && searched) *searched = YES;
|
||||
if (daemons) {
|
||||
for (NSString *container in daemons) {
|
||||
NSString *caches = [container stringByAppendingPathComponent:@"Library/Caches"];
|
||||
NSArray *items = wq_list(caches, NULL);
|
||||
for (NSString *name in [items sortedArrayUsingSelector:@selector(compare:)]) {
|
||||
if (![name hasPrefix:@"com.apple.LaunchServices-"] || ![name hasSuffix:@".csstore"]) continue;
|
||||
wq_ls_consider_candidate([caches stringByAppendingPathComponent:name], &bestPath, &best, seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep a valid cached path usable if enumeration is temporarily unavailable.
|
||||
if (!bestPath && cachedValid) {
|
||||
bestPath = cached;
|
||||
}
|
||||
return bestPath;
|
||||
}
|
||||
|
||||
// Record layouts by release. Offsets are from the record body (after the
|
||||
// 8-byte header). iOS 26 records are 0x14 bytes shorter, with the container
|
||||
// path and the fields after it 4 bytes earlier than on 27.0.
|
||||
typedef struct {
|
||||
const char *label;
|
||||
uint32_t bundleSize, bundleID, container, name, displayName, bundlePath;
|
||||
uint32_t pluginSize, pluginBundleID, pluginContainer;
|
||||
} WQLSLayout;
|
||||
static const WQLSLayout kWQLSLayouts[] = {
|
||||
{ "27.0", 0x250, 0x0c, 0x68, 0x6c, 0x74, 0x00, 0x118, 0x00, 0x54 },
|
||||
{ "26", 0x23c, 0x0c, 0x64, 0x68, 0x70, 0x00, 0x104, 0x00, 0x50 },
|
||||
// iOS 26.5 (from a device's layout hint, issue #37): same record sizes,
|
||||
// container fields 4 bytes earlier than on 26.7. Whether the bundle id
|
||||
// moved too isn't known, so both positions are tried.
|
||||
{ "26.5", 0x23c, 0x0c, 0x60, 0x64, 0x6c, 0x00, 0x104, 0x00, 0x4c },
|
||||
{ "26.5b", 0x23c, 0x08, 0x60, 0x64, 0x6c, 0x00, 0x104, 0x00, 0x4c },
|
||||
// iOS 27.2 (from a device's layout hint): container fields 4 bytes later
|
||||
// than on 27.0. Whether the bundle id moved too isn't known yet, so both
|
||||
// positions are tried; the one that maps the most containers wins.
|
||||
{ "27.2", 0x25c, 0x0c, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x00, 0x58 },
|
||||
{ "27.2b", 0x25c, 0x10, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x04, 0x58 },
|
||||
{ "27.2c", 0x25c, 0x0c, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x04, 0x58 },
|
||||
{ "27.2d", 0x25c, 0x10, 0x6c, 0x70, 0x78, 0x00, 0x120, 0x00, 0x58 },
|
||||
};
|
||||
static const size_t kWQLSLayoutCount = sizeof(kWQLSLayouts) / sizeof(kWQLSLayouts[0]);
|
||||
static const size_t kWQLSMaxRecordSize = 0x25c;
|
||||
|
||||
static uint32_t wq_rd32(const uint8_t *p) { uint32_t v; memcpy(&v, p, 4); return v; }
|
||||
|
||||
static NSString *wq_cs_string(const uint8_t *b, size_t n, size_t o)
|
||||
{
|
||||
if (o > n || n - o < 8) return nil;
|
||||
uint32_t len = wq_rd32(b + o + 4);
|
||||
// Strings are padded with zero bytes to a multiple of 4. One whose length
|
||||
// is already a multiple of 4 has no terminator (container paths are 84 or
|
||||
// 88 bytes), so only shorter ones must end in a zero byte. A string must
|
||||
// still end before the buffer does.
|
||||
if (len == 0 || len >= 0x800 || len >= n - o - 8 || (len % 4 && b[o + 8 + len] != 0)) return nil;
|
||||
if (memchr(b + o + 8, 0, len)) return nil;
|
||||
NSString *s = [[NSString alloc] initWithBytes:b + o + 8 length:len encoding:NSUTF8StringEncoding];
|
||||
return s;
|
||||
}
|
||||
|
||||
static BOOL wq_looks_like_bundle_id(NSString *s)
|
||||
{
|
||||
static NSCharacterSet *bad;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
bad = [[NSCharacterSet characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_"] invertedSet];
|
||||
});
|
||||
return s.length > 2 && [s containsString:@"."] && ![s hasPrefix:@"."]
|
||||
&& [s rangeOfCharacterFromSet:bad].location == NSNotFound;
|
||||
}
|
||||
|
||||
// Unicode format characters include bidi overrides/isolates, zero-width
|
||||
// characters, and BOM-like markers. They are replaced visibly while ordinary
|
||||
// international letters, accents, and scripts remain unchanged.
|
||||
static BOOL wq_is_name_format_scalar(uint32_t c)
|
||||
{
|
||||
return c == 0x00ad || (c >= 0x0600 && c <= 0x0605) || c == 0x061c || c == 0x06dd ||
|
||||
c == 0x070f || (c >= 0x0890 && c <= 0x0891) || c == 0x08e2 || c == 0x180e ||
|
||||
(c >= 0x200b && c <= 0x200f) || (c >= 0x202a && c <= 0x202e) ||
|
||||
(c >= 0x2060 && c <= 0x206f) || c == 0xfeff;
|
||||
}
|
||||
|
||||
static NSString *wq_safe_name(NSString *value)
|
||||
{
|
||||
if (!value.length) return nil;
|
||||
NSMutableString *safe = [NSMutableString stringWithCapacity:value.length];
|
||||
for (NSUInteger i = 0; i < value.length;) {
|
||||
uint32_t scalar = [value characterAtIndex:i++];
|
||||
if (scalar >= 0xd800 && scalar <= 0xdbff) {
|
||||
if (i >= value.length) return nil;
|
||||
unichar low = [value characterAtIndex:i++];
|
||||
if (low < 0xdc00 || low > 0xdfff) return nil;
|
||||
scalar = 0x10000u + ((scalar - 0xd800u) << 10) + (low - 0xdc00u);
|
||||
} else if (scalar >= 0xdc00 && scalar <= 0xdfff) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (wq_is_name_format_scalar(scalar) || scalar == 0x2028 || scalar == 0x2029) {
|
||||
[safe appendString:@"\uFFFD"];
|
||||
} else if (scalar <= 0xffff) {
|
||||
[safe appendFormat:@"%C", (unichar)scalar];
|
||||
} else {
|
||||
scalar -= 0x10000u;
|
||||
[safe appendFormat:@"%C%C", (unichar)(0xd800u + (scalar >> 10)),
|
||||
(unichar)(0xdc00u + (scalar & 0x3ffu))];
|
||||
}
|
||||
}
|
||||
|
||||
NSString *normalized = [safe precomposedStringWithCanonicalMapping];
|
||||
normalized = [normalized stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
if (!normalized.length || [normalized hasPrefix:@"/"] || [normalized containsString:@".app/"]) return nil;
|
||||
if ([normalized rangeOfCharacterFromSet:NSCharacterSet.controlCharacterSet].location != NSNotFound) return nil;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// Data container path -> bundle id. Apps: the Bundle record's +0x68 field;
|
||||
// extensions: 0x118-byte plugin records with the bundle id at +0x00 and the
|
||||
// path at +0x54. On iOS 27.2 the containers' identifier attribute can't be
|
||||
// read, so this is how their folders get a bundle id (and a name).
|
||||
static BOOL wq_is_data_container_path(NSString *path)
|
||||
{
|
||||
static NSArray<NSString *> *prefixes;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
prefixes = @[@"/private/var/mobile/Containers/Data/Application/",
|
||||
@"/private/var/mobile/Containers/Data/PluginKitPlugin/"];
|
||||
});
|
||||
for (NSString *prefix in prefixes) {
|
||||
if (![path hasPrefix:prefix] || path.length != prefix.length + 36) continue;
|
||||
return [[NSUUID alloc] initWithUUIDString:[path substringFromIndex:prefix.length]] != nil;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Diagnostics: how many container paths the store holds, and which record
|
||||
// sizes and offsets refer to them. Only sizes and offsets are logged (no
|
||||
// names), so a Log from a release with a new layout shows where it moved.
|
||||
static int wq_u32_cmp(const void *a, const void *b)
|
||||
{
|
||||
uint32_t x = *(const uint32_t *)a, y = *(const uint32_t *)b;
|
||||
return x < y ? -1 : x > y;
|
||||
}
|
||||
|
||||
// String refs of the data container paths stored in the database, sorted;
|
||||
// independent of the record layout.
|
||||
static NSMutableData *wq_ls_container_path_refs(const uint8_t *b, size_t n)
|
||||
{
|
||||
NSMutableData *refData = [NSMutableData new];
|
||||
static const char needle[] = "/var/mobile/Containers/Data/";
|
||||
const size_t needleLen = sizeof(needle) - 1;
|
||||
const uint8_t *cursor = b, *end = b + n;
|
||||
while (cursor < end) {
|
||||
const uint8_t *hit = memmem(cursor, (size_t)(end - cursor), needle, needleLen);
|
||||
if (!hit) break;
|
||||
cursor = hit + needleLen;
|
||||
const uint8_t *start = hit;
|
||||
if ((size_t)(hit - b) >= 8 && memcmp(hit - 8, "/private", 8) == 0) start = hit - 8;
|
||||
size_t o = (size_t)(start - b);
|
||||
if (o < 8 || o % 4) continue;
|
||||
uint32_t len = wq_rd32(start - 4);
|
||||
if (len < needleLen || len > 0x100 || len > (size_t)(end - start)) continue;
|
||||
uint32_t ref = wq_rd32(start - 8) << 2;
|
||||
if (ref) [refData appendBytes:&ref length:sizeof(ref)];
|
||||
if (refData.length / sizeof(uint32_t) >= 20000) break;
|
||||
}
|
||||
NSUInteger count = refData.length / sizeof(uint32_t);
|
||||
if (count) qsort(refData.mutableBytes, count, sizeof(uint32_t), wq_u32_cmp);
|
||||
return refData;
|
||||
}
|
||||
|
||||
static void wq_ls_log_summary(const uint8_t *b, size_t n, NSString *layout, NSUInteger bundleRecords,
|
||||
NSUInteger pluginRecords, NSUInteger names, NSUInteger mapped)
|
||||
{
|
||||
NSMutableData *refData = wq_ls_container_path_refs(b, n);
|
||||
NSUInteger pathCount = refData.length / sizeof(uint32_t);
|
||||
uint32_t *refs = refData.mutableBytes;
|
||||
wq_log(@"names: store %.1f MB, layout %@: %lu bundle and %lu plugin records, %lu names, %lu of %lu container paths mapped",
|
||||
n / 1048576.0, layout, (unsigned long)bundleRecords, (unsigned long)pluginRecords, (unsigned long)names,
|
||||
(unsigned long)mapped, (unsigned long)pathCount);
|
||||
if (!pathCount || mapped * 2 >= pathCount) return;
|
||||
|
||||
// Vote for (record size, offset of the container reference).
|
||||
NSCountedSet<NSNumber *> *votes = [NSCountedSet new];
|
||||
for (size_t q = 0; q + 4 <= n; q += 4) {
|
||||
uint32_t v = wq_rd32(b + q);
|
||||
if (!v || (v & 3) || !bsearch(&v, refs, pathCount, sizeof(uint32_t), wq_u32_cmp)) continue;
|
||||
for (size_t k = 0; k < 0x400 && q >= 8 + k; k += 4) {
|
||||
uint32_t size = wq_rd32(b + q - 8 - k + 4);
|
||||
if (size >= 0x40 && size <= 0x800 && size % 4 == 0 && k < size)
|
||||
[votes addObject:@((uint64_t)size << 16 | k)];
|
||||
}
|
||||
}
|
||||
NSArray *top = [votes.allObjects sortedArrayUsingComparator:^NSComparisonResult(NSNumber *x, NSNumber *y) {
|
||||
NSUInteger cx = [votes countForObject:x], cy = [votes countForObject:y];
|
||||
return cx > cy ? NSOrderedAscending : cx < cy ? NSOrderedDescending : [x compare:y];
|
||||
}];
|
||||
NSMutableArray *parts = [NSMutableArray new];
|
||||
for (NSNumber *key in [top subarrayWithRange:NSMakeRange(0, MIN(top.count, (NSUInteger)8))]) {
|
||||
uint64_t value = key.unsignedLongLongValue;
|
||||
[parts addObject:[NSString stringWithFormat:@"0x%llx@0x%llx x%lu", value >> 16, value & 0xffff,
|
||||
(unsigned long)[votes countForObject:key]]];
|
||||
}
|
||||
wq_log(@"names: layout hint (record size@container offset): %@", [parts componentsJoinedByString:@", "]);
|
||||
}
|
||||
|
||||
static BOOL wq_parse_ls_store(NSData *data, NSDictionary **displayOut,
|
||||
NSDictionary **bundleNameOut, NSDictionary **containersOut,
|
||||
NSDictionary **bundlePathsOut)
|
||||
{
|
||||
if (bundlePathsOut) *bundlePathsOut = nil;
|
||||
if (displayOut) *displayOut = nil;
|
||||
if (bundleNameOut) *bundleNameOut = nil;
|
||||
if (containersOut) *containersOut = nil;
|
||||
|
||||
const uint8_t *b = data.bytes;
|
||||
size_t n = data.length;
|
||||
if (n < kWQLSMinStoreBytes || !b || memcmp(b, "bdsl", 4) != 0) {
|
||||
wq_log(@"names: not a LaunchServices store");
|
||||
return NO;
|
||||
}
|
||||
NSRange heapRange = [data rangeOfData:[NSData dataWithBytes:"<string>" length:8]
|
||||
options:0 range:NSMakeRange(0, n)];
|
||||
size_t heap = heapRange.location == NSNotFound ? 0 : heapRange.location;
|
||||
|
||||
// Pass 1: Bundle and plug-in records of every known layout, and which
|
||||
// string ids they need. The layout that maps the most containers wins.
|
||||
const uint32_t kIDs = kWQLSMaxStringID;
|
||||
size_t bitsetBytes = kIDs / 8;
|
||||
uint8_t *need = calloc(bitsetBytes, 1), *inHeap = calloc(bitsetBytes, 1);
|
||||
NSMutableArray<NSMutableData *> *layoutRecs = [NSMutableArray new], *layoutPlugins = [NSMutableArray new];
|
||||
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
|
||||
[layoutRecs addObject:[NSMutableData new]];
|
||||
[layoutPlugins addObject:[NSMutableData new]]; // pairs: bundle id ref, container path ref
|
||||
}
|
||||
if (!need || !inHeap) {
|
||||
free(need);
|
||||
free(inHeap);
|
||||
wq_log(@"names: LaunchServices parser allocation failed");
|
||||
return NO;
|
||||
}
|
||||
|
||||
BOOL tooManyRecords = NO;
|
||||
for (size_t o = 0; o + 8 + kWQLSMaxRecordSize <= n && !tooManyRecords; o += 4) {
|
||||
uint32_t size = wq_rd32(b + o + 4);
|
||||
const uint8_t *u = b + o + 8;
|
||||
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
|
||||
const WQLSLayout *L = &kWQLSLayouts[l];
|
||||
if (size == L->pluginSize) {
|
||||
NSMutableData *plugins = layoutPlugins[l];
|
||||
uint32_t p[2] = { wq_rd32(u + L->pluginBundleID), wq_rd32(u + L->pluginContainer) };
|
||||
if (p[0] && !(p[0] & 3) && (p[0] >> 2) < kIDs && p[1] && !(p[1] & 3) && (p[1] >> 2) < kIDs &&
|
||||
plugins.length / sizeof(p) < kWQLSMaxBundleRecords) {
|
||||
[plugins appendBytes:p length:sizeof(p)];
|
||||
for (int i = 0; i < 2; i++) need[(p[i] >> 2) / 8] |= 1 << ((p[i] >> 2) % 8);
|
||||
}
|
||||
}
|
||||
if (size != L->bundleSize) continue;
|
||||
NSMutableData *recs = layoutRecs[l];
|
||||
if (recs.length / (5 * sizeof(uint32_t)) >= kWQLSMaxBundleRecords) {
|
||||
tooManyRecords = YES;
|
||||
break;
|
||||
}
|
||||
uint32_t r[5] = { wq_rd32(u + L->bundleID), wq_rd32(u + L->name), wq_rd32(u + L->displayName), wq_rd32(u + L->container),
|
||||
wq_rd32(u + L->bundlePath) };
|
||||
if (!r[0] || (r[0] & 3) || (r[0] >> 2) >= kIDs) continue;
|
||||
[recs appendBytes:r length:sizeof(r)];
|
||||
for (int i = 0; i < 5; i++) {
|
||||
if (r[i] && !(r[i] & 3) && (r[i] >> 2) < kIDs)
|
||||
need[(r[i] >> 2) / 8] |= 1 << ((r[i] >> 2) % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tooManyRecords) {
|
||||
free(need);
|
||||
free(inHeap);
|
||||
wq_log(@"names: LaunchServices store exceeds record limit");
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Pass 2: the strings, preferring copies inside the string table. On
|
||||
// iOS 26 container paths live in a second table whose ids overlap the
|
||||
// string table's, so path-shaped strings are also kept by id on their own
|
||||
// (an id with two different paths is unusable).
|
||||
NSMutableDictionary<NSNumber *, NSString *> *strings = [NSMutableDictionary new];
|
||||
NSMutableDictionary<NSNumber *, id> *pathStrings = [NSMutableDictionary new];
|
||||
NSMutableDictionary<NSNumber *, id> *appPathStrings = [NSMutableDictionary new];
|
||||
NSUInteger decodedStringBytes = 0;
|
||||
BOOL tooManyStrings = NO;
|
||||
for (size_t o = 0; o <= n - 8; o += 4) {
|
||||
uint32_t sid = wq_rd32(b + o);
|
||||
if (!sid || sid >= kIDs || !(need[sid / 8] & (1 << (sid % 8)))) continue;
|
||||
BOOL here = o >= heap, had = strings[@(sid)] != nil;
|
||||
BOOL keepGeneral = !had || (here && !(inHeap[sid / 8] & (1 << (sid % 8))));
|
||||
uint32_t peekLen = o + 8 <= n ? wq_rd32(b + o + 4) : 0;
|
||||
BOOL maybePath = peekLen >= 6 && peekLen < 0x400 && o + 8 < n && b[o + 8] == '/';
|
||||
if (!keepGeneral && !maybePath) continue;
|
||||
NSString *s = wq_cs_string(b, n, o);
|
||||
if (!s) continue;
|
||||
if (maybePath) {
|
||||
NSMutableDictionary *table = [s containsString:@"/mobile/Containers/Data/"] ? pathStrings
|
||||
: ([s hasSuffix:@".app"] || [s hasSuffix:@".app/"]) ? appPathStrings : nil;
|
||||
id previous = table[@(sid)];
|
||||
if (table && !previous) table[@(sid)] = s;
|
||||
else if (table && ![previous isEqual:s]) table[@(sid)] = NSNull.null;
|
||||
}
|
||||
if (!keepGeneral) continue;
|
||||
if (!had && (strings.count >= kWQLSMaxDecodedStrings ||
|
||||
s.length > kWQLSMaxDecodedStringBytes - decodedStringBytes)) {
|
||||
tooManyStrings = YES;
|
||||
break;
|
||||
}
|
||||
strings[@(sid)] = s;
|
||||
if (!had) decodedStringBytes += s.length;
|
||||
if (here) inHeap[sid / 8] |= 1 << (sid % 8);
|
||||
}
|
||||
free(need);
|
||||
free(inHeap);
|
||||
if (tooManyStrings) {
|
||||
wq_log(@"names: LaunchServices store exceeds string limit");
|
||||
return NO;
|
||||
}
|
||||
NSUInteger anyRecords = 0, anyPlugins = 0;
|
||||
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
|
||||
anyRecords += layoutRecs[l].length / (5 * sizeof(uint32_t));
|
||||
anyPlugins += layoutPlugins[l].length / (2 * sizeof(uint32_t));
|
||||
}
|
||||
if (!anyRecords) {
|
||||
wq_ls_log_summary(b, n, @"none", 0, anyPlugins, 0, 0);
|
||||
wq_log(@"names: LaunchServices store contains no bundle records");
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSString *(^pathFor)(uint32_t) = ^NSString *(uint32_t ref) {
|
||||
id path = pathStrings[@(ref >> 2)];
|
||||
if (path == NSNull.null) return nil;
|
||||
return path ?: strings[@(ref >> 2)];
|
||||
};
|
||||
NSDictionary *display = nil, *bundleName = nil, *containers = nil, *bundlePaths = nil;
|
||||
size_t chosen = 0;
|
||||
for (size_t l = 0; l < kWQLSLayoutCount; l++) {
|
||||
NSMutableDictionary *layoutDisplay = [NSMutableDictionary new], *layoutBundleName = [NSMutableDictionary new];
|
||||
NSMutableDictionary *layoutBundlePaths = [NSMutableDictionary new];
|
||||
NSMutableDictionary<NSString *, id> *layoutContainers = [NSMutableDictionary new];
|
||||
// A path claimed by two different bundle ids is left out rather than guessed.
|
||||
// Plug-in records only claim extension containers and bundle records
|
||||
// only app containers, so a record type that happens to share a size
|
||||
// can't knock out the other table's paths.
|
||||
void (^claim)(NSString *, NSString *, NSString *) = ^(NSString *path, NSString *bid, NSString *kind) {
|
||||
// iOS 26 stores container paths with a trailing slash; lookups
|
||||
// use the /private/var form without one.
|
||||
if ([path hasSuffix:@"/"]) path = [path substringToIndex:path.length - 1];
|
||||
if ([path hasPrefix:@"/var/"]) path = [@"/private" stringByAppendingString:path];
|
||||
if (!wq_is_data_container_path(path) || !wq_looks_like_bundle_id(bid) || ![path containsString:kind]) return;
|
||||
id known = layoutContainers[path];
|
||||
if (!known) layoutContainers[path] = bid;
|
||||
else if (![known isEqual:bid]) layoutContainers[path] = NSNull.null;
|
||||
};
|
||||
NSData *plugins = layoutPlugins[l], *recs = layoutRecs[l];
|
||||
const uint32_t *pr = plugins.bytes;
|
||||
for (NSUInteger i = 0; i < plugins.length / sizeof(uint32_t); i += 2)
|
||||
claim(pathFor(pr[i + 1]), strings[@(pr[i] >> 2)], @"/Data/PluginKitPlugin/");
|
||||
const uint32_t *r = recs.bytes;
|
||||
for (NSUInteger i = 0; i < recs.length / sizeof(uint32_t); i += 5) {
|
||||
NSString *bid = strings[@(r[i] >> 2)];
|
||||
if (!wq_looks_like_bundle_id(bid)) continue;
|
||||
if (r[i + 3] && !(r[i + 3] & 3)) claim(pathFor(r[i + 3]), bid, @"/Data/Application/");
|
||||
NSString *nm = (r[i + 1] & 3) ? nil : strings[@(r[i + 1] >> 2)];
|
||||
NSString *dn = (r[i + 2] & 3) ? nil : strings[@(r[i + 2] >> 2)];
|
||||
NSString *safeDisplay = wq_safe_name(dn);
|
||||
NSString *safeBundleName = wq_safe_name(nm);
|
||||
if (safeDisplay && !layoutDisplay[bid]) layoutDisplay[bid] = safeDisplay;
|
||||
if (safeBundleName && !layoutBundleName[bid]) layoutBundleName[bid] = safeBundleName;
|
||||
id appPath = (r[i + 4] && !(r[i + 4] & 3)) ? appPathStrings[@(r[i + 4] >> 2)] : nil;
|
||||
if ([appPath isKindOfClass:NSString.class] && !layoutBundlePaths[bid]) {
|
||||
NSString *clean = [appPath hasSuffix:@"/"] ? [appPath substringToIndex:[appPath length] - 1] : appPath;
|
||||
if ([clean hasPrefix:@"/var/"]) clean = [@"/private" stringByAppendingString:clean];
|
||||
layoutBundlePaths[bid] = clean;
|
||||
}
|
||||
}
|
||||
for (NSString *path in layoutContainers.allKeys)
|
||||
if (layoutContainers[path] == NSNull.null) [layoutContainers removeObjectForKey:path];
|
||||
if (!containers || layoutContainers.count > containers.count ||
|
||||
(layoutContainers.count == containers.count && layoutDisplay.count > display.count)) {
|
||||
display = layoutDisplay;
|
||||
bundleName = layoutBundleName;
|
||||
bundlePaths = layoutBundlePaths;
|
||||
containers = layoutContainers;
|
||||
chosen = l;
|
||||
}
|
||||
}
|
||||
// A few Apple apps' names, to check the name fields of a new layout.
|
||||
NSMutableArray *samples = [NSMutableArray new];
|
||||
for (NSString *bid in @[@"com.apple.mobilesafari", @"com.apple.Preferences", @"com.apple.camera", @"com.apple.mobilenotes"]) {
|
||||
NSString *name = display[bid] ?: bundleName[bid];
|
||||
if (name) [samples addObject:[NSString stringWithFormat:@"%@ = %@", bid, name]];
|
||||
}
|
||||
// The container scan and the name loader each parse the same store; log
|
||||
// its summary once per distinct result, not once per parse.
|
||||
static NSString *lastSummary;
|
||||
NSString *summary = [NSString stringWithFormat:@"%zu %zu %lu %lu", n, chosen, (unsigned long)display.count,
|
||||
(unsigned long)containers.count];
|
||||
BOOL newSummary;
|
||||
@synchronized (NSObject.class) {
|
||||
newSummary = ![summary isEqualToString:lastSummary];
|
||||
if (newSummary) lastSummary = summary;
|
||||
}
|
||||
if (newSummary && samples.count) wq_log(@"names: sample: %@", [samples componentsJoinedByString:@", "]);
|
||||
if (newSummary) wq_ls_log_summary(b, n, @(kWQLSLayouts[chosen].label), layoutRecs[chosen].length / (5 * sizeof(uint32_t)),
|
||||
layoutPlugins[chosen].length / (2 * sizeof(uint32_t)), display.count, containers.count);
|
||||
// A layout that maps only a small part of the container paths in the store
|
||||
// is the wrong one (its names are wrong too): don't use the store at all,
|
||||
// so the app list falls back to the scan instead of showing a few apps.
|
||||
NSUInteger storePaths = wq_ls_container_path_refs(b, n).length / sizeof(uint32_t);
|
||||
if (storePaths >= 20 && containers.count * 2 < storePaths) {
|
||||
if (newSummary)
|
||||
wq_log(@"names: layout %s maps only %lu of %lu container paths; this iOS version's database layout isn't known, so it isn't used",
|
||||
kWQLSLayouts[chosen].label, (unsigned long)containers.count, (unsigned long)storePaths);
|
||||
return NO;
|
||||
}
|
||||
if (bundlePathsOut) *bundlePathsOut = [bundlePaths copy];
|
||||
if (displayOut) *displayOut = [display copy];
|
||||
if (bundleNameOut) *bundleNameOut = [bundleName copy];
|
||||
if (containersOut) *containersOut = [containers copy];
|
||||
return YES;
|
||||
}
|
||||
|
||||
static NSDictionary *gLSDisplay;
|
||||
static NSDictionary *gLSBundleName;
|
||||
static NSDictionary *gLSContainers;
|
||||
static NSDictionary *gLSBundlePaths;
|
||||
static NSString *gLSStorePath;
|
||||
static NSDictionary *gLSStoreSignature;
|
||||
static BOOL gLSLoaded;
|
||||
static NSUInteger gLSFailureCount;
|
||||
static NSTimeInterval gLSNextRetryAt;
|
||||
|
||||
static void wq_ls_clear_snapshot_if_changed(BOOL changed)
|
||||
{
|
||||
if (!changed) return;
|
||||
gLSDisplay = nil;
|
||||
gLSBundleName = nil;
|
||||
gLSContainers = nil;
|
||||
gLSBundlePaths = nil;
|
||||
gLSStorePath = nil;
|
||||
gLSStoreSignature = nil;
|
||||
gLSLoaded = NO;
|
||||
}
|
||||
|
||||
static void wq_ls_note_failure(NSTimeInterval now, NSString *reason)
|
||||
{
|
||||
gLSFailureCount = MIN(gLSFailureCount + 1, (NSUInteger)6);
|
||||
NSUInteger shift = gLSFailureCount > 1 ? gLSFailureCount - 1 : 0;
|
||||
NSTimeInterval delay = (NSTimeInterval)(1u << MIN(shift, (NSUInteger)5));
|
||||
gLSNextRetryAt = now + delay;
|
||||
if (reason) wq_log(@"names: %@; retrying in %.0f s", reason, delay);
|
||||
}
|
||||
|
||||
static void wq_load_ls_names_locked(void)
|
||||
{
|
||||
NSTimeInterval now = NSDate.date.timeIntervalSinceReferenceDate;
|
||||
BOOL currentChanged = NO;
|
||||
if (gLSLoaded && gLSStorePath) {
|
||||
struct stat current;
|
||||
if (wq_ls_candidate_info(gLSStorePath, ¤t)) {
|
||||
NSDictionary *signature = wq_ls_signature(¤t);
|
||||
currentChanged = !wq_ls_signature_equal(signature, gLSStoreSignature);
|
||||
if (!currentChanged) return;
|
||||
} else {
|
||||
currentChanged = YES;
|
||||
}
|
||||
} else if (!gLSLoaded && now < gLSNextRetryAt) {
|
||||
return;
|
||||
} else if (!gLSLoaded && ![NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey].length) {
|
||||
NSDate *missed = [NSUserDefaults.standardUserDefaults objectForKey:kWQLSMissKey];
|
||||
if ([missed isKindOfClass:NSDate.class] && now - missed.timeIntervalSinceReferenceDate < kWQLSMissRetry &&
|
||||
now >= missed.timeIntervalSinceReferenceDate) {
|
||||
gLSNextRetryAt = missed.timeIntervalSinceReferenceDate + kWQLSMissRetry;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
BOOL hadLocation = [NSUserDefaults.standardUserDefaults stringForKey:kWQLSStoreKey].length > 0;
|
||||
BOOL searched = NO;
|
||||
NSString *path = wq_find_ls_store(&searched);
|
||||
if (!path) {
|
||||
wq_ls_clear_snapshot_if_changed(currentChanged);
|
||||
if (!hadLocation && searched) {
|
||||
// Never found, after a complete search: wait a day (or a refresh).
|
||||
[NSUserDefaults.standardUserDefaults setObject:NSDate.date forKey:kWQLSMissKey];
|
||||
gLSNextRetryAt = now + kWQLSMissRetry;
|
||||
wq_log(@"names: LaunchServices database not found; searching again in a day or on refresh");
|
||||
} else {
|
||||
// A known database that's briefly unreadable, or a scan that failed.
|
||||
wq_ls_note_failure(now, @"LaunchServices database not found");
|
||||
}
|
||||
return;
|
||||
}
|
||||
[NSUserDefaults.standardUserDefaults removeObjectForKey:kWQLSMissKey];
|
||||
|
||||
struct stat before;
|
||||
if (!wq_ls_candidate_info(path, &before)) {
|
||||
wq_ls_clear_snapshot_if_changed(currentChanged);
|
||||
wq_ls_note_failure(now, wq_ls_error(path, @"candidate is no longer valid"));
|
||||
return;
|
||||
}
|
||||
NSDictionary *beforeSignature = wq_ls_signature(&before);
|
||||
BOOL snapshotChanged = !gLSLoaded || ![path isEqualToString:gLSStorePath] ||
|
||||
!wq_ls_signature_equal(beforeSignature, gLSStoreSignature);
|
||||
if (gLSLoaded && [path isEqualToString:gLSStorePath] &&
|
||||
wq_ls_signature_equal(beforeSignature, gLSStoreSignature)) {
|
||||
gLSFailureCount = 0;
|
||||
gLSNextRetryAt = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *error = nil;
|
||||
NSData *data = wq_read(path, kWQLSMaxStoreBytes + 1, &error);
|
||||
if (!data || data.length > kWQLSMaxStoreBytes) {
|
||||
wq_ls_clear_snapshot_if_changed(snapshotChanged);
|
||||
wq_ls_note_failure(now, wq_ls_error(path, error ?: @"store exceeds size limit"));
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *display = nil, *bundleName = nil, *containers = nil, *bundlePaths = nil;
|
||||
if (!wq_parse_ls_store(data, &display, &bundleName, &containers, &bundlePaths)) {
|
||||
wq_ls_clear_snapshot_if_changed(snapshotChanged);
|
||||
wq_ls_note_failure(now, wq_ls_error(path, @"store rejected by bounded parser"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not publish a snapshot that was changed while it was being read or parsed.
|
||||
struct stat after;
|
||||
if (!wq_ls_candidate_info(path, &after) || !wq_ls_stat_equal(&before, &after)) {
|
||||
wq_ls_clear_snapshot_if_changed(snapshotChanged);
|
||||
wq_ls_note_failure(now, wq_ls_error(path, @"store changed during read"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Dictionaries are immutable copies and are assigned only after a complete,
|
||||
// validated parse. Readers therefore see either the previous snapshot or this one.
|
||||
gLSDisplay = [display copy] ?: @{};
|
||||
gLSBundleName = [bundleName copy] ?: @{};
|
||||
gLSContainers = [containers copy] ?: @{};
|
||||
gLSBundlePaths = [bundlePaths copy] ?: @{};
|
||||
gLSStorePath = [path copy];
|
||||
gLSStoreSignature = [beforeSignature copy];
|
||||
gLSLoaded = YES;
|
||||
gLSFailureCount = 0;
|
||||
gLSNextRetryAt = 0;
|
||||
[NSUserDefaults.standardUserDefaults setObject:gLSStorePath forKey:kWQLSStoreKey];
|
||||
}
|
||||
|
||||
// Apple's own apps and services: LaunchServices refuses this app on 27.x
|
||||
// (OSStatus -54) and /Applications is not listable, so names come from here.
|
||||
static NSDictionary<NSString *, NSString *> *wq_builtin_names(void)
|
||||
{
|
||||
static NSDictionary *names;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
names = @{
|
||||
@"com.apple.AppStore": @"App Store", @"com.apple.mobilesafari": @"Safari", @"com.apple.weather": @"Weather",
|
||||
@"com.apple.news": @"News", @"com.apple.stocks": @"Stocks", @"com.apple.mobilemail": @"Mail",
|
||||
@"com.apple.MobileSMS": @"Messages", @"com.apple.mobilephone": @"Phone", @"com.apple.mobileslideshow": @"Photos",
|
||||
@"com.apple.camera": @"Camera", @"com.apple.mobilecal": @"Calendar", @"com.apple.mobilenotes": @"Notes",
|
||||
@"com.apple.reminders": @"Reminders", @"com.apple.Maps": @"Maps", @"com.apple.Music": @"Music",
|
||||
@"com.apple.tv": @"TV", @"com.apple.podcasts": @"Podcasts", @"com.apple.iBooks": @"Books",
|
||||
@"com.apple.Health": @"Health", @"com.apple.Passbook": @"Wallet", @"com.apple.Preferences": @"Settings",
|
||||
@"com.apple.mobiletimer": @"Clock", @"com.apple.calculator": @"Calculator", @"com.apple.compass": @"Compass",
|
||||
@"com.apple.VoiceMemos": @"Voice Memos", @"com.apple.facetime": @"FaceTime", @"com.apple.MobileAddressBook": @"Contacts",
|
||||
@"com.apple.DocumentsApp": @"Files", @"com.apple.findmy": @"Find My", @"com.apple.Home": @"Home",
|
||||
@"com.apple.shortcuts": @"Shortcuts", @"com.apple.Translate": @"Translate", @"com.apple.Fitness": @"Fitness",
|
||||
@"com.apple.Bridge": @"Watch", @"com.apple.freeform": @"Freeform", @"com.apple.journal": @"Journal",
|
||||
@"com.apple.Passwords": @"Passwords", @"com.apple.measure": @"Measure", @"com.apple.Magnifier": @"Magnifier",
|
||||
@"com.apple.tips": @"Tips", @"com.apple.siri": @"Siri", @"com.apple.games": @"Games", @"com.apple.gamecenter": @"Game Center",
|
||||
@"com.apple.Image-Playground": @"Image Playground", @"com.apple.Photos": @"Photos", @"com.apple.Keynote": @"Keynote",
|
||||
@"com.apple.Pages": @"Pages", @"com.apple.Numbers": @"Numbers", @"com.apple.mobileme.fmip1": @"Find iPhone",
|
||||
@"com.apple.store.Jolly": @"Apple Store", @"com.apple.supportapp": @"Apple Support", @"com.apple.Diagnostics": @"Diagnostics",
|
||||
@"com.apple.PosterBoard": @"Wallpaper", @"com.apple.MercuryPoster": @"Wallpaper (Mercury)",
|
||||
@"com.apple.WallpaperKit.CollectionsPoster": @"Wallpaper Collections",
|
||||
@"com.apple.PhotosUIPrivate.PhotosPosterProvider": @"Photo Wallpaper",
|
||||
@"com.apple.chrono.WidgetRenderer-Default": @"Widgets", @"com.apple.ScreenshotServicesService": @"Screenshots",
|
||||
@"com.apple.Sharing.AirDrop": @"AirDrop", @"com.apple.AppleMediaServicesUI": @"Apple Media Services",
|
||||
@"com.apple.AppleMediaServices": @"Apple Media Services", @"com.apple.ClarityCamera": @"Assistive Access Camera",
|
||||
@"com.apple.ClarityPhotos": @"Assistive Access Photos", @"com.apple.SystemVoiceAssistant": @"Voice Assistant",
|
||||
@"com.apple.ShazamEventsApp": @"Shazam", @"com.apple.ScreenTimeWidgetApplication": @"Screen Time",
|
||||
@"com.apple.CBRemoteSetup": @"Remote Setup", @"com.apple.DemoApp": @"Demo", @"com.apple.AAUIViewService": @"Apple Account",
|
||||
@"com.apple.AccountAuthenticationDialog": @"Account Sign-In", @"com.apple.StoreKitUISceneService": @"App Store Sheets",
|
||||
@"com.apple.NewDeviceOutreachApp": @"New Device Setup", @"com.apple.PDUIApp": @"Printing", @"com.apple.EventViewService": @"Events",
|
||||
@"com.apple.MXUIService": @"Media Picker", @"com.apple.ctkui": @"Smart Card", @"com.apple.InCallService": @"Call Screen",
|
||||
@"com.apple.springboard": @"SpringBoard", @"com.apple.SafariViewService": @"Safari View", @"com.apple.mobilesms.compose": @"Message Compose",
|
||||
@"com.apple.CompanionViewService": @"Companion", @"com.apple.musicrecognition": @"Music Recognition",
|
||||
@"com.apple.FontInstallViewService": @"Font Install", @"com.apple.CMViewSrvc": @"Continuity Markup",
|
||||
@"com.apple.AppleCashStickerPackHost": @"Apple Cash Stickers", @"com.apple.TVSetupUIService": @"Apple TV Setup",
|
||||
@"com.apple.HearingApp": @"Hearing", @"com.apple.CompanionSetup": @"Companion Setup",
|
||||
@"com.apple.CarPlayWallpaper": @"CarPlay Wallpaper", @"com.apple.CloudKit.ShareBear": @"iCloud Sharing",
|
||||
@"com.apple.EnhancedLogging": @"Enhanced Logging", @"com.apple.tcc.AuthorizationPromptService": @"Privacy Prompts",
|
||||
@"com.apple.WebContentFilter.remoteUI.WebContentAnalysisUI": @"Content Filter",
|
||||
@"com.apple.SystemPaperViewService": @"Quick Note", @"com.apple.HeadphoneProxService": @"Headphones",
|
||||
@"com.apple.datadetectors.DDActionsService": @"Data Detectors", @"com.apple.ServicesPaymentAngel": @"Apple Pay",
|
||||
@"com.apple.HomeCaptiveViewService": @"Wi-Fi Login", @"com.apple.DiagnosticsService": @"Diagnostics",
|
||||
@"com.apple.CarTirePressure": @"Tire Pressure", @"com.apple.FCAuthenticationUI": @"Authentication",
|
||||
@"com.apple.icloud.FindMyDevice.FindMyExtensionContainer": @"Find My",
|
||||
@"com.apple.messages.RemoteAlerts": @"Messages Alerts",
|
||||
@"com.apple.AccessoryNotifications.SourceSelection": @"Accessory Source",
|
||||
@"com.apple.printcenter": @"Print Center", @"com.apple.BarcodeScanner": @"Code Scanner",
|
||||
@"com.apple.chrono.WidgetRenderer-Snapshots": @"Widget Snapshots",
|
||||
@"com.apple.AMSEngagementViewService": @"Apple Media Services",
|
||||
@"com.apple.appManagedFeatures.Restrictions": @"Restrictions", @"com.apple.CarRadio": @"Car Radio",
|
||||
@"com.apple.PassbookUIService": @"Wallet Sheets", @"com.apple.Spotlight": @"Spotlight", @"com.apple.InputUI": @"Keyboard",
|
||||
};
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
static NSString *wq_known_name_locked(NSString *bundleID)
|
||||
{
|
||||
wq_load_ls_names_locked();
|
||||
return gLSDisplay[bundleID] ?: wq_builtin_names()[bundleID] ?: gLSBundleName[bundleID];
|
||||
}
|
||||
|
||||
BOOL wq_display_names_loaded(void)
|
||||
{
|
||||
@synchronized (wq_ls_lock()) { return gLSLoaded; }
|
||||
}
|
||||
|
||||
void wq_load_display_names(void)
|
||||
{
|
||||
@synchronized (wq_ls_lock()) { wq_load_ls_names_locked(); }
|
||||
}
|
||||
|
||||
void wq_display_names_reset_miss(void)
|
||||
{
|
||||
@synchronized (wq_ls_lock()) {
|
||||
[NSUserDefaults.standardUserDefaults removeObjectForKey:kWQLSMissKey];
|
||||
gLSNextRetryAt = 0;
|
||||
gLSFailureCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
static NSString *const kWQBundleContainerRoot = @"/private/var/containers/Bundle/Application/";
|
||||
|
||||
// App bundle containers (<root>/<UUID>, holding Name.app) -> bundle id, built
|
||||
// from the bundle paths; rebuilt when a new database snapshot is published.
|
||||
static NSDictionary<NSString *, NSString *> *wq_bundle_containers_locked(void)
|
||||
{
|
||||
static NSDictionary *source, *map;
|
||||
wq_load_ls_names_locked();
|
||||
if (map && source == gLSBundlePaths) return map;
|
||||
NSMutableDictionary *built = [NSMutableDictionary new];
|
||||
[gLSBundlePaths enumerateKeysAndObjectsUsingBlock:^(NSString *bid, NSString *path, BOOL *__unused stop) {
|
||||
if (![bid isKindOfClass:NSString.class] || ![path isKindOfClass:NSString.class]) return;
|
||||
NSString *app = path.stringByStandardizingPath;
|
||||
if ([app hasPrefix:@"/var/"]) app = [@"/private" stringByAppendingString:app];
|
||||
if (![app hasPrefix:kWQBundleContainerRoot] || ![app.pathExtension isEqualToString:@"app"]) return;
|
||||
NSString *container = app.stringByDeletingLastPathComponent;
|
||||
// Only <root>/<UUID>/Name.app: extensions and nested apps stay out.
|
||||
if (![container.stringByDeletingLastPathComponent isEqualToString:
|
||||
[kWQBundleContainerRoot substringToIndex:kWQBundleContainerRoot.length - 1]]) return;
|
||||
if (!built[container] || bid.length < [built[container] length]) built[container] = bid;
|
||||
}];
|
||||
source = gLSBundlePaths;
|
||||
map = [built copy];
|
||||
return map;
|
||||
}
|
||||
|
||||
NSString *wq_bundle_id_for_container(NSString *path)
|
||||
{
|
||||
if (!path.length) return nil;
|
||||
NSString *key = path.stringByStandardizingPath;
|
||||
if ([key hasPrefix:@"/var/"]) key = [@"/private" stringByAppendingString:key];
|
||||
@synchronized (wq_ls_lock()) {
|
||||
wq_load_ls_names_locked();
|
||||
NSString *bid = gLSContainers[key];
|
||||
if (!bid && [key hasPrefix:kWQBundleContainerRoot]) bid = wq_bundle_containers_locked()[key];
|
||||
return [bid isKindOfClass:NSString.class] ? bid : nil;
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<NSString *> *wq_ls_bundle_containers(void)
|
||||
{
|
||||
@synchronized (wq_ls_lock()) {
|
||||
return [wq_bundle_containers_locked().allKeys sortedArrayUsingSelector:@selector(compare:)];
|
||||
}
|
||||
}
|
||||
|
||||
NSString *wq_display_name(NSString *bundleID)
|
||||
{
|
||||
if (!bundleID) return nil;
|
||||
@synchronized (wq_ls_lock()) {
|
||||
NSString *name = wq_known_name_locked(bundleID);
|
||||
if (name) return WQLocalized(name);
|
||||
// Unknown id: use the closest parent app (com.apple.news.widget -> News).
|
||||
NSArray *parts = [bundleID componentsSeparatedByString:@"."];
|
||||
for (NSInteger n = (NSInteger)parts.count - 1; n >= 2; n--) {
|
||||
NSString *prefix = [[parts subarrayWithRange:NSMakeRange(0, (NSUInteger)n)] componentsJoinedByString:@"."];
|
||||
NSString *parent = wq_known_name_locked(prefix);
|
||||
if (parent) {
|
||||
NSString *suffix = wq_safe_name(parts.lastObject);
|
||||
NSString *display = suffix.length ? [NSString stringWithFormat:@"%@ \u2013 %@", parent, suffix] : parent;
|
||||
return WQLocalized(display);
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<NSString *> *wq_ls_container_paths(NSString *storeHint)
|
||||
{
|
||||
// No lock and no scan here: the container scan calls this while a name
|
||||
// load may be waiting for that scan.
|
||||
NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults;
|
||||
NSString *path = storeHint.length ? storeHint : [defaults stringForKey:kWQLSStoreKey];
|
||||
if (!path.length) return nil;
|
||||
NSString *error = nil;
|
||||
NSData *data = wq_read(path, kWQLSMaxStoreBytes + 1, &error);
|
||||
if (!data || data.length > kWQLSMaxStoreBytes) {
|
||||
wq_log(@"names: cannot read %@ (%@)", path, error ?: @"too large");
|
||||
return nil;
|
||||
}
|
||||
NSDictionary *containers = nil;
|
||||
if (!wq_parse_ls_store(data, NULL, NULL, &containers, NULL) || containers.count < 2) return nil;
|
||||
if (storeHint.length) {
|
||||
[defaults setObject:storeHint forKey:kWQLSStoreKey];
|
||||
[defaults removeObjectForKey:kWQLSMissKey];
|
||||
}
|
||||
// App group containers aren't mapped to an owner here; the store lists
|
||||
// the group containers of installed apps, which is what the list needs.
|
||||
NSMutableSet<NSString *> *all = [NSMutableSet setWithArray:containers.allKeys];
|
||||
static const char needle[] = "/mobile/Containers/Shared/AppGroup/";
|
||||
const uint8_t *bytes = data.bytes, *cursor = bytes, *end = bytes + data.length;
|
||||
while (cursor < end) {
|
||||
const uint8_t *hit = memmem(cursor, (size_t)(end - cursor), needle, sizeof(needle) - 1);
|
||||
if (!hit) break;
|
||||
cursor = hit + sizeof(needle) - 1;
|
||||
if ((size_t)(end - cursor) < 36) break;
|
||||
NSString *uuid = [[NSString alloc] initWithBytes:cursor length:36 encoding:NSASCIIStringEncoding];
|
||||
if (!uuid || ![[NSUUID alloc] initWithUUIDString:uuid]) continue;
|
||||
[all addObject:[@"/private/var/mobile/Containers/Shared/AppGroup/" stringByAppendingString:uuid.uppercaseString]];
|
||||
}
|
||||
return all.allObjects;
|
||||
}
|
||||
|
||||
NSString *wq_bundle_path_for_id(NSString *bundleID)
|
||||
{
|
||||
if (!bundleID.length) return nil;
|
||||
@synchronized (wq_ls_lock()) {
|
||||
wq_load_ls_names_locked();
|
||||
NSString *path = gLSBundlePaths[bundleID];
|
||||
return [path isKindOfClass:NSString.class] ? path : nil;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// WQLocalization.h
|
||||
// WQ Files
|
||||
//
|
||||
// Shared runtime localization helpers. English source strings are the keys;
|
||||
// zh-Hans.lproj supplies the Chinese translations and en.lproj is the
|
||||
// identity fallback.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
NSString *WQLocalized(NSString *key);
|
||||
NSString *_Nullable WQLocalizedNullable(NSString *_Nullable text);
|
||||
NSString *WQLocalizedFormat(NSString *key, ...) NS_FORMAT_FUNCTION(1, 2);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// WQLocalization.m
|
||||
// WQ Files
|
||||
//
|
||||
|
||||
#import "WQLocalization.h"
|
||||
|
||||
NSString *WQLocalized(NSString *key)
|
||||
{
|
||||
if (!key.length) return key;
|
||||
return NSLocalizedString(key, nil);
|
||||
}
|
||||
|
||||
NSString *WQLocalizedNullable(NSString *text)
|
||||
{
|
||||
return text ? WQLocalized(text) : nil;
|
||||
}
|
||||
|
||||
NSString *WQLocalizedFormat(NSString *key, ...)
|
||||
{
|
||||
va_list args;
|
||||
va_start(args, key);
|
||||
NSString *format = WQLocalized(key);
|
||||
NSString *text = [[NSString alloc] initWithFormat:format arguments:args];
|
||||
va_end(args);
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// WQMediaStream.h
|
||||
// WQ Files
|
||||
//
|
||||
// Plays media from other apps' folders without copying them first. The
|
||||
// player gets a custom URL; AVFoundation asks this object for byte ranges,
|
||||
// which WQ Files reads itself (the media process has no access to those
|
||||
// folders). If the item fails, onFailure runs once, so the caller can fall
|
||||
// back to copying the file.
|
||||
//
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NSData *_Nullable (^WQMediaReadRange)(unsigned long long offset, NSUInteger length, NSString *_Nullable *_Nullable error);
|
||||
|
||||
@interface WQMediaStream : NSObject <AVAssetResourceLoaderDelegate>
|
||||
|
||||
// The type identifier the player is told for a file name ("public.mpeg-4"),
|
||||
// or nil when the extension isn't one that streams this way.
|
||||
+ (nullable NSString *)contentTypeForFileName:(NSString *)name;
|
||||
|
||||
// A player item that reads `size` bytes through `read`. The item keeps the
|
||||
// stream alive. onFailure (main queue) gets the reason once if the item fails.
|
||||
+ (nullable AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size
|
||||
read:(WQMediaReadRange)read
|
||||
onFailure:(void (^)(NSString *reason))onFailure;
|
||||
|
||||
// For tests: answers one data request range synchronously.
|
||||
- (nullable NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString *_Nullable *_Nullable)error;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// WQMediaStream.m
|
||||
// WQ Files
|
||||
//
|
||||
|
||||
#import "WQMediaStream.h"
|
||||
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
static NSString *const kWQMediaScheme = @"wqmedia";
|
||||
static const NSUInteger kWQMediaChunk = 512 * 1024;
|
||||
static char kWQMediaStreamKey;
|
||||
|
||||
@interface WQMediaStream ()
|
||||
@property (nonatomic, copy) NSString *contentType;
|
||||
@property (nonatomic) unsigned long long size;
|
||||
@property (nonatomic, copy) WQMediaReadRange read;
|
||||
@property (nonatomic, copy) void (^onFailure)(NSString *reason);
|
||||
@property (nonatomic, weak) AVPlayerItem *item;
|
||||
@property (nonatomic) BOOL failed;
|
||||
@end
|
||||
|
||||
@implementation WQMediaStream {
|
||||
dispatch_queue_t _readQueue;
|
||||
}
|
||||
|
||||
+ (NSString *)contentTypeForFileName:(NSString *)name
|
||||
{
|
||||
NSString *ext = name.pathExtension.lowercaseString;
|
||||
// Playlists reference other files; those can't be served from here.
|
||||
if (!ext.length || [ext isEqualToString:@"m3u8"]) return nil;
|
||||
UTType *type = [UTType typeWithFilenameExtension:ext];
|
||||
if (!type || !([type conformsToType:UTTypeAudiovisualContent])) return nil;
|
||||
return type.identifier;
|
||||
}
|
||||
|
||||
+ (AVPlayerItem *)playerItemForName:(NSString *)name size:(unsigned long long)size
|
||||
read:(WQMediaReadRange)read onFailure:(void (^)(NSString *))onFailure
|
||||
{
|
||||
NSString *contentType = [self contentTypeForFileName:name];
|
||||
if (!contentType || size == 0) return nil;
|
||||
WQMediaStream *stream = [WQMediaStream new];
|
||||
stream.contentType = contentType;
|
||||
stream.size = size;
|
||||
stream.read = read;
|
||||
stream.onFailure = onFailure;
|
||||
// The extension stays on the URL: AVFoundation also looks at it.
|
||||
NSString *escaped = [name.lastPathComponent stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathAllowedCharacterSet];
|
||||
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@://media/%@", kWQMediaScheme, escaped ?: @"media"]];
|
||||
if (!url) return nil;
|
||||
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:url options:nil];
|
||||
[asset.resourceLoader setDelegate:stream queue:dispatch_queue_create("wq.media.loader", DISPATCH_QUEUE_SERIAL)];
|
||||
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:asset];
|
||||
stream.item = item;
|
||||
// The resource loader holds its delegate weakly: the item keeps it.
|
||||
objc_setAssociatedObject(item, &kWQMediaStreamKey, stream, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
[item addObserver:stream forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:&kWQMediaStreamKey];
|
||||
return item;
|
||||
}
|
||||
|
||||
- (instancetype)init
|
||||
{
|
||||
if ((self = [super init])) _readQueue = dispatch_queue_create("wq.media.read", DISPATCH_QUEUE_SERIAL);
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
@try { [_item removeObserver:self forKeyPath:@"status" context:&kWQMediaStreamKey]; } @catch (NSException *__unused e) {}
|
||||
}
|
||||
|
||||
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
|
||||
{
|
||||
if (context != &kWQMediaStreamKey) { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; return; }
|
||||
AVPlayerItem *item = object;
|
||||
if (item.status != AVPlayerItemStatusFailed) return;
|
||||
NSString *reason = item.error.localizedDescription ?: @"the player failed";
|
||||
NSError *underlying = item.error.userInfo[NSUnderlyingErrorKey];
|
||||
if (underlying) reason = [reason stringByAppendingFormat:@" (%@ %ld)", underlying.domain, (long)underlying.code];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (self.failed) return;
|
||||
self.failed = YES;
|
||||
if (self.onFailure) self.onFailure(reason);
|
||||
});
|
||||
}
|
||||
|
||||
- (NSData *)dataForOffset:(unsigned long long)offset length:(NSUInteger)length error:(NSString **)error
|
||||
{
|
||||
if (offset >= _size) return [NSData data];
|
||||
unsigned long long left = _size - offset;
|
||||
NSUInteger want = (NSUInteger)MIN((unsigned long long)length, left);
|
||||
return _read(offset, want, error);
|
||||
}
|
||||
|
||||
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader
|
||||
shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)request
|
||||
{
|
||||
if (![request.request.URL.scheme isEqualToString:kWQMediaScheme]) return NO;
|
||||
AVAssetResourceLoadingContentInformationRequest *info = request.contentInformationRequest;
|
||||
if (info) {
|
||||
info.contentType = _contentType;
|
||||
info.contentLength = (long long)_size;
|
||||
info.byteRangeAccessSupported = YES;
|
||||
}
|
||||
AVAssetResourceLoadingDataRequest *data = request.dataRequest;
|
||||
if (!data) { [request finishLoading]; return YES; }
|
||||
// Reads happen off the loader queue, in chunks, so a seek can cancel a
|
||||
// long request between chunks.
|
||||
dispatch_async(_readQueue, ^{
|
||||
long long start = data.currentOffset;
|
||||
long long end = data.requestsAllDataToEndOfResource ? (long long)self->_size
|
||||
: data.requestedOffset + (long long)data.requestedLength;
|
||||
end = MIN(end, (long long)self->_size);
|
||||
long long offset = start;
|
||||
while (offset < end) {
|
||||
if (request.isCancelled || request.isFinished) return;
|
||||
NSUInteger length = (NSUInteger)MIN((long long)kWQMediaChunk, end - offset);
|
||||
NSString *error = nil;
|
||||
NSData *chunk = [self dataForOffset:(unsigned long long)offset length:length error:&error];
|
||||
if (!chunk.length) {
|
||||
if (request.isCancelled || request.isFinished) return;
|
||||
[request finishLoadingWithError:[NSError errorWithDomain:@"WQMediaStream" code:1
|
||||
userInfo:@{NSLocalizedDescriptionKey: error ?: @"The file could not be read"}]];
|
||||
return;
|
||||
}
|
||||
[data respondWithData:chunk];
|
||||
offset += (long long)chunk.length;
|
||||
}
|
||||
if (!request.isCancelled && !request.isFinished) [request finishLoading];
|
||||
});
|
||||
return YES;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// WQPlistEditor.h
|
||||
// WQ Files
|
||||
//
|
||||
// Property list editor: browse dictionaries and arrays, edit values by type,
|
||||
// add, delete, rename and reorder. Saves in the file's original format through
|
||||
// wq_write_if_unchanged_get_version (backup first, verified contents).
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "WQEditing.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface WQPlistEditorViewController : UITableViewController <WQUnsavedEditing>
|
||||
// Loads `path` itself. openAsText / openAsHex (optional) switch to the other
|
||||
// viewers from the "Open As" menu.
|
||||
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly;
|
||||
@property (nonatomic, copy, nullable) void (^openAsText)(void);
|
||||
@property (nonatomic, copy, nullable) void (^openAsHex)(void);
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,738 @@
|
||||
//
|
||||
// WQPlistEditor.m
|
||||
// WQ Files
|
||||
//
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQPlistEditor.h"
|
||||
#import "WQAccess.h"
|
||||
#import "WQCore.h"
|
||||
#import "WQUI.h"
|
||||
#import "WQPlistEditorSupport.h"
|
||||
|
||||
typedef NS_ENUM(NSInteger, WQPType) {
|
||||
WQPTypeString, WQPTypeInteger, WQPTypeReal, WQPTypeBoolean, WQPTypeDate, WQPTypeData,
|
||||
WQPTypeDictionary, WQPTypeArray, WQPTypeUID, WQPTypeUnknown,
|
||||
};
|
||||
|
||||
static WQPType wqp_type(id v)
|
||||
{
|
||||
if ([v isKindOfClass:NSString.class]) return WQPTypeString;
|
||||
if ([v isKindOfClass:NSNumber.class]) {
|
||||
if (CFGetTypeID((__bridge CFTypeRef)v) == CFBooleanGetTypeID()) return WQPTypeBoolean;
|
||||
return CFNumberIsFloatType((__bridge CFNumberRef)v) ? WQPTypeReal : WQPTypeInteger;
|
||||
}
|
||||
if ([v isKindOfClass:NSDate.class]) return WQPTypeDate;
|
||||
if ([v isKindOfClass:NSData.class]) return WQPTypeData;
|
||||
if ([v isKindOfClass:NSDictionary.class]) return WQPTypeDictionary;
|
||||
if ([v isKindOfClass:NSArray.class]) return WQPTypeArray;
|
||||
if (WQPlistIsKeyedArchiveUID(v)) return WQPTypeUID;
|
||||
return WQPTypeUnknown;
|
||||
}
|
||||
|
||||
static NSString *wqp_type_name(WQPType t)
|
||||
{
|
||||
switch (t) {
|
||||
case WQPTypeString: return WQLocalized(@"String");
|
||||
case WQPTypeInteger: return WQLocalized(@"Number");
|
||||
case WQPTypeReal: return WQLocalized(@"Real");
|
||||
case WQPTypeBoolean: return WQLocalized(@"Boolean");
|
||||
case WQPTypeDate: return WQLocalized(@"Date");
|
||||
case WQPTypeData: return WQLocalized(@"Data");
|
||||
case WQPTypeDictionary: return WQLocalized(@"Dictionary");
|
||||
case WQPTypeArray: return WQLocalized(@"Array");
|
||||
case WQPTypeUID: return WQLocalized(@"Reference");
|
||||
case WQPTypeUnknown: return @"?";
|
||||
}
|
||||
return @"?";
|
||||
}
|
||||
|
||||
static NSString *wqp_hex(NSData *data, NSUInteger max)
|
||||
{
|
||||
const uint8_t *b = data.bytes;
|
||||
NSUInteger n = MIN(data.length, max);
|
||||
NSMutableString *s = [NSMutableString stringWithCapacity:n * 3];
|
||||
for (NSUInteger i = 0; i < n; i++) [s appendFormat:i ? @" %02x" : @"%02x", b[i]];
|
||||
if (data.length > max) [s appendString:@" …"];
|
||||
return s;
|
||||
}
|
||||
|
||||
static NSISO8601DateFormatter *wqp_dates(void)
|
||||
{
|
||||
static NSISO8601DateFormatter *f;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ f = [NSISO8601DateFormatter new]; });
|
||||
return f;
|
||||
}
|
||||
|
||||
static NSString *wqp_summary(id v)
|
||||
{
|
||||
switch (wqp_type(v)) {
|
||||
case WQPTypeString: return WQLocalized(WQPBoundedStringPreview(v));
|
||||
case WQPTypeInteger: case WQPTypeReal: return [v stringValue];
|
||||
case WQPTypeBoolean: return [v boolValue] ? WQLocalized(@"YES") : WQLocalized(@"NO");
|
||||
case WQPTypeDate: return [wqp_dates() stringFromDate:v];
|
||||
case WQPTypeData: return WQLocalizedFormat(@"%lu bytes · %@", (unsigned long)[v length], wqp_hex(v, 24));
|
||||
case WQPTypeDictionary: return WQLocalizedFormat(@"%lu keys", (unsigned long)[v count]);
|
||||
case WQPTypeArray: return WQLocalizedFormat(@"%lu items", (unsigned long)[v count]);
|
||||
case WQPTypeUID: return WQLocalized(@"keyed-archive reference");
|
||||
case WQPTypeUnknown: return WQPBoundedStringPreview([v description]);
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
static NSString *wqp_scalar_text(id value, WQPType t)
|
||||
{
|
||||
if (t == WQPTypeData) return wqp_hex(value, WQPMaxDataBytes);
|
||||
if (t == WQPTypeDate) return [wqp_dates() stringFromDate:value];
|
||||
if ([value isKindOfClass:NSString.class]) return value;
|
||||
if ([value isKindOfClass:NSNumber.class]) return [value stringValue];
|
||||
return @"";
|
||||
}
|
||||
|
||||
static id wqp_default(WQPType t)
|
||||
{
|
||||
switch (t) {
|
||||
case WQPTypeString: return @"";
|
||||
case WQPTypeInteger: return @0;
|
||||
case WQPTypeReal: return @0.0;
|
||||
case WQPTypeBoolean: return @NO;
|
||||
case WQPTypeDate: return NSDate.date;
|
||||
case WQPTypeData: return [NSData data];
|
||||
case WQPTypeDictionary: return [NSMutableDictionary dictionary];
|
||||
case WQPTypeArray: return [NSMutableArray array];
|
||||
case WQPTypeUID: return @"";
|
||||
case WQPTypeUnknown: return @"";
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
static void wqp_alert(UIViewController *host, NSString *title, NSString *message)
|
||||
{
|
||||
UIAlertController *a = wq_alert_controller(title, message, UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
|
||||
[host presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Document
|
||||
|
||||
@interface WQPlistDocument : NSObject
|
||||
@property (nonatomic, copy) NSString *path;
|
||||
@property (nonatomic, strong) id root;
|
||||
@property (nonatomic) NSPropertyListFormat format;
|
||||
@property (nonatomic) BOOL readOnly;
|
||||
@property (nonatomic, strong) WQEditState *edit;
|
||||
@property (nonatomic, strong) NSDictionary<NSString *, NSNumber *> *version;
|
||||
@end
|
||||
|
||||
@implementation WQPlistDocument
|
||||
@end
|
||||
|
||||
#pragma mark - Editor
|
||||
|
||||
@interface WQPlistEditorViewController ()
|
||||
@property (nonatomic, strong) WQPlistDocument *doc;
|
||||
@property (nonatomic, strong) id container; // NSMutableDictionary or NSMutableArray
|
||||
@property (nonatomic, copy) NSArray *keys; // sorted keys (dictionary) or nil (array)
|
||||
@property (nonatomic, copy) NSString *status; // loading / error text
|
||||
@property (nonatomic) BOOL isRoot;
|
||||
@end
|
||||
|
||||
@implementation WQPlistEditorViewController {
|
||||
NSArray *_sortedKeyCache;
|
||||
BOOL _keysDirty;
|
||||
}
|
||||
|
||||
- (instancetype)initWithPath:(NSString *)path readOnly:(BOOL)readOnly
|
||||
{
|
||||
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
|
||||
_doc = [WQPlistDocument new];
|
||||
_doc.path = path;
|
||||
_doc.readOnly = readOnly;
|
||||
_doc.edit = [WQEditState new];
|
||||
_isRoot = YES;
|
||||
_status = WQLocalized(@"Loading…");
|
||||
self.title = path.lastPathComponent;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithDocument:(WQPlistDocument *)doc container:(id)container title:(NSString *)title
|
||||
{
|
||||
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
|
||||
_doc = doc;
|
||||
_container = container;
|
||||
self.title = title;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
self.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
if (self.isRoot) {
|
||||
self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) { [weakSelf leave]; }];
|
||||
[self load];
|
||||
} else {
|
||||
self.navigationItem.backAction = [UIAction actionWithHandler:^(UIAction *__unused a) {
|
||||
if ([weakSelf blockWhileSaving]) return;
|
||||
[weakSelf.navigationController popViewControllerAnimated:YES];
|
||||
}];
|
||||
}
|
||||
[self updateBar];
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated
|
||||
{
|
||||
[super viewWillAppear:animated];
|
||||
[self refresh];
|
||||
}
|
||||
|
||||
- (void)load
|
||||
{
|
||||
NSString *path = self.doc.path;
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||
NSString *error = nil;
|
||||
NSDictionary *__unused before = wq_file_version(path);
|
||||
NSData *data = wq_read(path, WQPMaxSerializedBytes, &error);
|
||||
NSDictionary *version = wq_file_version(path);
|
||||
if (data && (!before || ![before isEqualToDictionary:version])) {
|
||||
data = nil;
|
||||
error = WQLocalized(@"The file changed while it was being read. Open it again.");
|
||||
}
|
||||
NSPropertyListFormat format = NSPropertyListBinaryFormat_v1_0;
|
||||
NSError *parseError = nil;
|
||||
id root = data ? [NSPropertyListSerialization propertyListWithData:data
|
||||
options:NSPropertyListMutableContainersAndLeaves
|
||||
format:&format error:&parseError] : nil;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self.doc.version = version;
|
||||
if (!root) {
|
||||
self.status = data ? WQLocalizedFormat(@"Not a property list: %@", parseError.localizedDescription)
|
||||
: (wq_permission_hint(path, NO) ?: WQLocalizedFormat(@"Cannot read this file: %@", error));
|
||||
} else if (![root isKindOfClass:NSMutableDictionary.class] && ![root isKindOfClass:NSMutableArray.class]) {
|
||||
// A plist whose root is a single value: wrap it for display only.
|
||||
self.doc.format = format;
|
||||
self.doc.root = root;
|
||||
self.doc.readOnly = YES;
|
||||
self.container = [NSMutableArray arrayWithObject:root];
|
||||
self.status = nil;
|
||||
} else {
|
||||
if (wq_permission_hint(path, YES)) self.doc.readOnly = YES; // readable, but only the owner may write
|
||||
if (!WQPlistFormatIsWritable(format)) self.doc.readOnly = YES; // OpenStep/unknown format
|
||||
self.doc.root = root;
|
||||
self.doc.format = format;
|
||||
self.container = root;
|
||||
self.status = nil;
|
||||
}
|
||||
[self refresh];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (void)refresh
|
||||
{
|
||||
if ([self.container isKindOfClass:NSDictionary.class]) {
|
||||
if (!_sortedKeyCache || _keysDirty) {
|
||||
_sortedKeyCache = [[self.container allKeys] sortedArrayUsingSelector:@selector(localizedStandardCompare:)];
|
||||
_keysDirty = NO;
|
||||
}
|
||||
self.keys = _sortedKeyCache;
|
||||
} else {
|
||||
self.keys = nil;
|
||||
_sortedKeyCache = nil;
|
||||
_keysDirty = NO;
|
||||
}
|
||||
[self updateBar];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (BOOL)isArray
|
||||
{
|
||||
return [self.container isKindOfClass:NSArray.class];
|
||||
}
|
||||
|
||||
- (void)updateBar
|
||||
{
|
||||
NSMutableArray *items = [NSMutableArray array];
|
||||
if (!self.doc.readOnly && self.container) {
|
||||
UIBarButtonItem *save = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Save") style:wq_primary_button_style()
|
||||
target:self action:@selector(save)];
|
||||
save.enabled = self.doc.edit.dirty && !self.doc.edit.saving;
|
||||
[items addObject:save];
|
||||
UIBarButtonItem *add = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd
|
||||
target:self action:@selector(add)];
|
||||
add.enabled = !self.doc.edit.saving;
|
||||
[items addObject:add];
|
||||
}
|
||||
NSMutableArray *menu = [NSMutableArray array];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
if (self.isArray && !self.doc.readOnly && !self.doc.edit.saving && self.container) {
|
||||
[menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(self.tableView.editing ? @"Done Reordering" : @"Reorder Items")
|
||||
image:[UIImage systemImageNamed:@"arrow.up.arrow.down"] identifier:nil
|
||||
handler:^(UIAction *__unused a) {
|
||||
[weakSelf.tableView setEditing:!weakSelf.tableView.editing animated:YES];
|
||||
[weakSelf updateBar];
|
||||
}]];
|
||||
}
|
||||
if (self.openAsText) [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open as XML Text") image:[UIImage systemImageNamed:@"doc.plaintext"]
|
||||
identifier:nil handler:^(UIAction *__unused a) { [weakSelf leaveThen:weakSelf.openAsText]; }]];
|
||||
if (self.openAsHex) [menu addObject:[UIAction actionWithTitle:WQLocalizedNullable(@"Open in Hex Viewer") image:[UIImage systemImageNamed:@"number"]
|
||||
identifier:nil handler:^(UIAction *__unused a) { [weakSelf leaveThen:weakSelf.openAsHex]; }]];
|
||||
if (menu.count)
|
||||
[items addObject:[[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"]
|
||||
menu:[UIMenu menuWithChildren:menu]]];
|
||||
self.navigationItem.rightBarButtonItems = items;
|
||||
}
|
||||
|
||||
#pragma mark Leaving and saving
|
||||
|
||||
- (BOOL)wq_needsLeaveConfirmation
|
||||
{
|
||||
// Ordinary dirty child navigation keeps the shared edits, but no editor
|
||||
// may leave while the shared document is being written.
|
||||
return self.doc.edit.saving || (self.isRoot && self.doc.edit.dirty);
|
||||
}
|
||||
|
||||
- (void)leave
|
||||
{
|
||||
[self leaveThen:nil];
|
||||
}
|
||||
|
||||
- (BOOL)blockWhileSaving
|
||||
{
|
||||
if (!self.doc.edit.saving) return NO;
|
||||
wqp_alert(self, @"Save in progress", @"Wait for the current save to finish before leaving or making more changes.");
|
||||
return YES;
|
||||
}
|
||||
|
||||
// Asks before throwing away unsaved changes, then pops to the folder and runs `then`.
|
||||
- (void)leaveThen:(void (^)(void))then
|
||||
{
|
||||
if ([self blockWhileSaving]) return;
|
||||
UINavigationController *nav = self.navigationController;
|
||||
NSUInteger rootIndex = [nav.viewControllers indexOfObjectPassingTest:^BOOL(UIViewController *vc, NSUInteger __unused i, BOOL *__unused stop) {
|
||||
return [vc isKindOfClass:WQPlistEditorViewController.class] && ((WQPlistEditorViewController *)vc).isRoot &&
|
||||
((WQPlistEditorViewController *)vc).doc == self.doc;
|
||||
}];
|
||||
void (^go)(void) = ^{
|
||||
if (rootIndex != NSNotFound && rootIndex > 0)
|
||||
[nav popToViewController:nav.viewControllers[rootIndex - 1] animated:then == nil];
|
||||
if (then) then();
|
||||
};
|
||||
if (!self.doc.edit.dirty) { go(); return; }
|
||||
UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your edits to this property list have not been saved.", UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
|
||||
[self.doc.edit discard];
|
||||
go();
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// Home button: ask before throwing away unsaved changes.
|
||||
- (void)confirmLeaveThen:(void (^)(void))proceed
|
||||
{
|
||||
if ([self blockWhileSaving]) return;
|
||||
if (!self.doc.edit.dirty) { proceed(); return; }
|
||||
UIAlertController *a = wq_alert_controller(@"Discard changes?", @"Your edits to this property list have not been saved.", UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"Keep Editing", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Discard", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
|
||||
[self.doc.edit discard];
|
||||
proceed();
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)save
|
||||
{
|
||||
if ([self blockWhileSaving] || self.doc.readOnly || !self.doc.edit.dirty) return;
|
||||
WQPlistDocument *doc = self.doc;
|
||||
if (!WQPlistFormatIsWritable(doc.format)) {
|
||||
wqp_alert(self, @"Read-only format", @"This property list uses an OpenStep/unknown format that cannot be saved by the editor. Use Open as XML Text and choose a supported format explicitly.");
|
||||
return;
|
||||
}
|
||||
if (!doc.version) {
|
||||
wqp_alert(self, @"Cannot save safely", @"The current file version is unavailable. Reopen the file before saving again.");
|
||||
return;
|
||||
}
|
||||
NSString *fmt = WQLocalized(WQPlistFormatName(doc.format));
|
||||
UIAlertController *a = wq_alert_controller(@"Save changes?", WQLocalizedFormat(@"%@ is overwritten in place (%@ format). The current version is backed up first.", doc.path, fmt), UIAlertControllerStyleAlert);
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Save", UIAlertActionStyleDestructive, ^(UIAlertAction *__unused x) {
|
||||
NSUInteger savedRevision = [doc.edit beginSave];
|
||||
NSDictionary *expectedVersion = [doc.version copy];
|
||||
NSString *path = [doc.path copy];
|
||||
NSPropertyListFormat format = doc.format;
|
||||
id root = doc.root;
|
||||
UINavigationController *navigationController = self.navigationController;
|
||||
__weak WQPlistEditorViewController *weakSelf = self;
|
||||
[self refresh];
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||
NSError *serializationError = nil;
|
||||
NSString *writeError = nil;
|
||||
BOOL ok = NO;
|
||||
NSDictionary *savedVersion = nil;
|
||||
NSString *copyError = nil;
|
||||
id snapshot = WQPlistImmutableCopy(root, ©Error);
|
||||
NSData *data = snapshot ? [NSPropertyListSerialization dataWithPropertyList:snapshot
|
||||
format:format
|
||||
options:0
|
||||
error:&serializationError] : nil;
|
||||
if (!snapshot) {
|
||||
writeError = copyError ?: WQLocalized(@"The edited property list contains an unsupported value.");
|
||||
} else if (!data) {
|
||||
writeError = serializationError.localizedDescription ?: WQLocalized(@"The edited property list is not valid.");
|
||||
} else if (!WQPSerializedDataWithinLimit(data)) {
|
||||
writeError = WQLocalizedFormat(@"The serialized property list is too large (maximum %lu MiB).", (unsigned long)(WQPMaxSerializedBytes / (1024 * 1024)));
|
||||
} else {
|
||||
NSError *validationError = nil;
|
||||
id check = [NSPropertyListSerialization propertyListWithData:data
|
||||
options:NSPropertyListImmutable
|
||||
format:NULL
|
||||
error:&validationError];
|
||||
if (!check) {
|
||||
writeError = validationError.localizedDescription ?: WQLocalized(@"The serialized property list could not be validated.");
|
||||
} else {
|
||||
ok = wq_write_if_unchanged_get_version(path, data, expectedVersion, &savedVersion, &writeError);
|
||||
}
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[doc.edit finishSave:ok revision:savedRevision];
|
||||
// Returned while the saved bytes were verified on the locked
|
||||
// descriptor; a later external change is rejected next save.
|
||||
if (ok) doc.version = savedVersion;
|
||||
|
||||
WQPlistEditorViewController *origin = weakSelf;
|
||||
UINavigationController *nav = navigationController ?: origin.navigationController;
|
||||
WQPlistEditorViewController *visible = nil;
|
||||
for (UIViewController *controller in nav.viewControllers) {
|
||||
if (![controller isKindOfClass:WQPlistEditorViewController.class]) continue;
|
||||
WQPlistEditorViewController *editor = (WQPlistEditorViewController *)controller;
|
||||
if (editor.doc != doc) continue;
|
||||
[editor refresh];
|
||||
if (controller == nav.visibleViewController) visible = editor;
|
||||
}
|
||||
if (!visible && origin.doc == doc) {
|
||||
[origin refresh];
|
||||
if (origin.viewIfLoaded.window) visible = origin;
|
||||
}
|
||||
if (!ok && visible.viewIfLoaded.window) {
|
||||
NSString *hint = wq_permission_hint(path, YES);
|
||||
wqp_alert(visible, @"Save failed", hint ?: writeError ?: @"Unknown error");
|
||||
}
|
||||
});
|
||||
});
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)changed
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
// Callers have already changed the data. Record it even during a save: the
|
||||
// newer revision keeps the document dirty when that save finishes. The
|
||||
// guard above also protects stale UI callbacks from changing readonly or
|
||||
// frozen documents.
|
||||
_keysDirty = YES;
|
||||
[self.doc.edit noteChange];
|
||||
[self refresh];
|
||||
}
|
||||
|
||||
#pragma mark Table
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||
{
|
||||
return self.status ? 0 : (NSInteger)[self.container count];
|
||||
}
|
||||
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
|
||||
{
|
||||
if (self.status) return self.status;
|
||||
if (!self.isRoot) return nil;
|
||||
NSString *fmt = WQLocalized(WQPlistFormatName(self.doc.format));
|
||||
if (self.doc.readOnly) {
|
||||
NSString *reason = WQPlistFormatIsWritable(self.doc.format) ? WQLocalized(@"read only") : WQLocalized(@"read only · writing is not supported");
|
||||
return WQLocalizedFormat(@"%@ property list · %@", fmt, reason);
|
||||
}
|
||||
return WQLocalizedFormat(@"%@ property list. Tap a value to edit it, swipe to delete or rename, + to add.", fmt);
|
||||
}
|
||||
|
||||
- (id)keyAt:(NSInteger)row
|
||||
{
|
||||
return self.isArray ? @(row) : self.keys[(NSUInteger)row];
|
||||
}
|
||||
|
||||
- (id)valueAt:(NSInteger)row
|
||||
{
|
||||
return self.isArray ? self.container[(NSUInteger)row] : self.container[self.keys[(NSUInteger)row]];
|
||||
}
|
||||
|
||||
- (void)setValue:(id)value at:(NSInteger)row
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
if (self.isArray) self.container[(NSUInteger)row] = value;
|
||||
else self.container[self.keys[(NSUInteger)row]] = value;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"plist"];
|
||||
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"plist"];
|
||||
cell.accessoryView = nil;
|
||||
cell.accessoryType = UITableViewCellAccessoryNone;
|
||||
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
|
||||
UIListContentConfiguration *c = [UIListContentConfiguration subtitleCellConfiguration];
|
||||
id value = [self valueAt:indexPath.row];
|
||||
WQPType t = wqp_type(value);
|
||||
c.text = self.isArray ? WQLocalizedFormat(@"Item %ld", (long)indexPath.row) : [self keyAt:indexPath.row];
|
||||
c.secondaryText = WQLocalizedFormat(@"%@ · %@", wqp_type_name(t), wqp_summary(value));
|
||||
c.secondaryTextProperties.numberOfLines = 3;
|
||||
c.secondaryTextProperties.color = UIColor.secondaryLabelColor;
|
||||
if (t == WQPTypeDictionary || t == WQPTypeArray) {
|
||||
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||
} else if (t == WQPTypeBoolean) {
|
||||
UISwitch *sw = [UISwitch new];
|
||||
sw.on = [value boolValue];
|
||||
sw.enabled = !self.doc.readOnly && !self.doc.edit.saving;
|
||||
sw.tag = indexPath.row;
|
||||
[sw addTarget:self action:@selector(boolChanged:) forControlEvents:UIControlEventValueChanged];
|
||||
cell.accessoryView = sw;
|
||||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
}
|
||||
cell.contentConfiguration = c;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)boolChanged:(UISwitch *)sw
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) {
|
||||
sw.on = [[self valueAt:sw.tag] boolValue];
|
||||
return;
|
||||
}
|
||||
[self setValue:@(sw.on) at:sw.tag];
|
||||
[self changed];
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
[tableView deselectRowAtIndexPath:indexPath animated:YES];
|
||||
if (self.doc.edit.saving) return;
|
||||
id value = [self valueAt:indexPath.row];
|
||||
WQPType t = wqp_type(value);
|
||||
NSString *name = self.isArray ? WQLocalizedFormat(@"Item %ld", (long)indexPath.row) : [self keyAt:indexPath.row];
|
||||
if (t == WQPTypeDictionary || t == WQPTypeArray) {
|
||||
WQPlistEditorViewController *child = [[WQPlistEditorViewController alloc] initWithDocument:self.doc container:value title:name];
|
||||
[self.navigationController pushViewController:child animated:YES];
|
||||
return;
|
||||
}
|
||||
if (t == WQPTypeUID) {
|
||||
wqp_alert(self, @"Reference value", @"Keyed-archive references are read-only in this editor. Edit the archived data through its owning key instead.");
|
||||
return;
|
||||
}
|
||||
if (t == WQPTypeBoolean || self.doc.readOnly || self.doc.edit.saving) return;
|
||||
NSInteger row = indexPath.row;
|
||||
[self editValueOfType:t current:value title:name done:^(id newValue) {
|
||||
[self setValue:newValue at:row];
|
||||
[self changed];
|
||||
}];
|
||||
}
|
||||
|
||||
// Asks for a scalar value of type `t`; `done` gets the parsed value.
|
||||
- (void)editValueOfType:(WQPType)t current:(id)current title:(NSString *)title done:(void (^)(id value))done
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
if (t == WQPTypeUID) return;
|
||||
if (t == WQPTypeData && [current length] > WQPMaxDataBytes) {
|
||||
wqp_alert(self, @"Too large to edit here", @"Data over 4 KB can't be edited in this editor.");
|
||||
return;
|
||||
}
|
||||
if (t == WQPTypeString && [current length] > WQPMaxStringInputCharacters) {
|
||||
wqp_alert(self, @"Too large to edit here", @"Strings over 64 KB can't be edited in this editor.");
|
||||
return;
|
||||
}
|
||||
NSString *hint = nil;
|
||||
switch (t) {
|
||||
case WQPTypeInteger: hint = WQLocalized(@"Whole number"); break;
|
||||
case WQPTypeReal: hint = WQLocalized(@"Number"); break;
|
||||
case WQPTypeDate: hint = WQLocalized(@"ISO 8601, e.g. 2026-11-09T15:00:00Z"); break;
|
||||
case WQPTypeData: hint = WQLocalized(@"Hex bytes, e.g. 01 ab ff"); break;
|
||||
default: break;
|
||||
}
|
||||
UIAlertController *a = wq_alert_controller(title, [wqp_type_name(t) stringByAppendingString:hint ? [@" · " stringByAppendingString:hint] : @""], UIAlertControllerStyleAlert);
|
||||
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
|
||||
f.text = wqp_scalar_text(current, t);
|
||||
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
f.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
f.clearButtonMode = UITextFieldViewModeAlways;
|
||||
if (t == WQPTypeInteger) f.keyboardType = UIKeyboardTypeNumbersAndPunctuation;
|
||||
if (t == WQPTypeReal) f.keyboardType = UIKeyboardTypeDecimalPad;
|
||||
}];
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"Set", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
|
||||
NSString *text = a.textFields.firstObject.text ?: @"";
|
||||
id value = nil;
|
||||
if (t != WQPTypeData && !WQPStringInputWithinLimit(text)) {
|
||||
wqp_alert(self, @"Value too large", @"Scalar values are limited to 64 KB in this editor.");
|
||||
return;
|
||||
}
|
||||
if (t == WQPTypeData && !WQPDataInputWithinLimit(text)) {
|
||||
wqp_alert(self, @"Value too large", @"Hex input is limited to 16 KB in this editor.");
|
||||
return;
|
||||
}
|
||||
switch (t) {
|
||||
case WQPTypeString: value = text; break;
|
||||
case WQPTypeInteger: {
|
||||
NSScanner *sc = [NSScanner scannerWithString:text];
|
||||
long long n;
|
||||
if ([sc scanLongLong:&n] && sc.isAtEnd) value = @(n);
|
||||
break;
|
||||
}
|
||||
case WQPTypeReal: {
|
||||
NSScanner *sc = [NSScanner scannerWithString:text];
|
||||
double d;
|
||||
if ([sc scanDouble:&d] && sc.isAtEnd) value = @(d);
|
||||
break;
|
||||
}
|
||||
case WQPTypeDate: value = [wqp_dates() dateFromString:text]; break;
|
||||
case WQPTypeData: {
|
||||
NSData *data = WQParseHex(text, YES);
|
||||
if (data.length > WQPMaxDataBytes) {
|
||||
wqp_alert(self, @"Value too large", @"Data values are limited to 4 KB in this editor.");
|
||||
return;
|
||||
}
|
||||
value = data;
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
if (!value) { wqp_alert(self, @"Not a valid value", WQLocalizedFormat(@"Expected: %@", hint ?: wqp_type_name(t))); return; }
|
||||
done(value);
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark Adding, deleting, renaming, moving
|
||||
|
||||
- (void)add
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
UIAlertController *sheet = wq_alert_controller(@"Add", @"Type of the new value", UIAlertControllerStyleActionSheet);
|
||||
for (WQPType t = WQPTypeString; t <= WQPTypeArray; t++) {
|
||||
[sheet addAction:wq_alert_action(wqp_type_name(t), UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
|
||||
[self addValueOfType:t];
|
||||
})];
|
||||
}
|
||||
[sheet addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
sheet.popoverPresentationController.barButtonItem = self.navigationItem.rightBarButtonItems.count > 1
|
||||
? self.navigationItem.rightBarButtonItems[1] : self.navigationItem.rightBarButtonItems.firstObject;
|
||||
[self presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)addValueOfType:(WQPType)t
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
void (^insert)(NSString *key) = ^(NSString *key) {
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
id value = wqp_default(t);
|
||||
void (^put)(id) = ^(id v) {
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
if (self.isArray) {
|
||||
[self.container addObject:v];
|
||||
} else {
|
||||
if (!key.length) return; // dictionary insertion always has a validated key
|
||||
NSMutableDictionary *dictionary = self.container;
|
||||
dictionary[key] = v;
|
||||
}
|
||||
[self changed];
|
||||
};
|
||||
if (t == WQPTypeDictionary || t == WQPTypeArray || t == WQPTypeBoolean) { put(value); return; }
|
||||
[self editValueOfType:t current:value title:key ?: @"New Item" done:put];
|
||||
};
|
||||
if (self.isArray) { insert(nil); return; }
|
||||
[self askKey:nil title:WQLocalizedNullable(@"Key") done:insert];
|
||||
}
|
||||
|
||||
- (void)askKey:(NSString *)current title:(NSString *)title done:(void (^)(NSString *key))done
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
UIAlertController *a = wq_alert_controller(title, nil, UIAlertControllerStyleAlert);
|
||||
[a addTextFieldWithConfigurationHandler:^(UITextField *f) {
|
||||
f.text = current;
|
||||
f.placeholder = WQLocalizedNullable(@"Key");
|
||||
f.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
f.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
}];
|
||||
[a addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, nil)];
|
||||
[a addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, ^(UIAlertAction *__unused x) {
|
||||
NSString *key = a.textFields.firstObject.text ?: @"";
|
||||
if (key.length == 0) return;
|
||||
if (![key isEqualToString:current] && self.container[key]) {
|
||||
wqp_alert(self, @"Key exists", WQLocalizedFormat(@"There is already a key named \"%@\".", key));
|
||||
return;
|
||||
}
|
||||
done(key);
|
||||
})];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tableView
|
||||
trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return nil;
|
||||
NSInteger row = indexPath.row;
|
||||
UIContextualAction *del = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive title:WQLocalizedNullable(@"Delete")
|
||||
handler:^(UIContextualAction *__unused action, UIView *__unused view, void (^completion)(BOOL)) {
|
||||
if (self.doc.readOnly || self.doc.edit.saving) { completion(NO); return; }
|
||||
if (self.isArray) [self.container removeObjectAtIndex:(NSUInteger)row];
|
||||
else [self.container removeObjectForKey:self.keys[(NSUInteger)row]];
|
||||
[self changed];
|
||||
completion(YES);
|
||||
}];
|
||||
if (self.isArray) return [UISwipeActionsConfiguration configurationWithActions:@[del]];
|
||||
UIContextualAction *rename = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:WQLocalizedNullable(@"Rename")
|
||||
handler:^(UIContextualAction *__unused action, UIView *__unused view, void (^completion)(BOOL)) {
|
||||
if (self.doc.readOnly || self.doc.edit.saving) { completion(NO); return; }
|
||||
NSString *old = self.keys[(NSUInteger)row];
|
||||
[self askKey:old title:WQLocalizedNullable(@"Rename Key") done:^(NSString *key) {
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
if ([key isEqualToString:old]) return;
|
||||
id v = self.container[old];
|
||||
[self.container removeObjectForKey:old];
|
||||
self.container[key] = v;
|
||||
[self changed];
|
||||
}];
|
||||
completion(YES);
|
||||
}];
|
||||
return [UISwipeActionsConfiguration configurationWithActions:@[del, rename]];
|
||||
}
|
||||
|
||||
- (BOOL)tableView:(UITableView *)tableView canMoveRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
return self.isArray && !self.doc.readOnly && !self.doc.edit.saving;
|
||||
}
|
||||
|
||||
- (UITableViewCellEditingStyle)tableView:(UITableView *)tableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
return UITableViewCellEditingStyleNone;
|
||||
}
|
||||
|
||||
- (BOOL)tableView:(UITableView *)tableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView moveRowAtIndexPath:(NSIndexPath *)from toIndexPath:(NSIndexPath *)to
|
||||
{
|
||||
if (self.doc.readOnly || self.doc.edit.saving) return;
|
||||
id v = self.container[(NSUInteger)from.row];
|
||||
[self.container removeObjectAtIndex:(NSUInteger)from.row];
|
||||
[self.container insertObject:v atIndex:(NSUInteger)to.row];
|
||||
[self.doc.edit noteChange];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ [self refresh]; });
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// WQPlistEditorSupport.h
|
||||
// WQ Files
|
||||
//
|
||||
// Portable limits shared by the property-list editor and its regression tests.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
enum {
|
||||
WQPMaxDisplayedStringCharacters = 512,
|
||||
WQPMaxStringInputCharacters = 64 * 1024,
|
||||
WQPMaxDataBytes = 4 * 1024,
|
||||
WQPMaxDataInputCharacters = 16 * 1024,
|
||||
WQPMaxSerializedBytes = 64 * 1024 * 1024,
|
||||
};
|
||||
|
||||
static inline BOOL WQPStringInputWithinLimit(NSString *text)
|
||||
{
|
||||
return text.length <= WQPMaxStringInputCharacters;
|
||||
}
|
||||
|
||||
static inline BOOL WQPDataInputWithinLimit(NSString *text)
|
||||
{
|
||||
return text.length <= WQPMaxDataInputCharacters;
|
||||
}
|
||||
|
||||
static inline BOOL WQPSerializedDataWithinLimit(NSData *data)
|
||||
{
|
||||
return data.length <= WQPMaxSerializedBytes;
|
||||
}
|
||||
|
||||
// CoreFoundation can write only XML and binary property lists. OpenStep and
|
||||
// unknown formats are readable but must be treated as read-only documents.
|
||||
static inline BOOL WQPlistFormatIsWritable(NSPropertyListFormat format)
|
||||
{
|
||||
return format == NSPropertyListXMLFormat_v1_0 || format == NSPropertyListBinaryFormat_v1_0;
|
||||
}
|
||||
|
||||
static inline NSString *WQPlistFormatName(NSPropertyListFormat format)
|
||||
{
|
||||
switch (format) {
|
||||
case NSPropertyListXMLFormat_v1_0: return @"XML";
|
||||
case NSPropertyListBinaryFormat_v1_0: return @"Binary";
|
||||
case NSPropertyListOpenStepFormat: return @"OpenStep";
|
||||
default: return @"Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static inline NSString *WQPBoundedStringPreview(NSString *value)
|
||||
{
|
||||
if (value.length == 0) return @"(empty)";
|
||||
if (value.length <= WQPMaxDisplayedStringCharacters) return value;
|
||||
|
||||
NSString *suffix = [NSString stringWithFormat:@" … (%lu chars)", (unsigned long)value.length];
|
||||
NSUInteger prefixLength = suffix.length < WQPMaxDisplayedStringCharacters
|
||||
? WQPMaxDisplayedStringCharacters - suffix.length : 0;
|
||||
return [[value substringToIndex:prefixLength] stringByAppendingString:suffix];
|
||||
}
|
||||
|
||||
// NSKeyedArchiver stores object references as CFKeyedArchiverUID values. They
|
||||
// are not NSNumber/NSString/NSData, and they do not implement -stringValue or
|
||||
// NSCopying. They are nevertheless valid property-list leaves and must be
|
||||
// preserved when the document is re-serialized.
|
||||
static inline BOOL WQPlistIsKeyedArchiveUID(id value)
|
||||
{
|
||||
if (!value) return NO;
|
||||
NSString *className = NSStringFromClass([value class]);
|
||||
if (![className isEqualToString:@"__NSCFType"] && ![className containsString:@"KeyedArchiverUID"])
|
||||
return NO;
|
||||
NSString *description = [value description];
|
||||
return [description hasPrefix:@"<CFKeyedArchiverUID "];
|
||||
}
|
||||
|
||||
// Immutable, serializable copy of the editor's mutable document. Keyed-archive
|
||||
// UID leaves are immutable and are passed through unchanged; unsupported
|
||||
// objects fail the copy instead of reaching NSPropertyListSerialization.
|
||||
static inline id _Nullable WQPlistImmutableCopy(id value, NSString *_Nullable *_Nullable errorOut)
|
||||
{
|
||||
if (!value) {
|
||||
if (errorOut) *errorOut = @"The property list contains a nil value.";
|
||||
return nil;
|
||||
}
|
||||
if (WQPlistIsKeyedArchiveUID(value)) return value;
|
||||
if ([value isKindOfClass:NSDictionary.class]) {
|
||||
NSDictionary *source = value;
|
||||
NSMutableDictionary *copy = [NSMutableDictionary dictionaryWithCapacity:source.count];
|
||||
for (id key in source) {
|
||||
if (![key isKindOfClass:NSString.class]) {
|
||||
if (errorOut) *errorOut = @"A dictionary key is not a string.";
|
||||
return nil;
|
||||
}
|
||||
id child = WQPlistImmutableCopy(source[key], errorOut);
|
||||
if (!child) return nil;
|
||||
copy[key] = child;
|
||||
}
|
||||
return [copy copy];
|
||||
}
|
||||
if ([value isKindOfClass:NSArray.class]) {
|
||||
NSArray *source = value;
|
||||
NSMutableArray *copy = [NSMutableArray arrayWithCapacity:source.count];
|
||||
for (id childValue in source) {
|
||||
id child = WQPlistImmutableCopy(childValue, errorOut);
|
||||
if (!child) return nil;
|
||||
[copy addObject:child];
|
||||
}
|
||||
return [copy copy];
|
||||
}
|
||||
if ([value isKindOfClass:NSString.class] || [value isKindOfClass:NSNumber.class] ||
|
||||
[value isKindOfClass:NSDate.class] || [value isKindOfClass:NSData.class]) {
|
||||
return [value copy];
|
||||
}
|
||||
if (errorOut) *errorOut = @"The property list contains an unsupported value.";
|
||||
return nil;
|
||||
}
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// WQSQLiteViewer.h
|
||||
// WQ Files
|
||||
//
|
||||
// Read-only SQLite viewer: tables and views, rows, single-row details,
|
||||
// read-only SQL queries and CSV export. Works on a private copy of the
|
||||
// database (and its -wal file) in WQ Files' temporary folder, so the app's
|
||||
// own files are never opened for writing and no journal files appear in its
|
||||
// container.
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface WQSQLiteViewController : UITableViewController
|
||||
- (instancetype)initWithPath:(NSString *)path;
|
||||
@end
|
||||
|
||||
// Whether the first bytes of a file are an SQLite 3 header.
|
||||
BOOL wq_is_sqlite_header(NSData *head);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,636 @@
|
||||
//
|
||||
// WQSQLiteViewer.m
|
||||
// WQ Files
|
||||
//
|
||||
// Read-only SQLite viewer. The database and its -wal file are copied into a
|
||||
// private temporary folder first (a -shm file is rebuilt by SQLite there),
|
||||
// then opened with PRAGMA query_only. Every statement is checked with
|
||||
// sqlite3_stmt_readonly before it runs.
|
||||
//
|
||||
|
||||
#import "WQSQLiteViewer.h"
|
||||
#import "WQAccess.h"
|
||||
#import "WQSharing.h"
|
||||
#import "WQUI.h"
|
||||
#import <sqlite3.h>
|
||||
|
||||
BOOL wq_is_sqlite_header(NSData *head)
|
||||
{
|
||||
return head.length >= 16 && memcmp(head.bytes, "SQLite format 3\0", 16) == 0;
|
||||
}
|
||||
|
||||
static const NSUInteger kWQSQLitePage = 100;
|
||||
static const NSUInteger kWQSQLiteQueryLimit = 1000;
|
||||
|
||||
#pragma mark - Snapshot
|
||||
|
||||
// A private copy of the database. All SQLite calls run on its serial queue.
|
||||
@interface WQSQLiteSnapshot : NSObject
|
||||
@property (nonatomic, readonly) dispatch_queue_t queue;
|
||||
@property (nonatomic, readonly, nullable) sqlite3 *db;
|
||||
@property (nonatomic, copy, readonly) NSString *sourcePath;
|
||||
@property (nonatomic, readonly) BOOL copiedWAL;
|
||||
@end
|
||||
|
||||
@implementation WQSQLiteSnapshot {
|
||||
NSString *_dir;
|
||||
}
|
||||
|
||||
// Streams `src` into `dst` through wq_read_range (which asks for access when
|
||||
// needed). Returns NO with an error text on failure.
|
||||
static BOOL wq_sqlite_copy(NSString *src, NSString *dst, NSString **error)
|
||||
{
|
||||
if (![NSFileManager.defaultManager createFileAtPath:dst contents:nil attributes:nil]) {
|
||||
*error = WQLocalized(@"cannot create the temporary copy");
|
||||
return NO;
|
||||
}
|
||||
NSFileHandle *out = [NSFileHandle fileHandleForWritingAtPath:dst];
|
||||
if (!out) { *error = WQLocalized(@"cannot write the temporary copy"); return NO; }
|
||||
const NSUInteger chunk = 1024 * 1024;
|
||||
unsigned long long offset = 0;
|
||||
BOOL ok = YES;
|
||||
@try {
|
||||
for (;;) {
|
||||
NSString *readError = nil;
|
||||
NSData *data = wq_read_range(src, offset, chunk, &readError);
|
||||
if (!data) { *error = readError ?: WQLocalized(@"read failed"); ok = NO; break; }
|
||||
if (data.length) [out writeData:data];
|
||||
offset += data.length;
|
||||
if (data.length < chunk) break;
|
||||
}
|
||||
} @catch (NSException *exception) {
|
||||
*error = exception.reason ?: WQLocalized(@"write failed");
|
||||
ok = NO;
|
||||
}
|
||||
[out closeFile];
|
||||
return ok;
|
||||
}
|
||||
|
||||
- (nullable instancetype)initWithPath:(NSString *)path error:(NSString **)error
|
||||
{
|
||||
if (!(self = [super init])) return nil;
|
||||
_sourcePath = [path copy];
|
||||
_queue = dispatch_queue_create("wqfiles.sqlite", DISPATCH_QUEUE_SERIAL);
|
||||
_dir = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[@"wq-sqlite-" stringByAppendingString:NSUUID.UUID.UUIDString]];
|
||||
if (![NSFileManager.defaultManager createDirectoryAtPath:_dir withIntermediateDirectories:YES attributes:nil error:nil]) {
|
||||
*error = WQLocalized(@"cannot create a temporary folder");
|
||||
return nil;
|
||||
}
|
||||
NSString *copy = [_dir stringByAppendingPathComponent:@"db.sqlite"];
|
||||
if (!wq_sqlite_copy(path, copy, error)) return nil;
|
||||
// Recent changes of WAL databases are still in the -wal file. The -shm
|
||||
// index is not copied: SQLite rebuilds it next to the copy.
|
||||
NSString *wal = [path stringByAppendingString:@"-wal"];
|
||||
struct stat st;
|
||||
if (wq_stat(wal, &st) && S_ISREG(st.st_mode) && st.st_size > 0) {
|
||||
NSString *walError = nil;
|
||||
_copiedWAL = wq_sqlite_copy(wal, [copy stringByAppendingString:@"-wal"], &walError);
|
||||
if (!_copiedWAL) wq_log(@"sqlite: -wal of %@ not copied (%@); recent changes may be missing", path, walError);
|
||||
}
|
||||
// Read-write on the private copy so SQLite can apply the WAL there;
|
||||
// query_only and the per-statement check keep it read-only in practice.
|
||||
sqlite3 *db = NULL;
|
||||
int rc = sqlite3_open_v2(copy.fileSystemRepresentation, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_NOMUTEX, NULL);
|
||||
if (rc != SQLITE_OK) {
|
||||
*error = db ? @(sqlite3_errmsg(db)) : WQLocalized(@"cannot open the database");
|
||||
if (db) sqlite3_close(db);
|
||||
return nil;
|
||||
}
|
||||
sqlite3_exec(db, "PRAGMA query_only = ON", NULL, NULL, NULL);
|
||||
// Fail early on files that only look like databases.
|
||||
sqlite3_stmt *probe = NULL;
|
||||
rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM sqlite_master", -1, &probe, NULL);
|
||||
if (rc == SQLITE_OK) rc = sqlite3_step(probe);
|
||||
sqlite3_finalize(probe);
|
||||
if (rc != SQLITE_ROW) {
|
||||
*error = @(sqlite3_errmsg(db));
|
||||
sqlite3_close(db);
|
||||
return nil;
|
||||
}
|
||||
_db = db;
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
if (_db) sqlite3_close(_db);
|
||||
if (_dir) [NSFileManager.defaultManager removeItemAtPath:_dir error:nil];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Values
|
||||
|
||||
static NSString *wq_sqlite_quote_identifier(NSString *name)
|
||||
{
|
||||
return [NSString stringWithFormat:@"\"%@\"", [name stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]];
|
||||
}
|
||||
|
||||
static NSString *wq_sqlite_hex(const uint8_t *bytes, int length, int limit)
|
||||
{
|
||||
NSMutableString *hex = [NSMutableString new];
|
||||
for (int i = 0; i < length && i < limit; i++) [hex appendFormat:@"%02x%s", bytes[i], (i % 4 == 3) ? " " : ""];
|
||||
if (length > limit) [hex appendString:@"…"];
|
||||
return hex;
|
||||
}
|
||||
|
||||
// A column value as display text; `full` shows long text and blobs in more detail.
|
||||
static NSString *wq_sqlite_value(sqlite3_stmt *stmt, int column, BOOL full)
|
||||
{
|
||||
switch (sqlite3_column_type(stmt, column)) {
|
||||
case SQLITE_NULL: return WQLocalized(@"NULL");
|
||||
case SQLITE_INTEGER: return [NSString stringWithFormat:@"%lld", sqlite3_column_int64(stmt, column)];
|
||||
case SQLITE_FLOAT: return [NSString stringWithFormat:@"%.15g", sqlite3_column_double(stmt, column)];
|
||||
case SQLITE_BLOB: {
|
||||
const uint8_t *bytes = sqlite3_column_blob(stmt, column);
|
||||
int length = sqlite3_column_bytes(stmt, column);
|
||||
if (length >= 8 && bytes && memcmp(bytes, "bplist0", 7) == 0)
|
||||
return WQLocalizedFormat(@"<binary plist, %d bytes>", length);
|
||||
NSString *hex = bytes ? wq_sqlite_hex(bytes, length, full ? 512 : 16) : @"";
|
||||
return WQLocalizedFormat(@"<%d bytes> %@", length, hex);
|
||||
}
|
||||
default: {
|
||||
const unsigned char *text = sqlite3_column_text(stmt, column);
|
||||
NSString *s = text ? [NSString stringWithUTF8String:(const char *)text] : nil;
|
||||
if (!s) return WQLocalized(@"<text not UTF-8>");
|
||||
if (!full && s.length > 200) s = [[s substringToIndex:200] stringByAppendingString:@"…"];
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static NSString *wq_sqlite_csv_field(sqlite3_stmt *stmt, int column)
|
||||
{
|
||||
if (sqlite3_column_type(stmt, column) == SQLITE_NULL) return @"";
|
||||
if (sqlite3_column_type(stmt, column) == SQLITE_BLOB) {
|
||||
const uint8_t *bytes = sqlite3_column_blob(stmt, column);
|
||||
int length = sqlite3_column_bytes(stmt, column);
|
||||
NSData *data = bytes ? [NSData dataWithBytes:bytes length:(NSUInteger)length] : [NSData data];
|
||||
return [@"base64:" stringByAppendingString:[data base64EncodedStringWithOptions:0]];
|
||||
}
|
||||
NSString *value = wq_sqlite_value(stmt, column, YES);
|
||||
if ([value rangeOfCharacterFromSet:[NSCharacterSet characterSetWithCharactersInString:@",\"\n\r"]].location == NSNotFound)
|
||||
return value;
|
||||
return [NSString stringWithFormat:@"\"%@\"", [value stringByReplacingOccurrencesOfString:@"\"" withString:@"\"\""]];
|
||||
}
|
||||
|
||||
#pragma mark - Row detail
|
||||
|
||||
@interface WQSQLiteRowViewController : UITableViewController
|
||||
- (instancetype)initWithColumns:(NSArray<NSString *> *)columns values:(NSArray<NSString *> *)values title:(NSString *)title;
|
||||
@end
|
||||
|
||||
@implementation WQSQLiteRowViewController {
|
||||
NSArray<NSString *> *_columns;
|
||||
NSArray<NSString *> *_values;
|
||||
}
|
||||
|
||||
- (instancetype)initWithColumns:(NSArray<NSString *> *)columns values:(NSArray<NSString *> *)values title:(NSString *)title
|
||||
{
|
||||
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
|
||||
_columns = [columns copy];
|
||||
_values = [values copy];
|
||||
self.title = title;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return (NSInteger)_columns.count; }
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return 1; }
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { return _columns[(NSUInteger)section]; }
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"v"]
|
||||
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"v"];
|
||||
UIListContentConfiguration *c = cell.defaultContentConfiguration;
|
||||
c.text = _values[(NSUInteger)indexPath.section];
|
||||
c.textProperties.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular];
|
||||
c.textProperties.numberOfLines = 0;
|
||||
cell.contentConfiguration = c;
|
||||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (UIContextMenuConfiguration *)tableView:(UITableView *)tableView
|
||||
contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point
|
||||
{
|
||||
NSString *value = _values[(NSUInteger)indexPath.section];
|
||||
return [UIContextMenuConfiguration configurationWithIdentifier:nil previewProvider:nil
|
||||
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *__unused suggested) {
|
||||
return [UIMenu menuWithChildren:@[[UIAction actionWithTitle:WQLocalizedNullable(@"Copy") image:[UIImage systemImageNamed:@"doc.on.doc"]
|
||||
identifier:nil handler:^(UIAction *__unused a) {
|
||||
UIPasteboard.generalPasteboard.string = value;
|
||||
}]]];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Rows (a table, a view or a query)
|
||||
|
||||
@interface WQSQLiteRowsViewController : UITableViewController
|
||||
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot sql:(NSString *)sql title:(NSString *)title
|
||||
exportName:(nullable NSString *)exportName limit:(NSUInteger)limit;
|
||||
@end
|
||||
|
||||
@implementation WQSQLiteRowsViewController {
|
||||
WQSQLiteSnapshot *_snapshot;
|
||||
NSString *_sql;
|
||||
NSString *_exportName;
|
||||
NSUInteger _limit; // 0 = page through everything
|
||||
NSArray<NSString *> *_columns;
|
||||
NSMutableArray<NSArray<NSString *> *> *_rows;
|
||||
BOOL _loading, _done;
|
||||
NSString *_error;
|
||||
}
|
||||
|
||||
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot sql:(NSString *)sql title:(NSString *)title
|
||||
exportName:(NSString *)exportName limit:(NSUInteger)limit
|
||||
{
|
||||
if ((self = [super initWithStyle:UITableViewStylePlain])) {
|
||||
_snapshot = snapshot;
|
||||
_sql = [sql copy];
|
||||
_exportName = [exportName copy];
|
||||
_limit = limit;
|
||||
_rows = [NSMutableArray new];
|
||||
self.title = title;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
if (_exportName) {
|
||||
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"CSV") style:UIBarButtonItemStylePlain
|
||||
target:self action:@selector(exportCSV:)];
|
||||
}
|
||||
[self loadMore];
|
||||
}
|
||||
|
||||
- (void)loadMore
|
||||
{
|
||||
if (_loading || _done) return;
|
||||
_loading = YES;
|
||||
NSUInteger offset = _rows.count;
|
||||
NSUInteger page = _limit ? _limit : kWQSQLitePage;
|
||||
WQSQLiteSnapshot *snapshot = _snapshot;
|
||||
// Tables page with LIMIT/OFFSET; a query (which may be a PRAGMA or EXPLAIN,
|
||||
// and can't be wrapped) runs as typed and stops after `_limit` rows.
|
||||
NSString *sql = _limit ? _sql : [NSString stringWithFormat:@"SELECT * FROM (%@) LIMIT %lu OFFSET %lu",
|
||||
_sql, (unsigned long)page, (unsigned long)offset];
|
||||
dispatch_async(snapshot.queue, ^{
|
||||
NSMutableArray *columns = [NSMutableArray new];
|
||||
NSMutableArray *rows = [NSMutableArray new];
|
||||
NSString *error = nil;
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) != SQLITE_OK) {
|
||||
error = @(sqlite3_errmsg(snapshot.db));
|
||||
} else if (!sqlite3_stmt_readonly(stmt)) {
|
||||
error = WQLocalized(@"Only read-only statements can run here.");
|
||||
} else {
|
||||
int count = sqlite3_column_count(stmt);
|
||||
for (int i = 0; i < count; i++) [columns addObject:@(sqlite3_column_name(stmt, i) ?: "?")];
|
||||
int rc = SQLITE_DONE;
|
||||
while (rows.count < page && (rc = sqlite3_step(stmt)) == SQLITE_ROW) {
|
||||
NSMutableArray *values = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
|
||||
for (int i = 0; i < count; i++) [values addObject:wq_sqlite_value(stmt, i, YES)];
|
||||
[rows addObject:values];
|
||||
}
|
||||
if (rows.count < page && rc != SQLITE_DONE) error = @(sqlite3_errmsg(snapshot.db));
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self->_loading = NO;
|
||||
if (error) { self->_error = error; self->_done = YES; [self.tableView reloadData]; return; }
|
||||
if (!self->_columns) self->_columns = columns;
|
||||
[self->_rows addObjectsFromArray:rows];
|
||||
if (rows.count < page || self->_limit) self->_done = YES;
|
||||
[self.tableView reloadData];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||
{
|
||||
return (_error || (!_rows.count && _done)) ? 1 : (NSInteger)_rows.count;
|
||||
}
|
||||
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
|
||||
{
|
||||
if (_error || !_columns) return nil;
|
||||
NSString *more = _done ? @"" : @"+";
|
||||
if (_limit && _rows.count >= _limit) more = WQLocalizedFormat(@" (first %lu)", (unsigned long)_limit);
|
||||
return WQLocalizedFormat(@"%lu%@ rows · %lu columns", (unsigned long)_rows.count, more, (unsigned long)_columns.count);
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"]
|
||||
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"row"];
|
||||
UIListContentConfiguration *c = cell.defaultContentConfiguration;
|
||||
if (_error || !_rows.count) {
|
||||
c.text = _error ?: WQLocalized(@"No rows");
|
||||
c.textProperties.color = _error ? UIColor.systemRedColor : UIColor.secondaryLabelColor;
|
||||
c.textProperties.numberOfLines = 0;
|
||||
cell.contentConfiguration = c;
|
||||
cell.accessoryType = UITableViewCellAccessoryNone;
|
||||
return cell;
|
||||
}
|
||||
NSArray<NSString *> *values = _rows[(NSUInteger)indexPath.row];
|
||||
NSMutableArray *parts = [NSMutableArray new];
|
||||
for (NSUInteger i = 0; i < values.count && i < _columns.count; i++) {
|
||||
NSString *v = values[i];
|
||||
if (v.length > 80) v = [[v substringToIndex:80] stringByAppendingString:@"…"];
|
||||
[parts addObject:[NSString stringWithFormat:@"%@: %@", _columns[i], v]];
|
||||
}
|
||||
c.text = parts.firstObject;
|
||||
c.secondaryText = parts.count > 1 ? [[parts subarrayWithRange:NSMakeRange(1, parts.count - 1)] componentsJoinedByString:@" · "] : nil;
|
||||
c.textProperties.font = [UIFont monospacedSystemFontOfSize:14 weight:UIFontWeightRegular];
|
||||
c.secondaryTextProperties.numberOfLines = 2;
|
||||
c.secondaryTextProperties.color = UIColor.secondaryLabelColor;
|
||||
cell.contentConfiguration = c;
|
||||
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||
if ((NSUInteger)indexPath.row + 20 >= _rows.count) [self loadMore];
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
[tableView deselectRowAtIndexPath:indexPath animated:YES];
|
||||
if (_error || (NSUInteger)indexPath.row >= _rows.count) return;
|
||||
WQSQLiteRowViewController *detail = [[WQSQLiteRowViewController alloc]
|
||||
initWithColumns:_columns values:_rows[(NSUInteger)indexPath.row]
|
||||
title:WQLocalizedFormat(@"Row %ld", (long)indexPath.row + 1)];
|
||||
[self.navigationController pushViewController:detail animated:YES];
|
||||
}
|
||||
|
||||
- (void)exportCSV:(UIBarButtonItem *)sender
|
||||
{
|
||||
WQSQLiteSnapshot *snapshot = _snapshot;
|
||||
NSString *sql = _sql;
|
||||
NSString *name = [[_exportName stringByReplacingOccurrencesOfString:@"/" withString:@"_"] stringByAppendingPathExtension:@"csv"];
|
||||
NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent:@"wq-sqlite-export"];
|
||||
[NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
|
||||
NSString *out = [dir stringByAppendingPathComponent:name];
|
||||
sender.enabled = NO;
|
||||
dispatch_async(snapshot.queue, ^{
|
||||
NSString *error = nil;
|
||||
[NSFileManager.defaultManager createFileAtPath:out contents:nil attributes:nil];
|
||||
NSFileHandle *file = [NSFileHandle fileHandleForWritingAtPath:out];
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (!file) error = WQLocalized(@"cannot create the CSV file");
|
||||
else if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) != SQLITE_OK) error = @(sqlite3_errmsg(snapshot.db));
|
||||
else if (!sqlite3_stmt_readonly(stmt)) error = WQLocalized(@"Only read-only statements can run here.");
|
||||
else {
|
||||
int count = sqlite3_column_count(stmt);
|
||||
NSMutableArray *header = [NSMutableArray new];
|
||||
for (int i = 0; i < count; i++) [header addObject:@(sqlite3_column_name(stmt, i) ?: "?")];
|
||||
[file writeData:[[[header componentsJoinedByString:@","] stringByAppendingString:@"\r\n"] dataUsingEncoding:NSUTF8StringEncoding]];
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
@autoreleasepool {
|
||||
NSMutableArray *fields = [NSMutableArray arrayWithCapacity:(NSUInteger)count];
|
||||
for (int i = 0; i < count; i++) [fields addObject:wq_sqlite_csv_field(stmt, i)];
|
||||
[file writeData:[[[fields componentsJoinedByString:@","] stringByAppendingString:@"\r\n"] dataUsingEncoding:NSUTF8StringEncoding]];
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
[file closeFile];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sender.enabled = YES;
|
||||
if (error) { wq_alert(self, @"Cannot export", error); return; }
|
||||
wq_share(self, out, nil);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Query
|
||||
|
||||
@interface WQSQLiteQueryViewController : UIViewController
|
||||
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot;
|
||||
@end
|
||||
|
||||
@implementation WQSQLiteQueryViewController {
|
||||
WQSQLiteSnapshot *_snapshot;
|
||||
UITextView *_textView;
|
||||
}
|
||||
|
||||
- (instancetype)initWithSnapshot:(WQSQLiteSnapshot *)snapshot
|
||||
{
|
||||
if ((self = [super initWithNibName:nil bundle:nil])) {
|
||||
_snapshot = snapshot;
|
||||
self.title = WQLocalizedNullable(@"SQL");
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = UIColor.systemBackgroundColor;
|
||||
_textView = [UITextView new];
|
||||
_textView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_textView.font = [UIFont monospacedSystemFontOfSize:15 weight:UIFontWeightRegular];
|
||||
_textView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; // SQL stays left to right
|
||||
_textView.textAlignment = NSTextAlignmentLeft;
|
||||
_textView.autocapitalizationType = UITextAutocapitalizationTypeNone;
|
||||
_textView.autocorrectionType = UITextAutocorrectionTypeNo;
|
||||
_textView.smartQuotesType = UITextSmartQuotesTypeNo;
|
||||
_textView.smartDashesType = UITextSmartDashesTypeNo;
|
||||
_textView.text = @"SELECT name, type FROM sqlite_master";
|
||||
[self.view addSubview:_textView];
|
||||
UILayoutGuide *g = self.view.safeAreaLayoutGuide;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_textView.topAnchor constraintEqualToAnchor:g.topAnchor],
|
||||
[_textView.bottomAnchor constraintEqualToAnchor:self.view.keyboardLayoutGuide.topAnchor],
|
||||
[_textView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:8],
|
||||
[_textView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-8],
|
||||
]];
|
||||
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"Run") style:UIBarButtonItemStyleProminent
|
||||
target:self action:@selector(run)];
|
||||
[_textView becomeFirstResponder];
|
||||
}
|
||||
|
||||
- (void)run
|
||||
{
|
||||
NSString *sql = [_textView.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
while ([sql hasSuffix:@";"]) sql = [[sql substringToIndex:sql.length - 1] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
if (!sql.length) return;
|
||||
// Only a single read-only statement: checked before anything runs.
|
||||
WQSQLiteSnapshot *snapshot = _snapshot;
|
||||
__block NSString *error = nil;
|
||||
dispatch_sync(snapshot.queue, ^{
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
const char *tail = NULL;
|
||||
if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, &tail) != SQLITE_OK) error = @(sqlite3_errmsg(snapshot.db));
|
||||
else if (!stmt) error = WQLocalized(@"Nothing to run.");
|
||||
else if (!sqlite3_stmt_readonly(stmt)) error = WQLocalized(@"Only read-only statements (SELECT, read-only PRAGMA, EXPLAIN) can run here.");
|
||||
else if (tail && *tail) {
|
||||
NSString *rest = [@(tail) stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
if (rest.length) error = WQLocalized(@"Run one statement at a time.");
|
||||
}
|
||||
else if (sqlite3_column_count(stmt) == 0) error = WQLocalized(@"This statement returns no rows.");
|
||||
sqlite3_finalize(stmt);
|
||||
});
|
||||
if (error) { wq_alert(self, @"Cannot run", error); return; }
|
||||
WQSQLiteRowsViewController *rows = [[WQSQLiteRowsViewController alloc] initWithSnapshot:snapshot sql:sql title:WQLocalizedNullable(@"Result")
|
||||
exportName:@"query" limit:kWQSQLiteQueryLimit];
|
||||
[self.navigationController pushViewController:rows animated:YES];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Tables
|
||||
|
||||
@implementation WQSQLiteViewController {
|
||||
NSString *_path;
|
||||
WQSQLiteSnapshot *_snapshot;
|
||||
NSArray<NSDictionary *> *_tables; // name, type, rows (NSNumber, filled in later)
|
||||
NSMutableDictionary<NSString *, NSNumber *> *_counts;
|
||||
NSString *_error;
|
||||
BOOL _loading;
|
||||
}
|
||||
|
||||
- (instancetype)initWithPath:(NSString *)path
|
||||
{
|
||||
if ((self = [super initWithStyle:UITableViewStyleInsetGrouped])) {
|
||||
_path = [path copy];
|
||||
_counts = [NSMutableDictionary new];
|
||||
_loading = YES;
|
||||
self.title = path.lastPathComponent;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad
|
||||
{
|
||||
[super viewDidLoad];
|
||||
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:WQLocalizedNullable(@"SQL") style:UIBarButtonItemStylePlain
|
||||
target:self action:@selector(openQuery)];
|
||||
self.navigationItem.rightBarButtonItem.enabled = NO;
|
||||
NSString *path = _path;
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||
NSString *error = nil;
|
||||
WQSQLiteSnapshot *snapshot = [[WQSQLiteSnapshot alloc] initWithPath:path error:&error];
|
||||
NSMutableArray *tables = [NSMutableArray new];
|
||||
if (snapshot) {
|
||||
dispatch_sync(snapshot.queue, ^{
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(snapshot.db, "SELECT name, type FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name",
|
||||
-1, &stmt, NULL) == SQLITE_OK) {
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
const unsigned char *name = sqlite3_column_text(stmt, 0), *type = sqlite3_column_text(stmt, 1);
|
||||
if (name && type) [tables addObject:@{ @"name": @((const char *)name), @"type": @((const char *)type) }];
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
});
|
||||
}
|
||||
if (!snapshot) wq_log(@"sqlite: cannot open %@ (%@)", path, error);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
self->_loading = NO;
|
||||
self->_snapshot = snapshot;
|
||||
self->_tables = tables;
|
||||
self->_error = snapshot ? nil : error;
|
||||
self.navigationItem.rightBarButtonItem.enabled = snapshot != nil;
|
||||
[self.tableView reloadData];
|
||||
if (snapshot) [self countRows];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Row counts for tables, one at a time in the background (big tables take a while).
|
||||
- (void)countRows
|
||||
{
|
||||
WQSQLiteSnapshot *snapshot = _snapshot;
|
||||
NSArray *tables = _tables;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_async(snapshot.queue, ^{
|
||||
for (NSDictionary *table in tables) {
|
||||
if (!weakSelf) return;
|
||||
if (![table[@"type"] isEqualToString:@"table"]) continue;
|
||||
NSString *sql = [@"SELECT count(*) FROM " stringByAppendingString:wq_sqlite_quote_identifier(table[@"name"])];
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
long long count = -1;
|
||||
if (sqlite3_prepare_v2(snapshot.db, sql.UTF8String, -1, &stmt, NULL) == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW)
|
||||
count = sqlite3_column_int64(stmt, 0);
|
||||
sqlite3_finalize(stmt);
|
||||
NSString *name = table[@"name"];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
WQSQLiteViewController *strongSelf = weakSelf;
|
||||
if (!strongSelf || count < 0) return;
|
||||
strongSelf->_counts[name] = @(count);
|
||||
[strongSelf.tableView reloadData];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
- (void)openQuery
|
||||
{
|
||||
if (!_snapshot) return;
|
||||
[self.navigationController pushViewController:[[WQSQLiteQueryViewController alloc] initWithSnapshot:_snapshot] animated:YES];
|
||||
}
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
|
||||
{
|
||||
return (_loading || _error || !_tables.count) ? 1 : (NSInteger)_tables.count;
|
||||
}
|
||||
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section
|
||||
{
|
||||
return _tables.count ? WQLocalized(@"Tables and views") : nil;
|
||||
}
|
||||
|
||||
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section
|
||||
{
|
||||
if (!_snapshot) return nil;
|
||||
NSString *wal = _snapshot.copiedWAL ? WQLocalized(@" and its -wal file") : @"";
|
||||
return WQLocalizedFormat(@"Read-only. This is a copy of the database%@ made when it was opened; the app's own file is not changed. Close the app first for an up-to-date copy.", wal);
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"t"]
|
||||
?: [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"t"];
|
||||
UIListContentConfiguration *c = [UIListContentConfiguration valueCellConfiguration];
|
||||
cell.accessoryType = UITableViewCellAccessoryNone;
|
||||
if (_loading) {
|
||||
c.text = WQLocalizedNullable(@"Copying and opening…");
|
||||
} else if (_error) {
|
||||
c.text = [WQLocalized(@"Cannot open this database: ") stringByAppendingString:_error];
|
||||
c.textProperties.color = UIColor.systemRedColor;
|
||||
c.textProperties.numberOfLines = 0;
|
||||
} else if (!_tables.count) {
|
||||
c.text = WQLocalizedNullable(@"No tables");
|
||||
c.textProperties.color = UIColor.secondaryLabelColor;
|
||||
} else {
|
||||
NSDictionary *table = _tables[(NSUInteger)indexPath.row];
|
||||
BOOL view = [table[@"type"] isEqualToString:@"view"];
|
||||
c.text = table[@"name"];
|
||||
c.image = [UIImage systemImageNamed:view ? @"eye" : @"tablecells"];
|
||||
NSNumber *count = _counts[table[@"name"]];
|
||||
c.secondaryText = view ? WQLocalized(@"view") : (count ? WQLocalizedFormat(@"%@ rows", count) : @"…");
|
||||
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||
}
|
||||
cell.contentConfiguration = c;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
|
||||
{
|
||||
[tableView deselectRowAtIndexPath:indexPath animated:YES];
|
||||
if (!_snapshot || (NSUInteger)indexPath.row >= _tables.count) return;
|
||||
NSString *name = _tables[(NSUInteger)indexPath.row][@"name"];
|
||||
NSString *sql = [@"SELECT * FROM " stringByAppendingString:wq_sqlite_quote_identifier(name)];
|
||||
WQSQLiteRowsViewController *rows = [[WQSQLiteRowsViewController alloc] initWithSnapshot:_snapshot sql:sql title:name
|
||||
exportName:name limit:0];
|
||||
[self.navigationController pushViewController:rows animated:YES];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// WQSharing.h
|
||||
// WQ Files temporary exports and share sheets
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
void wq_share(UIViewController *host, NSString *path, UIView *_Nullable source);
|
||||
void wq_share_folder(UIViewController *host, NSString *dir, NSString *_Nullable zipName, UIView *_Nullable source);
|
||||
void wq_share_cancel(UIViewController *host);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,239 @@
|
||||
//
|
||||
// WQSharing.m
|
||||
// WQ Files temporary exports and share sheets
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQSharing.h"
|
||||
#import "WQUI.h"
|
||||
#import "WQAccess.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <sys/stat.h>
|
||||
|
||||
@interface WQSharePreparation : NSObject
|
||||
@property (nonatomic, weak) UIViewController *host;
|
||||
@property (nonatomic, weak) UIAlertController *hud;
|
||||
@property (nonatomic, copy) NSString *exportDir;
|
||||
@property (atomic, getter=isCancelled) BOOL cancelled;
|
||||
@end
|
||||
|
||||
@implementation WQSharePreparation
|
||||
@end
|
||||
|
||||
static char kWQSharePreparationKey;
|
||||
static char kWQShareInFlightKey;
|
||||
|
||||
static NSString *wq_unique_export_directory(void)
|
||||
{
|
||||
NSString *dir = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[@"WQShare-" stringByAppendingString:NSUUID.UUID.UUIDString]];
|
||||
NSError *error = nil;
|
||||
return [NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES
|
||||
attributes:nil error:&error] ? dir : nil;
|
||||
}
|
||||
|
||||
static void wq_remove_export(NSString *exportDir)
|
||||
{
|
||||
if (exportDir.length) [NSFileManager.defaultManager removeItemAtPath:exportDir error:nil];
|
||||
}
|
||||
|
||||
static BOOL wq_share_host_is_active(UIViewController *host)
|
||||
{
|
||||
if (!host || !host.isViewLoaded || !host.view.window) return NO;
|
||||
UIWindowScene *scene = host.view.window.windowScene;
|
||||
if (scene && scene.activationState != UISceneActivationStateForegroundActive) return NO;
|
||||
UINavigationController *nav = host.navigationController;
|
||||
if (nav && nav.topViewController != host) return NO;
|
||||
if (host.presentedViewController && !host.presentedViewController.isBeingDismissed) return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL wq_shareable_regular_file(NSString *path)
|
||||
{
|
||||
if (!path.length) return NO;
|
||||
struct stat st;
|
||||
// Do not follow a final symlink: wq_copy_to_local uses O_NOFOLLOW too.
|
||||
if (lstat(path.fileSystemRepresentation, &st) == 0) return S_ISREG(st.st_mode);
|
||||
if (wq_grant(path) && lstat(path.fileSystemRepresentation, &st) == 0) return S_ISREG(st.st_mode);
|
||||
return NO;
|
||||
}
|
||||
|
||||
static void wq_clear_preparation(UIViewController *host, WQSharePreparation *preparation)
|
||||
{
|
||||
if (!host) return;
|
||||
if (objc_getAssociatedObject(host, &kWQSharePreparationKey) == preparation) {
|
||||
objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL wq_share_in_flight(UIViewController *host)
|
||||
{
|
||||
return host && (objc_getAssociatedObject(host, &kWQSharePreparationKey) ||
|
||||
objc_getAssociatedObject(host, &kWQShareInFlightKey));
|
||||
}
|
||||
|
||||
static void wq_clear_in_flight(UIViewController *host)
|
||||
{
|
||||
if (!host) return;
|
||||
objc_setAssociatedObject(host, &kWQShareInFlightKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
static BOOL wq_present_share(UIViewController *host, NSString *file, NSString *exportDir, UIView *source)
|
||||
{
|
||||
if (!wq_share_host_is_active(host)) {
|
||||
wq_remove_export(exportDir);
|
||||
return NO;
|
||||
}
|
||||
UIActivityViewController *share = [[UIActivityViewController alloc]
|
||||
initWithActivityItems:@[[NSURL fileURLWithPath:file]] applicationActivities:nil];
|
||||
UIView *anchor = source.window ? source : host.view;
|
||||
share.popoverPresentationController.sourceView = anchor;
|
||||
share.popoverPresentationController.sourceRect = anchor.bounds;
|
||||
share.completionWithItemsHandler = ^(UIActivityType __unused activityType, BOOL __unused completed,
|
||||
NSArray *__unused returnedItems, NSError *__unused activityError) {
|
||||
wq_remove_export(exportDir);
|
||||
};
|
||||
[host presentViewController:share animated:YES completion:^{
|
||||
// A presentation rejected after the visibility check must not strand the export.
|
||||
if (!share.presentingViewController) wq_remove_export(exportDir);
|
||||
}];
|
||||
return YES;
|
||||
}
|
||||
|
||||
// Cancel folder preparation when its browser is popped or otherwise abandoned.
|
||||
// The export is removed by the worker's completion, after any in-progress copy has stopped.
|
||||
void wq_share_cancel(UIViewController *host)
|
||||
{
|
||||
if (!host) return;
|
||||
wq_clear_in_flight(host);
|
||||
WQSharePreparation *preparation = objc_getAssociatedObject(host, &kWQSharePreparationKey);
|
||||
preparation.cancelled = YES;
|
||||
if (host) objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
UIAlertController *hud = preparation.hud;
|
||||
if (hud && host.presentedViewController == hud) {
|
||||
[host dismissViewControllerAnimated:NO completion:nil];
|
||||
}
|
||||
}
|
||||
|
||||
// Copies a file into a unique temporary directory so concurrent shares cannot
|
||||
// overwrite one another. The export is removed when the share sheet finishes.
|
||||
void wq_share(UIViewController *host, NSString *path, UIView *source)
|
||||
{
|
||||
if (!host) return;
|
||||
if (wq_share_in_flight(host)) {
|
||||
wq_alert(host, @"Share in progress", @"Wait for the current share to finish before starting another.");
|
||||
return;
|
||||
}
|
||||
objc_setAssociatedObject(host, &kWQShareInFlightKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
__weak UIViewController *weakHost = host;
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
||||
NSString *error = nil;
|
||||
NSString *outDir = nil;
|
||||
NSString *tmp = nil;
|
||||
BOOL ok = NO;
|
||||
if (!wq_shareable_regular_file(path)) {
|
||||
error = @"Only regular files can be shared.";
|
||||
} else {
|
||||
outDir = wq_unique_export_directory();
|
||||
tmp = outDir ? [outDir stringByAppendingPathComponent:path.lastPathComponent] : nil;
|
||||
ok = tmp && wq_copy_to_local(path, tmp, &error);
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIViewController *strongHost = weakHost;
|
||||
if (!ok) {
|
||||
wq_remove_export(outDir);
|
||||
wq_clear_in_flight(strongHost);
|
||||
if (wq_share_host_is_active(strongHost)) {
|
||||
wq_alert(strongHost, @"Cannot share", error ?: @"Could not create a temporary export folder");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!wq_share_host_is_active(strongHost) ||
|
||||
!wq_present_share(strongHost, tmp, outDir, source)) {
|
||||
wq_remove_export(outDir);
|
||||
}
|
||||
wq_clear_in_flight(strongHost);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static NSString *wq_zip_name(NSString *dir);
|
||||
|
||||
// Zips a folder (the system's own zipper via NSFileCoordinator) and opens the
|
||||
// share sheet, so a backup can go out over AirDrop, Files, Mail…
|
||||
void wq_share_folder(UIViewController *host, NSString *dir, NSString *zipName, UIView *source)
|
||||
{
|
||||
if (!wq_share_host_is_active(host)) return;
|
||||
if (wq_share_in_flight(host)) {
|
||||
wq_alert(host, @"Share in progress", @"Wait for the current share to finish before starting another.");
|
||||
return;
|
||||
}
|
||||
WQSharePreparation *preparation = [WQSharePreparation new];
|
||||
preparation.host = host;
|
||||
UIAlertController *hud = wq_alert_controller(@"Preparing…", @"Compressing the folder", UIAlertControllerStyleAlert);
|
||||
[hud addAction:wq_alert_action(@"Cancel", UIAlertActionStyleCancel, ^(UIAlertAction *__unused action) {
|
||||
preparation.cancelled = YES;
|
||||
objc_setAssociatedObject(host, &kWQSharePreparationKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
[host dismissViewControllerAnimated:YES completion:nil];
|
||||
})];
|
||||
preparation.hud = hud;
|
||||
objc_setAssociatedObject(host, &kWQSharePreparationKey, preparation, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
[host presentViewController:hud animated:YES completion:nil];
|
||||
dispatch_async(wq_io(), ^{
|
||||
if (preparation.isCancelled) return;
|
||||
NSString *resolvedName = zipName ?: wq_zip_name(dir);
|
||||
NSString *safe = [[resolvedName componentsSeparatedByCharactersInSet:
|
||||
[NSCharacterSet characterSetWithCharactersInString:@"/:"]] componentsJoinedByString:@"-"];
|
||||
NSString *outDir = wq_unique_export_directory();
|
||||
preparation.exportDir = outDir;
|
||||
NSString *zip = outDir ? [outDir stringByAppendingPathComponent:[safe stringByAppendingPathExtension:@"zip"]] : nil;
|
||||
NSFileManager *fm = NSFileManager.defaultManager;
|
||||
wq_grant(dir);
|
||||
__block NSError *copyError = nil;
|
||||
NSError *coordError = nil;
|
||||
if (zip && !preparation.isCancelled) {
|
||||
[[NSFileCoordinator new] coordinateReadingItemAtURL:[NSURL fileURLWithPath:dir]
|
||||
options:NSFileCoordinatorReadingForUploading
|
||||
error:&coordError byAccessor:^(NSURL *zipped) {
|
||||
if (!preparation.isCancelled) [fm copyItemAtURL:zipped toURL:[NSURL fileURLWithPath:zip] error:©Error];
|
||||
}];
|
||||
}
|
||||
BOOL ok = zip && !preparation.isCancelled && [fm fileExistsAtPath:zip];
|
||||
NSString *why = (coordError ?: copyError).localizedDescription ?: @"Could not create the ZIP";
|
||||
wq_log(@"share zip: %@ -> %@ %@", dir, zip.lastPathComponent, ok ? @"OK" : why);
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIViewController *strongHost = preparation.host;
|
||||
BOOL hudPresented = strongHost && strongHost.presentedViewController == hud;
|
||||
void (^finish)(void) = ^{
|
||||
BOOL stale = preparation.isCancelled || !wq_share_host_is_active(strongHost);
|
||||
wq_clear_preparation(strongHost, preparation);
|
||||
if (!ok || stale) {
|
||||
wq_remove_export(outDir);
|
||||
if (!stale && wq_share_host_is_active(strongHost)) {
|
||||
wq_alert(strongHost, @"Cannot share", why);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!wq_present_share(strongHost, zip, outDir, source)) wq_remove_export(outDir);
|
||||
};
|
||||
if (hudPresented) [strongHost dismissViewControllerAnimated:YES completion:finish];
|
||||
else finish();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// "Santander 2026-11-09 14.02.11" for AppBackups/<bundle id>/<date>, else the folder name.
|
||||
static NSString *wq_zip_name(NSString *dir)
|
||||
{
|
||||
NSArray *parts = dir.pathComponents;
|
||||
NSUInteger i = [parts indexOfObject:@"AppBackups"];
|
||||
if (i != NSNotFound && i + 2 == parts.count - 1) {
|
||||
NSString *bid = parts[i + 1];
|
||||
return [NSString stringWithFormat:@"%@ %@", wq_display_name(bid) ?: bid, parts.lastObject];
|
||||
}
|
||||
if (i != NSNotFound && i + 1 == parts.count - 1) {
|
||||
NSString *bid = parts.lastObject;
|
||||
return [NSString stringWithFormat:@"%@ backups", wq_display_name(bid) ?: bid];
|
||||
}
|
||||
return dir.lastPathComponent;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// WQSyntax.h
|
||||
// WQ Files
|
||||
//
|
||||
// Small syntax highlighter for the text viewer: one regular expression per
|
||||
// language finds comments, strings, numbers, keywords and tags left to right
|
||||
// (so a "//" inside a string stays part of the string). Foundation only; the
|
||||
// viewer maps the token kinds to colors.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(uint8_t, WQSyntaxKind) {
|
||||
WQSyntaxComment = 1,
|
||||
WQSyntaxString,
|
||||
WQSyntaxKey, // JSON keys, CSS properties, INI/YAML keys
|
||||
WQSyntaxKeyword,
|
||||
WQSyntaxNumber,
|
||||
WQSyntaxTag, // XML/HTML tag names, CSS selectors
|
||||
WQSyntaxAttribute, // XML attributes
|
||||
WQSyntaxPreprocessor, // #include, @media, shebang
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
NSRange range;
|
||||
WQSyntaxKind kind;
|
||||
} WQSyntaxToken;
|
||||
|
||||
// Larger texts are shown plain (matching would take too long).
|
||||
extern const NSUInteger kWQSyntaxMaxLength;
|
||||
|
||||
// The language for a file, from its name (and for files without an extension,
|
||||
// a "#!" first line), or nil for plain text.
|
||||
NSString *_Nullable wq_syntax_language(NSString *fileName, NSString *_Nullable text);
|
||||
|
||||
// Tokens (WQSyntaxToken structs, in text order) for text in language, or nil
|
||||
// when the language is unknown, the text is too long, or cancelled() says so.
|
||||
NSData *_Nullable wq_syntax_tokens(NSString *text, NSString *language, BOOL (^_Nullable cancelled)(void));
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// WQSyntax.m
|
||||
// WQ Files
|
||||
//
|
||||
|
||||
#import "WQSyntax.h"
|
||||
|
||||
const NSUInteger kWQSyntaxMaxLength = 1024 * 1024;
|
||||
|
||||
// Each rule is @[kind, pattern], the pattern with non-capturing groups only;
|
||||
// the combined expression wraps every rule in one capture group, so the group
|
||||
// that matched gives the token kind. Earlier rules win where two could start
|
||||
// at one place.
|
||||
|
||||
static NSString *const kDQ = @"\"(?:[^\"\\\\\\n]|\\\\.)*\""; // "…" on one line, with escapes
|
||||
static NSString *const kSQ = @"'(?:[^'\\\\\\n]|\\\\.)*'";
|
||||
static NSString *const kBQ = @"`(?:[^`\\\\]|\\\\.)*`";
|
||||
static NSString *const kNumber = @"\\b(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|\\d[\\d_]*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)[uUlLfF]*\\b";
|
||||
static NSString *const kSlashComments = @"//[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))";
|
||||
static NSString *const kHashComment = @"#[^\\n]*";
|
||||
|
||||
static NSString *wq_words(NSString *words)
|
||||
{
|
||||
NSArray *list = [words componentsSeparatedByCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
list = [list filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"length > 0"]];
|
||||
return [NSString stringWithFormat:@"\\b(?:%@)\\b", [list componentsJoinedByString:@"|"]];
|
||||
}
|
||||
|
||||
static NSString *const kCKeywords =
|
||||
@"auto break case char const continue default do double else enum extern float for goto if inline int long "
|
||||
"register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while "
|
||||
"bool true false NULL nullptr class namespace template typename public private protected virtual override "
|
||||
"new delete this try catch throw using operator const_cast static_cast dynamic_cast reinterpret_cast "
|
||||
"id self super nil Nil YES NO BOOL SEL IMP instancetype nonatomic atomic strong weak copy assign readonly readwrite "
|
||||
"nullable nonnull _Nullable _Nonnull __weak __strong __block";
|
||||
static NSString *const kObjCDirectives = @"@(?:interface|implementation|end|protocol|property|synthesize|dynamic|class|"
|
||||
"selector|encode|optional|required|import|autoreleasepool|synchronized|try|catch|finally|throw|available)\\b";
|
||||
static NSString *const kJSKeywords =
|
||||
@"async await break case catch class const continue debugger default delete do else export extends false finally "
|
||||
"for function if import in instanceof let new null of return static super switch this throw true try typeof "
|
||||
"undefined var void while with yield interface type enum implements readonly private public protected "
|
||||
"abstract declare namespace as any number string boolean never unknown";
|
||||
static NSString *const kSwiftKeywords =
|
||||
@"associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private "
|
||||
"protocol public rethrows static struct subscript typealias var break case continue default defer do else "
|
||||
"fallthrough for guard if in repeat return switch where while as catch false is nil super self Self throw "
|
||||
"throws true try async await some any final lazy override mutating weak unowned convenience required";
|
||||
static NSString *const kJavaKeywords =
|
||||
@"abstract assert boolean break byte case catch char class const continue default do double else enum extends "
|
||||
"final finally float for goto if implements import instanceof int interface long native new package private "
|
||||
"protected public return short static strictfp super switch synchronized this throw throws transient try void "
|
||||
"volatile while true false null var val fun object when is in override data sealed companion suspend";
|
||||
static NSString *const kGoKeywords =
|
||||
@"break case chan const continue default defer else fallthrough for func go goto if import interface map "
|
||||
"package range return select struct switch type var true false nil iota";
|
||||
static NSString *const kRustKeywords =
|
||||
@"as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move "
|
||||
"mut pub ref return self Self static struct super trait true type unsafe use where while";
|
||||
static NSString *const kPythonKeywords =
|
||||
@"and as assert async await break class continue def del elif else except False finally for from global if "
|
||||
"import in is lambda None nonlocal not or pass raise return True try while with yield self";
|
||||
static NSString *const kShellKeywords =
|
||||
@"if then else elif fi case esac for select while until do done in function return exit export local readonly "
|
||||
"declare unset shift source alias echo printf cd test set eval exec trap break continue true false";
|
||||
static NSString *const kRubyKeywords =
|
||||
@"alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or "
|
||||
"redo rescue retry return self super then true undef unless until when while yield require attr_accessor";
|
||||
static NSString *const kSQLKeywords =
|
||||
@"select from where and or not insert into values update set delete create table index view trigger drop alter "
|
||||
"add column primary key foreign references unique null default join left right inner outer full cross on as "
|
||||
"order by group having limit offset distinct union all exists in is like glob between case when then else end "
|
||||
"begin commit rollback transaction pragma integer text real blob numeric asc desc if replace without rowid "
|
||||
"autoincrement check constraint collate cast";
|
||||
static NSString *const kLuaKeywords =
|
||||
@"and break do else elseif end false for function goto if in local nil not or repeat return then true until while";
|
||||
|
||||
// Rules per language family: @[@[kind, pattern], …] and regex options.
|
||||
static NSArray *wq_language_rules(NSString *language, NSRegularExpressionOptions *options)
|
||||
{
|
||||
*options = NSRegularExpressionAnchorsMatchLines;
|
||||
if ([language isEqualToString:@"xml"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxComment), @"<!--[\\s\\S]*?(?:-->|$(?![\\s\\S]))"],
|
||||
@[@(WQSyntaxString), @"<!\\[CDATA\\[[\\s\\S]*?\\]\\]>"],
|
||||
@[@(WQSyntaxPreprocessor), @"<[?!][^>]*>"],
|
||||
@[@(WQSyntaxTag), @"</?[A-Za-z_][\\w:.-]*|/?>"],
|
||||
@[@(WQSyntaxAttribute), @"\\b[A-Za-z_][\\w:.-]*(?=\\s*=\\s*[\"'])"],
|
||||
@[@(WQSyntaxString), @"\"[^\"<]*\"|'[^'<]*'"],
|
||||
@[@(WQSyntaxKeyword), @"&#?\\w+;"],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"json"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxComment), kSlashComments], // JSON5 / JSONC
|
||||
@[@(WQSyntaxKey), [kDQ stringByAppendingString:@"(?=\\s*:)"]],
|
||||
@[@(WQSyntaxString), kDQ],
|
||||
@[@(WQSyntaxKeyword), @"\\b(?:true|false|null)\\b"],
|
||||
@[@(WQSyntaxNumber), @"-?\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b"],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"css"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxComment), @"/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))"],
|
||||
@[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]],
|
||||
@[@(WQSyntaxPreprocessor), @"@[\\w-]+"],
|
||||
@[@(WQSyntaxKey), @"(?<![\\w-])-{0,2}[A-Za-z][\\w-]*(?=\\s*:(?![:\\w]*\\s*\\{))"],
|
||||
@[@(WQSyntaxNumber), @"#[0-9a-fA-F]{3,8}\\b|-?\\b\\d+(?:\\.\\d+)?(?:px|em|rem|%|vh|vw|s|ms|pt|deg)?"],
|
||||
@[@(WQSyntaxKeyword), @"!important\\b"],
|
||||
@[@(WQSyntaxTag), @"[.#]?[A-Za-z_][\\w-]*(?=[^{};]*\\{)"],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"sql"]) {
|
||||
*options |= NSRegularExpressionCaseInsensitive;
|
||||
return @[
|
||||
@[@(WQSyntaxComment), @"--[^\\n]*|/\\*[\\s\\S]*?(?:\\*/|$(?![\\s\\S]))"],
|
||||
@[@(WQSyntaxString), @"'(?:[^']|'')*'"],
|
||||
@[@(WQSyntaxKey), @"\"(?:[^\"]|\"\")*\"|`[^`]*`|\\[[^\\]\\n]*\\]"],
|
||||
@[@(WQSyntaxKeyword), wq_words(kSQLKeywords)],
|
||||
@[@(WQSyntaxNumber), kNumber],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"ini"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxComment), @"^\\s*[#;][^\\n]*"],
|
||||
@[@(WQSyntaxTag), @"^\\s*\\[[^\\]\\n]*\\]"],
|
||||
@[@(WQSyntaxKey), @"^\\s*[\\w.$-]+(?=\\s*[=:])"],
|
||||
@[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]],
|
||||
@[@(WQSyntaxKeyword), @"\\b(?:true|false|yes|no|on|off|null)\\b"],
|
||||
@[@(WQSyntaxNumber), kNumber],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"yaml"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxComment), @"(?:^|(?<=\\s))#[^\\n]*"],
|
||||
@[@(WQSyntaxPreprocessor), @"^(?:---|\\.\\.\\.)\\s*$"],
|
||||
@[@(WQSyntaxKey), @"^\\s*(?:-\\s+)?[^\\s#:\\-\"'][^:#\\n]*(?=:(?:\\s|$))"],
|
||||
@[@(WQSyntaxString), [NSString stringWithFormat:@"%@|%@", kDQ, kSQ]],
|
||||
@[@(WQSyntaxKeyword), @"\\b(?:true|false|yes|no|on|off|null)\\b|~"],
|
||||
@[@(WQSyntaxNumber), kNumber],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"markdown"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxString), @"^```[\\s\\S]*?(?:^```|$(?![\\s\\S]))|`[^`\\n]+`"],
|
||||
@[@(WQSyntaxKeyword), @"^#{1,6}[^\\n]*"],
|
||||
@[@(WQSyntaxTag), @"\\[[^\\]\\n]*\\]\\([^)\\n]*\\)"],
|
||||
@[@(WQSyntaxAttribute), @"\\*\\*[^*\\n]+\\*\\*|__[^_\\n]+__"],
|
||||
@[@(WQSyntaxComment), @"^>[^\\n]*"],
|
||||
@[@(WQSyntaxNumber), @"^\\s*(?:[-*+]|\\d+\\.)(?=\\s)"],
|
||||
];
|
||||
}
|
||||
// Hash-comment languages.
|
||||
NSDictionary *hash = @{ @"python": kPythonKeywords, @"shell": kShellKeywords, @"ruby": kRubyKeywords };
|
||||
if (hash[language]) {
|
||||
NSString *strings = [language isEqualToString:@"python"]
|
||||
? [NSString stringWithFormat:@"(?:[rRbBfFuU]{0,2})(?:\"\"\"[\\s\\S]*?(?:\"\"\"|$(?![\\s\\S]))|'''[\\s\\S]*?(?:'''|$(?![\\s\\S]))|%@|%@)", kDQ, kSQ]
|
||||
: [NSString stringWithFormat:@"%@|%@|%@", kDQ, kSQ, kBQ];
|
||||
return @[
|
||||
@[@(WQSyntaxPreprocessor), @"\\A#![^\\n]*"],
|
||||
@[@(WQSyntaxComment), [language isEqualToString:@"shell"] ? @"(?:^|(?<=[\\s;]))#[^\\n]*" : kHashComment],
|
||||
@[@(WQSyntaxString), strings],
|
||||
@[@(WQSyntaxPreprocessor), [language isEqualToString:@"python"] ? @"^\\s*@[\\w.]+" : @"\\$\\{[^}\\n]*\\}|\\$[\\w@#?$!*-]"],
|
||||
@[@(WQSyntaxKeyword), wq_words(hash[language])],
|
||||
@[@(WQSyntaxNumber), kNumber],
|
||||
];
|
||||
}
|
||||
if ([language isEqualToString:@"lua"]) {
|
||||
return @[
|
||||
@[@(WQSyntaxComment), @"--\\[\\[[\\s\\S]*?(?:\\]\\]|$(?![\\s\\S]))|--[^\\n]*"],
|
||||
@[@(WQSyntaxString), [NSString stringWithFormat:@"\\[\\[[\\s\\S]*?\\]\\]|%@|%@", kDQ, kSQ]],
|
||||
@[@(WQSyntaxKeyword), wq_words(kLuaKeywords)],
|
||||
@[@(WQSyntaxNumber), kNumber],
|
||||
];
|
||||
}
|
||||
// C-like languages.
|
||||
NSDictionary *cLike = @{ @"c": kCKeywords, @"js": kJSKeywords, @"swift": kSwiftKeywords, @"java": kJavaKeywords,
|
||||
@"go": kGoKeywords, @"rust": kRustKeywords };
|
||||
if (cLike[language]) {
|
||||
BOOL c = [language isEqualToString:@"c"];
|
||||
NSString *strings = c ? [NSString stringWithFormat:@"@?%@|%@", kDQ, kSQ]
|
||||
: [language isEqualToString:@"swift"] ? [NSString stringWithFormat:@"\"\"\"[\\s\\S]*?(?:\"\"\"|$(?![\\s\\S]))|%@", kDQ]
|
||||
: [NSString stringWithFormat:@"%@|%@|%@", kDQ, kSQ, kBQ];
|
||||
return @[
|
||||
@[@(WQSyntaxComment), kSlashComments],
|
||||
@[@(WQSyntaxString), strings],
|
||||
@[@(WQSyntaxPreprocessor), c ? [@"^\\s*#\\s*\\w+|" stringByAppendingString:kObjCDirectives]
|
||||
: @"^\\s*#!\\[?[^\\n]*|@[A-Za-z_]\\w*"],
|
||||
@[@(WQSyntaxKeyword), wq_words(cLike[language])],
|
||||
@[@(WQSyntaxNumber), kNumber],
|
||||
];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *wq_syntax_language(NSString *fileName, NSString *text)
|
||||
{
|
||||
static NSDictionary<NSString *, NSString *> *byExtension;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
NSMutableDictionary *map = [NSMutableDictionary new];
|
||||
void (^add)(NSString *, NSString *) = ^(NSString *language, NSString *extensions) {
|
||||
for (NSString *e in [extensions componentsSeparatedByString:@" "]) map[e] = language;
|
||||
};
|
||||
add(@"xml", @"xml plist html htm xhtml svg xib storyboard entitlements mobileconfig xsd xsl rss atom");
|
||||
add(@"json", @"json jsonc json5 geojson webmanifest har ipynb");
|
||||
add(@"css", @"css scss less");
|
||||
add(@"sql", @"sql");
|
||||
add(@"ini", @"ini cfg conf toml properties env gitconfig editorconfig");
|
||||
add(@"yaml", @"yaml yml");
|
||||
add(@"markdown", @"md markdown");
|
||||
add(@"python", @"py pyw pyi");
|
||||
add(@"shell", @"sh bash zsh ksh command profile bashrc zshrc");
|
||||
add(@"ruby", @"rb gemspec podspec");
|
||||
add(@"lua", @"lua");
|
||||
add(@"c", @"c h m mm cc cpp cxx hpp hh hxx metal strings");
|
||||
add(@"js", @"js mjs cjs jsx ts tsx");
|
||||
add(@"swift", @"swift");
|
||||
add(@"java", @"java kt kts gradle groovy scala dart cs");
|
||||
add(@"go", @"go");
|
||||
add(@"rust", @"rs");
|
||||
byExtension = map;
|
||||
});
|
||||
NSString *ext = fileName.pathExtension.lowercaseString;
|
||||
if (!ext.length && [fileName hasPrefix:@"."]) ext = [fileName substringFromIndex:1].lowercaseString; // .zshrc
|
||||
NSString *language = ext.length ? byExtension[ext] : nil;
|
||||
if (language) return language;
|
||||
if (!text.length) return nil;
|
||||
// No known extension: a shebang, or text that looks like XML or JSON.
|
||||
NSString *head = [text substringToIndex:MIN(text.length, (NSUInteger)200)];
|
||||
NSString *trimmed = [head stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
|
||||
if ([trimmed hasPrefix:@"#!"]) {
|
||||
NSString *line = [trimmed componentsSeparatedByString:@"\n"].firstObject;
|
||||
if ([line containsString:@"python"]) return @"python";
|
||||
if ([line containsString:@"ruby"]) return @"ruby";
|
||||
if ([line containsString:@"node"]) return @"js";
|
||||
if ([line containsString:@"lua"]) return @"lua";
|
||||
return @"shell";
|
||||
}
|
||||
if ([trimmed hasPrefix:@"<?xml"] || [trimmed hasPrefix:@"<!DOCTYPE"] || [trimmed hasPrefix:@"<plist"]) return @"xml";
|
||||
if (([trimmed hasPrefix:@"{"] || [trimmed hasPrefix:@"["]) && ext.length == 0 &&
|
||||
[NSJSONSerialization JSONObjectWithData:[text dataUsingEncoding:NSUTF8StringEncoding] ?: [NSData data]
|
||||
options:NSJSONReadingFragmentsAllowed error:nil])
|
||||
return @"json";
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *wq_syntax_tokens(NSString *text, NSString *language, BOOL (^cancelled)(void))
|
||||
{
|
||||
if (text.length > kWQSyntaxMaxLength) return nil;
|
||||
static NSMutableDictionary<NSString *, id> *cache;
|
||||
static NSMutableDictionary<NSString *, NSArray *> *kindsCache;
|
||||
static NSObject *lock;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ cache = [NSMutableDictionary new]; kindsCache = [NSMutableDictionary new]; lock = [NSObject new]; });
|
||||
NSRegularExpression *regex = nil;
|
||||
NSArray<NSNumber *> *kinds = nil;
|
||||
@synchronized (lock) {
|
||||
id cached = cache[language];
|
||||
if (!cached) {
|
||||
NSRegularExpressionOptions options = 0;
|
||||
NSArray *rules = wq_language_rules(language, &options);
|
||||
NSMutableArray *parts = [NSMutableArray new], *ruleKinds = [NSMutableArray new];
|
||||
for (NSArray *rule in rules) {
|
||||
[parts addObject:[NSString stringWithFormat:@"(%@)", rule[1]]];
|
||||
[ruleKinds addObject:rule[0]];
|
||||
}
|
||||
NSError *error = nil;
|
||||
NSRegularExpression *built = rules.count
|
||||
? [NSRegularExpression regularExpressionWithPattern:[parts componentsJoinedByString:@"|"] options:options error:&error]
|
||||
: nil;
|
||||
if (rules.count && !built) NSLog(@"WQSyntax: %@ pattern failed: %@", language, error);
|
||||
cached = built ?: NSNull.null;
|
||||
cache[language] = cached;
|
||||
if (built) kindsCache[language] = ruleKinds;
|
||||
}
|
||||
if (cached != NSNull.null) { regex = cached; kinds = kindsCache[language]; }
|
||||
}
|
||||
if (!regex) return nil;
|
||||
NSMutableData *tokens = [NSMutableData new];
|
||||
__block BOOL stopped = NO;
|
||||
__block NSUInteger checked = 0;
|
||||
[regex enumerateMatchesInString:text options:0 range:NSMakeRange(0, text.length)
|
||||
usingBlock:^(NSTextCheckingResult *match, NSMatchingFlags __unused flags, BOOL *stop) {
|
||||
if (cancelled && ++checked % 512 == 0 && cancelled()) { stopped = YES; *stop = YES; return; }
|
||||
if (!match || match.range.length == 0) return;
|
||||
for (NSUInteger g = 1; g < match.numberOfRanges && g <= kinds.count; g++) {
|
||||
NSRange r = [match rangeAtIndex:g];
|
||||
if (r.location == NSNotFound) continue;
|
||||
WQSyntaxToken token = { r, (WQSyntaxKind)kinds[g - 1].unsignedCharValue };
|
||||
[tokens appendBytes:&token length:sizeof(token)];
|
||||
break;
|
||||
}
|
||||
}];
|
||||
return stopped ? nil : tokens;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// WQUI.h
|
||||
// WQ Files shared UI helpers
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "WQLocalization.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
static inline UIBarButtonItemStyle wq_primary_button_style(void)
|
||||
{
|
||||
if (@available(iOS 26.0, *)) return UIBarButtonItemStyleProminent;
|
||||
return UIBarButtonItemStylePlain;
|
||||
}
|
||||
|
||||
dispatch_queue_t wq_io(void);
|
||||
void wq_alert(UIViewController *host, NSString *title, NSString *_Nullable message);
|
||||
// Settings → Allow Deleting (on by default). When off, every delete, clear and
|
||||
// clean action is hidden or refused.
|
||||
BOOL wq_deleting_allowed(void);
|
||||
void wq_set_deleting_allowed(BOOL allowed);
|
||||
// Settings → Keep Data Before Restore (on by default). When off, the safety
|
||||
// backup an app restore makes first is deleted once the restore succeeded.
|
||||
BOOL wq_keep_before_restore(void);
|
||||
void wq_set_keep_before_restore(BOOL keep);
|
||||
// YES (after telling the user) when deleting is turned off.
|
||||
BOOL wq_refuse_if_deleting_off(UIViewController *host);
|
||||
UIAlertController *wq_alert_controller(NSString *_Nullable title, NSString *_Nullable message, UIAlertControllerStyle style);
|
||||
UIAlertAction *wq_alert_action(NSString *title, UIAlertActionStyle style, void (^_Nullable handler)(UIAlertAction *));
|
||||
NSString *wq_size_text(unsigned long long size);
|
||||
UIView *wq_device_header(CGFloat width);
|
||||
// "iPhone 17 Pro Max (iPhone18,2) · iOS 27.0.1 (24A446)"
|
||||
NSString *wq_device_line(void);
|
||||
UIImage *_Nullable wq_app_icon(NSString *_Nullable bundleID);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,354 @@
|
||||
//
|
||||
// WQUI.m
|
||||
// WQ Files shared UI helpers
|
||||
// GPL-3.0-or-later.
|
||||
//
|
||||
|
||||
#import "WQUI.h"
|
||||
#import "WQAccess.h"
|
||||
#import "WQLaunchServices.h"
|
||||
#import <fcntl.h>
|
||||
#import <sys/sysctl.h>
|
||||
#import <sys/utsname.h>
|
||||
|
||||
// "iPhone 17 Pro Max (iPhone18,2)\niOS 27.0.1 (24A446)"
|
||||
NSString *wq_device_line(void)
|
||||
{
|
||||
struct utsname u;
|
||||
NSString *machine = (uname(&u) == 0) ? @(u.machine) : @"unknown";
|
||||
char build[64] = {0};
|
||||
size_t len = sizeof(build);
|
||||
NSString *buildStr = (sysctlbyname("kern.osversion", build, &len, NULL, 0) == 0) ? @(build) : nil;
|
||||
NSDictionary *names = @{
|
||||
@"iPhone17,1": @"iPhone 16 Pro", @"iPhone17,2": @"iPhone 16 Pro Max",
|
||||
@"iPhone17,3": @"iPhone 16", @"iPhone17,4": @"iPhone 16 Plus", @"iPhone17,5": @"iPhone 16e",
|
||||
@"iPhone18,1": @"iPhone 17 Pro", @"iPhone18,2": @"iPhone 17 Pro Max",
|
||||
@"iPhone18,3": @"iPhone 17", @"iPhone18,4": @"iPhone Air",
|
||||
};
|
||||
NSString *marketing = names[machine];
|
||||
NSString *model = marketing ? [NSString stringWithFormat:@"%@ (%@)", marketing, machine] : machine;
|
||||
NSString *ver = UIDevice.currentDevice.systemVersion;
|
||||
return buildStr ? [NSString stringWithFormat:@"%@ · iOS %@ (%@)", model, ver, buildStr]
|
||||
: [NSString stringWithFormat:@"%@ · iOS %@", model, ver];
|
||||
}
|
||||
|
||||
// A small centered label with the device/iOS line, for a table header.
|
||||
UIView *wq_device_header(CGFloat width)
|
||||
{
|
||||
UIView *box = [[UIView alloc] initWithFrame:CGRectMake(0, 0, width, 50)];
|
||||
UILabel *header = [UILabel new];
|
||||
header.numberOfLines = 0;
|
||||
header.textAlignment = NSTextAlignmentCenter;
|
||||
header.font = [UIFont monospacedSystemFontOfSize:11 weight:UIFontWeightMedium];
|
||||
header.textColor = UIColor.secondaryLabelColor;
|
||||
header.backgroundColor = UIColor.secondarySystemBackgroundColor;
|
||||
header.text = wq_device_line();
|
||||
header.frame = CGRectMake(0, 0, width, 34);
|
||||
header.autoresizingMask = UIViewAutoresizingFlexibleWidth;
|
||||
[box addSubview:header]; // 16 pt of clear space below the strip
|
||||
return box;
|
||||
}
|
||||
|
||||
// Private: app icon by bundle id (in UIKit; guarded with respondsToSelector).
|
||||
@interface UIImage (WQPrivate)
|
||||
+ (UIImage *)_applicationIconImageForBundleIdentifier:(NSString *)bid format:(int)fmt scale:(CGFloat)scale;
|
||||
@end
|
||||
|
||||
// Small grey fingerprint of an icon, to recognise iOS's blank placeholder.
|
||||
static NSData *wq_icon_fingerprint(UIImage *img)
|
||||
{
|
||||
const size_t s = 12;
|
||||
uint8_t px[12 * 12 * 4] = {0};
|
||||
CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
|
||||
CGContextRef ctx = CGBitmapContextCreate(px, s, s, 8, s * 4, cs, (CGBitmapInfo)kCGImageAlphaPremultipliedLast);
|
||||
CGColorSpaceRelease(cs);
|
||||
if (!ctx) return nil;
|
||||
if (img.CGImage) CGContextDrawImage(ctx, CGRectMake(0, 0, s, s), img.CGImage);
|
||||
CGContextRelease(ctx);
|
||||
return [NSData dataWithBytes:px length:sizeof(px)];
|
||||
}
|
||||
|
||||
static UIImage *wq_raw_icon(NSString *bundleID)
|
||||
{
|
||||
if (![UIImage respondsToSelector:@selector(_applicationIconImageForBundleIdentifier:format:scale:)]) return nil;
|
||||
CGFloat scale = 2.0; // Safe fallback for background preparation without UI context.
|
||||
if (NSThread.isMainThread) {
|
||||
for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
|
||||
if ([scene isKindOfClass:UIWindowScene.class] && scene.activationState == UISceneActivationStateForegroundActive) {
|
||||
scale = ((UIWindowScene *)scene).screen.scale;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return [UIImage _applicationIconImageForBundleIdentifier:bundleID format:2 scale:scale];
|
||||
}
|
||||
|
||||
// The real icon for bundleID, or nil when iOS only has its placeholder.
|
||||
static UIImage *wq_real_icon(NSString *bundleID)
|
||||
{
|
||||
static NSData *placeholder;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ placeholder = wq_icon_fingerprint(wq_raw_icon(@"cz.kolbi.wqfiles.no-such-app")); });
|
||||
UIImage *raw = wq_raw_icon(bundleID);
|
||||
if (!raw || !raw.CGImage) return nil;
|
||||
NSData *fp = wq_icon_fingerprint(raw);
|
||||
if (!placeholder || fp.length != placeholder.length) return raw;
|
||||
// Near-identical to the placeholder (allow small rendering differences).
|
||||
const uint8_t *a = fp.bytes, *b = placeholder.bytes;
|
||||
unsigned long diff = 0;
|
||||
for (NSUInteger i = 0; i < fp.length; i++) diff += (unsigned long)abs((int)a[i] - (int)b[i]);
|
||||
return diff / fp.length < 8 ? nil : raw;
|
||||
}
|
||||
|
||||
// The icon from the app's own bundle, for when iOS only hands out its
|
||||
// placeholder (iOS 26 refuses this app's icon requests). Reads the primary
|
||||
// icon names from Info.plist and loads the matching PNG files.
|
||||
// The first few outcomes go to the Log, to see why icons are missing.
|
||||
static void wq_icon_note(NSString *bundleID, NSString *what)
|
||||
{
|
||||
static NSUInteger notes;
|
||||
@synchronized (NSNull.null) {
|
||||
if (notes >= 8) return;
|
||||
notes++;
|
||||
}
|
||||
wq_log(@"icon: %@: %@", bundleID, what);
|
||||
}
|
||||
|
||||
static UIImage *wq_bundle_icon(NSString *bundleID)
|
||||
{
|
||||
NSString *path = wq_bundle_path_for_id(bundleID);
|
||||
if (!path.length) { wq_icon_note(bundleID, @"no bundle path in the app database"); return nil; }
|
||||
NSString *plistPath = [path stringByAppendingPathComponent:@"Info.plist"];
|
||||
NSDictionary *info = [NSDictionary dictionaryWithContentsOfFile:plistPath];
|
||||
if (!info && wq_grant(path)) info = [NSDictionary dictionaryWithContentsOfFile:plistPath];
|
||||
if (![info isKindOfClass:NSDictionary.class]) {
|
||||
int fd = open(plistPath.fileSystemRepresentation, O_RDONLY);
|
||||
int err = fd < 0 ? errno : 0;
|
||||
if (fd >= 0) close(fd);
|
||||
wq_icon_note(bundleID, [NSString stringWithFormat:@"can't read %@ (%s)", plistPath, err ? strerror(err) : "not a plist"]);
|
||||
return nil;
|
||||
}
|
||||
NSMutableArray<NSString *> *names = [NSMutableArray new];
|
||||
for (NSString *key in @[@"CFBundleIcons", @"CFBundleIcons~ipad"]) {
|
||||
NSDictionary *primary = [info[key] isKindOfClass:NSDictionary.class] ? info[key][@"CFBundlePrimaryIcon"] : nil;
|
||||
if (![primary isKindOfClass:NSDictionary.class]) continue;
|
||||
if ([primary[@"CFBundleIconName"] isKindOfClass:NSString.class]) [names addObject:primary[@"CFBundleIconName"]];
|
||||
NSArray *files = [primary[@"CFBundleIconFiles"] isKindOfClass:NSArray.class] ? primary[@"CFBundleIconFiles"] : @[];
|
||||
for (id file in files.reverseObjectEnumerator) if ([file isKindOfClass:NSString.class]) [names addObject:file];
|
||||
}
|
||||
if ([info[@"CFBundleIconFile"] isKindOfClass:NSString.class]) [names addObject:info[@"CFBundleIconFile"]];
|
||||
[names addObjectsFromArray:@[@"AppIcon60x60", @"AppIcon"]];
|
||||
// Only the PNG files apps ship next to their asset catalog. Loading from
|
||||
// the catalog (UIImage imageNamed:inBundle:) aborts inside UIKit when an
|
||||
// entry exists but can't be rendered (crash on 27.0.1, build 21).
|
||||
for (NSString *name in names) {
|
||||
NSString *base = name.stringByDeletingPathExtension;
|
||||
for (NSString *suffix in @[@"@3x.png", @"@2x.png", @"@2x~ipad.png", @"~ipad.png", @".png"]) {
|
||||
UIImage *image = [UIImage imageWithContentsOfFile:[path stringByAppendingPathComponent:[base stringByAppendingString:suffix]]];
|
||||
if (image.CGImage) {
|
||||
wq_icon_note(bundleID, [NSString stringWithFormat:@"loaded %@%@ from the app", base, suffix]);
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
wq_icon_note(bundleID, [NSString stringWithFormat:@"no icon PNG in %@ (tried %@)", path.lastPathComponent,
|
||||
[names componentsJoinedByString:@", "]]);
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UIImage *wq_round_icon(UIImage *raw)
|
||||
{
|
||||
CGFloat s = 40;
|
||||
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(s, s)];
|
||||
return [r imageWithActions:^(UIGraphicsImageRendererContext *__unused ctx) {
|
||||
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:s * 0.225] addClip];
|
||||
[raw drawInRect:CGRectMake(0, 0, s, s)];
|
||||
}];
|
||||
}
|
||||
|
||||
// Symbol tile for system services and extensions that have no icon at all,
|
||||
// picked from keywords in the bundle id.
|
||||
static UIImage *wq_service_tile(NSString *bundleID)
|
||||
{
|
||||
static NSArray<NSArray *> *rules;
|
||||
static NSCache *tiles;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
tiles = [NSCache new];
|
||||
UIColor *blue = UIColor.systemBlueColor, *green = UIColor.systemGreenColor, *red = UIColor.systemRedColor,
|
||||
*orange = UIColor.systemOrangeColor, *purple = UIColor.systemPurpleColor, *indigo = UIColor.systemIndigoColor,
|
||||
*teal = UIColor.systemTealColor, *pink = UIColor.systemPinkColor, *gray = UIColor.systemGrayColor;
|
||||
// keywords (lower case, any match), symbol, colour
|
||||
rules = @[
|
||||
@[@[@"emoji"], @"face.smiling.inverse", orange],
|
||||
@[@[@"keyboard", @"inputui", @"textinput", @"dictation"], @"keyboard.fill", gray],
|
||||
@[@[@"airdrop", @"sharing", @"sharesheet"], @"antenna.radiowaves.left.and.right", blue],
|
||||
@[@[@"widget", @"chrono"], @"square.grid.2x2.fill", blue],
|
||||
@[@[@"screenshot", @"screencapture", @"screenrecord"], @"camera.viewfinder", indigo],
|
||||
@[@[@"poster", @"wallpaper"], @"photo.on.rectangle.angled", purple],
|
||||
@[@[@"siri", @"assistant"], @"waveform", indigo],
|
||||
@[@[@"camera"], @"camera.fill", gray],
|
||||
@[@[@"photo"], @"photo.fill", orange],
|
||||
@[@[@"music", @"audio", @"shazam", @"musicrecognition"], @"music.note", pink],
|
||||
@[@[@"headphone", @"airpods", @"bluetooth"], @"headphones", gray],
|
||||
@[@[@"hearing"], @"ear.fill", blue],
|
||||
@[@[@"weather"], @"cloud.sun.fill", teal],
|
||||
@[@[@"location", @"maps", @"navigation"], @"location.fill", blue],
|
||||
@[@[@"health", @"fitness", @"workout"], @"heart.fill", red],
|
||||
@[@[@"pay", @"wallet", @"passbook", @"cash", @"finance"], @"creditcard.fill", green],
|
||||
@[@[@"mail"], @"envelope.fill", blue],
|
||||
@[@[@"message", @"sms", @"imessage"], @"message.fill", green],
|
||||
@[@[@"facetime", @"video"], @"video.fill", green],
|
||||
@[@[@"phone", @"call", @"telephony"], @"phone.fill", green],
|
||||
@[@[@"notification", @"usernotification", @"bulletin"], @"bell.fill", red],
|
||||
@[@[@"shortcut", @"workflow", @"intent"], @"square.on.square", indigo],
|
||||
@[@[@"safari", @"web"], @"safari.fill", blue],
|
||||
@[@[@"news", @"stocks"], @"newspaper.fill", red],
|
||||
@[@[@"calendar", @"event"], @"calendar", red],
|
||||
@[@[@"note", @"paper"], @"note.text", orange],
|
||||
@[@[@"reminder"], @"checklist", blue],
|
||||
@[@[@"game"], @"gamecontroller.fill", purple],
|
||||
@[@[@"auth", @"account", @"appsso", @"password", @"keychain", @"tcc", @"privacy"], @"lock.fill", gray],
|
||||
@[@[@"print"], @"printer.fill", gray],
|
||||
@[@[@"clarity", @"accessib"], @"accessibility", blue],
|
||||
@[@[@"home"], @"house.fill", orange],
|
||||
@[@[@"findmy", @"fmd", @"fmip"], @"location.circle.fill", green],
|
||||
@[@[@"carplay", @"carradio", @"cartrip", @"car."], @"car.fill", gray],
|
||||
@[@[@"tvremote", @"tvsetup", @"com.apple.tv"], @"tv.fill", gray],
|
||||
@[@[@"book"], @"book.fill", orange],
|
||||
@[@[@"podcast"], @"mic.fill", purple],
|
||||
@[@[@"spotlight", @"search"], @"magnifyingglass", gray],
|
||||
@[@[@"diagnostic", @"logging", @"crash", @"analytics"], @"stethoscope", gray],
|
||||
@[@[@"font"], @"textformat", gray],
|
||||
@[@[@"translate"], @"character.bubble.fill", blue],
|
||||
@[@[@"file", @"document", @"cloud"], @"folder.fill", blue],
|
||||
@[@[@"setup", @"buddy", @"onboarding"], @"gearshape.2.fill", gray],
|
||||
];
|
||||
});
|
||||
NSString *key = bundleID.lowercaseString;
|
||||
NSString *symbol = @"gearshape.fill";
|
||||
UIColor *color = UIColor.systemGrayColor;
|
||||
for (NSArray *rule in rules) {
|
||||
BOOL hit = NO;
|
||||
for (NSString *word in rule[0]) if ([key containsString:word]) { hit = YES; break; }
|
||||
if (hit) { symbol = rule[1]; color = rule[2]; break; }
|
||||
}
|
||||
NSString *cacheKey = [symbol stringByAppendingString:color.description];
|
||||
UIImage *tile = [tiles objectForKey:cacheKey];
|
||||
if (tile) return tile;
|
||||
CGFloat s = 40;
|
||||
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:CGSizeMake(s, s)];
|
||||
UIColor *fill = [color resolvedColorWithTraitCollection:[UITraitCollection traitCollectionWithUserInterfaceStyle:UIUserInterfaceStyleLight]];
|
||||
tile = [r imageWithActions:^(UIGraphicsImageRendererContext *__unused ctx) {
|
||||
[fill setFill];
|
||||
[[UIBezierPath bezierPathWithRoundedRect:CGRectMake(0, 0, s, s) cornerRadius:s * 0.225] fill];
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:19 weight:UIImageSymbolWeightSemibold];
|
||||
UIImage *g = [[UIImage systemImageNamed:symbol withConfiguration:cfg] ?: [UIImage systemImageNamed:@"gearshape.fill" withConfiguration:cfg]
|
||||
imageWithTintColor:UIColor.whiteColor renderingMode:UIImageRenderingModeAlwaysOriginal];
|
||||
[g drawInRect:CGRectMake((s - g.size.width) / 2, (s - g.size.height) / 2, g.size.width, g.size.height)];
|
||||
}];
|
||||
[tiles setObject:tile forKey:cacheKey];
|
||||
return tile;
|
||||
}
|
||||
|
||||
// Rounded app icon for a bundle id, cached. Falls back to the closest parent
|
||||
// app's icon (extensions: com.foo.app.widget -> com.foo.app), then to a
|
||||
// neutral service tile. Nil only without a bundle id.
|
||||
UIImage *wq_app_icon(NSString *bundleID)
|
||||
{
|
||||
if (!bundleID.length) return nil;
|
||||
static NSCache *cache;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ cache = [NSCache new]; });
|
||||
UIImage *cached = [cache objectForKey:bundleID];
|
||||
if (cached) return cached;
|
||||
// The bundle fallback reads files and may ask for access: never on the
|
||||
// main thread. Lists warm the cache from their background passes; a
|
||||
// main-thread miss gets the tile without caching it.
|
||||
BOOL background = !NSThread.isMainThread;
|
||||
UIImage *system = wq_real_icon(bundleID);
|
||||
if (system && background) wq_icon_note(bundleID, @"iOS returned an icon");
|
||||
UIImage *raw = system ?: (background ? wq_bundle_icon(bundleID) : nil);
|
||||
NSArray *parts = [bundleID componentsSeparatedByString:@"."];
|
||||
for (NSInteger n = (NSInteger)parts.count - 1; !raw && n >= 3; n--)
|
||||
{
|
||||
NSString *parent = [[parts subarrayWithRange:NSMakeRange(0, (NSUInteger)n)] componentsJoinedByString:@"."];
|
||||
raw = wq_real_icon(parent) ?: (background ? wq_bundle_icon(parent) : nil);
|
||||
}
|
||||
UIImage *icon = raw ? wq_round_icon(raw) : wq_service_tile(bundleID);
|
||||
if (raw || background) [cache setObject:icon forKey:bundleID];
|
||||
return icon;
|
||||
}
|
||||
|
||||
dispatch_queue_t wq_io(void)
|
||||
{
|
||||
static dispatch_queue_t queue;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ queue = dispatch_queue_create("wqfiles.io", DISPATCH_QUEUE_SERIAL); });
|
||||
return queue;
|
||||
}
|
||||
|
||||
static NSString *const kWQDeletingDisabledPref = @"WQDeletingDisabled";
|
||||
|
||||
BOOL wq_deleting_allowed(void)
|
||||
{
|
||||
return ![NSUserDefaults.standardUserDefaults boolForKey:kWQDeletingDisabledPref];
|
||||
}
|
||||
|
||||
void wq_set_deleting_allowed(BOOL allowed)
|
||||
{
|
||||
[NSUserDefaults.standardUserDefaults setBool:!allowed forKey:kWQDeletingDisabledPref];
|
||||
}
|
||||
|
||||
static NSString *const kWQDiscardBeforeRestorePref = @"WQDiscardBeforeRestore";
|
||||
|
||||
BOOL wq_keep_before_restore(void)
|
||||
{
|
||||
return ![NSUserDefaults.standardUserDefaults boolForKey:kWQDiscardBeforeRestorePref];
|
||||
}
|
||||
|
||||
void wq_set_keep_before_restore(BOOL keep)
|
||||
{
|
||||
[NSUserDefaults.standardUserDefaults setBool:!keep forKey:kWQDiscardBeforeRestorePref];
|
||||
}
|
||||
|
||||
BOOL wq_refuse_if_deleting_off(UIViewController *host)
|
||||
{
|
||||
if (wq_deleting_allowed()) return NO;
|
||||
UIAlertController *alert = wq_alert_controller(@"Deleting is turned off",
|
||||
@"Turn on Allow Deleting on the start screen of WQ Files to delete, clear or clean.", UIAlertControllerStyleAlert);
|
||||
[alert addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
|
||||
[host presentViewController:alert animated:YES completion:nil];
|
||||
return YES;
|
||||
}
|
||||
|
||||
UIAlertController *wq_alert_controller(NSString *title, NSString *message, UIAlertControllerStyle style)
|
||||
{
|
||||
return [UIAlertController alertControllerWithTitle:WQLocalizedNullable(title)
|
||||
message:WQLocalizedNullable(message)
|
||||
preferredStyle:style];
|
||||
}
|
||||
|
||||
UIAlertAction *wq_alert_action(NSString *title, UIAlertActionStyle style, void (^handler)(UIAlertAction *))
|
||||
{
|
||||
return [UIAlertAction actionWithTitle:WQLocalized(title) style:style handler:handler];
|
||||
}
|
||||
|
||||
void wq_alert(UIViewController *host, NSString *title, NSString *message)
|
||||
{
|
||||
if (!host || !host.isViewLoaded || !host.view.window) return;
|
||||
UIWindowScene *scene = host.view.window.windowScene;
|
||||
if (scene && scene.activationState != UISceneActivationStateForegroundActive) return;
|
||||
UINavigationController *nav = host.navigationController;
|
||||
if (nav && nav.topViewController != host) return;
|
||||
if (host.presentedViewController && !host.presentedViewController.isBeingDismissed) return;
|
||||
UIAlertController *alert = wq_alert_controller(title, message, UIAlertControllerStyleAlert);
|
||||
[alert addAction:wq_alert_action(@"OK", UIAlertActionStyleDefault, nil)];
|
||||
[host presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
NSString *wq_size_text(unsigned long long size)
|
||||
{
|
||||
if (size == 0) return @"0 KB";
|
||||
return [NSByteCountFormatter stringFromByteCount:(long long)size countStyle:NSByteCountFormatterCountStyleFile];
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user