Skip to content

Commit 85ee84b

Browse files
authored
Archify 1.5.1 (#18)
feat: Archify 1.5.1
2 parents ed5ac5c + c52335b commit 85ee84b

12 files changed

Lines changed: 477 additions & 54 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 1.5.1
4+
5+
- Optimize Apps points out apps that have updated themselves since you optimized them, which usually puts back the code for other Macs, and optimizes them again in one step. It scans on opening once you have optimized apps. Apps optimized with 1.5.0 are recognized after their next optimization.
6+
- The window opens where you left it, at the size you left it (#16).
7+
- The command-line tool has a dry run, `-n` or `--dry_run`, that lists the binaries it would thin and the space that would free, without copying or changing anything (#2).
8+
39
## 1.5.0
410

511
### Security

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Releases are signed with a Developer ID and notarized by Apple. From 1.5 on, Arc
2828
## Features
2929

3030
- **Optimize App** — creates a smaller copy of one app in a folder you choose. The original is never changed. If an app with the same name already exists there, you can keep both or replace the old one (it goes to the Trash).
31-
- **Optimize Apps** — scans /Applications and ~/Applications, shows how much each app can save, and optimizes the apps you select in place.
31+
- **Optimize Apps** — scans /Applications and ~/Applications, shows how much each app can save, and optimizes the apps you select in place. Apps that have updated themselves since you optimized them, putting back code for other Macs, are marked so you can optimize them again in one step.
3232
- **Space Savings** — estimates how much space selected apps would free, without changing anything.
3333
- **Languages** — removes language files for languages you don't use. Your preferred languages, each app's development language, and any language an app's signature depends on are always kept.
3434
- **Installed Apps** — browse your apps by architecture: universal, Apple Silicon, Intel, or other.
@@ -80,6 +80,7 @@ Under **Advanced** in Optimize App:
8080
| `-Ne`, `--no_entitlements` | Don't reuse entitlements when signing |
8181
| `-cs`, `--codesign` | Ad-hoc sign the copy with `codesign` |
8282
| `-l`, `--no_launch` | Don't launch the copy before optimizing |
83+
| `-n`, `--dry_run` | Only list the binaries that would be thinned and the space it would free; nothing is copied or changed |
8384
| `-Nc`, `--no_compress` | Don't compress the thinned binaries |
8485

8586
Example:
@@ -89,7 +90,7 @@ Example:
8990
## Known limitations
9091

9192
- Some apps check their own files for changes, use copy protection, or ship their own updaters, and may object to an optimized copy even though macOS accepts its signature. Optimize a copy first if you are unsure.
92-
- A self-updating app may restore the removed architecture or languages when it updates.
93+
- A self-updating app may restore the removed architecture or languages when it updates. Optimize Apps points out these apps so you can optimize them again.
9394

9495
## Building from source
9596

‎appcast.xml‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ IMPORTANT: This file was signed by Sparkle. Any modifications to this file requi
88
<link>https://github.com/Oct4Pie/archify/releases</link>
99
<description>Stable Archify updates</description>
1010
<language>en</language>
11+
<item>
12+
<title>1.5.1</title>
13+
<pubDate>Fri, 09 Oct 2026 19:12:26 -0700</pubDate>
14+
<link>https://github.com/Oct4Pie/archify/releases/tag/v1.5.1</link>
15+
<sparkle:version>13</sparkle:version>
16+
<sparkle:shortVersionString>1.5.1</sparkle:shortVersionString>
17+
<sparkle:minimumSystemVersion>12.0</sparkle:minimumSystemVersion>
18+
<enclosure url="https://github.com/Oct4Pie/archify/releases/download/v1.5.1/Archify-1.5.1.zip" length="4077220" type="application/octet-stream" sparkle:edSignature="njAAzLIKuMcbJbBbLJmy9psIHA+AOKkwWe6pkFhiRgjH1e1tiefSBx2M2qq9R8Ep6H2BGxvRG7OFdG57p/ZQCA=="/>
19+
</item>
1120
<item>
1221
<title>1.5.0</title>
1322
<pubDate>Fri, 09 Oct 2026 05:34:30 -0700</pubDate>
@@ -20,6 +29,6 @@ IMPORTANT: This file was signed by Sparkle. Any modifications to this file requi
2029
</channel>
2130
</rss>
2231
<!-- sparkle-signatures:
23-
edSignature: 7N6zns74Pxo+WgM/yWupCldGXsB7NHTFfmorKnq1oepZ+P2IAVSSErmFFr7aJKoITd469Q4mtVjL8viqJsIDCA==
24-
length: 1290
32+
edSignature: TWRvEBKPjMhhrlGTGjzMxwzpI5OAbdnc1PQiZlN4feg3Oz38Lryn+cBcXr3kRxMLJTeJsXxEEDxAdLX3rFN9Dw==
33+
length: 1969
2534
-->

‎archify.py‎

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -638,42 +638,26 @@ def _code_resources_path(directory):
638638
return os.path.join(directory, "_CodeSignature", "CodeResources")
639639

640640

641-
def thin_app_transactionally(app_path, target_arch, will_resign=False, compress=True):
642-
"""Thin every universal binary in the app.
641+
def signature_requirements(app, will_resign):
642+
"""Whether the app's signature must stay valid, and whether deeply.
643643
644-
Unless the caller re-signs the whole app afterwards (``will_resign``),
645-
binaries sealed as resources are skipped in signed apps and the result
646-
is rolled back if the existing signature no longer verifies. Thinned
647-
binaries are stored compressed unless ``compress`` is false.
644+
Returns ``(required, deep)``. Nothing is required when the caller
645+
re-signs the whole app afterwards.
648646
"""
649-
app = Path(app_path).expanduser().resolve(strict=True)
650-
transaction_directory = Path(
651-
tempfile.mkdtemp(
652-
prefix=".archify-transaction-",
653-
dir=str(app.parent),
654-
)
655-
)
656-
transaction_directory.chmod(0o700)
657-
658647
if will_resign:
659-
require_deep_signature_validation = False
660-
require_signature_validation = False
661-
else:
662-
require_deep_signature_validation = has_valid_code_signature(
663-
str(app),
664-
deep=True,
665-
)
666-
require_signature_validation = (
667-
require_deep_signature_validation
668-
or has_valid_code_signature(str(app))
669-
)
648+
return False, False
649+
deep = has_valid_code_signature(str(app), deep=True)
650+
return deep or has_valid_code_signature(str(app)), deep
651+
652+
653+
def plan_thinning(app, target_arch, keep_signature_valid):
654+
"""The universal binaries thinning would change, as sorted
655+
``(path, architecture to keep)`` pairs. Binaries sealed as resources
656+
are left out when the signature must stay valid."""
670657
sealed_resources = (
671-
SealedResourceIndex(app) if require_signature_validation else None
658+
SealedResourceIndex(app) if keep_signature_valid else None
672659
)
673660
planned = []
674-
675-
# Snapshot candidates before creating staging files so we never discover
676-
# Archify's own temporary output during the same traversal.
677661
for root, directories, files in os.walk(
678662
str(app),
679663
followlinks=False,
@@ -696,6 +680,63 @@ def thin_app_transactionally(app_path, target_arch, will_resign=False, compress=
696680
planned.append((file_path, architecture))
697681

698682
planned.sort(key=lambda item: item[0])
683+
return planned
684+
685+
686+
def dry_run(app_path, target_arch, will_resign):
687+
"""Report what thinning a copy of the app would change, without
688+
copying or changing anything. Returns the bytes it would remove."""
689+
app = Path(app_path).expanduser().resolve(strict=True)
690+
if app.suffix.lower() != ".app" or not app.is_dir():
691+
raise RuntimeError(f"Input is not an application bundle: {app}")
692+
required, _ = signature_requirements(app, will_resign)
693+
removable_total = 0
694+
Log.append(f"\n{app.name}")
695+
for file_path, architecture in plan_thinning(app, target_arch, required):
696+
slices = get_mach_slices(file_path) or []
697+
removable = sum(
698+
size for name, size in slices if name != architecture
699+
)
700+
removable_total += removable
701+
Log.append(
702+
f" {os.path.relpath(file_path, app)}: keep {architecture}, "
703+
f"remove {', '.join(n for n, _ in slices if n != architecture)} "
704+
f"({human_readable_size(removable)})"
705+
)
706+
if removable_total:
707+
Log.append(
708+
f" Would remove about {human_readable_size(removable_total)} "
709+
"before compression."
710+
)
711+
else:
712+
Log.append(" Nothing to remove.")
713+
return removable_total
714+
715+
716+
def thin_app_transactionally(app_path, target_arch, will_resign=False, compress=True):
717+
"""Thin every universal binary in the app.
718+
719+
Unless the caller re-signs the whole app afterwards (``will_resign``),
720+
binaries sealed as resources are skipped in signed apps and the result
721+
is rolled back if the existing signature no longer verifies. Thinned
722+
binaries are stored compressed unless ``compress`` is false.
723+
"""
724+
app = Path(app_path).expanduser().resolve(strict=True)
725+
transaction_directory = Path(
726+
tempfile.mkdtemp(
727+
prefix=".archify-transaction-",
728+
dir=str(app.parent),
729+
)
730+
)
731+
transaction_directory.chmod(0o700)
732+
733+
(
734+
require_signature_validation,
735+
require_deep_signature_validation,
736+
) = signature_requirements(app, will_resign)
737+
# Snapshot candidates before creating staging files so we never discover
738+
# Archify's own temporary output during the same traversal.
739+
planned = plan_thinning(app, target_arch, require_signature_validation)
699740
prepared = []
700741
try:
701742
for file_path, architecture in planned:
@@ -1426,6 +1467,16 @@ def parse_arguments():
14261467
),
14271468
action="store_true",
14281469
)
1470+
parser.add_argument(
1471+
"-n",
1472+
"--dry_run",
1473+
default=False,
1474+
help=(
1475+
"Only report which binaries would be thinned and how much "
1476+
"space that would free; nothing is copied or changed"
1477+
),
1478+
action="store_true",
1479+
)
14291480
parser.add_argument(
14301481
"-Nc",
14311482
"--no_compress",
@@ -1454,6 +1505,26 @@ def main():
14541505
return 1
14551506
app_dirs = sorted(set(args.app_dir))
14561507

1508+
if args.dry_run:
1509+
exit_status = 0
1510+
total = 0
1511+
for app_dir in app_dirs:
1512+
try:
1513+
total += dry_run(app_dir, args.arch, args.codesign)
1514+
except OSError as error:
1515+
Log.append(f"App is not accessible: {app_dir}: {error}")
1516+
exit_status = 1
1517+
except RuntimeError as error:
1518+
Log.append(str(error))
1519+
exit_status = 1
1520+
if len(app_dirs) > 1:
1521+
Log.append(
1522+
f"\nTotal: about {human_readable_size(total)} "
1523+
"before compression."
1524+
)
1525+
Log.append("Dry run: nothing was copied or changed.")
1526+
return exit_status
1527+
14571528
try:
14581529
output_dir = str(
14591530
Path(args.output_dir)

‎archify.xcodeproj/project.pbxproj‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@
522522
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = YES;
523523
CODE_SIGN_STYLE = Manual;
524524
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
525-
CURRENT_PROJECT_VERSION = 12;
525+
CURRENT_PROJECT_VERSION = 13;
526526
DEAD_CODE_STRIPPING = YES;
527527
DEVELOPMENT_TEAM = "";
528528
DONT_GENERATE_INFOPLIST_FILE = NO;
@@ -539,7 +539,7 @@
539539
LAUNCHD_PLIST_PATH = "$(SRCROOT)/archify/archifyhelper/com.oct4pie.archifyhelper.legacy.plist";
540540
LLVM_LTO = YES;
541541
MACOSX_DEPLOYMENT_TARGET = 12.0;
542-
MARKETING_VERSION = 1.5.0;
542+
MARKETING_VERSION = 1.5.1;
543543
OTHER_LDFLAGS = (
544544
"-sectcreate",
545545
__TEXT,
@@ -574,7 +574,7 @@
574574
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
575575
CODE_SIGN_STYLE = Manual;
576576
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
577-
CURRENT_PROJECT_VERSION = 12;
577+
CURRENT_PROJECT_VERSION = 13;
578578
DEAD_CODE_STRIPPING = YES;
579579
DEVELOPMENT_TEAM = "";
580580
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9827C97648;
@@ -592,7 +592,7 @@
592592
LAUNCHD_PLIST_PATH = "$(SRCROOT)/archify/archifyhelper/com.oct4pie.archifyhelper.legacy.plist";
593593
LLVM_LTO = YES;
594594
MACOSX_DEPLOYMENT_TARGET = 12.0;
595-
MARKETING_VERSION = 1.5.0;
595+
MARKETING_VERSION = 1.5.1;
596596
OTHER_LDFLAGS = (
597597
"-sectcreate",
598598
__TEXT,
@@ -747,7 +747,7 @@
747747
CODE_SIGN_IDENTITY = "-";
748748
CODE_SIGN_STYLE = Manual;
749749
COMBINE_HIDPI_IMAGES = YES;
750-
CURRENT_PROJECT_VERSION = 12;
750+
CURRENT_PROJECT_VERSION = 13;
751751
DEAD_CODE_STRIPPING = YES;
752752
DEVELOPMENT_TEAM = "";
753753
ENABLE_HARDENED_RUNTIME = YES;
@@ -764,7 +764,7 @@
764764
"@executable_path/../Frameworks",
765765
);
766766
MACOSX_DEPLOYMENT_TARGET = 12.0;
767-
MARKETING_VERSION = 1.5.0;
767+
MARKETING_VERSION = 1.5.1;
768768
ONLY_ACTIVE_ARCH = YES;
769769
OTHER_LDFLAGS = "-flto";
770770
PRODUCT_BUNDLE_IDENTIFIER = com.oct4pie.archify;
@@ -793,7 +793,7 @@
793793
CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO;
794794
CODE_SIGN_STYLE = Manual;
795795
COMBINE_HIDPI_IMAGES = YES;
796-
CURRENT_PROJECT_VERSION = 12;
796+
CURRENT_PROJECT_VERSION = 13;
797797
DEAD_CODE_STRIPPING = YES;
798798
DEVELOPMENT_TEAM = "";
799799
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9827C97648;
@@ -811,7 +811,7 @@
811811
"@executable_path/../Frameworks",
812812
);
813813
MACOSX_DEPLOYMENT_TARGET = 12.0;
814-
MARKETING_VERSION = 1.5.0;
814+
MARKETING_VERSION = 1.5.1;
815815
ONLY_ACTIVE_ARCH = NO;
816816
OTHER_LDFLAGS = "-flto";
817817
PRODUCT_BUNDLE_IDENTIFIER = com.oct4pie.archify;

‎archify/BatchProcessing.swift‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,38 @@ class BatchProcessing: ObservableObject {
2929
/// The latest scan was canceled, so results cover only part of the apps.
3030
@Published private(set) var scanWasStopped = false
3131
@Published private(set) var isQuittingApps = false
32+
/// Apps optimized before that have removable code again, usually
33+
/// because they updated themselves.
34+
@Published private(set) var updatedApps: [OptimizationHistory.Update] = []
3235
/// Pause/Resume and Cancel for the scan and for optimization runs.
3336
let control = RunControl()
3437

3538
private let universalApps = UniversalApps()
3639
private let applicationDiscovery = ApplicationDiscovery()
3740
private let localApplicationThinner = ApplicationThinner()
41+
private let history = OptimizationHistory()
3842
private var scanStartTime: Date?
43+
private var lastScanDate: Date?
3944
private var processStartTime: Date?
4045

46+
/// Scans when Optimize Apps opens, but only for someone who has
47+
/// optimized apps before, so updated ones are pointed out right away.
48+
/// Coming back later rescans too, so apps that updated in the
49+
/// meantime are found; a recent scan is reused.
50+
func scanIfPreviouslyOptimized() {
51+
guard !isScanning, !isProcessing, !isQuittingApps, !scanWasStopped,
52+
!history.isEmpty
53+
else {
54+
return
55+
}
56+
if let lastScanDate, Date().timeIntervalSince(lastScanDate) < 10 * 60 {
57+
return
58+
}
59+
startCalculatingSizes()
60+
}
61+
4162
func startCalculatingSizes() {
42-
guard !isScanning, !isProcessing else { return }
63+
guard !isScanning, !isProcessing, !isQuittingApps else { return }
4364
isScanning = true
4465
scanWasStopped = false
4566
control.begin()
@@ -62,10 +83,16 @@ class BatchProcessing: ObservableObject {
6283
}
6384
}
6485
) { sortedAppSizes in
86+
// Reads each app's Info.plist, so keep it off the main thread.
87+
let updatedApps = self.history.updates(
88+
among: sortedAppSizes.map(\.path)
89+
)
6590
DispatchQueue.main.async {
6691
self.appSizes = sortedAppSizes.map {
6792
($0.path, $0.totalSize, $0.savableSize)
6893
}
94+
self.updatedApps = updatedApps
95+
self.lastScanDate = Date()
6996
self.scanWasStopped = self.control.isCanceled
7097
self.control.finish()
7198
self.isScanning = false
@@ -277,6 +304,8 @@ class BatchProcessing: ObservableObject {
277304
appSizes.remove(at: appIndex)
278305
}
279306
selectedApps.remove(app)
307+
history.record(app)
308+
updatedApps.removeAll { $0.path == app }
280309
} else {
281310
let reason = errorString ?? "Unknown error"
282311
failedApps.append((app, reason))
@@ -343,6 +372,10 @@ class BatchProcessing: ObservableObject {
343372
func deselectAllApps() {
344373
selectedApps.removeAll()
345374
}
375+
376+
func selectUpdatedApps() {
377+
selectedApps = Set(updatedApps.map(\.path))
378+
}
346379

347380
private func systemArchitecture() -> String {
348381
ProcessInfo.processInfo.machineArchitecture

0 commit comments

Comments
 (0)