Skip to content

Commit c43759f

Browse files
committed
Add Sparkle auto-updates; move Multi-Verse into themes; UI polish
Auto-updates (Sparkle 2, reusable for future apps): - SPUStandardUpdaterController wrapper (UpdateController) with launch + interval checks, opt-out, cadence, beta channel, silent auto-download, and a "Check for Updates…" menu command; guarded so it only starts once a real EdDSA key is set (never in the test host). - Settings ▸ Actualizări tab + in-app Versions picker (install / roll back to any release through Sparkle, signature-verified). - Info.plist Sparkle keys (real SUPublicEDKey), sandbox entitlements (network.client + Sparkle mach-lookup exceptions), Sparkle SPM dep. - CI: ad-hoc sign + monotonic CFBundleVersion; sign & publish a signed appcast to GitHub Pages (scripts/publish_appcast.sh + appcast_upsert.py), guarded on the SPARKLE_PRIVATE_KEY secret so CI stays green until set. - UPDATES.md runbook (setup, ad-hoc caveat, Developer ID upgrade path). Multi-Verse options now live in the theme (verse cassette), not globals: - ContentOptions gains multiVerseLayout / showNumbers / customEnabled / customText; edited in the Layout Editor on the verse cassette, carried by theme export/import, and read by every previewer and the live output. - Custom text supports {verses} {ref} {n} tokens when 2+ verses show. Right sidebar / settings cleanup: - Removed the dead "Afișează verse numbers/cross-refs/footnotes" toggles (they bound to keys nothing read); kept the working interlinear toggle. - Rewired the Settings footnotes toggle to the key BibleView actually uses. - Preview panel ~10% wider; button labels no longer wrap to two lines. Library / Bible polish: - Song source file + web link in the DB and header; clickable info chips; redesigned song detail header; subtle section headings in list & grid. - Bible tab title shows the language before the version, e.g. "(RO) EDC100".
1 parent e8fd565 commit c43759f

26 files changed

Lines changed: 1074 additions & 176 deletions

.github/workflows/build-and-release.yml

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,24 @@ jobs:
4242

4343
- name: Build
4444
run: |
45+
# Monotonic CFBundleVersion so Sparkle can tell releases apart (the project's
46+
# CURRENT_PROJECT_VERSION is a fixed 1). Ad-hoc code signing ("-") so the app's
47+
# entitlements (sandbox + Sparkle XPC) actually apply and the in-app updater's
48+
# installer works — no Apple certs needed (Gatekeeper still asks for right-click
49+
# → Open, exactly as before). Swap "-" for a Developer ID identity + add
50+
# notarization later for silent updates; see UPDATES.md.
4551
xcodebuild \
4652
-scheme "$SCHEME" \
4753
-project "$PRODUCT_NAME.xcodeproj" \
4854
-configuration Release \
4955
-derivedDataPath build \
5056
-destination 'platform=macOS' \
5157
CODE_SIGN_IDENTITY="-" \
58+
CODE_SIGN_STYLE=Manual \
5259
CODE_SIGNING_REQUIRED=NO \
53-
CODE_SIGNING_ALLOWED=NO \
60+
CODE_SIGNING_ALLOWED=YES \
5461
DEVELOPMENT_TEAM="" \
62+
CURRENT_PROJECT_VERSION=${{ github.run_number }} \
5563
clean build
5664
5765
- name: Run Unit Tests
@@ -121,8 +129,14 @@ jobs:
121129
runs-on: macos-15
122130
permissions:
123131
contents: write
132+
env:
133+
# Empty until you add the repo secret — the appcast step then no-ops (CI stays green).
134+
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
124135

125136
steps:
137+
- name: Checkout (for appcast scripts)
138+
uses: actions/checkout@v5
139+
126140
- name: Download artifact
127141
uses: actions/download-artifact@v5
128142
with:
@@ -159,6 +173,21 @@ jobs:
159173
TopPresenter-*.zip
160174
TopPresenter-*.dmg
161175
176+
- name: Publish appcast (beta channel)
177+
if: ${{ env.SPARKLE_PRIVATE_KEY != '' }}
178+
env:
179+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
180+
ZIP: TopPresenter-${{ needs.build.outputs.version }}.zip
181+
DOWNLOAD_URL: https://github.com/${{ github.repository }}/releases/download/${{ needs.build.outputs.prerelease_tag }}/TopPresenter-${{ needs.build.outputs.version }}.zip
182+
SHORT_VERSION: ${{ needs.build.outputs.prerelease_version }}
183+
BUILD_VERSION: ${{ github.run_number }}
184+
CHANNEL: beta
185+
REPO: ${{ github.repository }}
186+
NOTES: ${{ github.event.head_commit.message }}
187+
run: |
188+
chmod +x scripts/publish_appcast.sh
189+
scripts/publish_appcast.sh
190+
162191
# Stable release on final version tags (v1.0.0, v1.1.0, etc.)
163192
release:
164193
name: Stable Release
@@ -167,8 +196,13 @@ jobs:
167196
runs-on: macos-15
168197
permissions:
169198
contents: write
199+
env:
200+
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
170201

171202
steps:
203+
- name: Checkout (for appcast scripts)
204+
uses: actions/checkout@v5
205+
172206
- name: Download artifact
173207
uses: actions/download-artifact@v5
174208
with:
@@ -189,3 +223,17 @@ jobs:
189223
files: |
190224
TopPresenter-*.zip
191225
TopPresenter-*.dmg
226+
227+
- name: Publish appcast (stable channel)
228+
if: ${{ env.SPARKLE_PRIVATE_KEY != '' }}
229+
env:
230+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
231+
ZIP: TopPresenter-${{ needs.build.outputs.version }}.zip
232+
DOWNLOAD_URL: https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/TopPresenter-${{ needs.build.outputs.version }}.zip
233+
SHORT_VERSION: ${{ needs.build.outputs.version }}
234+
BUILD_VERSION: ${{ github.run_number }}
235+
CHANNEL: ""
236+
REPO: ${{ github.repository }}
237+
run: |
238+
chmod +x scripts/publish_appcast.sh
239+
scripts/publish_appcast.sh

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ TopPresenter/
186186
- **Sidebar (v10.2)** = `SidebarItem.contentItems` (bible/songs/media/schedule/customSlides) in the top `List`, + a PINNED bottom group (`utilityItems` = `.history`, `.account` as selectable destinations, plus a **Settings** button via `@Environment(\.openSettings)`). `.history``HistoryView`, `.account``ProfileView` (local prefs, `@AppStorage` only — no login) route through `ContentAreaView`; both return `EmptyView` in `PreviewPanelView` and the preview column is HIDDEN for them in `MainControlView` (full-width). Any new switch over `SidebarItem` must handle `.history` + `.account`
187187
- **Single output window**: locate it via `presentationWindows` (plural) and call `dedupePresentationWindows()` (closes extras) at the top of `showPresentationWindow`/`movePresentationWindow`/`positionOnScreen` + after the launch auto-open (guarded by `hasPresentationWindow`); the presentation `WindowGroup` is `.restorationBehavior(.disabled)`. This killed the "two overlapping outputs" (state-restoration + auto-open) bug — don't reintroduce an unguarded `openWindow(.presentation)`
188188
- **Song verified flag + edit log (v10.2)**: `Song.verified` (Bool, round-trips through GOAT — `songDictV2` writes `"verified"`, `TopPresenterSongImporter` reads it), `Song.modifiedDate` (drives the Recente sort), `Song.editLogJSON`→`editLog: [SongEditEntry]` (coarse change log, INTERNAL — not exported). The song editor snapshots the song to GOAT on open (`ExportService.exportSongToTopPresenterJSON`); **Renunță** reverts via `ImportService.applyResult(_:to:modelContext:)` (the GOAT→Song builder extracted from `createSongFromResult` — clears + rebuilds versions/sections, reused by import too); **Gata** diffs old↔new via `ImportService.summarizeChanges(old:new:)` → appends edit-log entries. `SectionEditorCard` uses `@FocusState` so clicking a section drives the editor preview. Library: verified badge in `songBadges`, "Doar verificate" filter + `verificat`/✓ search token, sort header chips (`SongSortKey` = A-Z/Artist/Carte/Limbă/Recente). Song slide thumbnails have PREVIEW + trash (delete = remove the section behind the slide, `.confirmationDialog`)
189+
- **Song library browse (v10.3)**: the browser search lives on `LibraryManager.songLibraryQuery` (SHARED — NOT the Quick Search `songSearchQuery`), so detail-panel chips can set it. `SongDetailPanel` chips are clickable `searchChip(_:query:)`/`searchText(_:query:)` that set `songLibraryQuery` (find similar by artist/book/language/style/theme). `filtered` is grouped into subtle `Section` headers by the active `SortKey` (`grouped` + `initialLetter` diacritic-folded; Recente = ungrouped) in BOTH list and grid (grid uses `pinnedViews: [.sectionHeaders]`). `Song.sourceFile` (filename, stamped in `parseDirectory` default + `ImportService` single-file paths, set in `applyResult`) + `Song.webURL` (best-effort URL dug out of `_extensions`) show above the slides; the detail header is Title · book · artist(≤~half width) left, history "Prezentat ×" + key/chords + Edit (large) right
189190
- **Unified z-order for EVERY box** (sections + custom + media interleaved): per-profile `boxOrder` token list ("section:<raw>" / "custom:<uuid>" / "media:<uuid>"), reconciled via `orderedBoxTokens()` (pure — safe in view body; new boxes land on top, media defaults to the back). ALL render paths (output `orderedBoxes`, preview card, editor canvas) iterate this order — never hardcode section/media layering again. Reorder via drag in the Casete list (front-first, `reorderBoxToken(_:above:)`) or the Ordonare context menu on any box (canvas + list)
190191
- Custom + media boxes are renamable (`name` field, context-menu Redenumește); translation & subtitle rows have a trash button that HIDES them (built-ins are never deleted)
191192
- Hidden boxes are COMPLETELY invisible everywhere — preview card AND editor canvas pass `showsHiddenBoxes: false`; the only place a hidden box appears is the Casete list (dimmed, eye to re-enable)

Info.plist

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
33
<plist version="1.0">
44
<dict>
5+
<!-- Sparkle auto-update (see UPDATES.md). Replace SUPublicEDKey with the EdDSA
6+
public key from `generate_keys`; the appcast is published to GitHub Pages. -->
7+
<key>SUFeedURL</key>
8+
<string>https://robyrew.github.io/TopPresenter/appcast.xml</string>
9+
<key>SUPublicEDKey</key>
10+
<string>4X2KEouvDSSL8Yyj8xr8OmxVFnGGSk1L2olrXkkm8CM=</string>
11+
<key>SUEnableAutomaticChecks</key>
12+
<true/>
13+
<key>SUScheduledCheckInterval</key>
14+
<integer>7200</integer>
15+
<key>SUAutomaticallyUpdate</key>
16+
<false/>
17+
<key>SUEnableInstallerLauncherService</key>
18+
<true/>
519
<key>CFBundleIconFile</key>
620
<string>AppIcon</string>
721
<key>CFBundleIconName</key>

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ The design studio behind everything you see on screen:
104104

105105
### 🪟 Multi-Window Tabs
106106

107-
⌘T opens native tabs — each with its own module, Bible translation, and selection. Tabs are titled by **type + name + reference** (e.g. *Bible - EDC100 - Ioan 3:16*, *Songs — Înaintea Ta venim*), or rename any tab manually. One output, driven by whichever tab presses Show.
107+
⌘T opens native tabs — each with its own module, Bible translation, and selection. Tabs are titled by **type + (lang) version + reference** (e.g. *Bible - (RO) EDC100 - Ioan 3:16*, *Songs — Înaintea Ta venim*), or rename any tab manually. One output, driven by whichever tab presses Show.
108108

109109
### 🧭 Sidebar &amp; History
110110

TopPresenter.xcodeproj/project.pbxproj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
objectVersion = 77;
77
objects = {
88

9+
/* Begin PBXBuildFile section */
10+
5AB1E0F100000000000000C1 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB1E0F100000000000000B1 /* Sparkle */; };
11+
/* End PBXBuildFile section */
12+
913
/* Begin PBXContainerItemProxy section */
1014
A9D0D40A2F65FF4300CE2417 /* PBXContainerItemProxy */ = {
1115
isa = PBXContainerItemProxy;
@@ -52,6 +56,7 @@
5256
isa = PBXFrameworksBuildPhase;
5357
buildActionMask = 2147483647;
5458
files = (
59+
5AB1E0F100000000000000C1 /* Sparkle in Frameworks */,
5560
);
5661
runOnlyForDeploymentPostprocessing = 0;
5762
};
@@ -112,6 +117,7 @@
112117
);
113118
name = TopPresenter;
114119
packageProductDependencies = (
120+
5AB1E0F100000000000000B1 /* Sparkle */,
115121
);
116122
productName = TopPresenter;
117123
productReference = A9D0D3FA2F65FF3F00CE2417 /* TopPresenter.app */;
@@ -195,6 +201,9 @@
195201
);
196202
mainGroup = A9D0D3F12F65FF3F00CE2417;
197203
minimizedProjectReferenceProxies = 1;
204+
packageReferences = (
205+
5AB1E0F100000000000000A1 /* XCRemoteSwiftPackageReference "Sparkle" */,
206+
);
198207
preferredProjectObjectVersion = 77;
199208
productRefGroup = A9D0D3FB2F65FF3F00CE2417 /* Products */;
200209
projectDirPath = "";
@@ -605,6 +614,25 @@
605614
defaultConfigurationName = Release;
606615
};
607616
/* End XCConfigurationList section */
617+
618+
/* Begin XCRemoteSwiftPackageReference section */
619+
5AB1E0F100000000000000A1 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
620+
isa = XCRemoteSwiftPackageReference;
621+
repositoryURL = "https://github.com/sparkle-project/Sparkle";
622+
requirement = {
623+
kind = upToNextMajorVersion;
624+
minimumVersion = 2.6.0;
625+
};
626+
};
627+
/* End XCRemoteSwiftPackageReference section */
628+
629+
/* Begin XCSwiftPackageProductDependency section */
630+
5AB1E0F100000000000000B1 /* Sparkle */ = {
631+
isa = XCSwiftPackageProductDependency;
632+
package = 5AB1E0F100000000000000A1 /* XCRemoteSwiftPackageReference "Sparkle" */;
633+
productName = Sparkle;
634+
};
635+
/* End XCSwiftPackageProductDependency section */
608636
};
609637
rootObject = A9D0D3F22F65FF3F00CE2417 /* Project object */;
610638
}

TopPresenter/Core/AppCommands.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,19 @@ struct ViewCommands: Commands {
164164
}
165165
}
166166

167+
// MARK: - Updater (app menu, beside "About")
168+
struct UpdaterCommands: Commands {
169+
@ObservedObject var updater: UpdateController
170+
var body: some Commands {
171+
CommandGroup(after: .appInfo) {
172+
Button(String(localized: "Caută actualizări…", comment: "Menu item")) {
173+
updater.checkForUpdates()
174+
}
175+
.disabled(!updater.canCheckForUpdates)
176+
}
177+
}
178+
}
179+
167180
// MARK: - Help Menu Commands
168181
struct HelpCommands: Commands {
169182
var body: some Commands {

TopPresenter/Core/LibraryManager.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ final class LibraryManager {
3939
var songSearchQuery: String = ""
4040
var songSearchResults: [SongSearchResult] = []
4141
var isSongSearching: Bool = false
42+
/// The song-LIBRARY browser's live filter text (distinct from the Quick Search
43+
/// overlay above). Shared so clickable chips in the detail can search by tag/source.
44+
var songLibraryQuery: String = ""
4245

4346
/// When set, the Songs view presents the visual song editor for this song.
4447
var songToEdit: Song?
@@ -133,12 +136,26 @@ final class LibraryManager {
133136
}
134137

135138
/// Formatted text for selected verses, respecting layout and prefix settings.
136-
func formattedSelectedVersesText(layout: String = "inline", showPrefix: Bool = false) -> String {
139+
/// When `customEnabled` and 2+ verses are selected, the joined verses are run
140+
/// through `customTemplate` (tokens: {verses} {ref} {n}); a template without
141+
/// {verses} gets the verses appended so they're never lost.
142+
func formattedSelectedVersesText(layout: String = "inline", showPrefix: Bool = false,
143+
customEnabled: Bool = false, customTemplate: String = "") -> String {
137144
if selectedVerses.isEmpty { return "" }
138145
let separator = layout == "newLine" ? "\n" : " "
139-
return selectedVerses.map { v in
146+
let joined = selectedVerses.map { v in
140147
showPrefix ? "(\(v.verseNumber)) \(v.text)" : v.text
141148
}.joined(separator: separator)
149+
150+
guard customEnabled, selectedVerses.count > 1 else { return joined }
151+
let template = customTemplate.trimmingCharacters(in: .whitespacesAndNewlines)
152+
guard !template.isEmpty else { return joined }
153+
var out = template
154+
.replacingOccurrences(of: "{verses}", with: joined)
155+
.replacingOccurrences(of: "{ref}", with: selectedVersesReference)
156+
.replacingOccurrences(of: "{n}", with: "\(selectedVerses.count)")
157+
if !template.contains("{verses}") { out += separator + joined }
158+
return out
142159
}
143160

144161
/// Navigate to previous/next verse in the current chapter.

TopPresenter/Core/PresentationManager.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,17 @@ final class PresentationManager {
359359
var interlinearColumnSpacing: Double = 12
360360
var interlinearRowSpacing: Double = 2
361361

362+
// MARK: Multi-verse (Bible) — how several selected verses render together.
363+
/// "inline" (one flowing paragraph) | "newLine" (each verse on its own line).
364+
var multiVerseLayoutRaw: String = "inline"
365+
/// Prefix each verse with its number, e.g. "(3) For God so loved…".
366+
var multiVerseShowNumbers: Bool = false
367+
/// Wrap the joined verses in a custom template when 2+ verses show.
368+
var multiVerseCustomEnabled: Bool = false
369+
/// Template used when `multiVerseCustomEnabled`. Tokens: {verses} {ref} {n}.
370+
/// If it omits {verses}, the verses are appended after the template.
371+
var multiVerseCustomText: String = ""
372+
362373
init() {}
363374

364375
init(from decoder: Decoder) throws {
@@ -377,6 +388,10 @@ final class PresentationManager {
377388
interlinearMorphScale = try c.decodeIfPresent(Double.self, forKey: .interlinearMorphScale) ?? 0.38
378389
interlinearColumnSpacing = try c.decodeIfPresent(Double.self, forKey: .interlinearColumnSpacing) ?? 12
379390
interlinearRowSpacing = try c.decodeIfPresent(Double.self, forKey: .interlinearRowSpacing) ?? 2
391+
multiVerseLayoutRaw = try c.decodeIfPresent(String.self, forKey: .multiVerseLayoutRaw) ?? "inline"
392+
multiVerseShowNumbers = try c.decodeIfPresent(Bool.self, forKey: .multiVerseShowNumbers) ?? false
393+
multiVerseCustomEnabled = try c.decodeIfPresent(Bool.self, forKey: .multiVerseCustomEnabled) ?? false
394+
multiVerseCustomText = try c.decodeIfPresent(String.self, forKey: .multiVerseCustomText) ?? ""
380395
}
381396
}
382397

@@ -404,6 +419,14 @@ final class PresentationManager {
404419
mutateProfile(key) { $0.options = options }
405420
}
406421

422+
/// Multi-verse rendering settings for the active Bible theme — the single
423+
/// source of truth now that these live in the theme (not global defaults).
424+
/// Read by the preview card, the verse controls (live push), and BibleView.
425+
var bibleMultiVerse: (layout: String, showNumbers: Bool, customEnabled: Bool, customText: String) {
426+
let o = contentOptions(for: "bible")
427+
return (o.multiVerseLayoutRaw, o.multiVerseShowNumbers, o.multiVerseCustomEnabled, o.multiVerseCustomText)
428+
}
429+
407430
static func transformText(_ text: String, raw: String) -> String {
408431
switch raw {
409432
case "upper": return text.uppercased()

0 commit comments

Comments
 (0)