Rename the Xcode workspace and sources so the app is Graduately.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"images" : [
|
||||
{ "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" },
|
||||
{ "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" },
|
||||
{ "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" },
|
||||
{ "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" },
|
||||
{ "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" },
|
||||
{ "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" },
|
||||
{ "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" },
|
||||
{ "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" },
|
||||
{ "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" },
|
||||
{ "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 912 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 116 KiB |
6
macos/Graduately/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
10
macos/Graduately/Graduately.entitlements
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?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>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
166
macos/Graduately/GraduatelyApp.swift
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MaturitaApp: App {
|
||||
@StateObject private var vm = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MacRoot(vm: vm)
|
||||
}
|
||||
.defaultSize(width: 1280, height: 840)
|
||||
.windowResizability(.contentMinSize)
|
||||
.windowToolbarStyle(.unified)
|
||||
.windowStyle(.automatic)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
CommandGroup(after: .sidebar) {
|
||||
Button(vm.tr("search_home")) {
|
||||
vm.tab = .practice
|
||||
vm.practicePath = []
|
||||
}
|
||||
.keyboardShortcut("1", modifiers: [.command])
|
||||
Button(vm.tr("stats")) {
|
||||
vm.tab = .progress
|
||||
}
|
||||
.keyboardShortcut("2", modifiers: [.command])
|
||||
Button(vm.tr("search")) {
|
||||
vm.tab = .search
|
||||
}
|
||||
.keyboardShortcut("k", modifiers: [.command])
|
||||
Button(vm.tr("settings")) {
|
||||
vm.tab = .settings
|
||||
}
|
||||
.keyboardShortcut("3", modifiers: [.command])
|
||||
}
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsScreen(vm: vm)
|
||||
.formStyle(.grouped)
|
||||
.frame(minWidth: 480, minHeight: 520)
|
||||
.tint(vm.palette.tint)
|
||||
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MacRoot: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
tabs
|
||||
.tint(vm.palette.tint)
|
||||
.preferredColorScheme(vm.mode == .dark ? .dark : .light)
|
||||
.overlay(alignment: .top) {
|
||||
if showsUpdateAccessory {
|
||||
UpdateBanner(vm: vm)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.onAppear { vm.checkUpdate(false) }
|
||||
.frame(minWidth: 980, minHeight: 640)
|
||||
}
|
||||
|
||||
private var showsUpdateAccessory: Bool {
|
||||
vm.update.canInstall || vm.update.status == "downloading" || vm.update.status == "staged"
|
||||
}
|
||||
|
||||
private var tabs: some View {
|
||||
TabView(selection: $vm.tab) {
|
||||
Tab(vm.tr("search_home"), systemImage: "square.grid.2x2.fill", value: AppTab.practice) {
|
||||
NavigationStack(path: $vm.practicePath) {
|
||||
PracticeHome(vm: vm)
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
RouteDestination(vm: vm, route: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab(vm.tr("stats"), systemImage: "chart.bar.fill", value: AppTab.progress) {
|
||||
NavigationStack {
|
||||
StatsScreen(vm: vm)
|
||||
}
|
||||
}
|
||||
Tab(vm.tr("settings"), systemImage: "gearshape.fill", value: AppTab.settings) {
|
||||
NavigationStack {
|
||||
SettingsScreen(vm: vm)
|
||||
}
|
||||
}
|
||||
Tab(vm.tr("search"), systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
|
||||
NavigationStack {
|
||||
SearchScreen(vm: vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.toolbar {
|
||||
MacWindowToolbar(vm: vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MacWindowToolbar: ToolbarContent {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
vm.tab = .search
|
||||
} label: {
|
||||
Label(vm.tr("search"), systemImage: "magnifyingglass")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.help(vm.tr("search"))
|
||||
|
||||
Button {
|
||||
vm.tab = .progress
|
||||
} label: {
|
||||
Label(vm.tr("stats"), systemImage: "chart.bar.fill")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.help(vm.tr("stats"))
|
||||
}
|
||||
|
||||
ToolbarSpacer(.fixed, placement: .primaryAction)
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
vm.tab = .settings
|
||||
} label: {
|
||||
Label(vm.tr("settings"), systemImage: "gearshape")
|
||||
}
|
||||
.controlSize(.large)
|
||||
.help(vm.tr("settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UpdateBanner: View {
|
||||
@ObservedObject var vm: AppModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.down.app.fill")
|
||||
.font(.title3)
|
||||
Text(bannerText)
|
||||
.font(.body)
|
||||
Spacer()
|
||||
if vm.update.canInstall {
|
||||
Button(vm.tr("update_install")) { vm.installUpdate() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.softSurface(cornerRadius: 16)
|
||||
.frame(maxWidth: 640)
|
||||
}
|
||||
|
||||
private var bannerText: String {
|
||||
if let arg = vm.update.messageArg {
|
||||
return vm.fmt(vm.update.messageKey, arg)
|
||||
}
|
||||
return vm.tr(vm.update.messageKey)
|
||||
}
|
||||
}
|
||||
12
macos/Graduately/Info.plist
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?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>GITCommit</key>
|
||||
<string>$(COMMIT)</string>
|
||||
<key>UPDATE_BRANCH</key>
|
||||
<string>builds</string>
|
||||
<key>UPDATE_REPO</key>
|
||||
<string>pepaskopec-blip/Graduately</string>
|
||||
</dict>
|
||||
</plist>
|
||||
38
macos/Graduately/Platform.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#if os(macOS)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension NSColor {
|
||||
static var tertiarySystemFill: NSColor {
|
||||
NSColor.quaternaryLabelColor.withAlphaComponent(0.16)
|
||||
}
|
||||
|
||||
static var secondarySystemGroupedBackground: NSColor {
|
||||
.controlBackgroundColor
|
||||
}
|
||||
|
||||
static var separator: NSColor { .separatorColor }
|
||||
}
|
||||
|
||||
enum TextInputAutocapitalization {
|
||||
case never
|
||||
}
|
||||
|
||||
enum UIKeyboardType {
|
||||
case decimalPad
|
||||
}
|
||||
|
||||
enum MacNavBarTitleMode {
|
||||
case large
|
||||
case inline
|
||||
}
|
||||
|
||||
extension View {
|
||||
func textInputAutocapitalization(_ style: TextInputAutocapitalization) -> some View { self }
|
||||
|
||||
func keyboardType(_ type: UIKeyboardType) -> some View { self }
|
||||
|
||||
func navigationBarTitleDisplayMode(_ mode: MacNavBarTitleMode) -> some View { self }
|
||||
}
|
||||
|
||||
#endif
|
||||
233
macos/Graduately/Update/Updater.swift
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
|
||||
struct UpdateState {
|
||||
var status = "idle"
|
||||
var remote: String?
|
||||
var messageKey = "update_current"
|
||||
var messageArg: String? = String(AppConfig.commit.prefix(7))
|
||||
var canInstall = false
|
||||
}
|
||||
|
||||
final class Updater {
|
||||
private let repo = AppConfig.updateRepo
|
||||
private let branch = AppConfig.updateBranch
|
||||
|
||||
func check(interactive: Bool, cb: @escaping (UpdateState) -> Void) {
|
||||
if AppConfig.commit == "dev" {
|
||||
emit(cb, UpdateState(status: "dev", messageKey: "update_err_devbuild", messageArg: nil))
|
||||
return
|
||||
}
|
||||
emit(cb, UpdateState(status: "checking", messageKey: "update_checking", messageArg: nil))
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
do {
|
||||
let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
|
||||
let remote: String
|
||||
if let tip = self.atomTip(atom),
|
||||
let marker = try? self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION-macos")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
marker.count >= 7 {
|
||||
remote = marker
|
||||
} else if let sha = self.atomMainSha(atom) {
|
||||
remote = sha
|
||||
} else if let tip = self.atomTip(atom) {
|
||||
remote = try self.get("https://raw.githubusercontent.com/\(self.repo)/\(tip)/VERSION")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
} else {
|
||||
throw URLError(.cannotParseResponse)
|
||||
}
|
||||
let local = AppConfig.commit
|
||||
if self.sameCommit(remote, local) {
|
||||
self.emit(cb, UpdateState(status: "ok", remote: remote, messageKey: "update_uptodate", messageArg: String(remote.prefix(7))))
|
||||
} else {
|
||||
self.emit(cb, UpdateState(status: "available", remote: remote, messageKey: "update_available", messageArg: String(remote.prefix(7)), canInstall: true))
|
||||
}
|
||||
} catch {
|
||||
self.emit(cb, UpdateState(status: "error", messageKey: "update_err_network", messageArg: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func install(cb: @escaping (UpdateState) -> Void) {
|
||||
emit(cb, UpdateState(status: "downloading", messageKey: "update_downloading", messageArg: nil))
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
do {
|
||||
let atom = try self.get("https://github.com/\(self.repo)/commits/\(self.branch).atom")
|
||||
let tip = self.atomTip(atom) ?? ""
|
||||
guard !tip.isEmpty else { throw URLError(.cannotParseResponse) }
|
||||
let zipName = Self.assetName()
|
||||
let url = "https://raw.githubusercontent.com/\(self.repo)/\(tip)/\(zipName)"
|
||||
let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
let staging = cache.appendingPathComponent("graduately-update-\(ProcessInfo.processInfo.processIdentifier)")
|
||||
try? FileManager.default.removeItem(at: staging)
|
||||
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
|
||||
let archive = staging.appendingPathComponent(zipName)
|
||||
try self.download(url, to: archive)
|
||||
let unpacked = staging.appendingPathComponent("new")
|
||||
try FileManager.default.createDirectory(at: unpacked, withIntermediateDirectories: true)
|
||||
try self.run("/usr/bin/ditto", ["-x", "-k", archive.path, unpacked.path])
|
||||
guard let source = self.findApp(in: unpacked) else {
|
||||
throw URLError(.cannotParseResponse)
|
||||
}
|
||||
guard let target = Self.runningAppURL() else {
|
||||
throw URLError(.cannotOpenFile)
|
||||
}
|
||||
let script = self.writeSwapScript()
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
|
||||
proc.arguments = [script.path, "\(ProcessInfo.processInfo.processIdentifier)", target.path, source.path, staging.path]
|
||||
try proc.run()
|
||||
self.emit(cb, UpdateState(status: "staged", remote: tip, messageKey: "update_staged", messageArg: String(tip.prefix(7))))
|
||||
DispatchQueue.main.async {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
} catch {
|
||||
self.emit(cb, UpdateState(status: "error", messageKey: "update_err_download", messageArg: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func assetName() -> String {
|
||||
#if arch(arm64)
|
||||
return "graduately-macos-arm64.zip"
|
||||
#else
|
||||
return "graduately-macos-x86_64.zip"
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func runningAppURL() -> URL? {
|
||||
let bundle = Bundle.main.bundleURL
|
||||
return bundle.pathExtension == "app" ? bundle : nil
|
||||
}
|
||||
|
||||
private func findApp(in root: URL) -> URL? {
|
||||
let keys: [URLResourceKey] = [.isDirectoryKey]
|
||||
guard let en = FileManager.default.enumerator(at: root, includingPropertiesForKeys: keys) else { return nil }
|
||||
for case let url as URL in en where url.pathExtension == "app" {
|
||||
return url
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func writeSwapScript() -> URL {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("graduately-swift-update.sh")
|
||||
let body = """
|
||||
#!/bin/sh
|
||||
if [ -z "$MATURITA_UPDATE_DETACHED" ]; then
|
||||
MATURITA_UPDATE_DETACHED=1
|
||||
export MATURITA_UPDATE_DETACHED
|
||||
nohup /bin/sh "$0" "$@" >/dev/null 2>&1 &
|
||||
exit 0
|
||||
fi
|
||||
pid=$1; target=$2; source=$3; staging=$4
|
||||
n=0
|
||||
while kill -0 "$pid" 2>/dev/null && [ $n -lt 600 ]; do
|
||||
sleep 0.2; n=$((n+1))
|
||||
done
|
||||
place=$target
|
||||
if /usr/bin/ditto "$source" "$target" 2>/dev/null; then
|
||||
xattr -cr "$target" 2>/dev/null
|
||||
else
|
||||
alt="$HOME/Applications/$(basename "$target")"
|
||||
mkdir -p "$HOME/Applications"
|
||||
if /usr/bin/ditto "$source" "$alt" 2>/dev/null; then
|
||||
xattr -cr "$alt" 2>/dev/null
|
||||
place=$alt
|
||||
fi
|
||||
fi
|
||||
open -n "$place"
|
||||
rm -rf "$staging"
|
||||
rm -f "$0"
|
||||
"""
|
||||
try? body.write(to: url, atomically: true, encoding: .utf8)
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
|
||||
return url
|
||||
}
|
||||
|
||||
private func emit(_ cb: @escaping (UpdateState) -> Void, _ state: UpdateState) {
|
||||
DispatchQueue.main.async { cb(state) }
|
||||
}
|
||||
|
||||
private func atomTip(_ atom: String) -> String? {
|
||||
if let m = atom.range(of: #"Commit/([0-9a-f]{40})"#, options: [.regularExpression, .caseInsensitive]) {
|
||||
return String(atom[m]).split(separator: "/").last.map(String.init)
|
||||
}
|
||||
if let m = atom.range(of: #"/commit/([0-9a-f]{7,40})"#, options: [.regularExpression, .caseInsensitive]) {
|
||||
return String(atom[m]).split(separator: "/").last.map(String.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func atomMainSha(_ atom: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"Build from ([0-9a-f]{7,40})"#, options: .caseInsensitive),
|
||||
let match = regex.firstMatch(in: atom, range: NSRange(atom.startIndex..., in: atom)),
|
||||
let range = Range(match.range(at: 1), in: atom)
|
||||
else { return nil }
|
||||
return String(atom[range])
|
||||
}
|
||||
|
||||
private func sameCommit(_ a: String, _ b: String) -> Bool {
|
||||
let n = min(a.count, b.count)
|
||||
return n >= 7 && a.prefix(n).lowercased() == b.prefix(n).lowercased()
|
||||
}
|
||||
|
||||
private func get(_ url: String) throws -> String {
|
||||
guard let u = URL(string: url) else { throw URLError(.badURL) }
|
||||
var req = URLRequest(url: u, timeoutInterval: 15)
|
||||
req.setValue("Graduately-macos", forHTTPHeaderField: "User-Agent")
|
||||
req.setValue("*/*", forHTTPHeaderField: "Accept")
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var result: Result<String, Error> = .failure(URLError(.unknown))
|
||||
URLSession.shared.dataTask(with: req) { data, resp, err in
|
||||
if let err { result = .failure(err) }
|
||||
else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
result = .failure(URLError(.badServerResponse))
|
||||
} else if let data, let text = String(data: data, encoding: .utf8) {
|
||||
result = .success(text)
|
||||
} else {
|
||||
result = .failure(URLError(.cannotDecodeContentData))
|
||||
}
|
||||
sem.signal()
|
||||
}.resume()
|
||||
_ = sem.wait(timeout: .now() + 20)
|
||||
return try result.get()
|
||||
}
|
||||
|
||||
private func download(_ url: String, to dest: URL) throws {
|
||||
guard let u = URL(string: url) else { throw URLError(.badURL) }
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var result: Result<Void, Error> = .failure(URLError(.unknown))
|
||||
let task = URLSession.shared.downloadTask(with: u) { file, resp, err in
|
||||
if let err { result = .failure(err) }
|
||||
else if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
result = .failure(URLError(.badServerResponse))
|
||||
} else if let file {
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
try FileManager.default.moveItem(at: file, to: dest)
|
||||
result = .success(())
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
} else {
|
||||
result = .failure(URLError(.cannotDecodeContentData))
|
||||
}
|
||||
sem.signal()
|
||||
}
|
||||
task.resume()
|
||||
_ = sem.wait(timeout: .now() + 1800)
|
||||
try result.get()
|
||||
}
|
||||
|
||||
private func run(_ launch: String, _ args: [String]) throws {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: launch)
|
||||
proc.arguments = args
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
if proc.terminationStatus != 0 {
|
||||
throw URLError(.cannotParseResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||