-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild-mac.py
More file actions
1028 lines (846 loc) · 35.7 KB
/
build-mac.py
File metadata and controls
1028 lines (846 loc) · 35.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
import os
import shutil
import subprocess
import zipfile
from pathlib import Path
import re
import xml.etree.ElementTree as ET
import time
# ============================================================================
# CONFIGURATION - Update these values
# ============================================================================
# Code Signing Identities
DEVELOPER_ID_APP = "ssdfsdff"
DEVELOPER_ID_INSTALLER = "sddsfsdfsdff"
# Apple Developer Credentials for Notarization
APPLE_ID = "sdfsdf@gmail.com"
TEAM_ID = "sdfsdfsdfssdf" # Find at https://developer.apple.com/account
APP_SPECIFIC_PASSWORD = "sdf-sdf-sdf-sdf" # Generate at appleid.apple.com
# App Details
APP_NAME = "AkademiTrack"
BUNDLE_IDENTIFIER = "com.CyberBrothers.akademitrack"
ICON_PATH = "./Assets/AT-1024.icns"
ENTITLEMENTS_PATH = Path("./entitlements.plist")
HELPER_APP_SOURCE = Path("./Assets/Helpers/AkademiTrack.app")
XCODE_PROJECT_PATH = Path("./AkademiTrack/AkademiTrack.xcodeproj")
# ============================================================================
# WIDGET BUILD FUNCTION
# ============================================================================
def build_widget_extension():
"""Build the widget extension using xcodebuild"""
print("\n🔧 Building Widget Extension...")
print("=" * 50)
if not XCODE_PROJECT_PATH.exists():
print("⚠️ Xcode project not found - skipping widget build")
return None
# Build the widget extension
cmd = [
"xcodebuild",
"-project", str(XCODE_PROJECT_PATH),
"-scheme", "AkademiTrackWidgetExtension",
"-configuration", "Release",
"-arch", "arm64",
"build"
]
result = run_command(cmd, "Building widget extension", check=False, show_output=True)
if not result or result.returncode != 0:
print("⚠️ Widget build failed - continuing without widget")
if result:
print(f"Error output: {result.stderr}")
return None
# Find the built widget extension
build_dir = Path.home() / "Library/Developer/Xcode/DerivedData"
# Search for the widget extension
for derived_data_dir in build_dir.glob("AkademiTrack-*"):
widget_path = derived_data_dir / "Build/Products/Release/AkademiTrackWidgetExtension.appex"
if widget_path.exists():
print(f"✅ Widget extension built: {widget_path.name}")
return widget_path
print("⚠️ Widget extension not found in DerivedData")
return None
# ============================================================================
# NEW: LaunchAgent Creation Function
# ============================================================================
def create_launchagent_plist(version):
"""Create LaunchAgent plist for auto-startup"""
print("\n📝 Creating LaunchAgent plist...")
plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.CyberBrothers.akademitrack</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>/Applications/AkademiTrack.app</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<false/>
</dict>
</plist>"""
plist_path = Path("com.CyberBrothers.akademitrack.plist")
with open(plist_path, "w") as f:
f.write(plist_content)
print(f"✅ Created LaunchAgent plist: {plist_path}")
return plist_path
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
def run_command(cmd, description="", check=True, show_output=False):
"""Run a command and handle errors"""
if description:
print(f" {description}...")
result = subprocess.run(cmd, capture_output=True, text=True)
if show_output and result.stdout:
print(result.stdout)
if check and result.returncode != 0:
print(f"❌ Failed: {result.stderr}")
return None
return result
def get_version_input():
"""Get version number from user or use current version from .csproj"""
print("\n📦 Version Configuration")
print("=" * 50)
current_version = get_current_version()
if current_version:
print(f"Current version in .csproj: {current_version}")
version = input(f"Enter version number (e.g., 1.0.1) or press Enter to use current [{current_version or '1.0.0'}]: ").strip()
if not version:
version = current_version or "1.0.0"
if not re.match(r'^\d+\.\d+\.\d+$', version):
print(f"⚠️ Invalid version format. Using default: 1.0.0")
version = "1.0.0"
return version
def get_current_version():
"""Read current version from .csproj file"""
try:
tree = ET.parse("./AkademiTrack.csproj")
root = tree.getroot()
for prop_group in root.findall('.//PropertyGroup'):
version_elem = prop_group.find('Version')
if version_elem is not None and version_elem.text:
return version_elem.text.strip()
return None
except Exception as e:
print(f"⚠️ Could not read version from .csproj: {e}")
return None
def update_csproj_version(version):
"""Update version in .csproj file"""
try:
tree = ET.parse("./AkademiTrack.csproj")
root = tree.getroot()
version_updated = False
for prop_group in root.findall('.//PropertyGroup'):
version_elem = prop_group.find('Version')
if version_elem is not None:
version_elem.text = version
version_updated = True
break
if not version_updated:
prop_groups = root.findall('.//PropertyGroup')
if prop_groups:
version_elem = ET.SubElement(prop_groups[0], 'Version')
version_elem.text = version
version_updated = True
if version_updated:
tree.write("./AkademiTrack.csproj", encoding='utf-8', xml_declaration=True)
print(f"✅ Updated .csproj version to {version}")
return True
else:
print(f"⚠️ Could not update version in .csproj")
return False
except Exception as e:
print(f"❌ Failed to update .csproj: {e}")
return False
# ============================================================================
# CODE SIGNING FUNCTIONS
# ============================================================================
def sign_file(file_path, identity, entitlements_path=None):
"""Sign a single file (dylib, executable, etc.)"""
cmd = [
"codesign", "--force", "--sign", identity,
"--timestamp", "--options", "runtime",
"--verbose"
]
if entitlements_path and entitlements_path.exists():
cmd.extend(["--entitlements", str(entitlements_path)])
cmd.append(str(file_path))
result = run_command(cmd, check=False)
return result and result.returncode == 0
def sign_all_binaries_in_app(app_path, identity, entitlements_path=None):
"""Sign all binaries inside an app bundle in the correct order (inside-out)"""
print(f"\n🔏 Signing all binaries in {app_path.name}...")
# Remove quarantine attributes first
subprocess.run(["xattr", "-cr", str(app_path)], check=False)
macos_dir = Path(app_path) / "Contents" / "MacOS"
# Collect all files that need signing
files_to_sign = []
# 1. First, sign all .dylib files (deepest first)
dylibs = sorted(macos_dir.rglob("*.dylib"), key=lambda x: len(x.parts), reverse=True)
files_to_sign.extend(dylibs)
# 2. Then sign any nested .app bundles
nested_apps = sorted(macos_dir.rglob("*.app"), key=lambda x: len(x.parts), reverse=True)
# 3. Sign executables (but not the main one yet)
main_executable = macos_dir / APP_NAME
executables = []
for file in macos_dir.rglob("*"):
if file.is_file() and os.access(file, os.X_OK):
if file != main_executable and not file.name.endswith('.dylib'):
executables.append(file)
files_to_sign.extend(sorted(executables, key=lambda x: len(x.parts), reverse=True))
# Sign all collected files
signed_count = 0
failed_count = 0
for file_path in files_to_sign:
print(f" 🔏 Signing: {file_path.relative_to(app_path)}...")
if sign_file(file_path, identity, entitlements_path):
signed_count += 1
else:
print(f" ⚠️ Failed to sign: {file_path.name}")
failed_count += 1
# Sign nested apps with deep signing
for nested_app in nested_apps:
print(f" 🔏 Deep signing nested app: {nested_app.name}...")
if sign_app(nested_app, identity, entitlements_path, deep=True):
signed_count += 1
else:
failed_count += 1
print(f"\n ✅ Signed {signed_count} binaries/apps")
if failed_count > 0:
print(f" ⚠️ Failed to sign {failed_count} items")
return failed_count == 0
def sign_app(app_path, identity, entitlements_path=None, deep=True):
"""Sign an application bundle"""
print(f"\n🔏 Signing app bundle: {app_path.name}...")
# First sign all internal binaries
if not sign_all_binaries_in_app(app_path, identity, entitlements_path):
print(f"⚠️ Some internal files failed to sign, but continuing...")
# Now sign the app bundle itself
cmd = ["codesign", "--force", "--sign", identity, "--timestamp"]
if deep:
cmd.append("--deep")
cmd.extend([
"--options", "runtime",
"--verbose"
])
if entitlements_path and entitlements_path.exists():
cmd.extend(["--entitlements", str(entitlements_path)])
cmd.append(str(app_path))
result = run_command(cmd, f"Final signing of {app_path.name}", check=False)
if result and result.returncode == 0:
print(f"✅ Signed: {app_path.name}")
# Verify signature
print(" Verifying signature...")
verify_result = run_command(
["codesign", "--verify", "--deep", "--strict", "--verbose=2", str(app_path)],
check=False,
show_output=True
)
if verify_result and verify_result.returncode == 0:
print(f"✅ Signature verified for {app_path.name}")
return True
else:
print(f"⚠️ Signature verification failed")
run_command(
["codesign", "-dv", "--verbose=4", str(app_path)],
check=False,
show_output=True
)
return False
else:
print(f"❌ Failed to sign {app_path.name}")
return False
def sign_pkg(pkg_path, identity):
"""Sign a .pkg installer"""
print(f"\n🔏 Signing installer package...")
signed_pkg = pkg_path.with_name(pkg_path.stem + "-signed.pkg")
cmd = [
"productsign",
"--sign", identity,
"--timestamp",
str(pkg_path),
str(signed_pkg)
]
result = run_command(cmd, f"Signing {pkg_path.name}", check=False)
if result and result.returncode == 0:
# Replace unsigned with signed
pkg_path.unlink()
signed_pkg.rename(pkg_path)
print(f"✅ Signed: {pkg_path.name}")
# Verify
print(" Verifying package signature...")
verify_result = run_command(
["pkgutil", "--check-signature", str(pkg_path)],
check=False,
show_output=True
)
return True
else:
print(f"❌ Failed to sign package")
if signed_pkg.exists():
signed_pkg.unlink()
return False
# ============================================================================
# NOTARIZATION FUNCTIONS
# ============================================================================
def notarize_file(file_path, bundle_id):
"""Submit file for notarization and wait for result"""
import json
print(f"\n📝 Notarizing {file_path.name}...")
print("⏳ This may take 5–15 minutes...")
# For .app bundles, we need to zip them first
if file_path.suffix == '.app':
print(" Creating temporary zip for notarization...")
zip_path = file_path.parent / f"{file_path.stem}-notarize.zip"
# Use ditto to preserve code signatures
result = run_command(
["ditto", "-c", "-k", "--keepParent", str(file_path), str(zip_path)],
"Creating zip with ditto",
check=False
)
if not result or result.returncode != 0:
print("❌ Failed to create zip")
return False
notarize_target = zip_path
else:
notarize_target = file_path
# Submit for notarization
cmd = [
"xcrun", "notarytool", "submit",
str(notarize_target),
"--apple-id", APPLE_ID,
"--team-id", TEAM_ID,
"--password", APP_SPECIFIC_PASSWORD,
"--wait",
"--output-format", "json"
]
print(f" Submitting to Apple notary service...")
result = run_command(cmd, check=False)
# Clean up temporary zip
if file_path.suffix == '.app' and notarize_target.exists() and notarize_target != file_path:
notarize_target.unlink()
if not result:
print("❌ Notarization command failed")
return False
# Parse response
try:
response_data = json.loads(result.stdout)
request_id = response_data.get("id")
status = response_data.get("status")
print(f"\n📋 Notarization Result:")
print(f" Request ID: {request_id}")
print(f" Status: {status}")
if result.returncode == 0 and status == "Accepted":
print("✅ Notarization successful!")
# Staple the notarization ticket
if file_path.suffix in ['.app', '.pkg']:
print(" Stapling notarization ticket...")
staple_cmd = ["xcrun", "stapler", "staple", str(file_path)]
staple_result = run_command(staple_cmd, check=False)
if staple_result and staple_result.returncode == 0:
print("✅ Notarization ticket stapled")
# Verify stapling
verify_result = run_command(
["xcrun", "stapler", "validate", str(file_path)],
check=False,
show_output=True
)
else:
print("⚠️ Failed to staple ticket")
return True
else:
print("❌ Notarization failed")
# Get detailed log
if request_id:
print("\n Fetching notarization log...")
log_cmd = [
"xcrun", "notarytool", "log",
request_id,
"--apple-id", APPLE_ID,
"--team-id", TEAM_ID,
"--password", APP_SPECIFIC_PASSWORD
]
log_result = run_command(log_cmd, check=False, show_output=True)
return False
except json.JSONDecodeError:
print("❌ Failed to parse notarization response")
print(f"Response: {result.stdout}")
return False
except Exception as e:
print(f"❌ Error processing notarization: {e}")
return False
# ============================================================================
# BUILD FUNCTIONS
# ============================================================================
def create_avalonia_macos_bundle(version, sign=True, notarize=True):
"""Create .app bundle for macOS"""
PROJECT_PATH = "./AkademiTrack.csproj"
BUILD_DIR = "./build"
print("\n🏗️ Building AkademiTrack app for macOS Apple Silicon...")
print("=" * 50)
if not os.path.exists(ICON_PATH):
print(f"❌ Icon file not found: {ICON_PATH}")
return False
if os.path.exists(BUILD_DIR):
print(f"🧹 Cleaning existing build directory: {BUILD_DIR}")
shutil.rmtree(BUILD_DIR)
build_cmd = [
"dotnet", "publish", PROJECT_PATH,
"--configuration", "Release",
"--runtime", "osx-arm64",
"--self-contained", "true",
"--output", BUILD_DIR,
"-p:PublishTrimmed=false",
"-p:PublishSingleFile=false",
"-p:IncludeNativeLibrariesForSelfExtract=true",
"-p:CopyOutputSymbolsToPublishDirectory=true",
"-p:IncludeAllContentForSelfExtract=true"
]
print(f"🔨 Building...")
result = subprocess.run(build_cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Build failed: {result.stderr}")
return False
print("✅ Build completed successfully")
build_path = Path(BUILD_DIR)
executable_path = build_path / APP_NAME
if not executable_path.exists():
executables = [f for f in build_path.iterdir() if f.is_file() and os.access(f, os.X_OK)]
if executables:
executable_path = executables[0]
else:
print("❌ No executables found!")
return False
print("📦 Creating app bundle...")
bundle_dir = build_path / f"{APP_NAME}.app"
contents_dir = bundle_dir / "Contents"
macos_dir = contents_dir / "MacOS"
resources_dir = contents_dir / "Resources"
macos_dir.mkdir(parents=True, exist_ok=True)
resources_dir.mkdir(parents=True, exist_ok=True)
# Copy all build output to MacOS directory
print(" Copying build files...")
for item in build_path.iterdir():
if item.name.endswith(".exe") or item.name == "AkademiAuth":
continue
if item != bundle_dir:
dest = macos_dir / item.name
if item.is_dir():
shutil.copytree(item, dest, dirs_exist_ok=True)
else:
shutil.copy2(item, dest)
# Copy icon
icon_filename = "AppIcon.icns" # Use consistent name
icon_dest = resources_dir / icon_filename
shutil.copy2(ICON_PATH, icon_dest)
if ENTITLEMENTS_PATH.exists():
entitlements_dest = resources_dir / "entitlements.plist"
shutil.copy2(ENTITLEMENTS_PATH, entitlements_dest)
print(f"✅ Copied entitlements.plist to bundle")
else:
print(f"⚠️ Entitlements file not found at {ENTITLEMENTS_PATH}")
# Create Info.plist
info_plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>{APP_NAME}</string>
<key>CFBundleIdentifier</key>
<string>com.CyberBrothers.akademitrack</string>
<key>CFBundleName</key>
<string>AkademiTrack</string>
<key>CFBundleDisplayName</key>
<string>AkademiTrack</string>
<key>CFBundleVersion</key>
<string>{version}</string>
<key>CFBundleShortVersionString</key>
<string>{version}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.education</string>
<key>NSHumanReadableCopyright</key>
<string>© 2025 CyberBrothers. All rights reserved.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>"""
plist_path = contents_dir / "Info.plist"
with open(plist_path, "w") as f:
f.write(info_plist_content)
print(f"✅ Created Info.plist with version {version}")
# Set executable permissions
executable_in_bundle = macos_dir / APP_NAME
if executable_in_bundle.exists():
os.chmod(executable_in_bundle, 0o755)
# Set permissions for all dylibs and executables
for lib in macos_dir.rglob("*"):
if lib.is_file() and (lib.suffix in ['.dylib', '.so'] or os.access(lib, os.X_OK)):
os.chmod(lib, 0o755)
# Remove quarantine attributes
subprocess.run(["xattr", "-cr", str(bundle_dir)], check=False)
# Build and bundle widget extension
widget_path = build_widget_extension()
if widget_path and widget_path.exists():
print("\n Bundling widget extension...")
plugins_dir = contents_dir / "PlugIns"
plugins_dir.mkdir(exist_ok=True)
widget_dest = plugins_dir / "AkademiTrackWidgetExtension.appex"
widget_entitlements = Path("./AkademiTrack/AkademiTrackWidgetExtension.entitlements")
try:
shutil.copytree(widget_path, widget_dest, dirs_exist_ok=True)
print("✅ Widget extension bundled in PlugIns")
if sign:
# Sign widget extension WITHOUT deep flag and with correct entitlements
print(" 🔏 Signing widget extension...")
if widget_entitlements.exists():
print(f" Using widget entitlements: {widget_entitlements}")
# Sign any executables inside widget first
for widget_file in widget_dest.rglob("*"):
if widget_file.is_file() and os.access(widget_file, os.X_OK):
sign_file(widget_file, DEVELOPER_ID_APP, widget_entitlements)
# Sign the widget extension bundle itself (NO --deep!)
cmd = [
"codesign", "--force", "--sign", DEVELOPER_ID_APP,
"--timestamp", "--options", "runtime",
"--entitlements", str(widget_entitlements),
str(widget_dest)
]
result = run_command(cmd, "Signing widget extension", check=False)
if result and result.returncode == 0:
print(" ✅ Widget extension signed successfully")
else:
print(" ⚠️ Widget signing failed")
else:
print(f" ⚠️ Widget entitlements not found at: {widget_entitlements}")
except Exception as e:
print(f"⚠️ Failed to bundle widget: {e}")
# Handle helper app if it exists
if HELPER_APP_SOURCE.exists():
print("\n Bundling helper app...")
helper_dest = resources_dir / "AkademiTrack.app"
try:
shutil.copytree(HELPER_APP_SOURCE, helper_dest, dirs_exist_ok=True)
print("✅ Helper app bundled in Resources")
if sign:
# Sign helper app first (it's nested)
sign_app(helper_dest, DEVELOPER_ID_APP, ENTITLEMENTS_PATH, deep=True)
except Exception as e:
print(f"⚠️ Failed to bundle helper: {e}")
# Sign the main app
if sign:
if not sign_app(bundle_dir, DEVELOPER_ID_APP, ENTITLEMENTS_PATH, deep=True):
print("❌ Signing failed")
return False
# Notarize the app
if notarize and sign:
if not notarize_file(bundle_dir, BUNDLE_IDENTIFIER):
print("⚠️ Notarization failed, but app is signed")
return bundle_dir
def create_portable_zip(bundle_dir, version, sign=True, notarize=True):
"""Create portable zip distribution"""
print("\n📦 Creating portable zip...")
print("=" * 50)
zip_path = Path(f"AkademiTrack-{version}-osx-Portable.zip").absolute()
if zip_path.exists():
zip_path.unlink()
try:
# Use ditto to preserve code signatures
result = run_command(
["ditto", "-c", "-k", "--keepParent", str(bundle_dir), str(zip_path)],
"Creating zip",
check=False
)
if result and result.returncode == 0:
print(f"✅ Portable zip created: {zip_path.name} ({zip_path.stat().st_size / 1024 / 1024:.1f} MB)")
if notarize and sign:
print(" ℹ️ The .app inside is already notarized - no need to notarize the zip")
return zip_path
else:
print("❌ Failed to create zip")
return None
except Exception as e:
print(f"❌ Failed to create zip: {e}")
return None
def create_velopack_release(bundle_dir, version, sign=True):
"""Create Velopack release package"""
print("\n📦 Creating Velopack release package...")
print("=" * 50)
# Create releases directory
releases_dir = Path("./Releases")
releases_dir.mkdir(exist_ok=True)
try:
# Use absolute path for icon
icon_abs_path = Path(ICON_PATH).absolute()
# Also try the transparent version as backup
icon_transparent = Path("./Assets/AT-Transparrent.icns").absolute()
icon_to_use = icon_abs_path if icon_abs_path.exists() else icon_transparent
# Use vpk to pack the app
cmd = [
"vpk", "pack",
"--packId", "AkademiTrack",
"--packVersion", version,
"--packDir", str(bundle_dir),
"--outputDir", str(releases_dir),
"--runtime", "osx-arm64",
"--mainExe", "AkademiTrack",
"--icon", str(icon_to_use),
"--packTitle", "AkademiTrack",
"--packAuthors", "CyberBrothers",
"--bundleId", BUNDLE_IDENTIFIER
]
if sign:
cmd.extend([
"--signAppIdentity", DEVELOPER_ID_APP
])
print(f"Using icon: {icon_to_use}")
print(f"Icon exists: {icon_to_use.exists()}")
print(f"Icon size: {icon_to_use.stat().st_size if icon_to_use.exists() else 'N/A'} bytes")
result = run_command(cmd, "Creating Velopack release", check=False)
if result and result.returncode == 0:
# Find the created release file
release_files = list(releases_dir.glob(f"AkademiTrack-{version}-*.nupkg"))
if release_files:
release_file = release_files[0]
print(f"✅ Velopack release created: {release_file.name} ({release_file.stat().st_size / 1024 / 1024:.1f} MB)")
return release_file
else:
print("❌ Velopack release file not found")
return None
else:
print("❌ Failed to create Velopack release")
if result and result.stderr:
print(f"Error: {result.stderr}")
if result and result.stdout:
print(f"Output: {result.stdout}")
return None
except Exception as e:
print(f"❌ Failed to create Velopack release: {e}")
return None
def create_installer_pkg(bundle_dir, version, sign=True, notarize=True):
"""Create .pkg installer with LaunchAgent"""
print("\n📦 Creating installer package with LaunchAgent...")
print("=" * 50)
# Create LaunchAgent plist
launchagent_plist = create_launchagent_plist(version)
# Create temporary root directory structure
temp_root = Path("./pkg_root")
if temp_root.exists():
shutil.rmtree(temp_root)
temp_root.mkdir(parents=True)
# Create directory structure
apps_dir = temp_root / "Applications"
launch_agents_dir = temp_root / "Library" / "LaunchAgents"
apps_dir.mkdir(parents=True)
launch_agents_dir.mkdir(parents=True)
print(" Copying app bundle...")
shutil.copytree(bundle_dir, apps_dir / f"{APP_NAME}.app", dirs_exist_ok=True)
print(" Copying LaunchAgent plist...")
shutil.copy2(launchagent_plist, launch_agents_dir / launchagent_plist.name)
pkg_path = Path(f"AkademiTrack-{version}-osx-Setup.pkg").absolute()
# Create pkg
cmd = [
"pkgbuild",
"--root", str(temp_root),
"--identifier", BUNDLE_IDENTIFIER,
"--version", version,
"--install-location", "/",
str(pkg_path)
]
result = run_command(cmd, "Building package", check=False)
# Clean up
if temp_root.exists():
shutil.rmtree(temp_root)
if not result or result.returncode != 0:
print("❌ Failed to create package")
return None
print(f"✅ Package created: {pkg_path.name} ({pkg_path.stat().st_size / 1024 / 1024:.1f} MB)")
# Sign the package
if sign:
if not sign_pkg(pkg_path, DEVELOPER_ID_INSTALLER):
print("⚠️ Package signing failed")
return pkg_path
# Notarize the package
if notarize and sign:
notarize_file(pkg_path, BUNDLE_IDENTIFIER)
return pkg_path
# ============================================================================
# VERIFICATION FUNCTIONS
# ============================================================================
def verify_all_signatures(bundle_dir):
"""Verify signatures of all files"""
print("\n🔍 Verifying all signatures...")
print("=" * 50)
# Verify main app
print(f"\n📱 Verifying main app bundle:")
run_command(
["codesign", "-dv", "--verbose=4", str(bundle_dir)],
check=False,
show_output=True
)
run_command(
["codesign", "--verify", "--deep", "--strict", str(bundle_dir)],
check=False,
show_output=True
)
# Check notarization
print(f"\n📝 Checking notarization:")
run_command(
["xcrun", "stapler", "validate", str(bundle_dir)],
check=False,
show_output=True
)
# Check Gatekeeper
print(f"\n🚪 Checking Gatekeeper assessment:")
run_command(
["spctl", "-a", "-vv", "-t", "exec", str(bundle_dir)],
check=False,
show_output=True
)
# ============================================================================
# MAIN FUNCTION
# ============================================================================
def main():
print("🚀 AkademiTrack Build, Sign & Notarize Tool")
print("=" * 50)
# Verify configuration
print("\n🔍 Checking configuration...")
if "sdfsdfsdfsdfds" in DEVELOPER_ID_APP or "sdfsdfdsfs" in APPLE_ID:
print("❌ Please update the configuration at the top of this script!")
print(" - DEVELOPER_ID_APP")
print(" - DEVELOPER_ID_INSTALLER")
print(" - APPLE_ID")
print(" - TEAM_ID")
print(" - APP_SPECIFIC_PASSWORD")
return
# List available signing identities
print("\n🔑 Available signing identities:")
run_command(
["security", "find-identity", "-v", "-p", "codesigning"],
check=False,
show_output=True
)
# Get version number
version = get_version_input()
print(f"\n📌 Using version: {version}")
# Ask if user wants to update .csproj
update_proj = input("\nUpdate version in .csproj file? (y/n) [y]: ").strip().lower()
if update_proj != 'n':
update_csproj_version(version)
# Ask about signing and notarization
print("\n🔐 Signing & Notarization Options:")
sign_choice = input("Sign applications? (y/n) [y]: ").strip().lower()
do_sign = sign_choice != 'n'
do_notarize = False
if do_sign:
notarize_choice = input("Notarize applications? (y/n) [y]: ").strip().lower()
do_notarize = notarize_choice != 'n'
# Build the app
print("\n" + "=" * 50)
bundle_dir = create_avalonia_macos_bundle(version, sign=do_sign, notarize=do_notarize)
if not bundle_dir:
print("❌ App bundle creation failed")
return
# Verify signatures if signed
if do_sign:
verify_all_signatures(bundle_dir)
# Ask what distributions to create
print("\n📦 Distribution Options:")
print("1. Portable ZIP")
print("2. Installer PKG (includes LaunchAgent)")
print("3. Velopack Release Package")
print("4. All of the above")
dist_choice = input("\nSelect option (1/2/3/4) [4]: ").strip()
if not dist_choice:
dist_choice = "4"
created_files = []
# Option 1: ZIP only
if dist_choice in ["1", "4"]:
zip_file = create_portable_zip(bundle_dir, version, sign=do_sign, notarize=do_notarize)
if zip_file:
created_files.append(("Portable ZIP", zip_file))
# Option 2: PKG with LaunchAgent
if dist_choice in ["2", "4"]:
pkg_file = create_installer_pkg(bundle_dir, version, sign=do_sign, notarize=do_notarize)
if pkg_file:
created_files.append(("Installer PKG", pkg_file))
# Option 3: Velopack Release
if dist_choice in ["3", "4"]:
velopack_file = create_velopack_release(bundle_dir, version, sign=do_sign)
if velopack_file:
created_files.append(("Velopack Release", velopack_file))
# Final summary
print("\n" + "=" * 50)
print("🎉 Build completed successfully!")
print(f"📦 Version: {version}")
print("\n📋 Files created:")
print(f" ✅ .app bundle: {bundle_dir}")
for file_type, file_path in created_files:
size_mb = file_path.stat().st_size / 1024 / 1024
print(f" ✅ {file_type}: {file_path.name} ({size_mb:.1f} MB)")
if do_sign:
print("\n✅ All files signed with Developer ID")
if do_notarize:
print("✅ All files notarized by Apple")
# LaunchAgent info
if dist_choice in ["2", "4"]:
print("\n🚀 LaunchAgent Information:")
print(" ✅ PKG installer includes LaunchAgent")
print(" 📝 Location: ~/Library/LaunchAgents/com.CyberBrothers.akademitrack.plist")
print(" 👤 Display name: AkademiTrack (shown in Background Items)")
print("\n After installation, users will see:")
print(" • System Settings → General → Login Items → Allow in Background")
print(" • Shows 'AkademiTrack' instead of your developer name!")
# Verification commands
print("\n🔍 Verification commands:")
print(f" codesign -dv --verbose=4 '{bundle_dir}'")
print(f" codesign --verify --deep --strict '{bundle_dir}'")
print(f" xcrun stapler validate '{bundle_dir}'")
print(f" spctl -a -vv -t exec '{bundle_dir}'")
for file_type, file_path in created_files:
if file_path.suffix == '.pkg':
print(f" pkgutil --check-signature '{file_path}'")
print(f" xcrun stapler validate '{file_path}'")
print("\n📋 Next steps:")