19 Commits
Author SHA1 Message Date
Darin KelkhoffandClaude Sonnet 4.6 2652d3a945 fix release recipe: clean zip before build; fail loudly if export app name is wrong
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:29:29 -05:00
Darin KelkhoffandClaude Sonnet 4.6 e6556207c6 release: update appcast for v1.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:18:56 -05:00
Darin KelkhoffandClaude Sonnet 4.6 d1c2b7c219 update Sparkle EdDSA public key to match newly generated private key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:09:01 -05:00
Darin KelkhoffandClaude Sonnet 4.6 74d81a168e fix: explicit push branch in release recipe; disable update check when unavailable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:58:46 -05:00
Darin Kelkhoff 191ae8bde0 fix: clear appcast temp dir before each release to avoid stale entries 2026-06-07 16:55:05 -05:00
Darin Kelkhoff 2b0a2dd747 update release recipe to regenerate and push appcast after each release 2026-06-07 16:53:50 -05:00
Darin Kelkhoff ea3a11b8dc add initial empty appcast.xml for Sparkle auto-update 2026-06-05 20:56:13 -05:00
Darin Kelkhoff 9ea6caeaa8 wire up SPUStandardUpdaterController and Check for Updates menu item 2026-06-05 20:36:31 -05:00
Darin KelkhoffandClaude Sonnet 4.6 b3940a3656 add Sparkle EdDSA public key and feed URL to Info.plist
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:22:48 -05:00
Darin KelkhoffandClaude Sonnet 4.6 33a6f6f78a add Sparkle SPM dependency for auto-update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 20:08:33 -05:00
Darin Kelkhoff f8722bb538 add Sparkle auto-update implementation plan 2026-06-05 20:05:28 -05:00
Darin Kelkhoff 60d5aa0964 add Sparkle auto-update design spec 2026-06-05 19:59:01 -05:00
Darin KelkhoffandClaude Sonnet 4.6 80e7d07fe3 fix tilde/caret edge cases in markdown extensions
- When subscript is disabled, escape ~text~ as \~text\~ so cmark-gfm
  renders literal tildes instead of strikethrough
- Restrict subscript and superscript to no-spaces content (following
  Pandoc convention) to avoid false positives on ~approx~ and ^caret^
  usage in prose; highlight and underline retain spaces for phrase markup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:56:42 -05:00
Darin KelkhoffandClaude Sonnet 4.6 93b47a15e2 fix subscript extension by pre-processing before the parser runs
cmark-gfm treats ~text~ as strikethrough before post-processing can
convert it to <sub>. Pre-process single-tilde pairs to inline HTML
<sub>...</sub> before Document(parsing:), skipping fenced code blocks
and inline code spans.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 12:29:19 -05:00
Darin KelkhoffandClaude Sonnet 4.6 1444cb1dc9 add Markdown settings tab and highlight color pickers to theme editor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:26:06 -05:00
Darin KelkhoffandClaude Sonnet 4.6 04ae4379b8 add post-processing pass for markdown extensions (highlight, superscript, subscript, underline)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:10:31 -05:00
Darin KelkhoffandClaude Sonnet 4.6 9fcd3947b7 add highlight CSS variable, mark rule, and highlight colors to all built-in themes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:04:20 -05:00
Darin KelkhoffandClaude Sonnet 4.6 1fd9ab9e1f add MarkdownExtensions struct and highlight color fields to ThemeColors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:36:03 -05:00
Darin Kelkhoff 48cff3807c add release justfile recipe and ExportOptions.plist to archive, notarize, and publish a GitHub release 2026-06-02 08:47:19 -05:00
37 changed files with 937 additions and 26 deletions

No files matched your search

+12
View 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>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>9MG4YT2G93</string>
<key>signingStyle</key>
<string>automatic</string>
</dict>
</plist>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>markdownViewr</title>
<link>https://github.com/darinkelkhoff/markdownViewr</link>
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>1.0</title>
<pubDate>Mon, 08 Jun 2026 08:18:43 -0500</pubDate>
<sparkle:version>1</sparkle:version>
<sparkle:shortVersionString>1.0</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.0/markdownViewr-1.1.0.zip" length="4188027" type="application/octet-stream" sparkle:edSignature="c1j2HlKXVEBF6bsfjG4DUNNhwOwOGIb+24OJoA7AKKSoBsZW1XnVqIuOlHWoKSiv8pL5lpwysOvaMK8hBc0DDg=="/>
</item>
</channel>
</rss>
@@ -0,0 +1,348 @@
# Sparkle Auto-Update Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Sparkle 2.x auto-update support so users are notified of new releases and can install them without manually visiting GitHub.
**Architecture:** Sparkle is added as an SPM dependency. `SPUStandardUpdaterController` is initialized at app launch and handles background update checks and the update UI. An `appcast.xml` file lives in the repo root and is served via raw.githubusercontent.com; the `just release` recipe gains a step to regenerate it with each release.
**Tech Stack:** Sparkle 2.x (SPM), XcodeGen (`project.yml`), `generate_appcast` CLI tool (bundled with Sparkle in DerivedData), `just` for release automation.
---
## File Map
| File | Change |
|---|---|
| `project.yml` | Add Sparkle SPM package; add to target dependencies; add `SUFeedURL` and `SUPublicEDKey` to `info.properties` |
| `markdownViewr/MarkdownViewrApp.swift` | Add `import Sparkle`; add `SPUStandardUpdaterController` stored property; add "Check for Updates..." menu item |
| `appcast.xml` | New — empty initial appcast committed to repo root |
| `justfile` | Update `release` recipe to run `generate_appcast`, commit, and push `appcast.xml` |
---
### Task 1: Add Sparkle as an SPM dependency
**Files:**
- Modify: `project.yml`
- [ ] **Step 1: Add Sparkle to the `packages` block in `project.yml`**
The `packages` block currently has one entry. Add `sparkle` directly after it:
```yaml
packages:
swift-markdown:
url: https://github.com/apple/swift-markdown.git
from: "0.4.0"
sparkle:
url: https://github.com/sparkle-project/Sparkle.git
from: "2.0.0"
```
- [ ] **Step 2: Add Sparkle to the target's `dependencies` list**
The `dependencies` block currently has one entry. Add `sparkle` directly after it:
```yaml
dependencies:
- package: swift-markdown
product: Markdown
- package: sparkle
product: Sparkle
```
- [ ] **Step 3: Regenerate the Xcode project**
```bash
xcodegen generate
```
Expected: `Generating project markdownViewr.xcodeproj` with no errors.
- [ ] **Step 4: Build to verify Sparkle resolves and compiles**
```bash
just build
```
Expected: `** BUILD SUCCEEDED **`. Sparkle will be downloaded and compiled on first build — this may take a minute.
- [ ] **Step 5: Commit**
```bash
git add project.yml markdownViewr.xcodeproj
git commit -m "add Sparkle SPM dependency for auto-update"
```
---
### Task 2: Generate EdDSA keys and add to Info.plist
Sparkle signs each update with an ed25519 key so the app can verify the update came from you. This task generates the key pair, stores the private key in your keychain, and bakes the public key into the app.
**Files:**
- Modify: `project.yml`
- [ ] **Step 1: Locate the `generate_keys` tool from the resolved Sparkle package**
After the build in Task 1, Sparkle's tools are in DerivedData. Find them:
```bash
find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin/generate_keys" 2>/dev/null | head -1
```
Expected: a path like `~/Library/Developer/Xcode/DerivedData/markdownViewr-<hash>/SourcePackages/artifacts/sparkle/Sparkle/bin/generate_keys`
- [ ] **Step 2: Run `generate_keys`**
```bash
$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin/generate_keys" 2>/dev/null | head -1)
```
The tool will:
1. Prompt for a keychain item name — enter `markdownViewr-sparkle`
2. Store the private key in your macOS keychain
3. Print a public key that looks like:
```
Public Key (SUPublicEDKey):
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Add the following to your app's Info.plist:
<key>SUPublicEDKey</key>
<string>AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</string>
```
Copy the base64 public key string (the `AAAA...=` part).
- [ ] **Step 3: Add `SUFeedURL` and `SUPublicEDKey` to `project.yml`'s `info.properties`**
The `info:` section currently ends with `UTImportedTypeDeclarations`. Add two new properties after it (substitute the actual base64 key from Step 2):
```yaml
info:
path: markdownViewr/Info.plist
properties:
CFBundleDocumentTypes:
... # leave existing content unchanged
UTImportedTypeDeclarations:
... # leave existing content unchanged
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
SUPublicEDKey: "PASTE_YOUR_BASE64_PUBLIC_KEY_HERE"
```
- [ ] **Step 4: Regenerate the Xcode project to write keys into Info.plist**
```bash
xcodegen generate
```
- [ ] **Step 5: Verify both keys appear in the generated Info.plist**
```bash
grep -A1 "SUFeedURL\|SUPublicEDKey" markdownViewr/Info.plist
```
Expected: both keys present with their values.
- [ ] **Step 6: Build to confirm no regressions**
```bash
just build
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 7: Commit**
```bash
git add project.yml markdownViewr/Info.plist
git commit -m "add Sparkle EdDSA public key and feed URL to Info.plist"
```
---
### Task 3: Wire up SPUStandardUpdaterController and menu item
**Files:**
- Modify: `markdownViewr/MarkdownViewrApp.swift`
- [ ] **Step 1: Add `import Sparkle` at the top of `MarkdownViewrApp.swift`**
After `import SwiftUI`:
```swift
import SwiftUI
import Sparkle
```
- [ ] **Step 2: Add the updater controller as a stored property**
Add this property to `MarkdownViewrApp`, immediately before the existing `@StateObject` declarations:
```swift
@main
struct MarkdownViewrApp: App {
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
@StateObject private var themeManager = ThemeManager()
@StateObject private var editorManager = EditorManager()
```
- [ ] **Step 3: Add "Check for Updates..." to the app menu**
In the `.commands { }` block, add a new `CommandGroup` as the first entry, before the existing `CommandGroup(replacing: .appSettings)`:
```swift
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterController.checkForUpdates(nil)
}
}
CommandGroup(replacing: .appSettings) {
// ... existing settings button
}
// ... rest of existing commands
```
- [ ] **Step 4: Build**
```bash
just build
```
Expected: `** BUILD SUCCEEDED **`
- [ ] **Step 5: Launch the app and verify "Check for Updates..." appears in the app menu**
```bash
just run
```
Open the app menu (the menu with the app name, leftmost in the menu bar). "Check for Updates..." should appear below "About markdownViewr".
- [ ] **Step 6: Commit**
```bash
git add markdownViewr/MarkdownViewrApp.swift
git commit -m "wire up SPUStandardUpdaterController and Check for Updates menu item"
```
---
### Task 4: Create the initial appcast.xml
**Files:**
- Create: `appcast.xml`
- [ ] **Step 1: Create `appcast.xml` in the repo root**
```xml
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>markdownViewr</title>
<link>https://github.com/darinkelkhoff/markdownViewr</link>
<description>markdownViewr releases</description>
<language>en</language>
</channel>
</rss>
```
- [ ] **Step 2: Verify the file is valid XML**
```bash
xmllint --noout appcast.xml && echo "valid"
```
Expected: `valid`
- [ ] **Step 3: Commit**
```bash
git add appcast.xml
git commit -m "add initial empty appcast.xml for Sparkle auto-update"
```
---
### Task 5: Update `just release` to regenerate the appcast
After each GitHub release, the appcast needs to be updated with the new version's entry (signed with your private key) and pushed so existing users are notified.
**Files:**
- Modify: `justfile`
- [ ] **Step 1: Add the appcast update step to the `release` recipe**
The current `release` recipe ends with:
```bash
echo ""
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
```
Replace that block with:
```bash
echo "==> Updating appcast..."
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
if [[ -z "$SPARKLE_BIN" ]]; then
echo "Error: Sparkle tools not found in DerivedData. Run 'just build' first."
exit 1
fi
mkdir -p /tmp/markdownViewr-appcast-input
cp "$ZIP" /tmp/markdownViewr-appcast-input/
"$SPARKLE_BIN/generate_appcast" \
--download-url-prefix "https://github.com/darinkelkhoff/markdownViewr/releases/download/v$VERSION/" \
-o appcast.xml \
/tmp/markdownViewr-appcast-input/
echo "==> Committing and pushing appcast..."
git add appcast.xml
git commit -m "release: update appcast for v$VERSION"
git push
echo ""
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
```
- [ ] **Step 2: Verify `justfile` has no syntax errors**
```bash
just --list
```
Expected: lists all recipes including `release` with no errors.
- [ ] **Step 3: Dry-run verify by checking `generate_appcast` is findable after a build**
```bash
find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1
```
Expected: prints a path (not empty). If empty, run `just build` first.
- [ ] **Step 4: Commit**
```bash
git add justfile
git commit -m "update release recipe to regenerate and push appcast after each release"
```
---
## Post-Implementation Verification
After all tasks are complete:
1. Run `just run` — confirm "Check for Updates..." appears in the app menu
2. Click "Check for Updates..." — Sparkle should show either "You're up to date" or a first-launch permission dialog asking whether to enable automatic checks
3. Run `just release` on a version bump (bump `MARKETING_VERSION` in `project.yml` first) — confirm `appcast.xml` is updated with the new version entry after the release completes
4. On a machine with the previous version installed, open the app — Sparkle should detect the new version and prompt for an update
@@ -0,0 +1,123 @@
# Sparkle Auto-Update Design
**Date:** 2026-06-05
**Status:** Approved
## Overview
Add automatic update support to markdownViewr using Sparkle 2.x. On first launch after install, Sparkle prompts the user whether to enable background update checks. When a new release is published, the app notifies users and handles download and installation. Users can also trigger a manual check via "Check for Updates..." in the app menu.
## Dependencies
Sparkle 2.x added to `project.yml` as a Swift Package dependency:
```yaml
packages:
sparkle:
url: https://github.com/sparkle-project/Sparkle.git
from: "2.0.0"
```
Added to the target's `dependencies` alongside `swift-markdown`. After adding, run `xcodegen generate` to regenerate the `.xcodeproj`.
## Signing Keys
Sparkle 2.x uses EdDSA (ed25519) signatures to verify update authenticity.
- Generate a key pair using Sparkle's bundled `generate_keys` tool (run once, at setup time).
- The **private key** is stored in the macOS keychain under the profile name `markdownViewr-sparkle` (never committed to the repo).
- The **public key** is stored in `Info.plist` as `SUPublicEDKey`.
## App Integration
`MarkdownViewrApp.swift` gets one new stored property:
```swift
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
```
`startingUpdater: true` starts the background updater on launch. Sparkle handles the first-launch permission prompt automatically.
A "Check for Updates..." menu item is added to the app menu using `CommandGroup(after: .appInfo)`:
```swift
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterController.checkForUpdates(nil)
}
}
```
## Info.plist
Two keys added to `markdownViewr/Info.plist`:
| Key | Value |
|---|---|
| `SUFeedURL` | `https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml` |
| `SUPublicEDKey` | _(EdDSA public key generated at setup)_ |
## Appcast
`appcast.xml` lives in the repo root. It is a standard Sparkle RSS appcast with one `<item>` per release. Sparkle's `generate_appcast` tool creates and updates it automatically.
Example structure:
```xml
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>markdownViewr</title>
<link>https://github.com/darinkelkhoff/markdownViewr</link>
<description>markdownViewr releases</description>
<language>en</language>
<item>
<title>markdownViewr 1.1.0</title>
<sparkle:version>2</sparkle:version>
<sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
<pubDate>Thu, 05 Jun 2026 12:00:00 +0000</pubDate>
<enclosure
url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.0/markdownViewr-1.1.0.zip"
sparkle:edSignature="..."
length="..."
type="application/octet-stream"/>
</item>
</channel>
</rss>
```
The `generate_appcast` tool reads the private key from the keychain, signs the zip, extracts version info from the app bundle, and writes the updated `appcast.xml`.
## Release Workflow Changes
The `just release` recipe gains a final step after creating the GitHub release:
1. Run `generate_appcast` against the export directory containing the notarized zip.
2. Commit the updated `appcast.xml` to the repo.
3. Push the commit.
The `generate_appcast` tool is accessed via the path inside the resolved Sparkle SPM package in DerivedData, or via a symlink/path set up at key-generation time.
## Files Changed
| File | Change |
|---|---|
| `project.yml` | Add Sparkle SPM package; add to target dependencies |
| `markdownViewr/Info.plist` | Add `SUFeedURL` and `SUPublicEDKey` |
| `markdownViewr/MarkdownViewrApp.swift` | Add `SPUStandardUpdaterController`; add "Check for Updates..." menu item |
| `appcast.xml` | New — initial empty appcast committed to repo root |
| `justfile` | Update `release` recipe to run `generate_appcast`, commit, and push `appcast.xml` |
## Setup Steps (One-Time, Not in Implementation Plan)
Before the implementation plan tasks can be completed, the developer must:
1. Run Sparkle's `generate_keys` tool to create the EdDSA key pair.
2. Store the private key in the keychain under profile `markdownViewr-sparkle`.
3. Note the public key for insertion into `Info.plist`.
These are manual one-time steps performed in the terminal, not automated tasks.
+58
View File
@@ -24,3 +24,61 @@ generate:
# Clean build artifacts
clean:
xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr clean
# Archive, notarize, and publish a GitHub release (bump MARKETING_VERSION in project.yml first)
release: kill
#!/usr/bin/env bash
set -euo pipefail
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
ARCHIVE="/tmp/markdownViewr.xcarchive"
EXPORT="/tmp/markdownViewr-export"
ZIP="/tmp/markdownViewr-$VERSION.zip"
echo "==> Archiving v$VERSION..."
rm -rf "$ARCHIVE" "$EXPORT" "$ZIP"
xcodebuild archive \
-project markdownViewr.xcodeproj \
-scheme markdownViewr \
-archivePath "$ARCHIVE" \
-destination 'generic/platform=macOS' \
ENABLE_HARDENED_RUNTIME=YES | xcpretty || true
echo "==> Exporting with Developer ID signing..."
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT" \
-exportOptionsPlist ExportOptions.plist
APP="$EXPORT/markdownViewr.app"
if [[ ! -d "$APP" ]]; then
echo "Error: expected $APP but found: $(ls "$EXPORT")"
exit 1
fi
echo "==> Notarizing (this takes a minute)..."
ditto -c -k --keepParent "$APP" "$ZIP"
xcrun notarytool submit "$ZIP" --keychain-profile "markdownViewr-notarytool" --wait
echo "==> Stapling notarization ticket..."
xcrun stapler staple "$APP"
echo "==> Packaging final release..."
ditto -c -k --keepParent "$APP" "$ZIP"
echo "==> Creating GitHub release v$VERSION..."
gh release create "v$VERSION" "$ZIP" \
--repo darinkelkhoff/markdownViewr \
--title "markdownViewr v$VERSION" \
--generate-notes
echo "==> Updating appcast..."
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
if [[ -z "$SPARKLE_BIN" ]]; then
echo "Error: Sparkle tools not found in DerivedData. Run 'just build' first."
exit 1
fi
rm -rf /tmp/markdownViewr-appcast-input
mkdir -p /tmp/markdownViewr-appcast-input
cp "$ZIP" /tmp/markdownViewr-appcast-input/
"$SPARKLE_BIN/generate_appcast" \
--download-url-prefix "https://github.com/darinkelkhoff/markdownViewr/releases/download/v$VERSION/" \
-o appcast.xml \
/tmp/markdownViewr-appcast-input/
echo "==> Committing and pushing appcast..."
git add appcast.xml
git commit -m "release: update appcast for v$VERSION"
git push origin main
echo ""
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
+8 -1
View File
@@ -37,7 +37,11 @@ struct ContentView: View {
}
private func rerender() {
renderedHTML = MarkdownDocument.convertToHTML(currentMarkdown, frontmatterMode: themeManager.frontmatterMode)
renderedHTML = MarkdownDocument.convertToHTML(
currentMarkdown,
frontmatterMode: themeManager.frontmatterMode,
extensions: themeManager.markdownExtensions
)
}
var body: some View {
@@ -57,6 +61,9 @@ struct ContentView: View {
.onReceive(themeManager.$frontmatterMode) { _ in
DispatchQueue.main.async { rerender() }
}
.onReceive(themeManager.$markdownExtensions) { _ in
DispatchQueue.main.async { rerender() }
}
.onReceive(liveContent.$rawMarkdown) { _ in
DispatchQueue.main.async { rerender() }
}
+4
View File
@@ -41,6 +41,10 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>SUFeedURL</key>
<string>https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml</string>
<key>SUPublicEDKey</key>
<string>flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc=</string>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
+135 -3
View File
@@ -42,7 +42,7 @@ struct MarkdownDocument: FileDocument {
case markdown = "Show as Markdown"
}
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide) -> String {
static func convertToHTML(_ markdown: String, frontmatterMode: FrontmatterMode = .hide, extensions: MarkdownExtensions = MarkdownExtensions()) -> String {
let (frontmatter, body) = parseFrontmatter(markdown)
var html = ""
@@ -58,11 +58,12 @@ struct MarkdownDocument: FileDocument {
}
}
let content = frontmatter != nil ? body : markdown
let rawContent = frontmatter != nil ? body : markdown
let content = preProcessSubscript(rawContent, enabled: extensions.subscript_)
let document = Document(parsing: content)
var htmlVisitor = HTMLConverter()
html += htmlVisitor.visit(document)
return html
return applyExtensions(html, extensions: extensions)
}
private static func parseFrontmatter(_ markdown: String) -> (frontmatter: String?, body: String) {
@@ -138,6 +139,137 @@ struct MarkdownDocument: FileDocument {
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
// Converts ~text~ to <sub>text</sub> in raw markdown before the parser runs,
// because cmark-gfm treats single-tilde pairs as strikethrough and consumes them.
private static func preProcessSubscript(_ markdown: String, enabled: Bool) -> String {
var result = ""
var inFence = false
var fenceChar: Character = "`"
var fenceLength = 0
let lines = markdown.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
let suffix = i < lines.count - 1 ? "\n" : ""
if !inFence {
let stripped = line.drop(while: { $0 == " " || $0 == "\t" })
let backticks = stripped.prefix(while: { $0 == "`" }).count
let tildes = stripped.prefix(while: { $0 == "~" }).count
if backticks >= 3 || tildes >= 3 {
let (ch, count) = backticks >= 3 ? (Character("`"), backticks) : (Character("~"), tildes)
inFence = true; fenceChar = ch; fenceLength = count
result += line + suffix
} else {
result += processSubscriptInLine(line, enabled: enabled) + suffix
}
} else {
let stripped = line.drop(while: { $0 == " " || $0 == "\t" })
let delimCount = stripped.prefix(while: { $0 == fenceChar }).count
let rest = String(stripped.dropFirst(delimCount)).trimmingCharacters(in: .whitespaces)
if delimCount >= fenceLength && rest.isEmpty { inFence = false }
result += line + suffix
}
}
return result
}
private static func processSubscriptInLine(_ line: String, enabled: Bool) -> String {
guard let codeSpanRegex = try? NSRegularExpression(pattern: "`+[^`]*`+", options: []) else {
return applySubscriptRegex(line, enabled: enabled)
}
var result = ""
var lastEnd = line.startIndex
let matches = codeSpanRegex.matches(in: line, range: NSRange(line.startIndex..., in: line))
for match in matches {
guard let range = Range(match.range, in: line) else { continue }
result += applySubscriptRegex(String(line[lastEnd..<range.lowerBound]), enabled: enabled)
result += String(line[range])
lastEnd = range.upperBound
}
result += applySubscriptRegex(String(line[lastEnd...]), enabled: enabled)
return result
}
private static func applySubscriptRegex(_ text: String, enabled: Bool) -> String {
// When disabled, backslash-escape the tildes so cmark-gfm renders them as literal ~text~
// instead of treating them as strikethrough.
let template = enabled ? "<sub>$1</sub>" : "\\\\~$1\\\\~"
guard let regex = try? NSRegularExpression(pattern: "(?<!~)~([^\\s~]+)~(?!~)", options: []) else { return text }
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: template)
}
private static func applyExtensions(_ html: String, extensions: MarkdownExtensions) -> String {
// Split on <pre...> tags (with optional attributes) so code blocks are never transformed.
guard let preOpenRegex = try? NSRegularExpression(pattern: "<pre(?:\\s[^>]*)?>", options: []) else {
return html // fail safe: pass through unchanged
}
var result = ""
var searchRange = html.startIndex..<html.endIndex
while let match = preOpenRegex.firstMatch(in: html, range: NSRange(searchRange, in: html)) {
guard let matchRange = Range(match.range, in: html) else { break }
// Process text before this <pre...> tag
let before = String(html[searchRange.lowerBound..<matchRange.lowerBound])
result += applyExtensionRegexes(before, extensions: extensions)
// Find the matching </pre>
let afterOpenTag = matchRange.upperBound
if let closeRange = html.range(of: "</pre>", range: afterOpenTag..<html.endIndex) {
// Include the <pre...>content</pre> verbatim
result += String(html[matchRange.lowerBound..<closeRange.upperBound])
searchRange = closeRange.upperBound..<html.endIndex
} else {
// No closing </pre> found — pass through the rest verbatim
result += String(html[matchRange.lowerBound...])
return result
}
}
// Process any remaining text after the last </pre>
result += applyExtensionRegexes(String(html[searchRange]), extensions: extensions)
return result
}
private static func applyExtensionRegexes(_ text: String, extensions: MarkdownExtensions) -> String {
guard let codeRegex = try? NSRegularExpression(pattern: "<code(?:\\s[^>]*)?>.*?</code>", options: [.dotMatchesLineSeparators]) else {
return applyRawExtensionRegexes(text, extensions: extensions)
}
var result = ""
var searchRange = text.startIndex..<text.endIndex
while let match = codeRegex.firstMatch(in: text, range: NSRange(searchRange, in: text)),
let matchRange = Range(match.range, in: text) {
result += applyRawExtensionRegexes(String(text[searchRange.lowerBound..<matchRange.lowerBound]), extensions: extensions)
result += String(text[matchRange])
searchRange = matchRange.upperBound..<text.endIndex
}
result += applyRawExtensionRegexes(String(text[searchRange]), extensions: extensions)
return result
}
private static func applyRawExtensionRegexes(_ text: String, extensions: MarkdownExtensions) -> String {
var result = text
if extensions.highlight {
result = applyRegex(result, pattern: "==(.+?)==", template: "<mark>$1</mark>")
}
if extensions.superscript {
result = applyRegex(result, pattern: "\\^([^\\s\\^]+)\\^", template: "<sup>$1</sup>")
}
// subscript: handled in preProcessSubscript before the parser runs,
// because cmark-gfm treats ~text~ as strikethrough before we can post-process.
if extensions.underline {
result = applyRegex(result, pattern: "\\+\\+(.+?)\\+\\+", template: "<ins>$1</ins>")
}
return result
}
private static func applyRegex(_ text: String, pattern: String, template: String) -> String {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return text }
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, range: range, withTemplate: template)
}
}
private struct HTMLConverter: MarkupWalker {
+8
View File
@@ -0,0 +1,8 @@
import Foundation
struct MarkdownExtensions: Codable {
var highlight: Bool = true
var superscript: Bool = true
var subscript_: Bool = true // trailing underscore: `subscript` is a Swift keyword
var underline: Bool = true
}
+13
View File
@@ -1,7 +1,13 @@
import SwiftUI
import Sparkle
@main
struct MarkdownViewrApp: App {
private let updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
@StateObject private var themeManager = ThemeManager()
@StateObject private var editorManager = EditorManager()
@@ -17,6 +23,13 @@ struct MarkdownViewrApp: App {
}
.defaultSize(width: 700, height: 900)
.commands {
CommandGroup(after: .appInfo) {
Button("Check for Updates...") {
updaterController.checkForUpdates(nil)
}
.disabled(!updaterController.updater.canCheckForUpdates)
}
CommandGroup(replacing: .appSettings) {
Button("Settings...") {
SettingsWindowController.shared.show(
+7
View File
@@ -224,6 +224,13 @@ del {
opacity: 0.6;
}
mark {
background-color: var(--highlight-bg);
color: var(--highlight-text);
border-radius: 2px;
padding: 0 2px;
}
.frontmatter {
margin: 0 0 24px 0;
padding: 12px 16px;
@@ -13,7 +13,9 @@
"codeBackground": "#e6e9ef",
"codeText": "#40a02b",
"blockquoteBorder": "#8839ef",
"blockquoteBackground": "#e6e9ef"
"blockquoteBackground": "#e6e9ef",
"highlightBackground": "#f9e2af",
"highlightText": "#4c4f69"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#181825",
"codeText": "#a6e3a1",
"blockquoteBorder": "#cba6f7",
"blockquoteBackground": "#24243a"
"blockquoteBackground": "#24243a",
"highlightBackground": "#f9e2af",
"highlightText": "#1e1e2e"
},
"fonts": {
"body": "System",
+3 -1
View File
@@ -13,7 +13,9 @@
"codeBackground": "#1e1f29",
"codeText": "#50fa7b",
"blockquoteBorder": "#bd93f9",
"blockquoteBackground": "#2e3042"
"blockquoteBackground": "#2e3042",
"highlightBackground": "#f1fa8c",
"highlightText": "#282a36"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#272e33",
"codeText": "#a7c080",
"blockquoteBorder": "#d699b6",
"blockquoteBackground": "#343f44"
"blockquoteBackground": "#343f44",
"highlightBackground": "#dbbc7f",
"highlightText": "#2d353b"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#161b22",
"codeText": "#e6edf3",
"blockquoteBorder": "#3b434b",
"blockquoteBackground": "#141a22"
"blockquoteBackground": "#141a22",
"highlightBackground": "#e3b341",
"highlightText": "#0d1117"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#f6f8fa",
"codeText": "#1f2328",
"blockquoteBorder": "#d0d7de",
"blockquoteBackground": "#f6f8fa"
"blockquoteBackground": "#f6f8fa",
"highlightBackground": "#fff8c5",
"highlightText": "#1f2328"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#1d2021",
"codeText": "#b8bb26",
"blockquoteBorder": "#d65d0e",
"blockquoteBackground": "#32302f"
"blockquoteBackground": "#32302f",
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#f2e5bc",
"codeText": "#79740e",
"blockquoteBorder": "#d65d0e",
"blockquoteBackground": "#f2e5bc"
"blockquoteBackground": "#f2e5bc",
"highlightBackground": "#fabd2f",
"highlightText": "#282828"
},
"fonts": {
"body": "System",
+3 -1
View File
@@ -13,7 +13,9 @@
"codeBackground": "#16161d",
"codeText": "#98bb6c",
"blockquoteBorder": "#957fb8",
"blockquoteBackground": "#2a2a37"
"blockquoteBackground": "#2a2a37",
"highlightBackground": "#ff9e3b",
"highlightText": "#1f1f28"
},
"fonts": {
"body": "System",
+3 -1
View File
@@ -13,7 +13,9 @@
"codeBackground": "#1e1f1c",
"codeText": "#a6e22e",
"blockquoteBorder": "#f92672",
"blockquoteBackground": "#2e2f2a"
"blockquoteBackground": "#2e2f2a",
"highlightBackground": "#e6db74",
"highlightText": "#272822"
},
"fonts": {
"body": "System",
+3 -1
View File
@@ -13,7 +13,9 @@
"codeBackground": "#3b4252",
"codeText": "#a3be8c",
"blockquoteBorder": "#81a1c1",
"blockquoteBackground": "#3b4252"
"blockquoteBackground": "#3b4252",
"highlightBackground": "#ebcb8b",
"highlightText": "#2e3440"
},
"fonts": {
"body": "System",
+3 -1
View File
@@ -13,7 +13,9 @@
"codeBackground": "#21252b",
"codeText": "#98c379",
"blockquoteBorder": "#c678dd",
"blockquoteBackground": "#2c313c"
"blockquoteBackground": "#2c313c",
"highlightBackground": "#e5c07b",
"highlightText": "#282c34"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#f0f0f0",
"codeText": "#50a14f",
"blockquoteBorder": "#a626a4",
"blockquoteBackground": "#f0f0f0"
"blockquoteBackground": "#f0f0f0",
"highlightBackground": "#ffd700",
"highlightText": "#383a42"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#f2e9e1",
"codeText": "#d7827e",
"blockquoteBorder": "#907aa9",
"blockquoteBackground": "#f2e9e1"
"blockquoteBackground": "#f2e9e1",
"highlightBackground": "#f6c177",
"highlightText": "#575279"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#1f1d2e",
"codeText": "#9ccfd8",
"blockquoteBorder": "#c4a7e7",
"blockquoteBackground": "#1f1d2e"
"blockquoteBackground": "#1f1d2e",
"highlightBackground": "#f6c177",
"highlightText": "#191724"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#073642",
"codeText": "#859900",
"blockquoteBorder": "#b58900",
"blockquoteBackground": "#073642"
"blockquoteBackground": "#073642",
"highlightBackground": "#b58900",
"highlightText": "#002b36"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#eee8d5",
"codeText": "#859900",
"blockquoteBorder": "#6c71c4",
"blockquoteBackground": "#eee8d5"
"blockquoteBackground": "#eee8d5",
"highlightBackground": "#b58900",
"highlightText": "#fdf6e3"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#cbccd1",
"codeText": "#33635c",
"blockquoteBorder": "#5a4a78",
"blockquoteBackground": "#cbccd1"
"blockquoteBackground": "#cbccd1",
"highlightBackground": "#f9c859",
"highlightText": "#343b58"
},
"fonts": {
"body": "System",
@@ -13,7 +13,9 @@
"codeBackground": "#16161e",
"codeText": "#9ece6a",
"blockquoteBorder": "#bb9af7",
"blockquoteBackground": "#1f2335"
"blockquoteBackground": "#1f2335",
"highlightBackground": "#e0af68",
"highlightText": "#1a1b26"
},
"fonts": {
"body": "System",
+3 -1
View File
@@ -13,7 +13,9 @@
"codeBackground": "#2b2b2b",
"codeText": "#7f9f7f",
"blockquoteBorder": "#cc9393",
"blockquoteBackground": "#4f4f4f"
"blockquoteBackground": "#4f4f4f",
"highlightBackground": "#f0dfaf",
"highlightText": "#3f3f3f"
},
"fonts": {
"body": "System",
+4
View File
@@ -19,6 +19,10 @@ $ markdownviewr --version
- [Hyperlinks](https://example.com) styled to your palette
- Inline `code spans` and fenced code blocks
## Extensions
This sentence uses ==highlighted text==, H~2~O for subscript, E=mc^2^ for superscript, and ++underlined text++.
### Code Example
```swift
+81
View File
@@ -18,6 +18,11 @@ struct SettingsView: View {
.tabItem {
Label("Themes", systemImage: "paintpalette")
}
MarkdownSettingsView()
.tabItem {
Label("Markdown", systemImage: "doc.plaintext")
}
}
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
}
@@ -262,6 +267,82 @@ struct EditorsSettingsView: View {
}
}
struct MarkdownSettingsView: View {
@EnvironmentObject var themeManager: ThemeManager
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text("Markdown Extensions")
.font(.headline)
.padding(.bottom, 8)
Text("Enable or disable inline syntax extensions applied during rendering.")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 16)
GroupBox {
VStack(spacing: 0) {
extensionRow(
name: "Highlight",
example: "==text==",
isOn: Binding(
get: { themeManager.markdownExtensions.highlight },
set: { themeManager.markdownExtensions.highlight = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Superscript",
example: "^text^",
isOn: Binding(
get: { themeManager.markdownExtensions.superscript },
set: { themeManager.markdownExtensions.superscript = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Subscript",
example: "~text~",
isOn: Binding(
get: { themeManager.markdownExtensions.subscript_ },
set: { themeManager.markdownExtensions.subscript_ = $0 }
)
)
Divider().padding(.vertical, 6)
extensionRow(
name: "Underline",
example: "++text++",
isOn: Binding(
get: { themeManager.markdownExtensions.underline },
set: { themeManager.markdownExtensions.underline = $0 }
)
)
}
.padding(4)
}
Spacer()
}
.padding(20)
}
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
HStack {
Text(name)
.frame(width: 100, alignment: .leading)
Text(example)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Toggle("", isOn: isOn)
.toggleStyle(.checkbox)
.labelsHidden()
}
.padding(.vertical, 2)
}
}
struct ThemeEditorItem: Identifiable {
let id = UUID()
let theme: Theme?
+34 -1
View File
@@ -48,6 +48,8 @@ struct ThemeColors: Codable, Hashable {
var codeText: String
var blockquoteBorder: String
var blockquoteBackground: String
var highlightBackground: String
var highlightText: String
init(
background: String = "#1e1e2e",
@@ -62,7 +64,9 @@ struct ThemeColors: Codable, Hashable {
codeBackground: String = "#181825",
codeText: String = "#a6e3a1",
blockquoteBorder: String = "#cba6f7",
blockquoteBackground: String = "#252535"
blockquoteBackground: String = "#252535",
highlightBackground: String = "#ffd700",
highlightText: String = "#1a1a1a"
) {
self.background = background
self.text = text
@@ -77,6 +81,35 @@ struct ThemeColors: Codable, Hashable {
self.codeText = codeText
self.blockquoteBorder = blockquoteBorder
self.blockquoteBackground = blockquoteBackground
self.highlightBackground = highlightBackground
self.highlightText = highlightText
}
enum CodingKeys: String, CodingKey {
case background, text
case heading1, heading2, heading3, heading4, heading5, heading6
case link, codeBackground, codeText
case blockquoteBorder, blockquoteBackground
case highlightBackground, highlightText
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
background = try c.decode(String.self, forKey: .background)
text = try c.decode(String.self, forKey: .text)
heading1 = try c.decode(String.self, forKey: .heading1)
heading2 = try c.decode(String.self, forKey: .heading2)
heading3 = try c.decode(String.self, forKey: .heading3)
heading4 = try c.decode(String.self, forKey: .heading4)
heading5 = try c.decode(String.self, forKey: .heading5)
heading6 = try c.decode(String.self, forKey: .heading6)
link = try c.decode(String.self, forKey: .link)
codeBackground = try c.decode(String.self, forKey: .codeBackground)
codeText = try c.decode(String.self, forKey: .codeText)
blockquoteBorder = try c.decode(String.self, forKey: .blockquoteBorder)
blockquoteBackground = try c.decode(String.self, forKey: .blockquoteBackground)
highlightBackground = try c.decodeIfPresent(String.self, forKey: .highlightBackground) ?? "#ffd700"
highlightText = try c.decodeIfPresent(String.self, forKey: .highlightText) ?? "#1a1a1a"
}
}
+2
View File
@@ -120,6 +120,8 @@ struct ThemeEditorView: View {
colorRow("Code Text", $colors.codeText)
colorRow("Quote Border", $colors.blockquoteBorder)
colorRow("Quote BG", $colors.blockquoteBackground)
colorRow("Highlight BG", $colors.highlightBackground)
colorRow("Highlight Text", $colors.highlightText)
}
}
}
+15
View File
@@ -23,6 +23,13 @@ class ThemeManager: ObservableObject {
UserDefaults.standard.set(frontmatterMode.rawValue, forKey: "frontmatterMode")
}
}
@Published var markdownExtensions: MarkdownExtensions {
didSet {
if let data = try? JSONEncoder().encode(markdownExtensions) {
UserDefaults.standard.set(data, forKey: "markdownExtensions")
}
}
}
var activeTheme: Theme {
themes.first { $0.name == activeThemeName } ?? themes.first ?? Self.fallbackTheme
@@ -37,6 +44,12 @@ class ThemeManager: ObservableObject {
self.zoomScale = savedZoom > 0 ? savedZoom : 1.0
let modeRaw = UserDefaults.standard.string(forKey: "frontmatterMode") ?? "Hide"
self.frontmatterMode = MarkdownDocument.FrontmatterMode(rawValue: modeRaw) ?? .hide
if let data = UserDefaults.standard.data(forKey: "markdownExtensions"),
let ext = try? JSONDecoder().decode(MarkdownExtensions.self, from: data) {
self.markdownExtensions = ext
} else {
self.markdownExtensions = MarkdownExtensions()
}
loadThemes()
}
@@ -299,6 +312,8 @@ class ThemeManager: ObservableObject {
--code-text: \(theme.colors.codeText);
--blockquote-border: \(theme.colors.blockquoteBorder);
--blockquote-bg: \(theme.colors.blockquoteBackground);
--highlight-bg: \(theme.colors.highlightBackground);
--highlight-text: \(theme.colors.highlightText);
--body-font: \(bodyFont);
--heading-font: \(headingFont);
--code-font: \(codeFont);
+8 -1
View File
@@ -10,6 +10,9 @@ packages:
swift-markdown:
url: https://github.com/apple/swift-markdown.git
from: "0.4.0"
sparkle:
url: https://github.com/sparkle-project/Sparkle.git
from: "2.0.0"
targets:
markdownViewr:
@@ -22,11 +25,13 @@ targets:
dependencies:
- package: swift-markdown
product: Markdown
- package: sparkle
product: Sparkle
settings:
base:
INFOPLIST_FILE: markdownViewr/Info.plist
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
MARKETING_VERSION: "1.0.0"
MARKETING_VERSION: "1.1.0"
CURRENT_PROJECT_VERSION: "1"
MACOSX_DEPLOYMENT_TARGET: "13.0"
ENABLE_APP_SANDBOX: "NO"
@@ -57,3 +62,5 @@ targets:
- markdown
- mdown
- mkd
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="