Compare commits
No files matched your search
@@ -16,6 +16,29 @@ just kill # force-kill running app
|
||||
|
||||
After adding/removing Swift files or resources, run `xcodegen generate` before building.
|
||||
|
||||
## Distribution
|
||||
|
||||
The app ships through **two** channels from one codebase, built from **two application targets** that share a `targetTemplates: AppBase` (sources, resources, swift-markdown, version, bundle id):
|
||||
|
||||
| Channel | Target | Scheme | Signing | Updates | Recipe |
|
||||
|---|---|---|---|---|---|
|
||||
| **Developer ID** | `markdownViewr` | `markdownViewr` | Developer ID | Sparkle + Homebrew cask | `just release` |
|
||||
| **Mac App Store** | `markdownViewr-MAS` | `markdownViewr-MAS` | Apple Distribution | App Store | `just release-mas` |
|
||||
|
||||
Both targets use the standard `Debug`/`Release` configs (no special `-MAS` configs). They differ ONLY in three things: the `markdownViewr` target depends on the `sparkle` package and the `markdownViewr-MAS` target does not; the MAS target sets `SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD` and applies `markdownViewr/markdownViewr-MAS.entitlements` (sandbox + user-selected read-only + app-scope bookmarks + network client). The Developer ID build is unchanged at runtime — all sandbox behavior is gated behind `#if MAS_BUILD`.
|
||||
|
||||
**Why two targets, not one target with two configs:** SwiftPM **auto-links a declared package-product dependency regardless of whether the source imports it**, so `#if !MAS_BUILD` around `import Sparkle` removes Sparkle *usage* but not the *link/embed*. A single target therefore always embeds `Sparkle.framework`, whose non-sandboxable helper executables (Autoupdate, Updater.app, the XPC services) make the App Store reject the build (error 90296). The framework can't be stripped post-build either — the binary links it, so removal causes a dyld launch crash. The only fix is a separate target that doesn't depend on Sparkle at all. (Both targets output `markdownViewr.app` to the same `Build/Products/Release/` in shared DerivedData, so alternating local builds overwrite each other — harmless, since the release recipes archive to their own paths.)
|
||||
|
||||
What `MAS_BUILD` / the MAS target changes:
|
||||
|
||||
- **No Sparkle** — the MAS target omits the `sparkle` dependency (so nothing is linked or embedded), and `import Sparkle`, `SPUStandardUpdaterController`, and the "Check for Updates…" command are behind `#if !MAS_BUILD` (so the MAS target compiles without the Sparkle module). The `SUFeedURL`/`SUPublicEDKey` Info.plist keys live in the `markdownViewr` target's `info.properties` (so the Developer ID build keeps them); the MAS target reuses that generated `Info.plist` and deletes those two keys in its unconditional `Strip Sparkle keys from MAS Info.plist` post-build script. (`INFOPLIST_KEY_`-prefixed injection does NOT work for non-Apple custom keys — verified — hence the post-build strip.)
|
||||
- **Folder access** — `FolderAccessManager` resolves/persists an app-scoped security-scoped bookmark for the open document's folder, used only to read sibling images. The grant banner is shown **only when the document actually references local images** (`ImageInliner.containsLocalImage`) and access isn't held yet — image-less docs (the common case) never prompt. Bookmarks are keyed by folder path in `UserDefaults` (`folderBookmark:<path>`).
|
||||
- **File watching is decoupled from the grant** — `ContentView` starts the `FileWatcher` unconditionally on open. The opened document is accessible via the document architecture, so live-reload works for every doc with no prompt; the folder bookmark is needed only for sibling images.
|
||||
- **Image inlining** — `ContentView.rerender()` rewrites relative-local `<img>` tags to `data:` URLs via `ImageInliner`, reading bytes through the folder bookmark. This makes the preview HTML self-contained so `MarkdownWebView` only grants WKWebView read access to the container temp dir (not `/`). Remote images load via the network-client entitlement.
|
||||
- **External editors** — verified to work under sandbox via `NSWorkspace.open(_:withApplicationAt:)` with the stored path string; LaunchServices mediates the launch, so no per-editor bookmark is needed.
|
||||
|
||||
**Remaining manual App Store Connect steps** (not automated): register the App ID and create Apple Distribution + Mac Installer Distribution certificates and a provisioning profile; create the app record; upload screenshots; write the App Privacy label (collects nothing) and export-compliance answer (no non-exempt crypto); submit for review.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **SwiftUI** app shell (toolbar, settings, window management)
|
||||
@@ -25,6 +48,16 @@ After adding/removing Swift files or resources, run `xcodegen generate` before b
|
||||
- HTML rendered in a template (`Resources/template.html`) with CSS variable-based theming
|
||||
- Settings and Theme Editor use custom `NSWindow` controllers (not SwiftUI `Settings` scene) because SwiftUI's Settings scene doesn't support resizing
|
||||
|
||||
## Portability (Apple platforms)
|
||||
|
||||
The app can grow to iOS/iPadOS/visionOS without a rewrite, provided one boundary stays clean: the render/model core must stay free of AppKit.
|
||||
|
||||
- **Portable core (pure Swift / web — reuse as-is):** markdown→HTML (`MarkdownDocument`, swift-markdown), `ImageInliner`, `Theme`/`ThemeManager`, and the entire render layer (`Resources/template.html`, CSS, JS). Keep `NS*`/AppKit types out of these files.
|
||||
- **Platform layer (macOS-bound — needs per-platform twins):** `MarkdownWebView` (`NSViewRepresentable` → `UIViewRepresentable`; WKWebView itself exists on iOS), `SettingsWindowController`/`HelpWindowController` (`NSWindow` → sheets/navigation), `FolderAccessManager` (`NSOpenPanel` → `UIDocumentPickerViewController`), `EditorManager`/`EditorConfig` (`NSWorkspace` external-editor launch — no iOS equivalent), `WindowResizer`/`WindowAccessor`.
|
||||
- `DocumentGroup` (app entry) is already cross-platform. The MAS sandboxing also moves macOS toward the always-sandboxed iOS model (security-scoped access instead of broad file reads), so it helps rather than hinders an iOS port.
|
||||
|
||||
Don't build cross-platform abstractions speculatively — just keep the render/model code AppKit-free until a second platform is actually on the table.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
@@ -38,6 +71,8 @@ After adding/removing Swift files or resources, run `xcodegen generate` before b
|
||||
| `ThemeEditorView.swift` | Theme creation/editing UI with live preview |
|
||||
| `SettingsView.swift` | General, Editors, Themes tabs + `ThemeEditorWindowController` |
|
||||
| `EditorConfig.swift` | External editor model + manager |
|
||||
| `ImageInliner.swift` | Rewrites relative-local `<img>` tags to `data:` URLs (MAS build only) |
|
||||
| `FolderAccessManager.swift` | Security-scoped folder access via app-scoped bookmarks (MAS build only) |
|
||||
| `FileWatcher.swift` | `DispatchSource` file system watcher |
|
||||
| `FindBarController.swift` | Find state + notification names |
|
||||
| `CSSHelpView.swift` | Shared CSS reference popover |
|
||||
@@ -79,6 +114,27 @@ SwiftUI's `Settings` scene and `.sheet` don't support resizing on macOS. Both Se
|
||||
- Theme order tracked in UserDefaults key `themeOrder`
|
||||
- Active theme tracked in UserDefaults key `activeTheme`
|
||||
|
||||
## Theme Schema Versioning
|
||||
|
||||
Theme JSON files carry a `schemaVersion` integer field. The current version is in `Theme.currentSchemaVersion` (`Theme.swift`).
|
||||
|
||||
**When you add a new field to `Theme`, `ThemeColors`, `ThemeFonts`, or `ThemeSizes`:**
|
||||
|
||||
1. Increment `Theme.currentSchemaVersion`.
|
||||
2. Add the new field with a sensible default in `init(from decoder:)` using `decodeIfPresent` so older files still load.
|
||||
3. Update the built-in theme JSON files in `Resources/themes/` with the new field.
|
||||
4. Add a migration step in `ThemeManager.migrateIfNeeded(_ theme: inout Theme)` — an `if theme.schemaVersion < N` block that fills in the new field and bumps `schemaVersion` to `N`. Stack these in order so a theme several versions behind migrates fully in one pass. `migrateIfNeeded` is already called from `loadUserThemes()` (with automatic write-back to disk if the version changed) and from `importTheme(_:resolution:)`. Do not remove the example comment block — it shows future-me the pattern.
|
||||
5. Update `ThemeEditorView` to expose any new user-facing fields.
|
||||
6. Update `ThemeManager.generateCSS(for:)` if the new field affects rendering.
|
||||
|
||||
**Import behaviour (already implemented in `SettingsView.importThemes()`):** if an imported theme's `schemaVersion` exceeds `currentSchemaVersion`, the user is warned before import proceeds. If it is lower, run `migrateIfNeeded` on it during import just as you would for any loaded theme.
|
||||
|
||||
**Schema version history:**
|
||||
|
||||
| Version | What changed |
|
||||
|---|---|
|
||||
| 1 | Initial versioned schema (colors, fonts, sizes, customCSS) |
|
||||
|
||||
## Find in Document
|
||||
|
||||
- Cmd+F toggles find bar per-window (each window has its own `FindBarController`)
|
||||
|
||||
@@ -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>app-store-connect</string>
|
||||
<key>teamID</key>
|
||||
<string>9MG4YT2G93</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Darin Kelkhoff
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,61 @@
|
||||
# markdownViewr
|
||||
|
||||
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
|
||||
|
||||
[](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## Download
|
||||
|
||||
**[Download for Mac](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)** — macOS 13 Ventura or later, free.
|
||||
|
||||
Open the DMG, drag markdownViewr to Applications, and you're done.
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew install --cask darinkelkhoff/tap/markdownviewr
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **GitHub Flavored Markdown** — tables, task lists, strikethrough, fenced code blocks
|
||||
- **Themed code blocks** — monospaced, with configurable code font and size
|
||||
- **Fully customizable themes** — built-in theme editor, unlimited user themes, custom CSS
|
||||
- **Table of contents** — toggleable, resizable, depth picker, depth markers, jump-to-heading
|
||||
- **Markdown source view** — side-by-side raw source, find across both panes, heading scroll-sync, collapse mirroring
|
||||
- **Collapsible headings** — click a heading to collapse its section, ⌥-click to anchor to it
|
||||
- **Live file watching** — re-renders on every save, scroll position preserved
|
||||
- **Vim navigation keys** — `j`/`k`, `Ctrl+d`/`u`, `gg`/`G`, `]]`/`[[`
|
||||
- **Find in document** — with match count and vim-style `/`, `n`, `N`
|
||||
- **YAML frontmatter** — hide, show as a metadata table, or render as Markdown
|
||||
- **Open in external editor** — configure any editor or app, one click from the toolbar
|
||||
- **Automatic updates** — stays current via Sparkle
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `j` / `k` | Scroll down / up |
|
||||
| `Ctrl+d` / `Ctrl+u` | Half page down / up |
|
||||
| `gg` / `G` | Top / bottom of document |
|
||||
| `]]` / `[[` | Next / previous heading |
|
||||
| `⌘F` | Find |
|
||||
| `/` | Focus find bar (vim-style) |
|
||||
| `n` / `N` | Next / previous match |
|
||||
| `⌘⇧↓` / `⌘⇧↑` | Next / previous theme |
|
||||
|
||||
## Building from Source
|
||||
|
||||
Requires Xcode 15+, [XcodeGen](https://github.com/yonaskolb/XcodeGen), and [just](https://github.com/casey/just).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/darinkelkhoff/markdownViewr.git
|
||||
cd markdownViewr
|
||||
just run
|
||||
```
|
||||
|
||||
`just --list` shows all available recipes.
|
||||
+16
-16
@@ -5,6 +5,22 @@
|
||||
<link>https://github.com/darinkelkhoff/markdownViewr</link>
|
||||
<description>markdownViewr releases</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>1.2.0</title>
|
||||
<pubDate>Tue, 09 Jun 2026 13:58:18 -0500</pubDate>
|
||||
<sparkle:version>10200</sparkle:version>
|
||||
<sparkle:shortVersionString>1.2.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.2.0/markdownViewr-1.2.0.zip" length="4220100" type="application/octet-stream" sparkle:edSignature="To0Yh+F2nAaYfLCwd/nxOR+bMkEwTIS01T1YA0tJNto6VYXc5haNeOU+uX2e4nx3JAHvknGFKWQRunkS1aCSBw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.1.6</title>
|
||||
<pubDate>Mon, 08 Jun 2026 12:01:59 -0500</pubDate>
|
||||
<sparkle:version>10106</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.6</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.6/markdownViewr-1.1.6.zip" length="4202331" type="application/octet-stream" sparkle:edSignature="PJHorFzsVMKacxkjrVMNfRYoUfoHrBvy0okZ7NO7+0fFSOqFEi50D0dMlog8DlCvOxeXEfkMXQoPjmugDArcCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.1.3</title>
|
||||
<pubDate>Mon, 08 Jun 2026 11:56:45 -0500</pubDate>
|
||||
@@ -13,21 +29,5 @@
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.5/markdownViewr-1.1.5.zip" length="4202371" type="application/octet-stream" sparkle:edSignature="8sD2QkODU6bzR134PtCSIQrmrEL7ihXcRelFWqZhnrB6RpdvrMmXs44uggcDxZDaNXERtJGP4JbISXFCLFedCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.1.3</title>
|
||||
<pubDate>Mon, 08 Jun 2026 10:07:17 -0500</pubDate>
|
||||
<sparkle:version>3</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.4/markdownViewr-1.1.4.zip" length="4202362" type="application/octet-stream" sparkle:edSignature="R5jaamgXCpnoMHHRU4H+WsT+VNfEhRZEMIdXchNKXgzPZyCEMV4lRbEr7TJN/TvxxmiSEJorOXeJjhjH5tMzCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.0</title>
|
||||
<pubDate>Mon, 08 Jun 2026 08:18:43 -0500</pubDate>
|
||||
<sparkle:version>1</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.2/markdownViewr-1.1.2.zip" length="4202333" type="application/octet-stream" sparkle:edSignature="FZ19CRV6Ib9Dbpt7Nr5PK4tCcaLJ8rHPlBOr2eGLjV1GnJxBETSnCp0FCEiOsvgWqpgsOlJc/eEQHDbIK/8UBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
+9
-3
@@ -141,12 +141,16 @@
|
||||
Download for Mac
|
||||
</a>
|
||||
|
||||
<p class="req">macOS 13 Ventura or later · Free</p>
|
||||
<p class="req">macOS 13 Ventura or later · Free · <a href="https://github.com/darinkelkhoff/markdownViewr" style="color:inherit">View on GitHub</a></p>
|
||||
|
||||
<p class="req" style="margin-top:8px">Also available via Homebrew: <code style="font-size:0.82rem">brew install --cask darinkelkhoff/tap/markdownviewr</code></p>
|
||||
|
||||
<div class="features">
|
||||
<span class="feature">GitHub Flavored Markdown</span>
|
||||
<span class="feature">Syntax-highlighted code blocks</span>
|
||||
<span class="feature">Themed code blocks</span>
|
||||
<span class="feature">Fully customizable themes</span>
|
||||
<span class="feature">Table of contents</span>
|
||||
<span class="feature">Markdown source view</span>
|
||||
<span class="feature">Live file watching</span>
|
||||
<span class="feature">Vim navigation keys</span>
|
||||
<span class="feature">Find in document</span>
|
||||
@@ -161,7 +165,9 @@
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="https://github.com/darinkelkhoff/markdownViewr">View on GitHub</a>
|
||||
<a href="privacy.html">Privacy Policy</a> ·
|
||||
<a href="https://github.com/darinkelkhoff/markdownViewr">GitHub</a>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr — Privacy Policy</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f5f5f7;
|
||||
color: #1d1d1f;
|
||||
line-height: 1.6;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 22%;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,0.15);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.effective {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p { margin-bottom: 12px; }
|
||||
|
||||
.lead {
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
ul { margin: 0 0 12px 22px; }
|
||||
li { margin-bottom: 6px; }
|
||||
|
||||
a { color: #0071e3; }
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
footer {
|
||||
max-width: 720px;
|
||||
margin: 56px auto 0;
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
footer a { color: #aaa; }
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #1c1c1e; color: #f5f5f7; }
|
||||
.lead { color: #ccc; }
|
||||
.effective { color: #777; }
|
||||
a { color: #4ea1ff; }
|
||||
footer, footer a { color: #555; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img class="icon" src="icon.png" alt="markdownViewr icon">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="effective">markdownViewr · Effective June 12, 2026</p>
|
||||
|
||||
<p class="lead">
|
||||
markdownViewr does not collect, store, transmit, or share any personal
|
||||
information. There are no accounts, no analytics, and no tracking of any kind.
|
||||
</p>
|
||||
|
||||
<h2>Information we collect</h2>
|
||||
<p>
|
||||
None. markdownViewr collects no personal data. It does not contain analytics,
|
||||
advertising, tracking technologies, crash reporters, or any mechanism that
|
||||
gathers information about you or your device.
|
||||
</p>
|
||||
|
||||
<h2>Your files</h2>
|
||||
<p>
|
||||
markdownViewr is a view-only document app. It reads only the files you
|
||||
explicitly open, and processes them entirely on your Mac to render them for
|
||||
display. Your documents are never uploaded, copied to any server, or sent to
|
||||
the developer or anyone else. Any settings and themes you create are stored
|
||||
locally on your Mac.
|
||||
</p>
|
||||
|
||||
<h2>Network activity</h2>
|
||||
<p>
|
||||
The app itself sends no information about you anywhere. The only network
|
||||
requests it makes are ones you initiate through your own content or actions:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Remote images.</strong> If a document you open references an image
|
||||
by a remote (<code>http</code>/<code>https</code>) URL, the app loads that
|
||||
image so it can be displayed. That request goes directly to the server
|
||||
hosting the image, which — like any web request — can see your IP address.
|
||||
markdownViewr adds no identifying information to these requests.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Update checks (non‑App‑Store version only).</strong> The
|
||||
version downloaded directly (DMG or Homebrew) periodically checks for new
|
||||
releases by fetching a public update feed hosted on GitHub. This request
|
||||
contains no personal data. The Mac App Store version does not do this —
|
||||
it is updated through the App Store.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Third parties</h2>
|
||||
<p>
|
||||
markdownViewr shares no data with third parties, because it collects none.
|
||||
The remote servers and update host described above receive only the ordinary
|
||||
network request needed to fulfill your action, never personal information from
|
||||
the app.
|
||||
</p>
|
||||
|
||||
<h2>Children's privacy</h2>
|
||||
<p>
|
||||
markdownViewr does not collect personal information from anyone, including
|
||||
children under 13.
|
||||
</p>
|
||||
|
||||
<h2>Changes to this policy</h2>
|
||||
<p>
|
||||
If this policy changes, the updated version will be posted on this page with a
|
||||
new effective date.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
Questions about this policy can be sent to
|
||||
<a href="mailto:darin.kelkhoff@gmail.com">darin.kelkhoff@gmail.com</a>.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<a href="./">← markdownViewr</a> ·
|
||||
<a href="https://github.com/darinkelkhoff/markdownViewr">GitHub</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,916 @@
|
||||
# Mac App Store Dual-Distribution 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 a sandboxed Mac App Store build variant alongside the existing Developer ID build, from one codebase, with all sandbox-specific behavior isolated behind a `MAS_BUILD` compile flag.
|
||||
|
||||
**Architecture:** A second pair of XcodeGen build configurations (`Debug-MAS`, `Release-MAS`) flips on the sandbox via an entitlements file and defines `MAS_BUILD`. Sparkle is compiled out of the MAS binary. A `FolderAccessManager` acquires a persisted security-scoped bookmark to the document's folder (powering both file-watching and image reads), and local images are inlined as `data:` URLs so the sandboxed WKWebView never needs broad file access. The Developer ID build is unchanged at runtime.
|
||||
|
||||
**Tech Stack:** Swift, SwiftUI, AppKit (NSOpenPanel, security-scoped bookmarks), WKWebView, XcodeGen, xcodebuild.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Responsibility | Action |
|
||||
|---|---|---|
|
||||
| `project.yml` | Add `configs` map; per-config sandbox/entitlements/flags; move Sparkle Info.plist keys to non-MAS configs | Modify |
|
||||
| `markdownViewr/markdownViewr-MAS.entitlements` | Sandbox entitlements for the MAS build | Create |
|
||||
| `markdownViewr/MarkdownViewrApp.swift` | Guard Sparkle behind `#if !MAS_BUILD` | Modify |
|
||||
| `markdownViewr/HelpView.swift` | Don't advertise Sparkle updates in MAS build | Modify |
|
||||
| `markdownViewr/ImageInliner.swift` | Pure helper: rewrite `<img>` local `src` to `data:` URLs | Create |
|
||||
| `markdownViewrTests/ImageInlinerTests.swift` | Unit tests for the inliner | Create |
|
||||
| `markdownViewr/FolderAccessManager.swift` | Acquire/persist/hold security-scoped folder access; resolve image bytes | Create |
|
||||
| `markdownViewr/ContentView.swift` | Gate watching on access; show grant banner; inline images on render | Modify |
|
||||
| `markdownViewr/MarkdownWebView.swift` | MAS: scope read-access to temp dir, no base tag | Modify |
|
||||
| `ExportOptions-MAS.plist` | App Store export options | Create |
|
||||
| `justfile` | `release-mas` recipe | Modify |
|
||||
|
||||
**Test runner (per user convention):** All tests run via `/tmp/markdownViewr-claude-run-tests.sh` (created in Task 3), which writes output to `/tmp/markdownViewr-claude-test-output.txt`. Builds are run inline with `xcodebuild`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: MAS build configurations + entitlements
|
||||
|
||||
**Files:**
|
||||
- Modify: `project.yml`
|
||||
- Create: `markdownViewr/markdownViewr-MAS.entitlements`
|
||||
|
||||
- [ ] **Step 1: Create the MAS entitlements file**
|
||||
|
||||
Create `markdownViewr/markdownViewr-MAS.entitlements`:
|
||||
|
||||
```xml
|
||||
<?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>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `configs` map to `project.yml`**
|
||||
|
||||
At the top level of `project.yml` (e.g. directly after the `options:` block), add:
|
||||
|
||||
```yaml
|
||||
configs:
|
||||
Debug: debug
|
||||
Release: release
|
||||
Debug-MAS: debug
|
||||
Release-MAS: release
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the always-non-sandboxed base setting and the Sparkle Info.plist keys**
|
||||
|
||||
In `project.yml`, in the `markdownViewr` target's `settings.base`, DELETE this line (sandbox is now controlled per-config via the entitlements file):
|
||||
|
||||
```yaml
|
||||
ENABLE_APP_SANDBOX: "NO"
|
||||
```
|
||||
|
||||
In the `markdownViewr` target's `info.properties`, DELETE these two lines (they will be re-injected for non-MAS configs only in the next step):
|
||||
|
||||
```yaml
|
||||
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add per-config settings to the `markdownViewr` target**
|
||||
|
||||
In `project.yml`, under the `markdownViewr` target's `settings:` (which currently only has `base:`), add a `configs:` sibling so it reads:
|
||||
|
||||
```yaml
|
||||
settings:
|
||||
base:
|
||||
INFOPLIST_FILE: markdownViewr/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
|
||||
MARKETING_VERSION: "1.2.0"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
configs:
|
||||
Debug:
|
||||
INFOPLIST_KEY_SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
INFOPLIST_KEY_SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
Release:
|
||||
INFOPLIST_KEY_SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
INFOPLIST_KEY_SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
Debug-MAS:
|
||||
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MAS_BUILD"
|
||||
Release-MAS:
|
||||
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: "$(inherited) MAS_BUILD"
|
||||
```
|
||||
|
||||
Note: `ENABLE_APP_SANDBOX` is intentionally NOT set — the entitlements file is the single source of truth for sandboxing, present only in the MAS configs.
|
||||
|
||||
- [ ] **Step 5: Regenerate and verify the configs exist**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `xcodebuild -project markdownViewr.xcodeproj -list`
|
||||
Expected: the `Build Configurations:` list includes `Debug`, `Release`, `Debug-MAS`, and `Release-MAS`.
|
||||
|
||||
- [ ] **Step 6: Verify the Developer ID Info.plist still has Sparkle keys, MAS does not**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release -showBuildSettings 2>/dev/null | grep -i 'INFOPLIST_KEY_SUFeedURL'`
|
||||
Expected: shows the appcast URL.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release-MAS -showBuildSettings 2>/dev/null | grep -ci 'INFOPLIST_KEY_SUFeedURL'`
|
||||
Expected: `0`
|
||||
|
||||
(If `INFOPLIST_KEY_`-prefixed custom keys do not appear in the built `Info.plist` on this Xcode version, fall back to two static Info.plist files: a shared `Info.plist` without Sparkle keys, plus `Info-DevID.plist` with them, and set `INFOPLIST_FILE` per-config. Verify by building and running `plutil -p` on the built app's `Contents/Info.plist`.)
|
||||
|
||||
- [ ] **Step 7: Build both variants to confirm the project is valid**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED` (Sparkle still compiles here — it is removed in Task 2).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add project.yml markdownViewr/markdownViewr-MAS.entitlements
|
||||
git commit -m "build: add Mac App Store build configurations and entitlements"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Compile Sparkle out of the MAS build
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/MarkdownViewrApp.swift:1-31`
|
||||
- Modify: `markdownViewr/HelpView.swift:54`
|
||||
|
||||
- [ ] **Step 1: Guard the Sparkle import**
|
||||
|
||||
In `markdownViewr/MarkdownViewrApp.swift`, replace lines 1-2:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
#if !MAS_BUILD
|
||||
import Sparkle
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Guard the updater controller property**
|
||||
|
||||
In `markdownViewr/MarkdownViewrApp.swift`, wrap the `updaterController` property (currently lines 6-10):
|
||||
|
||||
```swift
|
||||
#if !MAS_BUILD
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Guard the "Check for Updates" command**
|
||||
|
||||
In `markdownViewr/MarkdownViewrApp.swift`, wrap the entire `CommandGroup(after: .appInfo)` block (currently lines 26-31). `#if` is valid inside SwiftUI's `commands` result builder, so in the MAS build the menu item is never added to the menu:
|
||||
|
||||
```swift
|
||||
#if !MAS_BUILD
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
.disabled(!updaterController.updater.canCheckForUpdates)
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Don't advertise Sparkle in the MAS build's Help**
|
||||
|
||||
Read `markdownViewr/HelpView.swift` around line 54 to see the exact surrounding string-array/list context. The line currently reads:
|
||||
|
||||
```swift
|
||||
- Automatic updates via Sparkle
|
||||
```
|
||||
|
||||
Replace it so the bullet is conditional. If it is part of a multi-line Swift string literal, split the literal so only the non-MAS build includes the line, e.g.:
|
||||
|
||||
```swift
|
||||
#if !MAS_BUILD
|
||||
- Automatic updates via Sparkle
|
||||
#endif
|
||||
```
|
||||
|
||||
If `#if` cannot be placed inside that specific literal/view construct, instead remove the Sparkle bullet from the shared text and append it only in the non-MAS build via the surrounding view/string-builder. The requirement: the MAS build's Help must not mention Sparkle or automatic updates.
|
||||
|
||||
- [ ] **Step 5: Build the MAS variant and confirm Sparkle is compiled out**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED` with no reference to `SPUStandardUpdaterController` (the symbol is inside `#if !MAS_BUILD`).
|
||||
|
||||
- [ ] **Step 6: Build the Developer ID variant and confirm it is unchanged**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`; the "Check for Updates…" command and Sparkle updater still compile.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownViewrApp.swift markdownViewr/HelpView.swift
|
||||
git commit -m "build: compile Sparkle out of the Mac App Store build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Image-inlining helper (pure, TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `markdownViewr/ImageInliner.swift`
|
||||
- Test: `markdownViewrTests/ImageInlinerTests.swift`
|
||||
- Create: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
|
||||
- [ ] **Step 1: Create the test-runner script**
|
||||
|
||||
Write `/tmp/markdownViewr-claude-run-tests.sh` (the ONLY way tests are run; always invoked with zero arguments):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -o pipefail
|
||||
cd /Users/dkelkhoff/git/personal/markdownViewr
|
||||
xcodebuild test \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewrTests \
|
||||
-destination 'platform=macOS' \
|
||||
> /tmp/markdownViewr-claude-test-output.txt 2>&1
|
||||
echo "exit=$?" >> /tmp/markdownViewr-claude-test-output.txt
|
||||
```
|
||||
|
||||
Then: `chmod +x /tmp/markdownViewr-claude-run-tests.sh`
|
||||
|
||||
- [ ] **Step 2: Write the failing tests**
|
||||
|
||||
Create `markdownViewrTests/ImageInlinerTests.swift`:
|
||||
|
||||
```swift
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class ImageInlinerTests: XCTestCase {
|
||||
func testInlinesRelativeImageAsDataURL() {
|
||||
let html = "<p><img src=\"img/a.png\" alt=\"x\"></p>"
|
||||
let bytes = Data([0x01, 0x02, 0x03])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "img/a.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
XCTAssertFalse(out.contains("img/a.png"))
|
||||
XCTAssertTrue(out.contains("alt=\"x\""))
|
||||
}
|
||||
|
||||
func testLeavesRemoteImageUntouched() {
|
||||
let html = "<img src=\"https://example.com/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesAbsolutePathUntouched() {
|
||||
let html = "<img src=\"/Users/x/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesUnresolvableRelativeImageUntouched() {
|
||||
let html = "<img src=\"missing.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in nil }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testDecodesPercentEncodedPathBeforeResolving() {
|
||||
let html = "<img src=\"my%20pic.png\">"
|
||||
let bytes = Data([0xAA])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "my pic.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the new files to the project and run the tests to verify they fail**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
Then read `/tmp/markdownViewr-claude-test-output.txt`.
|
||||
Expected: compilation failure — `ImageInliner` is not defined yet.
|
||||
|
||||
- [ ] **Step 4: Implement the helper**
|
||||
|
||||
Create `markdownViewr/ImageInliner.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
|
||||
/// Rewrites `<img>` tags whose `src` is a local, relative path into self-contained
|
||||
/// `data:` URLs, using a caller-supplied byte resolver. Remote (`http(s)`), `data:`,
|
||||
/// and absolute-path sources are left untouched. Pure and synchronous so it is unit
|
||||
/// testable without any filesystem or sandbox dependency.
|
||||
enum ImageInliner {
|
||||
static func inlineLocalImages(in html: String, resolve: (String) -> Data?) -> String {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: "<img([^>]*?)src=\"([^\"]*)\"([^>]*)>",
|
||||
options: []
|
||||
) else { return html }
|
||||
|
||||
let ns = html as NSString
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
|
||||
guard !matches.isEmpty else { return html }
|
||||
|
||||
var result = ""
|
||||
var last = 0
|
||||
for match in matches {
|
||||
let full = match.range
|
||||
result += ns.substring(with: NSRange(location: last, length: full.location - last))
|
||||
|
||||
let pre = ns.substring(with: match.range(at: 1))
|
||||
let src = ns.substring(with: match.range(at: 2))
|
||||
let post = ns.substring(with: match.range(at: 3))
|
||||
|
||||
let decoded = src.removingPercentEncoding ?? src
|
||||
if isLocalRelative(src), let data = resolve(decoded) {
|
||||
let mime = mimeType(forExtension: (src as NSString).pathExtension)
|
||||
result += "<img\(pre)src=\"data:\(mime);base64,\(data.base64EncodedString())\"\(post)>"
|
||||
} else {
|
||||
result += ns.substring(with: full)
|
||||
}
|
||||
last = full.location + full.length
|
||||
}
|
||||
result += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
||||
return result
|
||||
}
|
||||
|
||||
static func isLocalRelative(_ src: String) -> Bool {
|
||||
if src.isEmpty { return false }
|
||||
if src.hasPrefix("data:") { return false }
|
||||
if src.contains("://") { return false } // http://, https://, file://
|
||||
if src.hasPrefix("/") { return false } // absolute path — unreadable under sandbox
|
||||
return true
|
||||
}
|
||||
|
||||
static func mimeType(forExtension ext: String) -> String {
|
||||
switch ext.lowercased() {
|
||||
case "png": return "image/png"
|
||||
case "jpg", "jpeg": return "image/jpeg"
|
||||
case "gif": return "image/gif"
|
||||
case "svg": return "image/svg+xml"
|
||||
case "webp": return "image/webp"
|
||||
case "bmp": return "image/bmp"
|
||||
case "tif", "tiff": return "image/tiff"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the file to the project and run the tests to verify they pass**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
Then read `/tmp/markdownViewr-claude-test-output.txt`.
|
||||
Expected: `exit=0`, all `ImageInlinerTests` pass.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/ImageInliner.swift markdownViewrTests/ImageInlinerTests.swift
|
||||
git commit -m "feat: add ImageInliner for sandbox-safe data-URL image embedding"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: FolderAccessManager (security-scoped folder access)
|
||||
|
||||
**Files:**
|
||||
- Create: `markdownViewr/FolderAccessManager.swift`
|
||||
|
||||
This type exists in BOTH builds with a uniform interface. In the non-MAS build it is a no-op that always reports access, so the `ContentView` body needs no `#if`. All sandbox/bookmark logic lives behind `#if MAS_BUILD` inside this file.
|
||||
|
||||
- [ ] **Step 1: Create the manager**
|
||||
|
||||
Create `markdownViewr/FolderAccessManager.swift`:
|
||||
|
||||
```swift
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Owns access to the directory containing the currently-open document. In the MAS
|
||||
/// (sandboxed) build it resolves or requests a persisted app-scoped security-scoped
|
||||
/// bookmark for that folder and holds access for the manager's lifetime. In the
|
||||
/// Developer ID build it is an inert pass-through that always reports access.
|
||||
final class FolderAccessManager: ObservableObject {
|
||||
/// True when the document's folder is accessible (always true in the non-MAS build).
|
||||
@Published private(set) var hasAccess: Bool = false
|
||||
|
||||
private var folderURL: URL?
|
||||
private var accessedURL: URL?
|
||||
|
||||
deinit {
|
||||
accessedURL?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
/// Resolves any stored access for the document's folder. Call once when a document opens.
|
||||
func prepare(for fileURL: URL) {
|
||||
let folder = fileURL.deletingLastPathComponent()
|
||||
folderURL = folder
|
||||
#if MAS_BUILD
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.key(for: folder)) else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
var stale = false
|
||||
guard let url = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
), url.startAccessingSecurityScopedResource() else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
accessedURL = url
|
||||
hasAccess = true
|
||||
if stale { storeBookmark(for: url) }
|
||||
#else
|
||||
hasAccess = true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Presents a folder picker (MAS only) to obtain and persist access. Calls back on the main queue.
|
||||
func requestAccess(completion: @escaping (Bool) -> Void) {
|
||||
#if MAS_BUILD
|
||||
guard let folder = folderURL else { completion(false); return }
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = folder
|
||||
panel.prompt = "Allow Access"
|
||||
panel.message = "Allow markdownViewr to access this folder to show images and auto-reload changes."
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url,
|
||||
url.startAccessingSecurityScopedResource() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.accessedURL?.stopAccessingSecurityScopedResource()
|
||||
self.accessedURL = url
|
||||
self.storeBookmark(for: url)
|
||||
self.hasAccess = true
|
||||
completion(true)
|
||||
}
|
||||
#else
|
||||
completion(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reads bytes for a path relative to the document's folder, or nil if unavailable.
|
||||
/// Used by the MAS render path to inline images. Returns nil when access is not held.
|
||||
func imageData(forRelativePath path: String) -> Data? {
|
||||
guard hasAccess, let base = accessedURL ?? folderURL else { return nil }
|
||||
let url = base.appendingPathComponent(path)
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
|
||||
#if MAS_BUILD
|
||||
private func storeBookmark(for url: URL) {
|
||||
guard let data = try? url.bookmarkData(
|
||||
options: [.withSecurityScope],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
) else { return }
|
||||
// Key by the document's folder so the same document re-grants silently next launch.
|
||||
if let folder = folderURL {
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: folder))
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: url))
|
||||
}
|
||||
|
||||
private static func key(for folder: URL) -> String {
|
||||
"folderBookmark:\(folder.standardizedFileURL.path)"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the file and build both variants**
|
||||
|
||||
Run: `xcodegen generate`
|
||||
Then: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED` (non-MAS: `hasAccess` defaults set in `prepare`, no bookmark code compiled).
|
||||
|
||||
Then: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/FolderAccessManager.swift
|
||||
git commit -m "feat: add FolderAccessManager for sandboxed folder access"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire folder access + grant banner into ContentView
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/ContentView.swift:20-99`
|
||||
|
||||
- [ ] **Step 1: Add the access manager and a render helper**
|
||||
|
||||
In `markdownViewr/ContentView.swift`, add a `@StateObject` for the manager alongside the existing `@StateObject` declarations (after line 30):
|
||||
|
||||
```swift
|
||||
@StateObject private var folderAccess = FolderAccessManager()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Make `rerender()` inline images in the MAS build**
|
||||
|
||||
Replace the existing `rerender()` (lines 39-45) with:
|
||||
|
||||
```swift
|
||||
private func rerender() {
|
||||
var html = MarkdownDocument.convertToHTML(
|
||||
currentMarkdown,
|
||||
frontmatterMode: themeManager.frontmatterMode,
|
||||
extensions: themeManager.markdownExtensions
|
||||
)
|
||||
#if MAS_BUILD
|
||||
if folderAccess.hasAccess {
|
||||
html = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
folderAccess.imageData(forRelativePath: path)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
renderedHTML = html
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add a helper to start watching once access is available**
|
||||
|
||||
Add this method to `ContentView` (next to `rerender()`):
|
||||
|
||||
```swift
|
||||
private func beginLiveContent() {
|
||||
guard let fileURL else { return }
|
||||
rerender()
|
||||
liveContent.startWatching(fileURL: fileURL)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `onAppear` to gate watching on access**
|
||||
|
||||
Replace the existing `.onAppear { ... }` block (lines 82-88) with:
|
||||
|
||||
```swift
|
||||
.onAppear {
|
||||
liveContent.rawMarkdown = document.rawMarkdown
|
||||
rerender()
|
||||
if let fileURL {
|
||||
folderAccess.prepare(for: fileURL)
|
||||
if folderAccess.hasAccess {
|
||||
beginLiveContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the Developer ID build `folderAccess.hasAccess` is always true after `prepare`, so this preserves today's behavior (watch immediately).
|
||||
|
||||
- [ ] **Step 5: Add the grant banner**
|
||||
|
||||
Wrap the `MarkdownWebView` in the `body`'s `VStack` so a banner can sit above it. Replace the `VStack(spacing: 0) { ... }` opening through the `MarkdownWebView(...)` call (lines 48-60) with:
|
||||
|
||||
```swift
|
||||
VStack(spacing: 0) {
|
||||
if findBar.isVisible {
|
||||
FindBarView(findBar: findBar)
|
||||
}
|
||||
if !folderAccess.hasAccess, fileURL != nil {
|
||||
FolderAccessBanner {
|
||||
folderAccess.requestAccess { granted in
|
||||
if granted { beginLiveContent() }
|
||||
}
|
||||
}
|
||||
}
|
||||
MarkdownWebView(
|
||||
html: renderedHTML,
|
||||
themeCSS: themeManager.generateCSS(for: themeManager.activeTheme),
|
||||
fileURL: fileURL,
|
||||
findBar: findBar,
|
||||
tocVisible: tocVisible,
|
||||
tocDepth: tocDepth
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Because `folderAccess.hasAccess` is always true in the non-MAS build, the banner never renders there — no `#if` needed in the view body.
|
||||
|
||||
- [ ] **Step 6: Define the banner view**
|
||||
|
||||
At the bottom of `markdownViewr/ContentView.swift` (file scope, after the `ContentView` struct), add:
|
||||
|
||||
```swift
|
||||
private struct FolderAccessBanner: View {
|
||||
let onGrant: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
Text("Allow access to this file's folder to show images and auto-reload on changes.")
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Button("Allow Access…", action: onGrant)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.yellow.opacity(0.18))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Build both variants**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
- [ ] **Step 8: Run the unit tests to confirm no regression**
|
||||
|
||||
Run: `/tmp/markdownViewr-claude-run-tests.sh`
|
||||
Then read `/tmp/markdownViewr-claude-test-output.txt`.
|
||||
Expected: `exit=0`, all tests pass.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/ContentView.swift
|
||||
git commit -m "feat: gate file-watching on folder access and add grant banner (MAS)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Scope WKWebView read access for the sandbox
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/MarkdownWebView.swift:65-98`
|
||||
|
||||
In the MAS build the preview HTML is self-contained (images already inlined by `rerender`), so the WebView only needs read access to the container temp dir, and the `<base href>` to the user's folder (unreadable under sandbox) must be omitted.
|
||||
|
||||
- [ ] **Step 1: Make the base tag and read-access scope build-specific**
|
||||
|
||||
In `markdownViewr/MarkdownWebView.swift`, in `loadContent`, replace the base-tag assignment (lines 75-79):
|
||||
|
||||
```swift
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
|
||||
} else {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
#if MAS_BUILD
|
||||
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
|
||||
// both unreadable and unnecessary.
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
#else
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
|
||||
} else {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Scope the read-access URL to the temp dir in the MAS build**
|
||||
|
||||
In `markdownViewr/MarkdownWebView.swift`, replace the temp-file write + load block (lines 83-93):
|
||||
|
||||
```swift
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
let tempDir = Self.previewDirectory
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
|
||||
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
|
||||
// Grant read access to "/" so both the temp file and the document's images are accessible
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||
context.coordinator.tempFileURL = tempHTML
|
||||
} else {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
}
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```swift
|
||||
if fileURL != nil {
|
||||
let tempDir = Self.previewDirectory
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
|
||||
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
|
||||
#if MAS_BUILD
|
||||
// Self-contained HTML (images inlined); only the container temp dir is needed.
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: tempDir)
|
||||
#else
|
||||
// Grant read access to "/" so both the temp file and the document's images are accessible
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||
#endif
|
||||
context.coordinator.tempFileURL = tempHTML
|
||||
} else {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build both variants**
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Release build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
Run: `xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS build`
|
||||
Expected: `BUILD SUCCEEDED`.
|
||||
|
||||
- [ ] **Step 4: Manual sandbox runtime check**
|
||||
|
||||
Launch the Debug-MAS build:
|
||||
```bash
|
||||
open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -configuration Debug-MAS -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" --args -NSDocumentRevisionsDebugMode YES
|
||||
```
|
||||
Then open a markdown file that has a relative local image (e.g. ``).
|
||||
Expected:
|
||||
1. Text renders immediately; the yellow banner appears.
|
||||
2. The image is broken/absent before granting.
|
||||
3. Clicking "Allow Access…" → confirming the folder → the image appears and editing the file on disk live-reloads the view.
|
||||
4. Closing and re-opening a file in the same folder does NOT show the banner again.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/MarkdownWebView.swift
|
||||
git commit -m "feat: scope WKWebView read access to temp dir in MAS build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: App Store export options + release recipe
|
||||
|
||||
**Files:**
|
||||
- Create: `ExportOptions-MAS.plist`
|
||||
- Modify: `justfile`
|
||||
|
||||
- [ ] **Step 1: Create the App Store export options**
|
||||
|
||||
Create `ExportOptions-MAS.plist`:
|
||||
|
||||
```xml
|
||||
<?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>app-store</string>
|
||||
<key>teamID</key>
|
||||
<string>9MG4YT2G93</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `release-mas` recipe**
|
||||
|
||||
Append to `justfile`:
|
||||
|
||||
```just
|
||||
# Archive and export a signed App Store .pkg (then upload via Transporter or altool)
|
||||
release-mas: kill
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "==> Regenerating Xcode project..."
|
||||
xcodegen generate
|
||||
ARCHIVE="/tmp/markdownViewr-mas.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-mas-export"
|
||||
rm -rf "$ARCHIVE" "$EXPORT"
|
||||
echo "==> Archiving v$VERSION (build $BUILD_NUMBER) for the App Store..."
|
||||
xcodebuild archive \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewr \
|
||||
-configuration Release-MAS \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-destination 'generic/platform=macOS' \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
|
||||
echo "==> Exporting App Store package..."
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportPath "$EXPORT" \
|
||||
-exportOptionsPlist ExportOptions-MAS.plist
|
||||
PKG=$(find "$EXPORT" -name '*.pkg' | head -1)
|
||||
echo ""
|
||||
echo "Done! Signed App Store package:"
|
||||
echo " $PKG"
|
||||
echo ""
|
||||
echo "Upload with Transporter.app, or:"
|
||||
echo " xcrun altool --upload-app -f \"$PKG\" -t macos \\"
|
||||
echo " --apiKey <KEY_ID> --apiIssuer <ISSUER_ID>"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the recipe is listed**
|
||||
|
||||
Run: `just --list`
|
||||
Expected: `release-mas` appears with its description.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add ExportOptions-MAS.plist justfile
|
||||
git commit -m "build: add App Store export options and release-mas recipe"
|
||||
```
|
||||
|
||||
Note: actually running `just release-mas` requires an Apple Distribution certificate and provisioning profile that may not exist yet (see Task 8 / out-of-scope account setup). The recipe is verified by listing and by code review here; a full archive+export run happens once the App Store Connect account is set up.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: External-editor sandbox verification
|
||||
|
||||
**Files:**
|
||||
- Possibly modify: `markdownViewr/EditorConfig.swift:60-74`
|
||||
|
||||
This task is a verify-then-decide item, per the design. Do NOT preemptively add bookmarks.
|
||||
|
||||
- [ ] **Step 1: Test launching a configured editor under the sandbox**
|
||||
|
||||
Launch the Debug-MAS build, configure an external editor in Settings (pick an installed app, e.g. an editor in `/Applications`), open a markdown file, and click the editor toolbar button (`ContentView.swift:231` → `EditorManager.openFile`).
|
||||
Expected (to determine): does `NSWorkspace.open(_:withApplicationAt:configuration:)` successfully launch the editor with the document, under sandbox, using the stored path string?
|
||||
|
||||
- [ ] **Step 2: Branch on the result**
|
||||
|
||||
- If it works: no code change. Note the verified behavior in `CLAUDE.md`'s sandbox notes and finish.
|
||||
- If it fails (no access to the stored editor path across launches): store a security-scoped bookmark for the editor app URL when the user selects it (in the editor-picker flow in `SettingsView`/`EditorConfig`), resolve and `startAccessingSecurityScopedResource()` around the launch in `EditorManager.openFile`, keyed like `FolderAccessManager`. Add `com.apple.security.files.user-selected.read-only` is already present; confirm it suffices for launching.
|
||||
|
||||
- [ ] **Step 3: Commit (only if code changed)**
|
||||
|
||||
```bash
|
||||
git add markdownViewr/EditorConfig.swift markdownViewr/SettingsView.swift
|
||||
git commit -m "fix: bookmark external-editor app access under sandbox"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Document the new distribution path
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Add a "Distribution" section to CLAUDE.md**
|
||||
|
||||
Add a section documenting: the two build configs (`Release`/`Release-MAS`), what `MAS_BUILD` gates (Sparkle out, sandbox on, folder-access banner, image inlining), the entitlements file, `just release` vs `just release-mas`, and the manual App Store Connect steps that remain (account/cert/profile, app record, screenshots, privacy label, submission). Keep it concise and factual, matching the existing CLAUDE.md style.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: document Mac App Store dual-distribution build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (manual, cannot be automated from the repo)
|
||||
|
||||
- Apple Developer account: register App ID `com.dkelkhoff.markdownViewr`, create **Apple Distribution** + **Mac Installer Distribution** certificates and a provisioning profile.
|
||||
- App Store Connect: create the app record, upload screenshots, write description/keywords, complete the **App Privacy** label (the app collects nothing), answer export-compliance (no non-exempt crypto).
|
||||
- Submit the build and respond to App Review.
|
||||
|
||||
These should be tracked as a manual checklist outside this plan.
|
||||
```
|
||||
@@ -0,0 +1,77 @@
|
||||
# Themed Scrollbars 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:** Give the main document, TOC, and raw-source pane matching, visible theme-aware scrollbars.
|
||||
|
||||
**Architecture:** Add shared scrollbar CSS to the bundled HTML template. Verify the contract by reading the real bundled template in an XCTest.
|
||||
|
||||
**Tech Stack:** Swift/XCTest, HTML/CSS, WebKit
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Shared themed scrollbar styling
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html`
|
||||
- Test: `markdownViewrTests/TemplateScrollbarTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add an XCTest that loads `template.html` and asserts shared `::-webkit-scrollbar-track`, `::-webkit-scrollbar-thumb`, and hover selectors use `--bg` and `--text`.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run `xcodebuild test -scheme markdownViewr -destination 'platform=macOS' -only-testing:markdownViewrTests/TemplateScrollbarTests` and expect missing-selector assertion failures.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
Add shared WebKit scrollbar track and thumb rules near the base CSS in `template.html`, deriving colors from the existing theme variables.
|
||||
|
||||
- [ ] **Step 4: Verify**
|
||||
|
||||
Run the focused test, the complete test suite, and visually inspect Nord with a long document.
|
||||
|
||||
### Task 2: Give the rendered pane its own scroll container
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html:18-47,220-225,420-426,876-1112`
|
||||
- Test: `markdownViewrTests/TemplateScrollbarTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Extend `TemplateScrollbarTests` to require `body { height: 100vh; overflow: hidden; }`, `#content { overflow-y: auto; }`, a `_contentScroller()` helper, and content-owned scroll listeners and navigation.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run `xcodebuild test -scheme markdownViewr -destination 'platform=macOS' -only-testing:markdownViewrTests/TemplateScrollbarTests` and expect failures for the missing content-scroller contract.
|
||||
|
||||
- [ ] **Step 3: Implement the pane scroll owner**
|
||||
|
||||
Bound the body and all three panes to the viewport. Make `#content` flexible and vertically scrollable. Add `_contentScroller()` and replace root-document reads, `window.scrollTo`/`scrollBy`, and `window` scroll listeners with equivalent operations on that element.
|
||||
|
||||
- [ ] **Step 4: Verify behavior**
|
||||
|
||||
Run the focused test and full `xcodebuild test -scheme markdownViewr -destination 'platform=macOS'`. Open a long document with TOC and source visible; verify each pane has a scrollbar at its own edge, scroll sync works in both directions, TOC highlighting follows the rendered pane, and Vim/find navigation still scrolls the rendered pane.
|
||||
|
||||
### Task 3: Keep the active TOC heading visible
|
||||
|
||||
**Files:**
|
||||
- Modify: `markdownViewr/Resources/template.html:1000-1025`
|
||||
- Test: `markdownViewrTests/TemplateScrollbarTests.swift`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add `testActiveTOCLinkIsKeptVisible`, requiring the active link to call `scrollIntoView` with `block: 'nearest'`.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run `xcodebuild test -scheme markdownViewr -destination 'platform=macOS' -only-testing:markdownViewrTests/TemplateScrollbarTests/testActiveTOCLinkIsKeptVisible` and expect the missing nearest-scroll assertion to fail.
|
||||
|
||||
- [ ] **Step 3: Implement nearest TOC scrolling**
|
||||
|
||||
After assigning the active class in `highlightActiveTOC`, call `activeLink.scrollIntoView({ block: 'nearest' })` so the TOC moves only when needed.
|
||||
|
||||
- [ ] **Step 4: Verify behavior**
|
||||
|
||||
Run the focused and full suites, then scroll a long rendered document far enough that the active TOC entry would otherwise leave the TOC viewport and confirm it remains visible.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Mac App Store Dual-Distribution — Design
|
||||
|
||||
**Date:** 2026-06-11
|
||||
**Branch:** `mas-dual-distribution`
|
||||
**Status:** Approved (design); implementation pending
|
||||
|
||||
## Goal
|
||||
|
||||
Ship markdownViewr through **two** distribution channels from one codebase:
|
||||
|
||||
1. **Developer ID** (existing) — notarized `.app` in a DMG, Sparkle auto-update, Homebrew cask. Unchanged by this work.
|
||||
2. **Mac App Store (MAS)** (new) — sandboxed `.pkg`, updates delivered by the App Store, no Sparkle.
|
||||
|
||||
The two builds differ only where the sandbox forces them to. All sandbox-specific code is isolated behind a `MAS_BUILD` compile-time flag so the Developer ID build remains byte-for-byte what it is today.
|
||||
|
||||
## Constraints discovered in the code
|
||||
|
||||
Three sandbox blockers exist in the current code:
|
||||
|
||||
| # | Blocker | Location |
|
||||
|---|---------|----------|
|
||||
| 1 | Sparkle third-party updater (forbidden on MAS) | `MarkdownViewrApp.swift:2,6`; `Info.plist` `SUFeedURL`/`SUPublicEDKey` |
|
||||
| 2 | File watching opens the doc fd directly and re-reads it | `FileWatcher.swift:20`; `ContentView.swift:10` |
|
||||
| 3 | `loadFileURL(..., allowingReadAccessTo: "/")` + relative images live in the doc's folder, which the sandbox does not grant access to | `MarkdownWebView.swift:89` |
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Build structure
|
||||
|
||||
- Keep the single `markdownViewr` application target.
|
||||
- Add build configurations **`Release-MAS`** and **`Debug-MAS`** in `project.yml` (via the `configs` map and per-config `settings`).
|
||||
- `Release-MAS` / `Debug-MAS` set:
|
||||
- `ENABLE_APP_SANDBOX: YES`
|
||||
- `CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements`
|
||||
- `SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD` (appended to existing conditions for that config)
|
||||
- The existing `Release`/`Debug` configs are untouched. Developer ID path is unchanged.
|
||||
- Bundle ID stays `com.dkelkhoff.markdownViewr` for both — Developer ID and MAS builds may share it.
|
||||
|
||||
### 2. Sparkle (Blocker 1)
|
||||
|
||||
- Wrap the `import Sparkle`, the `updaterController` property, and the "Check for Updates…" command in `#if !MAS_BUILD` in `MarkdownViewrApp.swift`.
|
||||
- Sparkle remains a Swift Package dependency (still linked), but no Sparkle runtime calls compile into the MAS binary.
|
||||
- Move the `SUFeedURL` and `SUPublicEDKey` keys out of the shared `Info.plist` so they are absent from the MAS bundle. (Mechanism: a non-MAS-only Info.plist injection via `project.yml` `info.properties` scoped to the non-MAS configs, or a separate plist for MAS. Exact mechanism resolved during implementation; requirement is: MAS bundle must not contain Sparkle feed keys.)
|
||||
- Update `HelpView.swift:54` ("Automatic updates via Sparkle") to not advertise Sparkle in the MAS build.
|
||||
|
||||
### 3. Folder access subsystem (Blockers 2 + 3) — all behind `#if MAS_BUILD`
|
||||
|
||||
New type `FolderAccessManager`:
|
||||
|
||||
- **Input:** a document URL.
|
||||
- **Lookup:** finds a persisted **app-scoped security-scoped bookmark** for the document's parent folder, keyed by folder path (stored in `UserDefaults`).
|
||||
- **Resolve + hold:** resolves the bookmark and holds `startAccessingSecurityScopedResource()` for the document window's lifetime; releases on close.
|
||||
|
||||
First-time-folder flow (no stored bookmark):
|
||||
|
||||
1. Document renders immediately — the markdown text is already available from DocumentGroup, so **text is never gated** on folder access.
|
||||
2. A dismissible banner appears in the document window: *"Allow access to this file's folder to show images and auto-reload on changes."*
|
||||
3. Button presents an `NSOpenPanel` pre-pointed at the document's folder. User confirms.
|
||||
4. App creates and persists an app-scoped security-scoped bookmark for the folder, then re-renders (images now resolve) and starts file-watching.
|
||||
5. That folder never prompts again on subsequent opens.
|
||||
|
||||
This single grant powers both remaining blockers:
|
||||
|
||||
- **File watching (Blocker 2):** `open(path, O_EVTONLY)` and re-reading the doc succeed while folder access is held. `FileWatcher`/`LiveContent` start only after access is acquired in the MAS build; in the non-MAS build they start immediately as today.
|
||||
- **Image reads (Blocker 3):** see below.
|
||||
|
||||
### 4. Image handling (Blocker 3)
|
||||
|
||||
The "load file vs inline" choice collapses under sandbox: `loadFileURL(_:allowingReadAccessTo:)` grants exactly **one** directory subtree, but the preview HTML lives in the app container temp dir while images live in the user's doc folder — two distinct trees. Writing the preview HTML into the user's folder would litter it and require a read-write grant.
|
||||
|
||||
Therefore:
|
||||
|
||||
- **MAS build:** at render time, resolve relative local image references, read the image bytes **through the held folder bookmark**, and inline them as `data:` URLs in the HTML. The preview HTML becomes self-contained, stays in the container temp dir, and `allowingReadAccessTo` only needs the temp dir. Remote (`http`/`https`) images are left as-is and load via the network-client entitlement.
|
||||
- **Non-MAS build:** unchanged — temp file + `allowingReadAccessTo: "/"`, no inlining, `<base href>` resolves relative images.
|
||||
|
||||
Implementation isolates the divergence in `MarkdownWebView.loadContent` (and a render-time image-inlining helper) behind `#if MAS_BUILD`.
|
||||
|
||||
### 5. Entitlements — `markdownViewr/markdownViewr-MAS.entitlements`
|
||||
|
||||
- `com.apple.security.app-sandbox` = true
|
||||
- `com.apple.security.files.user-selected.read-only` = true
|
||||
- `com.apple.security.files.bookmarks.app-scope` = true
|
||||
- `com.apple.security.network.client` = true (remote images)
|
||||
|
||||
### 6. External editors — verify during implementation
|
||||
|
||||
`EditorManager.openFile` (`EditorConfig.swift:73`) launches a user-configured editor via `NSWorkspace.open(_:withApplicationAt:configuration:)` using a stored **path string**. Under sandbox the stored path may not be accessible across launches without its own security-scoped bookmark.
|
||||
|
||||
Plan: treat as a scoped sub-task. During implementation, test whether Launch Services still permits launching an installed editor from a bare path under sandbox. If not, store a per-editor security-scoped bookmark when the user picks the editor and resolve it at launch time. Do not solve preemptively; confirm the actual sandbox behavior first.
|
||||
|
||||
### 7. Release pipeline
|
||||
|
||||
- New `ExportOptions-MAS.plist` with `method: app-store`.
|
||||
- New `just release-mas` recipe: archive with the `Release-MAS` config + Apple Distribution signing, export a `.pkg`, upload via `notarytool`/Transporter.
|
||||
- Existing `just release` (Developer ID → DMG → notarize → GitHub release → appcast → Homebrew) is untouched.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Manual / App Store Connect portal steps that cannot be done from the repo:
|
||||
|
||||
- Apple Developer account / App ID registration, Apple Distribution + Mac Installer Distribution certificates, provisioning profile.
|
||||
- App Store Connect app record, screenshots, description/keywords, App Privacy nutrition label, export-compliance answers.
|
||||
- The actual submission and App Review.
|
||||
|
||||
These will be documented as a checklist but are not implemented here.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Builds:** both `Release` and `Release-MAS` compile and link; MAS binary contains no Sparkle feed keys and no Sparkle runtime symbols are exercised.
|
||||
- **Sandbox runtime (MAS Debug):** open a doc with relative local images → text renders immediately, banner appears, granting folder access shows images and enables live-reload; second open of a doc in the same folder does not prompt.
|
||||
- **Non-MAS regression:** Developer ID build behavior (images, watching, Sparkle update check) is unchanged.
|
||||
- **External editors:** launch a configured editor from the sandboxed build; confirm it works or that the bookmark fallback works.
|
||||
- Existing unit tests (`markdownViewrTests`) continue to pass under both configs.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Themed Scrollbars — Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make the main document scrollbar as visible and theme-consistent as the table-of-contents scrollbar.
|
||||
|
||||
## Design
|
||||
|
||||
Define one set of WebKit scrollbar rules in `Resources/template.html`. The rules apply to the root document scrollbar and nested scroll containers such as the table of contents and raw-source pane. The track uses `--bg`; the thumb is derived from `--text` and `--bg`, with a stronger hover color. Scrolling behavior, dimensions, and macOS scrollbar preferences remain unchanged.
|
||||
|
||||
When the source pane is visible, the rendered document and source are peer panes. `#content` owns rendered-document scrolling; `#raw-source` owns source scrolling; the page itself does not scroll. This places each scrollbar at its pane's right edge. A single `_contentScroller()` helper is the source of truth for rendered scrolling, including scroll preservation, source synchronization, TOC highlighting, heading navigation, find navigation, and Vim keys.
|
||||
|
||||
The TOC remains independently scrollable. When rendered scrolling changes the active heading, the active TOC link is scrolled with `block: 'nearest'`, moving only enough to keep that link visible rather than recentering the TOC on every update.
|
||||
|
||||
## Validation
|
||||
|
||||
An XCTest verifies that the bundled template contains shared track, thumb, hover, and corner rules using theme variables. It also verifies that `#content` owns vertical overflow and that rendered navigation no longer targets `window` scrolling. Build and unit tests guard template packaging and application behavior; a long document with its source pane open verifies scrollbar placement and synchronized scrolling visually.
|
||||
@@ -17,6 +17,32 @@ run: kill build
|
||||
open file: build
|
||||
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
|
||||
|
||||
# Run the test suite
|
||||
test:
|
||||
xcodebuild test -project markdownViewr.xcodeproj -scheme markdownViewr -destination 'platform=macOS'
|
||||
|
||||
# Update the Homebrew cask to match the current release (run after a successful just release)
|
||||
update-cask:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
DMG="/tmp/markdownViewr.dmg"
|
||||
if [[ ! -f "$DMG" ]]; then
|
||||
echo "==> Downloading DMG for v$VERSION..."
|
||||
gh release download "v$VERSION" --repo darinkelkhoff/markdownViewr --pattern "markdownViewr.dmg" --dir /tmp --clobber
|
||||
fi
|
||||
DMG_SHA=$(shasum -a 256 "$DMG" | awk '{print $1}')
|
||||
TAP_DIR="/tmp/markdownViewr-homebrew-tap"
|
||||
rm -rf "$TAP_DIR"
|
||||
git clone git@github.com:darinkelkhoff/homebrew-tap.git "$TAP_DIR"
|
||||
sed -i.bak "s/version \".*\"/version \"$VERSION\"/" "$TAP_DIR/Casks/markdownviewr.rb"
|
||||
sed -i.bak "s/sha256 \".*\"/sha256 \"$DMG_SHA\"/" "$TAP_DIR/Casks/markdownviewr.rb"
|
||||
rm -f "$TAP_DIR/Casks/markdownviewr.rb.bak"
|
||||
git -C "$TAP_DIR" add Casks/markdownviewr.rb
|
||||
git -C "$TAP_DIR" commit -m "markdownViewr: update to v$VERSION"
|
||||
git -C "$TAP_DIR" push origin main
|
||||
echo "Done! brew install --cask darinkelkhoff/tap/markdownviewr"
|
||||
|
||||
# Regenerate the Xcode project from project.yml
|
||||
generate:
|
||||
xcodegen generate
|
||||
@@ -108,5 +134,67 @@ release: kill
|
||||
git add appcast.xml
|
||||
git commit -m "release: update appcast for v$VERSION"
|
||||
git push origin main
|
||||
just update-cask
|
||||
echo ""
|
||||
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
|
||||
echo " brew install --cask darinkelkhoff/tap/markdownviewr"
|
||||
|
||||
# Archive and export a signed App Store .pkg (then upload via Transporter or altool)
|
||||
release-mas: kill
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
VERSION=$(grep 'MARKETING_VERSION' project.yml | head -1 | sed 's/.*"\(.*\)".*/\1/')
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
PATCH=$(echo "$VERSION" | cut -d. -f3)
|
||||
BUILD_NUMBER=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
||||
echo "==> Regenerating Xcode project..."
|
||||
xcodegen generate
|
||||
ARCHIVE="/tmp/markdownViewr-mas.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-mas-export"
|
||||
rm -rf "$ARCHIVE" "$EXPORT"
|
||||
echo "==> Archiving v$VERSION (build $BUILD_NUMBER) for the App Store..."
|
||||
xcodebuild archive \
|
||||
-project markdownViewr.xcodeproj \
|
||||
-scheme markdownViewr-MAS \
|
||||
-configuration Release \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-destination 'generic/platform=macOS' \
|
||||
-allowProvisioningUpdates \
|
||||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" | xcpretty || true
|
||||
echo "==> Exporting App Store package..."
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE" \
|
||||
-exportPath "$EXPORT" \
|
||||
-exportOptionsPlist ExportOptions-MAS.plist \
|
||||
-allowProvisioningUpdates
|
||||
PKG=$(find "$EXPORT" -name '*.pkg' | head -1)
|
||||
echo ""
|
||||
echo "Done! Signed App Store package:"
|
||||
echo " $PKG"
|
||||
echo ""
|
||||
echo "Upload it with: just upload-mas"
|
||||
|
||||
# Key ID is read from the AuthKey_<id>.p8 filename in ~/.appstoreconnect/private_keys/;
|
||||
# issuer id from ~/.appstoreconnect/issuer_id (or the ASC_ISSUER_ID env var).
|
||||
# Validate and upload the exported App Store .pkg to App Store Connect
|
||||
upload-mas:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
KEYS_DIR="$HOME/.appstoreconnect/private_keys"
|
||||
KEY_FILE=$(ls "$KEYS_DIR"/AuthKey_*.p8 2>/dev/null | head -1)
|
||||
[[ -n "$KEY_FILE" ]] || { echo "No AuthKey_*.p8 in $KEYS_DIR"; exit 1; }
|
||||
KEY_ID=$(basename "$KEY_FILE" .p8); KEY_ID=${KEY_ID#AuthKey_}
|
||||
ISSUER_ID="${ASC_ISSUER_ID:-$(cat "$HOME/.appstoreconnect/issuer_id" 2>/dev/null || true)}"
|
||||
[[ -n "$ISSUER_ID" ]] || { echo "Set ASC_ISSUER_ID or write it to ~/.appstoreconnect/issuer_id"; exit 1; }
|
||||
PKG="/tmp/markdownViewr-mas-export/markdownViewr.pkg"
|
||||
[[ -f "$PKG" ]] || { echo "No package at $PKG — run 'just release-mas' first."; exit 1; }
|
||||
echo "==> Validating (key $KEY_ID)..."
|
||||
xcrun altool --validate-app -f "$PKG" -t macos --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
|
||||
echo "==> Uploading..."
|
||||
xcrun altool --upload-app -f "$PKG" -t macos --apiKey "$KEY_ID" --apiIssuer "$ISSUER_ID"
|
||||
echo "Done! Watch App Store Connect -> your app -> Build for processing (a few minutes)."
|
||||
|
||||
# Show App Store Connect build + version processing state (uses the same API key)
|
||||
asc-builds:
|
||||
python3 scripts/asc-builds.py
|
||||
+125
-13
@@ -2,26 +2,53 @@ import SwiftUI
|
||||
|
||||
struct CSSHelpView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("CSS Reference")
|
||||
Text("Custom CSS Guide")
|
||||
.font(.headline)
|
||||
|
||||
Group {
|
||||
Text("Structure")
|
||||
Text("How custom CSS works")
|
||||
.font(.subheadline).bold()
|
||||
Text("Custom CSS is injected after markdownViewr's base styles and theme variables. Use it for small overrides, per-theme polish, or global app-wide styling that the theme editor does not expose.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
codeBlock("""
|
||||
/* Theme variables are available everywhere */
|
||||
h1 { color: var(--h1); }
|
||||
a:hover { color: var(--link); }
|
||||
""")
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Document structure")
|
||||
.font(.subheadline).bold()
|
||||
codeBlock("""
|
||||
body
|
||||
nav#toc
|
||||
a
|
||||
a.active
|
||||
a.toc-h1 .. a.toc-h6
|
||||
div#toc-resizer
|
||||
div#content
|
||||
div.frontmatter > table > tr
|
||||
> td.fm-key, td.fm-value
|
||||
h1, h2, h3, h4, h5, h6
|
||||
> span.collapse-arrow
|
||||
h1.collapsed, h2.collapsed, ...
|
||||
p, ul, ol, blockquote, hr
|
||||
div#content-inner
|
||||
div.frontmatter > table > tr
|
||||
> td.fm-key, td.fm-value
|
||||
h1, h2, h3, h4, h5, h6
|
||||
> span.collapse-arrow
|
||||
h1.collapsed, h2.collapsed, ...
|
||||
p, ul, ol, blockquote, hr
|
||||
pre > code
|
||||
table > thead/tbody > tr > th/td
|
||||
img
|
||||
div#raw-resizer
|
||||
div#raw-source
|
||||
pre > code
|
||||
table > thead/tbody > tr > th/td
|
||||
img
|
||||
.raw-h, .raw-ellipsis
|
||||
""")
|
||||
Text("#content, #toc, and #raw-source are sibling scrollable panes. #content-inner is the centered rendered document body and is the safer target for width, padding, and document-background changes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
@@ -32,6 +59,64 @@ struct CSSHelpView: View {
|
||||
codeBlock("pre code.language-swift { ... }")
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Source Pane")
|
||||
.font(.subheadline).bold()
|
||||
Text("The Markdown source view (toolbar toggle):")
|
||||
.font(.caption)
|
||||
codeBlock("""
|
||||
#raw-source (the whole pane)
|
||||
#raw-source pre (the source block)
|
||||
#raw-source code (the source text)
|
||||
#raw-source .raw-h (heading lines)
|
||||
#raw-source .raw-ellipsis (collapsed marker)
|
||||
#toc-resizer, #raw-resizer (dividers)
|
||||
""")
|
||||
Text("Text color/background live on pre and code — style those, not just #raw-source.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Table of Contents")
|
||||
.font(.subheadline).bold()
|
||||
codeBlock("""
|
||||
#toc (the panel)
|
||||
#toc a (entries)
|
||||
#toc a.active (current entry)
|
||||
#toc .toc-h1 .. .toc-h6 (by level)
|
||||
#toc.bullets .toc-h2::before (markers)
|
||||
""")
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("Scrollbars")
|
||||
.font(.subheadline).bold()
|
||||
Text("markdownViewr uses WebKit scrollbar pseudo-elements. Style all panes globally, or scope selectors to one scrollable area.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
codeBlock("""
|
||||
/* All scrollbars */
|
||||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb { background: var(--text); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--link); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg); }
|
||||
|
||||
/* Rendered document only */
|
||||
#content::-webkit-scrollbar-thumb { ... }
|
||||
|
||||
/* Markdown source pane only */
|
||||
#raw-source::-webkit-scrollbar-thumb { ... }
|
||||
|
||||
/* Table of contents only */
|
||||
#toc::-webkit-scrollbar-thumb { ... }
|
||||
""")
|
||||
Text("Use #content for the rendered document scrollbar, not #content-inner. The inner element does not scroll.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Group {
|
||||
Text("CSS Variables")
|
||||
.font(.subheadline).bold()
|
||||
@@ -56,20 +141,47 @@ struct CSSHelpView: View {
|
||||
Text("Examples")
|
||||
.font(.subheadline).bold()
|
||||
codeBlock("""
|
||||
/* Remove heading underlines */
|
||||
h1, h2 { border-bottom: none; }
|
||||
/* Add heading underlines */
|
||||
h1, h2 {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Custom link hover */
|
||||
a:hover { color: var(--h1); }
|
||||
|
||||
/* Rounded images */
|
||||
img { border-radius: 12px; }
|
||||
|
||||
/* Recolor the Markdown source pane */
|
||||
#raw-source, #raw-source pre,
|
||||
#raw-source code { background: var(--code-bg); }
|
||||
#raw-source code { color: var(--code-text); }
|
||||
#raw-source .raw-h { color: var(--h2); }
|
||||
|
||||
/* Style the table of contents */
|
||||
#toc a.active { color: var(--h1); }
|
||||
#toc { background: var(--code-bg); }
|
||||
|
||||
/* Soft themed scrollbar thumbs */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
border: 3px solid var(--bg);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Make only the source scrollbar louder */
|
||||
#raw-source::-webkit-scrollbar-thumb {
|
||||
background: var(--link);
|
||||
}
|
||||
""")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.textSelection(.enabled)
|
||||
.padding(16)
|
||||
.frame(width: 320)
|
||||
}
|
||||
.frame(width: 460, height: 640)
|
||||
}
|
||||
|
||||
private func codeBlock(_ code: String) -> some View {
|
||||
|
||||
@@ -28,20 +28,41 @@ struct ContentView: View {
|
||||
@State private var missingEditorName = ""
|
||||
@StateObject private var liveContent = LiveContent()
|
||||
@StateObject private var findBar = FindBarController()
|
||||
@StateObject private var folderAccess = FolderAccessManager()
|
||||
@State private var renderedHTML = ""
|
||||
@State private var tocVisible = false
|
||||
@State private var tocDepth = 3
|
||||
@State private var docNeedsImageAccess = false
|
||||
@AppStorage("tocVisible") private var tocVisible = false
|
||||
@AppStorage("tocDepth") private var tocDepth = 3
|
||||
@AppStorage("tocWidth") private var tocWidth: Double = 220
|
||||
@AppStorage("tocWrap") private var tocWrap = false
|
||||
@AppStorage("tocBullets") private var tocBullets = false
|
||||
@AppStorage("rawVisible") private var rawVisible = false
|
||||
@AppStorage("rawWidth") private var rawWidth: Double = 400
|
||||
|
||||
private var currentMarkdown: String {
|
||||
liveContent.rawMarkdown.isEmpty ? document.rawMarkdown : liveContent.rawMarkdown
|
||||
}
|
||||
|
||||
private func rerender() {
|
||||
renderedHTML = MarkdownDocument.convertToHTML(
|
||||
let html = MarkdownDocument.convertToHTML(
|
||||
currentMarkdown,
|
||||
frontmatterMode: themeManager.frontmatterMode,
|
||||
extensions: themeManager.markdownExtensions
|
||||
)
|
||||
#if MAS_BUILD
|
||||
if folderAccess.hasAccess {
|
||||
renderedHTML = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
folderAccess.imageData(forRelativePath: path)
|
||||
}
|
||||
docNeedsImageAccess = false
|
||||
} else {
|
||||
renderedHTML = html
|
||||
// Only prompt for folder access when the document actually has local images.
|
||||
docNeedsImageAccess = ImageInliner.containsLocalImage(in: html)
|
||||
}
|
||||
#else
|
||||
renderedHTML = html
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -49,13 +70,28 @@ struct ContentView: View {
|
||||
if findBar.isVisible {
|
||||
FindBarView(findBar: findBar)
|
||||
}
|
||||
if docNeedsImageAccess {
|
||||
FolderAccessBanner {
|
||||
folderAccess.requestAccess { granted in
|
||||
if granted { rerender() }
|
||||
}
|
||||
}
|
||||
}
|
||||
MarkdownWebView(
|
||||
html: renderedHTML,
|
||||
themeCSS: themeManager.generateCSS(for: themeManager.activeTheme),
|
||||
fileURL: fileURL,
|
||||
findBar: findBar,
|
||||
tocVisible: tocVisible,
|
||||
tocDepth: tocDepth
|
||||
tocDepth: tocDepth,
|
||||
tocWidth: tocWidth,
|
||||
tocWrap: tocWrap,
|
||||
tocBullets: tocBullets,
|
||||
rawSource: currentMarkdown,
|
||||
rawVisible: rawVisible,
|
||||
rawWidth: rawWidth,
|
||||
onTocWidthChange: { tocWidth = $0 },
|
||||
onRawWidthChange: { rawWidth = $0 }
|
||||
)
|
||||
}
|
||||
.onReceive(themeManager.$frontmatterMode) { _ in
|
||||
@@ -81,10 +117,13 @@ struct ContentView: View {
|
||||
})
|
||||
.onAppear {
|
||||
liveContent.rawMarkdown = document.rawMarkdown
|
||||
rerender()
|
||||
if let fileURL {
|
||||
// Watching the opened document does not require the folder grant —
|
||||
// the document itself is accessible via the document architecture.
|
||||
folderAccess.prepare(for: fileURL)
|
||||
liveContent.startWatching(fileURL: fileURL)
|
||||
}
|
||||
rerender()
|
||||
}
|
||||
.onDisappear {
|
||||
liveContent.fileWatcher = nil
|
||||
@@ -92,6 +131,7 @@ struct ContentView: View {
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .automatic) {
|
||||
tocControls
|
||||
rawButton
|
||||
zoomControls
|
||||
palettePicker
|
||||
editorButton
|
||||
@@ -130,6 +170,15 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var rawButton: some View {
|
||||
Button {
|
||||
rawVisible.toggle()
|
||||
} label: {
|
||||
Image(systemName: rawVisible ? "doc.plaintext.fill" : "doc.plaintext")
|
||||
}
|
||||
.help(rawVisible ? "Hide Markdown Source" : "Show Markdown Source")
|
||||
}
|
||||
|
||||
private var zoomControls: some View {
|
||||
HStack(spacing: 2) {
|
||||
Button {
|
||||
@@ -232,6 +281,23 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct FolderAccessBanner: View {
|
||||
let onGrant: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
Text("Allow access to this file's folder to show images and auto-reload on changes.")
|
||||
.font(.callout)
|
||||
Spacer()
|
||||
Button("Allow Access…", action: onGrant)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.yellow.opacity(0.18))
|
||||
}
|
||||
}
|
||||
|
||||
struct FindBarView: View {
|
||||
@ObservedObject var findBar: FindBarController
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// Owns access to the directory containing the currently-open document. In the MAS
|
||||
/// (sandboxed) build it resolves or requests a persisted app-scoped security-scoped
|
||||
/// bookmark for that folder and holds access for the manager's lifetime. In the
|
||||
/// Developer ID build it is an inert pass-through that always reports access.
|
||||
final class FolderAccessManager: ObservableObject {
|
||||
/// True when the document's folder is accessible (always true in the non-MAS build).
|
||||
@Published private(set) var hasAccess: Bool = false
|
||||
|
||||
private var folderURL: URL?
|
||||
private var accessedURL: URL?
|
||||
|
||||
deinit {
|
||||
accessedURL?.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
/// Resolves any stored access for the document's folder. Call once when a document opens.
|
||||
func prepare(for fileURL: URL) {
|
||||
let folder = fileURL.deletingLastPathComponent()
|
||||
folderURL = folder
|
||||
#if MAS_BUILD
|
||||
guard let data = UserDefaults.standard.data(forKey: Self.key(for: folder)) else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
var stale = false
|
||||
guard let url = try? URL(
|
||||
resolvingBookmarkData: data,
|
||||
options: [.withSecurityScope],
|
||||
relativeTo: nil,
|
||||
bookmarkDataIsStale: &stale
|
||||
), url.startAccessingSecurityScopedResource() else {
|
||||
hasAccess = false
|
||||
return
|
||||
}
|
||||
accessedURL = url
|
||||
hasAccess = true
|
||||
if stale { storeBookmark(for: url) }
|
||||
#else
|
||||
hasAccess = true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Presents a folder picker (MAS only) to obtain and persist access. Calls back on the main queue.
|
||||
func requestAccess(completion: @escaping (Bool) -> Void) {
|
||||
#if MAS_BUILD
|
||||
guard let folder = folderURL else { completion(false); return }
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.directoryURL = folder
|
||||
panel.prompt = "Allow Access"
|
||||
panel.message = "Allow markdownViewr to access this folder to show images and auto-reload changes."
|
||||
panel.begin { response in
|
||||
guard response == .OK, let url = panel.url,
|
||||
url.startAccessingSecurityScopedResource() else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
self.accessedURL?.stopAccessingSecurityScopedResource()
|
||||
self.accessedURL = url
|
||||
self.storeBookmark(for: url)
|
||||
self.hasAccess = true
|
||||
completion(true)
|
||||
}
|
||||
#else
|
||||
completion(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reads bytes for a path relative to the document's folder, or nil if unavailable.
|
||||
/// Used by the MAS render path to inline images. Returns nil when access is not held.
|
||||
func imageData(forRelativePath path: String) -> Data? {
|
||||
guard hasAccess, let base = accessedURL ?? folderURL else { return nil }
|
||||
let url = base.appendingPathComponent(path)
|
||||
return try? Data(contentsOf: url)
|
||||
}
|
||||
|
||||
#if MAS_BUILD
|
||||
private func storeBookmark(for url: URL) {
|
||||
guard let data = try? url.bookmarkData(
|
||||
options: [.withSecurityScope],
|
||||
includingResourceValuesForKeys: nil,
|
||||
relativeTo: nil
|
||||
) else { return }
|
||||
// Key by the document's folder so the same document re-grants silently next launch.
|
||||
if let folder = folderURL {
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: folder))
|
||||
}
|
||||
UserDefaults.standard.set(data, forKey: Self.key(for: url))
|
||||
}
|
||||
|
||||
private static func key(for folder: URL) -> String {
|
||||
"folderBookmark:\(folder.standardizedFileURL.path)"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import WebKit
|
||||
private enum HelpTopic: String, CaseIterable, Identifiable {
|
||||
case overview = "Overview"
|
||||
case openingFiles = "Opening Files"
|
||||
case headings = "Headings"
|
||||
case tableOfContents = "Table of Contents"
|
||||
case sourceView = "Source View"
|
||||
case frontmatter = "Frontmatter"
|
||||
case extensions = "Markdown Extensions"
|
||||
case themes = "Themes"
|
||||
case customCSS = "Custom CSS"
|
||||
case editors = "External Editors"
|
||||
case shortcuts = "Keyboard Shortcuts"
|
||||
|
||||
@@ -31,7 +35,7 @@ struct HelpView: View {
|
||||
private func helpMarkdown(for topic: HelpTopic) -> String {
|
||||
switch topic {
|
||||
case .overview:
|
||||
return """
|
||||
var overview = """
|
||||
# Overview
|
||||
|
||||
markdownViewr is a fast, view-only markdown file viewer for macOS. It renders \
|
||||
@@ -47,11 +51,18 @@ struct HelpView: View {
|
||||
- Optional extensions: highlight, superscript, subscript, underline
|
||||
- YAML frontmatter support: hide it, show it as a metadata table, or render it as Markdown
|
||||
- Fully customizable themes with a built-in theme editor and custom CSS
|
||||
- Resizable table of contents with depth markers
|
||||
- Side-by-side Markdown source view, synced to the rendered document
|
||||
- Collapsible outline: click a heading to collapse its section, ⌥-click to anchor to it
|
||||
- Max content width setting to keep line lengths readable on wide monitors
|
||||
- Vim-style keyboard navigation
|
||||
- Find in document with match count
|
||||
- One-click open in any configured external editor
|
||||
- Automatic updates via Sparkle
|
||||
"""
|
||||
#if !MAS_BUILD
|
||||
overview += "\n- Automatic updates via Sparkle"
|
||||
#endif
|
||||
return overview
|
||||
|
||||
case .openingFiles:
|
||||
return """
|
||||
@@ -81,6 +92,72 @@ struct HelpView: View {
|
||||
position is preserved across reloads.
|
||||
"""
|
||||
|
||||
case .headings:
|
||||
return """
|
||||
# Headings
|
||||
|
||||
Headings in the rendered document are interactive:
|
||||
|
||||
- **Click** a heading to collapse or expand its section. Collapsing also \
|
||||
collapses the matching section in the source view (if open), leaving a ⋯ \
|
||||
placeholder.
|
||||
- **⌥-click** (Option-click) a heading to anchor it to the top of the view; \
|
||||
the table of contents and source view follow along.
|
||||
|
||||
You can also jump between headings with `]]` / `[[` (see Keyboard Shortcuts), \
|
||||
or click an entry in the table of contents.
|
||||
"""
|
||||
|
||||
case .tableOfContents:
|
||||
return """
|
||||
# Table of Contents
|
||||
|
||||
Toggle the table of contents with the list button in the toolbar. It lists \
|
||||
the document's headings and highlights the section you're currently reading. \
|
||||
Click an entry to jump to that heading.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Show / hide** — the list-bullet button in the toolbar.
|
||||
- **Depth** — the H1–H6 picker in the toolbar controls how many heading levels to include.
|
||||
- **Resize** — drag the divider on the panel's right edge. The width is \
|
||||
remembered across documents and launches.
|
||||
|
||||
## Display options (Settings > General)
|
||||
|
||||
- **Wrap long entries** — off by default; long headings are truncated with an \
|
||||
ellipsis (hover an entry to see its full text).
|
||||
- **Depth markers** — prefix each entry with a shape that varies by heading \
|
||||
level (●, ○, ◆, ◇, ■, □) so the outline depth is easy to scan.
|
||||
"""
|
||||
|
||||
case .sourceView:
|
||||
return """
|
||||
# Source View
|
||||
|
||||
Toggle the raw Markdown source with the document button in the toolbar to show \
|
||||
it in a pane beside the rendered view.
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Resize** — drag the divider on the pane's left edge; the width is remembered.
|
||||
- **Find** — ⌘F searches both panes at once, and Next / Previous move the \
|
||||
rendered and source views together to the same match.
|
||||
- **Scroll-sync** — scrolling either pane keeps the other aligned to the same \
|
||||
heading.
|
||||
- **Collapsing** — collapsing a section in the rendered view also collapses it \
|
||||
in the source, leaving a ⋯ placeholder.
|
||||
|
||||
The source pane is read-only — markdownViewr is a viewer, not an editor. To \
|
||||
edit, use **Open in external editor**.
|
||||
|
||||
## Customizing
|
||||
|
||||
The source pane and table of contents can be restyled with custom CSS \
|
||||
(Settings > General > Global Custom CSS, or a theme's Custom CSS). See the CSS \
|
||||
reference — the **?** button — for selectors such as `#raw-source` and `#toc`.
|
||||
"""
|
||||
|
||||
case .frontmatter:
|
||||
return """
|
||||
# Frontmatter
|
||||
@@ -195,10 +272,12 @@ struct HelpView: View {
|
||||
- Reorder themes by dragging
|
||||
- Delete user themes
|
||||
- Restore deleted built-in themes
|
||||
- Import themes from `.json` files (one or more at a time)
|
||||
- Export the selected theme as a `.json` file to share or back up
|
||||
|
||||
## Creating and editing themes
|
||||
|
||||
Click **New Theme** (or **Edit** on an existing user theme) to open the Theme \
|
||||
Click **New** (or **Edit** on an existing user theme) to open the Theme \
|
||||
Editor. Changes are previewed live against a sample document.
|
||||
|
||||
The editor covers:
|
||||
@@ -212,7 +291,9 @@ struct HelpView: View {
|
||||
## Custom CSS
|
||||
|
||||
Custom CSS is the escape hatch for anything the theme editor doesn't expose. \
|
||||
It targets the same HTML the renderer produces, so standard CSS selectors work:
|
||||
It targets the same HTML the renderer produces, so standard CSS selectors work. \
|
||||
Open the CSS Reference from the Custom CSS editor for a deeper selector guide, \
|
||||
including document layout, source pane, table of contents, and scrollbar selectors.
|
||||
|
||||
```css
|
||||
/* Wider line spacing */
|
||||
@@ -223,13 +304,119 @@ struct HelpView: View {
|
||||
|
||||
/* Larger code font */
|
||||
code { font-size: 0.95em; }
|
||||
|
||||
/* Softer scrollbar thumbs */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
}
|
||||
```
|
||||
|
||||
Useful targets include `#content-inner` for the rendered document body, \
|
||||
`#raw-source` for the source pane, `#toc` for the table of contents, and \
|
||||
`#content::-webkit-scrollbar-thumb` / `#raw-source::-webkit-scrollbar-thumb` \
|
||||
for pane-specific scrollbars.
|
||||
|
||||
## Max Content Width
|
||||
|
||||
**Settings > General > Max Content Width** limits how wide the document content \
|
||||
grows. Enable the checkbox and drag the slider to set a maximum width in pixels. \
|
||||
The width scales with zoom so the proportions stay consistent when you zoom in or out.
|
||||
|
||||
This is useful on wide monitors where full-width text lines become hard to read. \
|
||||
A value around 800–1000 px works well for most documents.
|
||||
|
||||
## Theme storage
|
||||
|
||||
User themes are stored as JSON files in \
|
||||
`~/Library/Application Support/markdownViewr/themes/`. Built-in themes are \
|
||||
bundled with the app and cannot be edited (but can be duplicated).
|
||||
bundled with the app and cannot be edited (but can be copied).
|
||||
"""
|
||||
|
||||
case .customCSS:
|
||||
return """
|
||||
# Custom CSS
|
||||
|
||||
Custom CSS is injected after markdownViewr's built-in styles and theme variables, \
|
||||
so it can override the rendered document, table of contents, source pane, and \
|
||||
scrollbars.
|
||||
|
||||
Add global CSS in **Settings > General > Global Custom CSS**, or add per-theme CSS \
|
||||
from the Theme Editor. The CSS Reference button opens a deeper selector guide with \
|
||||
the app's current HTML structure.
|
||||
|
||||
## Custom CSS Reference
|
||||
|
||||
The main panes are siblings:
|
||||
|
||||
```text
|
||||
body
|
||||
nav#toc
|
||||
div#toc-resizer
|
||||
div#content
|
||||
div#content-inner
|
||||
div#raw-resizer
|
||||
div#raw-source
|
||||
pre > code
|
||||
```
|
||||
|
||||
Use `#content-inner` for rendered document body styles such as padding, background, \
|
||||
or prose layout. Use `#content` only when you mean the rendered document scroll pane.
|
||||
|
||||
## Useful selectors
|
||||
|
||||
| Selector | Target |
|
||||
|---|---|
|
||||
| `#content-inner` | Rendered document body |
|
||||
| `h1` ... `h6` | Rendered headings |
|
||||
| `.collapse-arrow` | Heading disclosure marker |
|
||||
| `.frontmatter` | Rendered frontmatter table |
|
||||
| `#toc` | Table of contents pane |
|
||||
| `#toc a.active` | Current table-of-contents entry |
|
||||
| `#raw-source` | Markdown source pane |
|
||||
| `#raw-source pre` | Source text block |
|
||||
| `#raw-source .raw-h` | Source heading lines |
|
||||
|
||||
## Scrollbars
|
||||
|
||||
markdownViewr uses WebKit scrollbar pseudo-elements. You can style every scrollbar, \
|
||||
or scope styles to one pane:
|
||||
|
||||
```css
|
||||
/* All scrollbars */
|
||||
::-webkit-scrollbar { width: 12px; height: 12px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg); }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
border: 3px solid var(--bg);
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--link); }
|
||||
::-webkit-scrollbar-corner { background: var(--bg); }
|
||||
|
||||
/* Pane-specific scrollbar thumbs */
|
||||
#content::-webkit-scrollbar-thumb { background: var(--h1); }
|
||||
#toc::-webkit-scrollbar-thumb { background: var(--h2); }
|
||||
#raw-source::-webkit-scrollbar-thumb { background: var(--link); }
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
```css
|
||||
/* Add heading underlines */
|
||||
h1, h2 {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
|
||||
padding-bottom: 0.25em;
|
||||
}
|
||||
|
||||
/* Recolor the Markdown source pane */
|
||||
#raw-source, #raw-source pre,
|
||||
#raw-source code { background: var(--code-bg); }
|
||||
#raw-source code { color: var(--code-text); }
|
||||
#raw-source .raw-h { color: var(--h2); }
|
||||
|
||||
/* Style the active table-of-contents entry */
|
||||
#toc a.active { color: var(--h1); }
|
||||
```
|
||||
"""
|
||||
|
||||
case .editors:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
/// Rewrites `<img>` tags whose `src` is a local, relative path into self-contained
|
||||
/// `data:` URLs, using a caller-supplied byte resolver. Remote (`http(s)`), `data:`,
|
||||
/// and absolute-path sources are left untouched. Pure and synchronous so it is unit
|
||||
/// testable without any filesystem or sandbox dependency.
|
||||
enum ImageInliner {
|
||||
static func inlineLocalImages(in html: String, resolve: (String) -> Data?) -> String {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: "<img([^>]*?)src=\"([^\"]*)\"([^>]*)>",
|
||||
options: []
|
||||
) else { return html }
|
||||
|
||||
let ns = html as NSString
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
|
||||
guard !matches.isEmpty else { return html }
|
||||
|
||||
var result = ""
|
||||
var last = 0
|
||||
for match in matches {
|
||||
let full = match.range
|
||||
result += ns.substring(with: NSRange(location: last, length: full.location - last))
|
||||
|
||||
let pre = ns.substring(with: match.range(at: 1))
|
||||
let src = ns.substring(with: match.range(at: 2))
|
||||
let post = ns.substring(with: match.range(at: 3))
|
||||
|
||||
let decoded = src.removingPercentEncoding ?? src
|
||||
if isLocalRelative(src), let data = resolve(decoded) {
|
||||
let mime = mimeType(forExtension: (src as NSString).pathExtension)
|
||||
result += "<img\(pre)src=\"data:\(mime);base64,\(data.base64EncodedString())\"\(post)>"
|
||||
} else {
|
||||
result += ns.substring(with: full)
|
||||
}
|
||||
last = full.location + full.length
|
||||
}
|
||||
result += ns.substring(with: NSRange(location: last, length: ns.length - last))
|
||||
return result
|
||||
}
|
||||
|
||||
/// True if the HTML contains at least one `<img>` whose `src` is a local relative path.
|
||||
/// Used to decide whether folder access (and its grant prompt) is actually needed.
|
||||
static func containsLocalImage(in html: String) -> Bool {
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: "<img[^>]*?src=\"([^\"]*)\"[^>]*>",
|
||||
options: []
|
||||
) else { return false }
|
||||
let ns = html as NSString
|
||||
let matches = regex.matches(in: html, range: NSRange(location: 0, length: ns.length))
|
||||
for match in matches where isLocalRelative(ns.substring(with: match.range(at: 1))) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func isLocalRelative(_ src: String) -> Bool {
|
||||
if src.isEmpty { return false }
|
||||
if src.hasPrefix("data:") { return false }
|
||||
if src.contains("://") { return false } // http://, https://, file://
|
||||
if src.hasPrefix("/") { return false } // absolute path — unreadable under sandbox
|
||||
return true
|
||||
}
|
||||
|
||||
static func mimeType(forExtension ext: String) -> String {
|
||||
switch ext.lowercased() {
|
||||
case "png": return "image/png"
|
||||
case "jpg", "jpeg": return "image/jpeg"
|
||||
case "gif": return "image/gif"
|
||||
case "svg": return "image/svg+xml"
|
||||
case "webp": return "image/webp"
|
||||
case "bmp": return "image/bmp"
|
||||
case "tif", "tiff": return "image/tiff"
|
||||
default: return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,7 +320,12 @@ private struct HTMLConverter: MarkupWalker {
|
||||
case let list as UnorderedList:
|
||||
result += "<ul>\n"
|
||||
for item in list.listItems {
|
||||
result += "<li>"
|
||||
if let checkbox = item.checkbox {
|
||||
let checked = checkbox == .checked ? " checked" : ""
|
||||
result += "<li class=\"task-list-item\"><input type=\"checkbox\" disabled\(checked)> "
|
||||
} else {
|
||||
result += "<li>"
|
||||
}
|
||||
for child in item.children {
|
||||
if child is Paragraph {
|
||||
for inline in child.children {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import SwiftUI
|
||||
#if !MAS_BUILD
|
||||
import Sparkle
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct MarkdownViewrApp: App {
|
||||
#if !MAS_BUILD
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
updaterDelegate: nil,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
#endif
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var editorManager = EditorManager()
|
||||
|
||||
@@ -23,12 +27,14 @@ struct MarkdownViewrApp: App {
|
||||
}
|
||||
.defaultSize(width: 700, height: 900)
|
||||
.commands {
|
||||
#if !MAS_BUILD
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates...") {
|
||||
updaterController.checkForUpdates(nil)
|
||||
}
|
||||
.disabled(!updaterController.updater.canCheckForUpdates)
|
||||
}
|
||||
#endif
|
||||
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button("Settings...") {
|
||||
|
||||
@@ -9,6 +9,20 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
var findBar: FindBarController?
|
||||
var tocVisible: Bool = false
|
||||
var tocDepth: Int = 3
|
||||
var tocWidth: Double = 220
|
||||
var tocWrap: Bool = false
|
||||
var tocBullets: Bool = false
|
||||
var rawSource: String = ""
|
||||
var rawVisible: Bool = false
|
||||
var rawWidth: Double = 400
|
||||
var onTocWidthChange: ((Double) -> Void)?
|
||||
var onRawWidthChange: ((Double) -> Void)?
|
||||
|
||||
static func jsTemplateEscape(_ s: String) -> String {
|
||||
s.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "`", with: "\\`")
|
||||
.replacingOccurrences(of: "$", with: "\\$")
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
@@ -16,7 +30,11 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
|
||||
let userContent = WKUserContentController()
|
||||
userContent.add(context.coordinator, name: "vim")
|
||||
userContent.add(context.coordinator, name: "tocWidth")
|
||||
userContent.add(context.coordinator, name: "rawWidth")
|
||||
config.userContentController = userContent
|
||||
context.coordinator.onTocWidthChange = onTocWidthChange
|
||||
context.coordinator.onRawWidthChange = onRawWidthChange
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
@@ -27,6 +45,8 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.onTocWidthChange = onTocWidthChange
|
||||
context.coordinator.onRawWidthChange = onRawWidthChange
|
||||
if context.coordinator.lastHTML != html {
|
||||
let previousHTML = context.coordinator.lastHTML
|
||||
context.coordinator.lastHTML = html
|
||||
@@ -50,6 +70,30 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
context.coordinator.lastTocDepth = tocDepth
|
||||
webView.evaluateJavaScript("setTOCDepth(\(tocDepth))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastTocWidth != tocWidth {
|
||||
context.coordinator.lastTocWidth = tocWidth
|
||||
webView.evaluateJavaScript("setTOCWidth(\(tocWidth))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastTocWrap != tocWrap {
|
||||
context.coordinator.lastTocWrap = tocWrap
|
||||
webView.evaluateJavaScript("setTOCWrap(\(tocWrap))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastTocBullets != tocBullets {
|
||||
context.coordinator.lastTocBullets = tocBullets
|
||||
webView.evaluateJavaScript("setTOCBullets(\(tocBullets))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastRawSource != rawSource {
|
||||
context.coordinator.lastRawSource = rawSource
|
||||
webView.evaluateJavaScript("setRawSource(`\(Self.jsTemplateEscape(rawSource))`)") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastRawWidth != rawWidth {
|
||||
context.coordinator.lastRawWidth = rawWidth
|
||||
webView.evaluateJavaScript("setRawWidth(\(rawWidth))") { _, _ in }
|
||||
}
|
||||
if context.coordinator.lastRawVisible != rawVisible {
|
||||
context.coordinator.lastRawVisible = rawVisible
|
||||
webView.evaluateJavaScript("setRawVisible(\(rawVisible))") { _, _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private static let previewDirectory: URL = {
|
||||
@@ -72,21 +116,32 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
|
||||
context.coordinator.lastHTML = html
|
||||
context.coordinator.lastCSS = themeCSS
|
||||
#if MAS_BUILD
|
||||
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
|
||||
// both unreadable and unnecessary.
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
#else
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "<base href=\"\(fileDir.absoluteString)\">")
|
||||
} else {
|
||||
template = template.replacingOccurrences(of: "{{BASE_TAG}}", with: "")
|
||||
}
|
||||
#endif
|
||||
template = template.replacingOccurrences(of: "{{THEME_CSS}}", with: themeCSS)
|
||||
template = template.replacingOccurrences(of: "{{CONTENT}}", with: html)
|
||||
|
||||
if let fileDir = fileURL?.deletingLastPathComponent() {
|
||||
if fileURL != nil {
|
||||
let tempDir = Self.previewDirectory
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let tempHTML = tempDir.appendingPathComponent(UUID().uuidString + ".html")
|
||||
try? template.write(to: tempHTML, atomically: true, encoding: .utf8)
|
||||
#if MAS_BUILD
|
||||
// Self-contained HTML (images inlined); only the container temp dir is needed.
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: tempDir)
|
||||
#else
|
||||
// Grant read access to "/" so both the temp file and the document's images are accessible
|
||||
webView.loadFileURL(tempHTML, allowingReadAccessTo: URL(fileURLWithPath: "/"))
|
||||
#endif
|
||||
context.coordinator.tempFileURL = tempHTML
|
||||
} else {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
@@ -95,6 +150,12 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
// Queue TOC state to apply after page loads
|
||||
context.coordinator.pendingTocVisible = tocVisible
|
||||
context.coordinator.pendingTocDepth = tocDepth
|
||||
context.coordinator.pendingTocWidth = tocWidth
|
||||
context.coordinator.pendingTocWrap = tocWrap
|
||||
context.coordinator.pendingTocBullets = tocBullets
|
||||
context.coordinator.pendingRawSource = rawSource
|
||||
context.coordinator.pendingRawWidth = rawWidth
|
||||
context.coordinator.pendingRawVisible = rawVisible
|
||||
}
|
||||
|
||||
private func updateContent(in webView: WKWebView) {
|
||||
@@ -128,8 +189,22 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
var lastCSS: String = ""
|
||||
var lastTocVisible: Bool = false
|
||||
var lastTocDepth: Int = 3
|
||||
var lastTocWidth: Double = 220
|
||||
var lastTocWrap: Bool = false
|
||||
var lastTocBullets: Bool = false
|
||||
var lastRawSource: String = ""
|
||||
var lastRawVisible: Bool = false
|
||||
var lastRawWidth: Double = 400
|
||||
var pendingTocVisible: Bool?
|
||||
var pendingTocDepth: Int?
|
||||
var pendingTocWidth: Double?
|
||||
var pendingTocWrap: Bool?
|
||||
var pendingTocBullets: Bool?
|
||||
var pendingRawSource: String?
|
||||
var pendingRawVisible: Bool?
|
||||
var pendingRawWidth: Double?
|
||||
var onTocWidthChange: ((Double) -> Void)?
|
||||
var onRawWidthChange: ((Double) -> Void)?
|
||||
var tempFileURL: URL?
|
||||
private var findBarObservation: Any?
|
||||
private var lastSearchText: String = ""
|
||||
@@ -227,6 +302,22 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
if message.name == "tocWidth" {
|
||||
guard let width = (message.body as? NSNumber)?.doubleValue else { return }
|
||||
lastTocWidth = width
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onTocWidthChange?(width)
|
||||
}
|
||||
return
|
||||
}
|
||||
if message.name == "rawWidth" {
|
||||
guard let width = (message.body as? NSNumber)?.doubleValue else { return }
|
||||
lastRawWidth = width
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onRawWidthChange?(width)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard message.name == "vim", let action = message.body as? String else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -240,6 +331,21 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
if let width = pendingTocWidth {
|
||||
lastTocWidth = width
|
||||
webView.evaluateJavaScript("setTOCWidth(\(width))") { _, _ in }
|
||||
pendingTocWidth = nil
|
||||
}
|
||||
if let wrap = pendingTocWrap {
|
||||
lastTocWrap = wrap
|
||||
webView.evaluateJavaScript("setTOCWrap(\(wrap))") { _, _ in }
|
||||
pendingTocWrap = nil
|
||||
}
|
||||
if let bullets = pendingTocBullets {
|
||||
lastTocBullets = bullets
|
||||
webView.evaluateJavaScript("setTOCBullets(\(bullets))") { _, _ in }
|
||||
pendingTocBullets = nil
|
||||
}
|
||||
if let visible = pendingTocVisible {
|
||||
lastTocVisible = visible
|
||||
webView.evaluateJavaScript("setTOCVisible(\(visible))") { _, _ in }
|
||||
@@ -250,6 +356,23 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
webView.evaluateJavaScript("setTOCDepth(\(depth))") { _, _ in }
|
||||
pendingTocDepth = nil
|
||||
}
|
||||
if let source = pendingRawSource {
|
||||
lastRawSource = source
|
||||
webView.evaluateJavaScript("setRawSource(`\(MarkdownWebView.jsTemplateEscape(source))`)") { _, _ in }
|
||||
pendingRawSource = nil
|
||||
}
|
||||
if let width = pendingRawWidth {
|
||||
lastRawWidth = width
|
||||
webView.evaluateJavaScript("setRawWidth(\(width))") { _, _ in }
|
||||
pendingRawWidth = nil
|
||||
}
|
||||
if let visible = pendingRawVisible {
|
||||
lastRawVisible = visible
|
||||
webView.evaluateJavaScript("setRawVisible(\(visible))") { _, _ in }
|
||||
pendingRawVisible = nil
|
||||
}
|
||||
// Initial TOC state is set; re-enable transitions so toggles animate.
|
||||
webView.evaluateJavaScript("enableTOCTransitions()") { _, _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,32 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--text) 30%, var(--bg));
|
||||
border: 3px solid var(--bg);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--text) 45%, var(--bg));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -25,27 +49,104 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#toc {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
flex: 0 0 auto;
|
||||
width: 0;
|
||||
max-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 24px 12px 24px 16px;
|
||||
border-right: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
|
||||
padding: 24px 0;
|
||||
font-size: 1em;
|
||||
display: none;
|
||||
opacity: 0;
|
||||
transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#toc.visible {
|
||||
padding: 24px 12px 24px 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#toc.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.resizer {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 6px;
|
||||
height: 100vh;
|
||||
margin-left: -3px;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizer.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.resizer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
|
||||
.resizer:hover::before,
|
||||
.resizer.dragging::before {
|
||||
width: 2px;
|
||||
background: color-mix(in srgb, var(--link) 45%, transparent);
|
||||
}
|
||||
|
||||
#raw-source {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
width: 0;
|
||||
max-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
transition: width 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
#raw-source.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#raw-source.no-transition {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
#raw-source pre {
|
||||
margin: 0;
|
||||
padding: 32px 20px;
|
||||
font-family: var(--code-font);
|
||||
font-size: var(--code-font-size);
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
tab-size: 4;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#raw-source .raw-ellipsis {
|
||||
display: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
#toc ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -64,8 +165,44 @@ body {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#toc.wrap a {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
#toc.bullets a::before {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: 1.8em;
|
||||
margin-right: 1px;
|
||||
font-size: 0.5em;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
top: -0.15em;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
-webkit-text-stroke: 0.4px currentColor;
|
||||
}
|
||||
|
||||
/* When wrapping is on, hang-indent wrapped lines so text aligns past the marker. */
|
||||
#toc.wrap.bullets a {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
#toc.bullets .toc-h1::before { content: "\25CF"; } /* ● filled circle */
|
||||
#toc.bullets .toc-h2::before { content: "\25CB"; } /* ○ hollow circle */
|
||||
#toc.bullets .toc-h3::before { content: "\25C6"; } /* ◆ filled diamond */
|
||||
#toc.bullets .toc-h4::before { content: "\25C7"; } /* ◇ hollow diamond */
|
||||
#toc.bullets .toc-h5::before { content: "\25A0"; } /* ■ filled square */
|
||||
#toc.bullets .toc-h6::before { content: "\25A1"; } /* □ hollow square */
|
||||
|
||||
#toc a:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
@@ -86,7 +223,12 @@ body {
|
||||
#content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#content-inner {
|
||||
padding: 32px 48px;
|
||||
}
|
||||
|
||||
@@ -186,6 +328,15 @@ li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
li.task-list-item {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li.task-list-item > input[type="checkbox"] {
|
||||
margin: 0 0.4em 0.15em -1.4em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
li > ul, li > ol {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
@@ -263,34 +414,63 @@ mark {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav id="toc"></nav>
|
||||
<nav id="toc" class="no-transition"></nav>
|
||||
<div id="toc-resizer" class="resizer"></div>
|
||||
<div id="content">
|
||||
<div id="content-inner">
|
||||
{{CONTENT}}
|
||||
</div>
|
||||
</div>
|
||||
<div id="raw-resizer" class="resizer"></div>
|
||||
<div id="raw-source" class="no-transition"><pre><code></code></pre></div>
|
||||
<script>
|
||||
function _contentScroller() {
|
||||
return document.getElementById('content');
|
||||
}
|
||||
|
||||
function _contentContainer() {
|
||||
return document.getElementById('content-inner');
|
||||
}
|
||||
|
||||
function updateThemeCSS(css) {
|
||||
document.getElementById('theme-css').textContent = css;
|
||||
}
|
||||
|
||||
function updateContent(html) {
|
||||
var scrollRatio = document.documentElement.scrollTop /
|
||||
Math.max(1, document.documentElement.scrollHeight - document.documentElement.clientHeight);
|
||||
document.getElementById('content').innerHTML = html;
|
||||
var newMax = document.documentElement.scrollHeight - document.documentElement.clientHeight;
|
||||
document.documentElement.scrollTop = scrollRatio * newMax;
|
||||
var content = _contentScroller();
|
||||
var scrollRatio = content.scrollTop /
|
||||
Math.max(1, content.scrollHeight - content.clientHeight);
|
||||
_contentContainer().innerHTML = html;
|
||||
var newMax = content.scrollHeight - content.clientHeight;
|
||||
content.scrollTop = scrollRatio * newMax;
|
||||
setupCollapsibleHeadings();
|
||||
if (_tocVisible) buildTOC();
|
||||
_rebuildSyncAnchors();
|
||||
}
|
||||
|
||||
var _findMatches = [];
|
||||
var _findPairs = [];
|
||||
var _findIndex = -1;
|
||||
var _allFindMarks = [];
|
||||
|
||||
function findAll(query) {
|
||||
clearFind();
|
||||
if (!query) return JSON.stringify({current: 0, total: 0});
|
||||
function _findStatus() {
|
||||
return JSON.stringify({ current: _findIndex + 1, total: _findPairs.length });
|
||||
}
|
||||
|
||||
var content = document.getElementById('content');
|
||||
var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, null);
|
||||
function _styleMark(mark, active) {
|
||||
if (active) {
|
||||
mark.style.background = 'color-mix(in srgb, var(--text) 40%, transparent)';
|
||||
mark.style.outline = '2px solid color-mix(in srgb, var(--link) 60%, transparent)';
|
||||
} else {
|
||||
mark.style.background = 'color-mix(in srgb, var(--text) 25%, transparent)';
|
||||
mark.style.outline = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap every occurrence of query within root in a <mark>; returns the marks in order.
|
||||
function _markMatchesIn(root, query) {
|
||||
var marks = [];
|
||||
if (!root) return marks;
|
||||
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||||
var textNodes = [];
|
||||
var node;
|
||||
while (node = walker.nextNode()) textNodes.push(node);
|
||||
@@ -298,8 +478,7 @@ function findAll(query) {
|
||||
var lowerQuery = query.toLowerCase();
|
||||
for (var i = 0; i < textNodes.length; i++) {
|
||||
var n = textNodes[i];
|
||||
var text = n.textContent;
|
||||
var lower = text.toLowerCase();
|
||||
var lower = n.textContent.toLowerCase();
|
||||
var idx = 0;
|
||||
while ((idx = lower.indexOf(lowerQuery, idx)) !== -1) {
|
||||
var range = document.createRange();
|
||||
@@ -307,66 +486,100 @@ function findAll(query) {
|
||||
range.setEnd(n, idx + query.length);
|
||||
var mark = document.createElement('mark');
|
||||
mark.className = 'find-match';
|
||||
mark.style.background = 'color-mix(in srgb, var(--text) 25%, transparent)';
|
||||
mark.style.color = 'inherit';
|
||||
mark.style.borderRadius = '2px';
|
||||
range.surroundContents(mark);
|
||||
_findMatches.push(mark);
|
||||
// After surroundContents, the text node is split; re-walk from mark's next sibling
|
||||
_styleMark(mark, false);
|
||||
marks.push(mark);
|
||||
_allFindMarks.push(mark);
|
||||
n = mark.nextSibling;
|
||||
if (!n) break;
|
||||
lower = n.textContent.toLowerCase();
|
||||
idx = 0;
|
||||
}
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
if (_findMatches.length > 0) {
|
||||
function findAll(query) {
|
||||
clearFind();
|
||||
if (!query) return _findStatus();
|
||||
|
||||
var contentMarks = _markMatchesIn(document.getElementById('content'), query);
|
||||
var rawMarks = _rawVisible ? _markMatchesIn(document.getElementById('raw-source'), query) : [];
|
||||
|
||||
// Pair the k-th rendered hit with the k-th source hit so next/prev moves both panes.
|
||||
var n = Math.max(contentMarks.length, rawMarks.length);
|
||||
for (var k = 0; k < n; k++) {
|
||||
_findPairs.push({ c: contentMarks[k] || null, r: rawMarks[k] || null });
|
||||
}
|
||||
|
||||
if (_findPairs.length > 0) {
|
||||
_findIndex = 0;
|
||||
highlightCurrent();
|
||||
}
|
||||
return JSON.stringify({current: _findIndex + 1, total: _findMatches.length});
|
||||
return _findStatus();
|
||||
}
|
||||
|
||||
function findNext() {
|
||||
if (_findMatches.length === 0) return JSON.stringify({current: 0, total: 0});
|
||||
if (_findPairs.length === 0) return _findStatus();
|
||||
unhighlightCurrent();
|
||||
_findIndex = (_findIndex + 1) % _findMatches.length;
|
||||
_findIndex = (_findIndex + 1) % _findPairs.length;
|
||||
highlightCurrent();
|
||||
return JSON.stringify({current: _findIndex + 1, total: _findMatches.length});
|
||||
return _findStatus();
|
||||
}
|
||||
|
||||
function findPrev() {
|
||||
if (_findMatches.length === 0) return JSON.stringify({current: 0, total: 0});
|
||||
if (_findPairs.length === 0) return _findStatus();
|
||||
unhighlightCurrent();
|
||||
_findIndex = (_findIndex - 1 + _findMatches.length) % _findMatches.length;
|
||||
_findIndex = (_findIndex - 1 + _findPairs.length) % _findPairs.length;
|
||||
highlightCurrent();
|
||||
return JSON.stringify({current: _findIndex + 1, total: _findMatches.length});
|
||||
return _findStatus();
|
||||
}
|
||||
|
||||
function highlightCurrent() {
|
||||
if (_findIndex >= 0 && _findIndex < _findMatches.length) {
|
||||
_findMatches[_findIndex].style.background = 'color-mix(in srgb, var(--text) 40%, transparent)';
|
||||
_findMatches[_findIndex].style.outline = '2px solid color-mix(in srgb, var(--link) 60%, transparent)';
|
||||
_findMatches[_findIndex].scrollIntoView({block: 'center', behavior: 'smooth'});
|
||||
var pair = _findPairs[_findIndex];
|
||||
if (!pair) return;
|
||||
// Pause scroll-sync so find's explicit dual-scroll isn't overridden.
|
||||
_findScrolling = true;
|
||||
clearTimeout(_findScrollTimer);
|
||||
_findScrollTimer = setTimeout(function() { _findScrolling = false; }, 600);
|
||||
if (pair.c) {
|
||||
_styleMark(pair.c, true);
|
||||
pair.c.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
if (pair.r) {
|
||||
_styleMark(pair.r, true);
|
||||
_scrollRawCenter(pair.r);
|
||||
}
|
||||
}
|
||||
|
||||
function unhighlightCurrent() {
|
||||
if (_findIndex >= 0 && _findIndex < _findMatches.length) {
|
||||
_findMatches[_findIndex].style.background = 'color-mix(in srgb, var(--text) 25%, transparent)';
|
||||
_findMatches[_findIndex].style.outline = 'none';
|
||||
}
|
||||
var pair = _findPairs[_findIndex];
|
||||
if (!pair) return;
|
||||
if (pair.c) _styleMark(pair.c, false);
|
||||
if (pair.r) _styleMark(pair.r, false);
|
||||
}
|
||||
|
||||
// Scroll only the raw pane (not the window) to center a mark within it.
|
||||
function _scrollRawCenter(mark) {
|
||||
var raw = document.getElementById('raw-source');
|
||||
if (!raw) return;
|
||||
var top = mark.getBoundingClientRect().top - raw.getBoundingClientRect().top + raw.scrollTop;
|
||||
raw.scrollTo({ top: top - raw.clientHeight / 2, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function clearFind() {
|
||||
for (var i = 0; i < _findMatches.length; i++) {
|
||||
var mark = _findMatches[i];
|
||||
for (var i = 0; i < _allFindMarks.length; i++) {
|
||||
var mark = _allFindMarks[i];
|
||||
var parent = mark.parentNode;
|
||||
if (!parent) continue;
|
||||
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
|
||||
parent.removeChild(mark);
|
||||
parent.normalize();
|
||||
}
|
||||
_findMatches = [];
|
||||
_allFindMarks = [];
|
||||
_findPairs = [];
|
||||
_findIndex = -1;
|
||||
}
|
||||
|
||||
@@ -391,6 +604,11 @@ function setupCollapsibleHeadings() {
|
||||
|
||||
function toggleCollapse(e) {
|
||||
var h = e.currentTarget;
|
||||
if (e.altKey) {
|
||||
// Option-click: anchor/jump to this header (both panes follow via scroll-sync).
|
||||
h.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
var level = headingLevel(h);
|
||||
var collapsed = h.classList.toggle('collapsed');
|
||||
var sibling = h.nextElementSibling;
|
||||
@@ -424,10 +642,26 @@ function toggleCollapse(e) {
|
||||
}
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
applyRawCollapse();
|
||||
}
|
||||
|
||||
var _tocMaxDepth = 3;
|
||||
var _tocVisible = false;
|
||||
var _tocWidth = 220;
|
||||
var _tocWrap = false;
|
||||
var _tocBullets = false;
|
||||
var _rawVisible = false;
|
||||
var _rawWidth = 400;
|
||||
var _syncContentHeadings = [];
|
||||
var _syncRawHeadings = [];
|
||||
var _suppressContentSync = false;
|
||||
var _suppressRawSync = false;
|
||||
var _supContentTimer = null;
|
||||
var _supRawTimer = null;
|
||||
var _findScrolling = false;
|
||||
var _findScrollTimer = null;
|
||||
var _syncRafC = null;
|
||||
var _syncRafR = null;
|
||||
|
||||
function buildTOC() {
|
||||
var toc = document.getElementById('toc');
|
||||
@@ -471,45 +705,361 @@ function buildTOC() {
|
||||
function setTOCVisible(visible) {
|
||||
_tocVisible = visible;
|
||||
var toc = document.getElementById('toc');
|
||||
var resizer = document.getElementById('toc-resizer');
|
||||
if (visible) {
|
||||
toc.classList.add('visible');
|
||||
buildTOC();
|
||||
setupTOCScrollHighlight();
|
||||
toc.classList.add('visible');
|
||||
toc.style.width = _tocWidth + 'px';
|
||||
if (resizer) resizer.classList.add('visible');
|
||||
} else {
|
||||
toc.classList.remove('visible');
|
||||
toc.style.width = '0px';
|
||||
// Keep the divider through the collapse animation, then hide it.
|
||||
if (resizer) {
|
||||
setTimeout(function() {
|
||||
if (!_tocVisible) resizer.classList.remove('visible');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Removes the no-transition guard after initial state is applied, so the first
|
||||
// document render doesn't animate but later toggles do.
|
||||
function enableTOCTransitions() {
|
||||
requestAnimationFrame(function() {
|
||||
document.getElementById('toc').classList.remove('no-transition');
|
||||
var raw = document.getElementById('raw-source');
|
||||
if (raw) raw.classList.remove('no-transition');
|
||||
});
|
||||
}
|
||||
|
||||
function setTOCDepth(depth) {
|
||||
_tocMaxDepth = depth;
|
||||
if (_tocVisible) buildTOC();
|
||||
}
|
||||
|
||||
function setTOCWidth(px) {
|
||||
_tocWidth = Math.max(150, Math.min(500, px));
|
||||
if (_tocVisible) document.getElementById('toc').style.width = _tocWidth + 'px';
|
||||
}
|
||||
|
||||
function setTOCWrap(wrap) {
|
||||
_tocWrap = wrap;
|
||||
var toc = document.getElementById('toc');
|
||||
if (wrap) toc.classList.add('wrap'); else toc.classList.remove('wrap');
|
||||
}
|
||||
|
||||
function setTOCBullets(on) {
|
||||
_tocBullets = on;
|
||||
var toc = document.getElementById('toc');
|
||||
if (on) toc.classList.add('bullets'); else toc.classList.remove('bullets');
|
||||
}
|
||||
|
||||
function setRawVisible(visible) {
|
||||
_rawVisible = visible;
|
||||
var raw = document.getElementById('raw-source');
|
||||
var resizer = document.getElementById('raw-resizer');
|
||||
if (visible) {
|
||||
raw.classList.add('visible');
|
||||
raw.style.width = _rawWidth + 'px';
|
||||
if (resizer) resizer.classList.add('visible');
|
||||
_rebuildSyncAnchors();
|
||||
applyRawCollapse();
|
||||
syncRawFromContent();
|
||||
} else {
|
||||
raw.classList.remove('visible');
|
||||
raw.style.width = '0px';
|
||||
// Keep the divider through the collapse animation, then hide it.
|
||||
if (resizer) {
|
||||
setTimeout(function() {
|
||||
if (!_rawVisible) resizer.classList.remove('visible');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setRawSource(text) {
|
||||
var code = document.querySelector('#raw-source code');
|
||||
if (!code) return;
|
||||
code.textContent = '';
|
||||
var lines = text.split('\n');
|
||||
var inFence = false, fenceChar = '', fenceLen = 0;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i];
|
||||
var stripped = line.replace(/^[ \t]+/, '');
|
||||
var fm = stripped.match(/^(`{3,}|~{3,})/);
|
||||
var hl = 0;
|
||||
if (!inFence && fm) {
|
||||
inFence = true; fenceChar = fm[1].charAt(0); fenceLen = fm[1].length;
|
||||
} else if (inFence && fm && fm[1].charAt(0) === fenceChar && fm[1].length >= fenceLen && stripped.slice(fm[1].length).trim() === '') {
|
||||
inFence = false;
|
||||
} else if (!inFence) {
|
||||
var hm = stripped.match(/^(#{1,6})\s/);
|
||||
if (hm) hl = hm[1].length;
|
||||
}
|
||||
var span = document.createElement('span');
|
||||
span.className = hl ? 'raw-line raw-h' : 'raw-line';
|
||||
if (hl) span.setAttribute('data-level', hl);
|
||||
span.textContent = line + (i < lines.length - 1 ? '\n' : '');
|
||||
code.appendChild(span);
|
||||
if (hl) {
|
||||
var ell = document.createElement('span');
|
||||
ell.className = 'raw-ellipsis';
|
||||
ell.textContent = '⋯\n';
|
||||
code.appendChild(ell);
|
||||
}
|
||||
}
|
||||
_rebuildSyncAnchors();
|
||||
applyRawCollapse();
|
||||
}
|
||||
|
||||
// Mirror the rendered collapse state into the source pane: hide each collapsed
|
||||
// section's body lines and reveal its ellipsis placeholder.
|
||||
function applyRawCollapse() {
|
||||
var code = document.querySelector('#raw-source code');
|
||||
if (!code) return;
|
||||
var lines = code.querySelectorAll('.raw-line');
|
||||
var headingIdx = -1;
|
||||
var hideUntil = 0;
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var el = lines[i];
|
||||
var isHeading = el.classList.contains('raw-h');
|
||||
var ellipsis = (isHeading && el.nextElementSibling && el.nextElementSibling.classList.contains('raw-ellipsis'))
|
||||
? el.nextElementSibling : null;
|
||||
if (isHeading) {
|
||||
headingIdx++;
|
||||
var level = parseInt(el.getAttribute('data-level'), 10) || 1;
|
||||
if (hideUntil > 0 && level <= hideUntil) hideUntil = 0;
|
||||
if (hideUntil > 0) {
|
||||
el.style.display = 'none';
|
||||
if (ellipsis) ellipsis.style.display = 'none';
|
||||
} else {
|
||||
el.style.display = '';
|
||||
var rendered = _syncContentHeadings[headingIdx];
|
||||
if (rendered && rendered.classList.contains('collapsed')) {
|
||||
if (ellipsis) ellipsis.style.display = 'inline';
|
||||
hideUntil = level;
|
||||
} else if (ellipsis) {
|
||||
ellipsis.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
el.style.display = (hideUntil > 0) ? 'none' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setRawWidth(px) {
|
||||
// Cap at the viewport minus a reserve so the rendered pane never fully disappears.
|
||||
var maxW = Math.max(220, document.documentElement.clientWidth - 200);
|
||||
_rawWidth = Math.max(220, Math.min(maxW, px));
|
||||
if (_rawVisible) document.getElementById('raw-source').style.width = _rawWidth + 'px';
|
||||
}
|
||||
|
||||
// Drag-to-resize the raw-source pane (anchored to the right edge).
|
||||
(function() {
|
||||
var resizer = document.getElementById('raw-resizer');
|
||||
var raw = document.getElementById('raw-source');
|
||||
if (!resizer || !raw) return;
|
||||
var dragging = false;
|
||||
resizer.addEventListener('mousedown', function(e) {
|
||||
dragging = true;
|
||||
resizer.classList.add('dragging');
|
||||
raw.classList.add('no-transition');
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!dragging) return;
|
||||
setRawWidth(document.documentElement.clientWidth - e.clientX);
|
||||
});
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
resizer.classList.remove('dragging');
|
||||
raw.classList.remove('no-transition');
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.rawWidth) {
|
||||
window.webkit.messageHandlers.rawWidth.postMessage(_rawWidth);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// ---- Header-anchored scroll-sync between the rendered view and the raw pane ----
|
||||
|
||||
function _rebuildSyncAnchors() {
|
||||
_syncContentHeadings = Array.prototype.slice.call(
|
||||
document.querySelectorAll('#content h1, #content h2, #content h3, #content h4, #content h5, #content h6'));
|
||||
_syncRawHeadings = Array.prototype.slice.call(
|
||||
document.querySelectorAll('#raw-source .raw-h'));
|
||||
}
|
||||
|
||||
function syncRawFromContent() {
|
||||
if (!_rawVisible || _findScrolling) return;
|
||||
var ch = _syncContentHeadings, rh = _syncRawHeadings;
|
||||
var n = Math.min(ch.length, rh.length);
|
||||
if (n === 0) return;
|
||||
var content = _contentScroller();
|
||||
var contentTopVp = content.getBoundingClientRect().top, contentScroll = content.scrollTop;
|
||||
var raw = document.getElementById('raw-source');
|
||||
var rawTopVp = raw.getBoundingClientRect().top, rawScroll = raw.scrollTop;
|
||||
var y = contentScroll;
|
||||
function cTop(i) { return ch[i].getBoundingClientRect().top - contentTopVp + contentScroll; }
|
||||
function rTop(i) { return rh[i].getBoundingClientRect().top - rawTopVp + rawScroll; }
|
||||
var k = -1;
|
||||
for (var i = 0; i < n; i++) { if (cTop(i) <= y + 4) k = i; else break; }
|
||||
var target;
|
||||
if (k < 0) target = 0;
|
||||
else if (k >= n - 1) target = rTop(k);
|
||||
else {
|
||||
var c0 = cTop(k), c1 = cTop(k + 1);
|
||||
var frac = (y - c0) / Math.max(1, c1 - c0);
|
||||
target = rTop(k) + frac * (rTop(k + 1) - rTop(k));
|
||||
}
|
||||
_suppressRawSync = true;
|
||||
clearTimeout(_supRawTimer);
|
||||
_supRawTimer = setTimeout(function() { _suppressRawSync = false; }, 120);
|
||||
raw.scrollTop = target;
|
||||
}
|
||||
|
||||
function syncContentFromRaw() {
|
||||
if (!_rawVisible || _findScrolling) return;
|
||||
var ch = _syncContentHeadings, rh = _syncRawHeadings;
|
||||
var n = Math.min(ch.length, rh.length);
|
||||
if (n === 0) return;
|
||||
var content = _contentScroller();
|
||||
var contentTopVp = content.getBoundingClientRect().top, contentScroll = content.scrollTop;
|
||||
var raw = document.getElementById('raw-source');
|
||||
var rawTopVp = raw.getBoundingClientRect().top, rawScroll = raw.scrollTop;
|
||||
var ry = rawScroll;
|
||||
function cTop(i) { return ch[i].getBoundingClientRect().top - contentTopVp + contentScroll; }
|
||||
function rTop(i) { return rh[i].getBoundingClientRect().top - rawTopVp + rawScroll; }
|
||||
var k = -1;
|
||||
for (var i = 0; i < n; i++) { if (rTop(i) <= ry + 4) k = i; else break; }
|
||||
var target;
|
||||
if (k < 0) target = 0;
|
||||
else if (k >= n - 1) target = cTop(k);
|
||||
else {
|
||||
var r0 = rTop(k), r1 = rTop(k + 1);
|
||||
var frac = (ry - r0) / Math.max(1, r1 - r0);
|
||||
target = cTop(k) + frac * (cTop(k + 1) - cTop(k));
|
||||
}
|
||||
_suppressContentSync = true;
|
||||
clearTimeout(_supContentTimer);
|
||||
_supContentTimer = setTimeout(function() { _suppressContentSync = false; }, 120);
|
||||
content.scrollTop = target;
|
||||
}
|
||||
|
||||
var content = _contentScroller();
|
||||
content.addEventListener('scroll', function() {
|
||||
if (!_rawVisible || _findScrolling) return;
|
||||
if (_suppressContentSync) { _suppressContentSync = false; return; }
|
||||
if (_syncRafC) return;
|
||||
_syncRafC = requestAnimationFrame(function() { _syncRafC = null; syncRawFromContent(); });
|
||||
});
|
||||
|
||||
(function() {
|
||||
var raw = document.getElementById('raw-source');
|
||||
if (!raw) return;
|
||||
raw.addEventListener('scroll', function() {
|
||||
if (!_rawVisible || _findScrolling) return;
|
||||
if (_suppressRawSync) { _suppressRawSync = false; return; }
|
||||
if (_syncRafR) return;
|
||||
_syncRafR = requestAnimationFrame(function() { _syncRafR = null; syncContentFromRaw(); });
|
||||
});
|
||||
})();
|
||||
|
||||
// Drag-to-resize the TOC; reports the final width back to the app to persist.
|
||||
(function() {
|
||||
var resizer = document.getElementById('toc-resizer');
|
||||
var toc = document.getElementById('toc');
|
||||
if (!resizer || !toc) return;
|
||||
var dragging = false;
|
||||
resizer.addEventListener('mousedown', function(e) {
|
||||
dragging = true;
|
||||
resizer.classList.add('dragging');
|
||||
toc.classList.add('no-transition');
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'col-resize';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (!dragging) return;
|
||||
setTOCWidth(e.clientX - toc.getBoundingClientRect().left);
|
||||
});
|
||||
document.addEventListener('mouseup', function() {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
resizer.classList.remove('dragging');
|
||||
toc.classList.remove('no-transition');
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.tocWidth) {
|
||||
window.webkit.messageHandlers.tocWidth.postMessage(_tocWidth);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
function setupTOCScrollHighlight() {
|
||||
// Debounced scroll handler to highlight active TOC entry
|
||||
var timeout;
|
||||
window.addEventListener('scroll', function() {
|
||||
var content = _contentScroller();
|
||||
content.addEventListener('scroll', function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(highlightActiveTOC, 50);
|
||||
});
|
||||
}
|
||||
|
||||
function _scrollTOCLinkIntoView(link) {
|
||||
var toc = document.getElementById('toc');
|
||||
var tocFollowPadding = 24;
|
||||
var linkRect = link.getBoundingClientRect();
|
||||
var tocRect = toc.getBoundingClientRect();
|
||||
var activeTop = linkRect.top;
|
||||
var activeBottom = linkRect.bottom;
|
||||
var tocTop = tocRect.top;
|
||||
var tocBottom = tocRect.bottom;
|
||||
var targetScrollTop = toc.scrollTop;
|
||||
|
||||
if (activeTop < tocTop + tocFollowPadding) {
|
||||
targetScrollTop = toc.scrollTop + activeTop - tocTop - tocFollowPadding;
|
||||
} else if (activeBottom > tocBottom - tocFollowPadding) {
|
||||
targetScrollTop = toc.scrollTop + activeBottom - tocBottom + tocFollowPadding;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
targetScrollTop = Math.max(0, Math.min(targetScrollTop, toc.scrollHeight - toc.clientHeight));
|
||||
toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function highlightActiveTOC() {
|
||||
var toc = document.getElementById('toc');
|
||||
if (!toc.classList.contains('visible')) return;
|
||||
|
||||
var content = _contentScroller();
|
||||
var links = toc.querySelectorAll('a');
|
||||
var scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
var scrollTop = content.scrollTop;
|
||||
var contentTopVp = content.getBoundingClientRect().top;
|
||||
var activeLink = null;
|
||||
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
var target = document.getElementById(links[i].dataset.headingId);
|
||||
if (target && target.offsetTop <= scrollTop + 60) {
|
||||
var targetTop = target
|
||||
? target.getBoundingClientRect().top - contentTopVp + scrollTop
|
||||
: Infinity;
|
||||
if (targetTop <= scrollTop + 60) {
|
||||
activeLink = links[i];
|
||||
}
|
||||
links[i].classList.remove('active');
|
||||
}
|
||||
if (activeLink) activeLink.classList.add('active');
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active');
|
||||
_scrollTOCLinkIntoView(activeLink);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -547,7 +1097,8 @@ function _vimJumpHeading(dir) {
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
if (all[i].offsetParent !== null) visible.push(all[i]);
|
||||
}
|
||||
var y = window.scrollY;
|
||||
var content = _contentScroller();
|
||||
var y = content.scrollTop;
|
||||
var threshold = 2;
|
||||
var target = null;
|
||||
if (dir > 0) {
|
||||
@@ -578,7 +1129,7 @@ window.addEventListener('keydown', function(e) {
|
||||
|
||||
if (_vimPending === 'g' && key === 'g' && !ctrl) {
|
||||
_vimClearPending();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
_contentScroller().scrollTo({ top: 0, behavior: 'smooth' });
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
@@ -597,25 +1148,27 @@ window.addEventListener('keydown', function(e) {
|
||||
if (_vimPending) _vimClearPending();
|
||||
|
||||
if (ctrl) {
|
||||
var vh = window.innerHeight;
|
||||
if (key === 'd') { window.scrollBy({ top: vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
if (key === 'u') { window.scrollBy({ top: -vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
if (key === 'f') { window.scrollBy({ top: vh, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
if (key === 'b') { window.scrollBy({ top: -vh, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
var content = _contentScroller();
|
||||
var vh = content.clientHeight;
|
||||
if (key === 'd') { content.scrollBy({ top: vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
if (key === 'u') { content.scrollBy({ top: -vh / 2, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
if (key === 'f') { content.scrollBy({ top: vh, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
if (key === 'b') { content.scrollBy({ top: -vh, behavior: 'smooth' }); e.preventDefault(); return; }
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'j':
|
||||
window.scrollBy({ top: _vimLineHeight(), behavior: 'smooth' });
|
||||
_contentScroller().scrollBy({ top: _vimLineHeight(), behavior: 'smooth' });
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'k':
|
||||
window.scrollBy({ top: -_vimLineHeight(), behavior: 'smooth' });
|
||||
_contentScroller().scrollBy({ top: -_vimLineHeight(), behavior: 'smooth' });
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'G':
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
|
||||
var content = _contentScroller();
|
||||
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' });
|
||||
e.preventDefault();
|
||||
break;
|
||||
case 'g':
|
||||
|
||||
@@ -24,12 +24,14 @@ struct SettingsView: View {
|
||||
Label("Markdown", systemImage: "doc.plaintext")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 380, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@AppStorage("tocWrap") private var tocWrap = false
|
||||
@AppStorage("tocBullets") private var tocBullets = false
|
||||
@State private var isDefault = false
|
||||
@State private var checkPerformed = false
|
||||
@State private var showCSSHelp = false
|
||||
@@ -86,6 +88,49 @@ struct GeneralSettingsView: View {
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Max Content Width", isOn: $themeManager.contentWidthEnabled)
|
||||
.toggleStyle(.checkbox)
|
||||
if themeManager.contentWidthEnabled {
|
||||
HStack(spacing: 10) {
|
||||
Slider(value: $themeManager.contentWidthPx, in: 600...2400)
|
||||
.onChange(of: themeManager.contentWidthPx) { v in
|
||||
themeManager.contentWidthPx = (v / 10).rounded() * 10
|
||||
}
|
||||
Text(String(format: "%d px", Int(themeManager.contentWidthPx)))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 52, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Wrap long entries in the Table of Contents", isOn: $tocWrap)
|
||||
.toggleStyle(.checkbox)
|
||||
Text("When off, long headings are truncated with an ellipsis — hover an entry to see its full text.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Show depth markers in the Table of Contents", isOn: $tocBullets)
|
||||
.toggleStyle(.checkbox)
|
||||
Text("Marks each entry with a shape that varies by heading level (●, ○, ▪, –) to show depth.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
@@ -456,7 +501,7 @@ struct ThemeSettingsView: View {
|
||||
editorItem = ThemeEditorItem(theme: nil)
|
||||
}
|
||||
|
||||
Button("Duplicate") {
|
||||
Button("Copy") {
|
||||
guard let theme = selectedTheme else { return }
|
||||
var copy = theme
|
||||
copy.name = theme.name + " Copy"
|
||||
@@ -496,6 +541,15 @@ struct ThemeSettingsView: View {
|
||||
}
|
||||
.disabled(selectedThemeName == nil || selectedThemeIndex == themeManager.allThemes.count - 1)
|
||||
|
||||
Button("Import...") {
|
||||
importThemes()
|
||||
}
|
||||
|
||||
Button("Export...") {
|
||||
if let theme = selectedTheme { exportTheme(theme) }
|
||||
}
|
||||
.disabled(selectedThemeName == nil)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Restore Built-ins") {
|
||||
@@ -536,6 +590,64 @@ struct ThemeSettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importThemes() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Import Themes"
|
||||
panel.allowedContentTypes = [UTType.json]
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
guard panel.runModal() == .OK else { return }
|
||||
for url in panel.urls {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let theme = try? JSONDecoder().decode(Theme.self, from: data)
|
||||
else { continue }
|
||||
if theme.schemaVersion > Theme.currentSchemaVersion {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "\"\(theme.name)\" was made with a newer version of markdownViewr"
|
||||
alert.informativeText = "It may not display correctly. Import anyway?"
|
||||
alert.addButton(withTitle: "Import")
|
||||
alert.addButton(withTitle: "Skip")
|
||||
if alert.runModal() != .alertFirstButtonReturn { continue }
|
||||
}
|
||||
|
||||
let conflict = themeManager.allThemes.contains { $0.name == theme.name }
|
||||
if conflict {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "\"\(theme.name)\" already exists"
|
||||
alert.informativeText = "Choose how to handle the imported copy."
|
||||
alert.addButton(withTitle: "Overwrite")
|
||||
alert.addButton(withTitle: "Keep Both")
|
||||
alert.addButton(withTitle: "Skip")
|
||||
let resolution: ThemeManager.ImportConflictResolution
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn: resolution = .overwrite
|
||||
case .alertSecondButtonReturn: resolution = .keepBoth
|
||||
default: resolution = .skip
|
||||
}
|
||||
themeManager.importTheme(theme, resolution: resolution)
|
||||
} else {
|
||||
themeManager.importTheme(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exportTheme(_ theme: Theme) {
|
||||
let panel = NSSavePanel()
|
||||
panel.title = "Export Theme"
|
||||
panel.nameFieldStringValue = theme.name
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.lowercased()
|
||||
.appending(".json")
|
||||
panel.allowedContentTypes = [UTType.json]
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
if let data = try? encoder.encode(theme) {
|
||||
try? data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ThemeEditorWindowController: NSObject, NSWindowDelegate {
|
||||
|
||||
@@ -2,8 +2,11 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Theme: Codable, Identifiable, Hashable {
|
||||
static let currentSchemaVersion = 1
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var schemaVersion: Int = 1
|
||||
var name: String
|
||||
var colors: ThemeColors
|
||||
var fonts: ThemeFonts
|
||||
@@ -14,7 +17,7 @@ struct Theme: Codable, Identifiable, Hashable {
|
||||
var isBuiltIn: Bool = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, colors, fonts, sizes, customCSS
|
||||
case schemaVersion, name, colors, fonts, sizes, customCSS
|
||||
}
|
||||
|
||||
init(
|
||||
@@ -25,6 +28,7 @@ struct Theme: Codable, Identifiable, Hashable {
|
||||
customCSS: String = "",
|
||||
isBuiltIn: Bool = false
|
||||
) {
|
||||
self.schemaVersion = Self.currentSchemaVersion
|
||||
self.name = name
|
||||
self.colors = colors
|
||||
self.fonts = fonts
|
||||
@@ -32,6 +36,16 @@ struct Theme: Codable, Identifiable, Hashable {
|
||||
self.customCSS = customCSS
|
||||
self.isBuiltIn = isBuiltIn
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
schemaVersion = try c.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
colors = try c.decode(ThemeColors.self, forKey: .colors)
|
||||
fonts = try c.decode(ThemeFonts.self, forKey: .fonts)
|
||||
sizes = try c.decode(ThemeSizes.self, forKey: .sizes)
|
||||
customCSS = try c.decodeIfPresent(String.self, forKey: .customCSS) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeColors: Codable, Hashable {
|
||||
|
||||
@@ -30,6 +30,12 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published var contentWidthEnabled: Bool {
|
||||
didSet { UserDefaults.standard.set(contentWidthEnabled, forKey: "contentWidthEnabled") }
|
||||
}
|
||||
@Published var contentWidthPx: Double {
|
||||
didSet { UserDefaults.standard.set(contentWidthPx, forKey: "contentWidthPx") }
|
||||
}
|
||||
|
||||
var activeTheme: Theme {
|
||||
themes.first { $0.name == activeThemeName } ?? themes.first ?? Self.fallbackTheme
|
||||
@@ -50,6 +56,11 @@ class ThemeManager: ObservableObject {
|
||||
} else {
|
||||
self.markdownExtensions = MarkdownExtensions()
|
||||
}
|
||||
self.contentWidthEnabled = UserDefaults.standard.object(forKey: "contentWidthEnabled") != nil
|
||||
? UserDefaults.standard.bool(forKey: "contentWidthEnabled")
|
||||
: true
|
||||
let savedWidth = UserDefaults.standard.double(forKey: "contentWidthPx")
|
||||
self.contentWidthPx = savedWidth > 0 ? savedWidth : 1000
|
||||
loadThemes()
|
||||
}
|
||||
|
||||
@@ -81,8 +92,21 @@ class ThemeManager: ObservableObject {
|
||||
func loadThemes() {
|
||||
let deleted = deletedBuiltIns
|
||||
var combined: [Theme] = []
|
||||
combined.append(contentsOf: loadBuiltInThemes().filter { !deleted.contains($0.name) })
|
||||
combined.append(contentsOf: loadUserThemes())
|
||||
let builtIns = loadBuiltInThemes()
|
||||
let builtInNames = Set(builtIns.map(\.name))
|
||||
var userThemes = loadUserThemes()
|
||||
// A user theme sharing a built-in's name is an edit of that built-in (editing
|
||||
// one in place saves a same-named user file). Keep the user's copy so edits
|
||||
// persist, but mark it built-in so the UI still treats it as one, and drop the
|
||||
// now-shadowed bundled copy.
|
||||
for i in userThemes.indices where builtInNames.contains(userThemes[i].name) {
|
||||
userThemes[i].isBuiltIn = true
|
||||
}
|
||||
let overriddenNames = Set(userThemes.map(\.name))
|
||||
combined.append(contentsOf: builtIns.filter {
|
||||
!deleted.contains($0.name) && !overriddenNames.contains($0.name)
|
||||
})
|
||||
combined.append(contentsOf: userThemes)
|
||||
|
||||
let order = themeOrder
|
||||
if !order.isEmpty {
|
||||
@@ -230,18 +254,59 @@ class ThemeManager: ObservableObject {
|
||||
.filter { $0.pathExtension == "json" }
|
||||
.compactMap { url in
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let theme = try? decoder.decode(Theme.self, from: data)
|
||||
var theme = try? decoder.decode(Theme.self, from: data)
|
||||
else { return nil }
|
||||
let versionBefore = theme.schemaVersion
|
||||
migrateIfNeeded(&theme)
|
||||
if theme.schemaVersion != versionBefore {
|
||||
try? writeUserThemeFile(theme)
|
||||
}
|
||||
return theme
|
||||
}
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
func migrateIfNeeded(_ theme: inout Theme) {
|
||||
// Add migration steps here as the schema evolves. Each `if` block
|
||||
// should be a forward migration from one version to the next, in
|
||||
// order, so a theme that is several versions behind migrates fully
|
||||
// in a single load.
|
||||
//
|
||||
// Example (do not remove — shows the pattern):
|
||||
// if theme.schemaVersion < 2 {
|
||||
// theme.someNewField = derivedDefault(from: theme)
|
||||
// theme.schemaVersion = 2
|
||||
// }
|
||||
_ = theme // suppress unused-inout warning until first real migration
|
||||
}
|
||||
|
||||
func saveUserTheme(_ theme: Theme) throws {
|
||||
try writeUserThemeFile(theme)
|
||||
loadThemes()
|
||||
}
|
||||
|
||||
enum ImportConflictResolution { case overwrite, keepBoth, skip }
|
||||
|
||||
func importTheme(_ theme: Theme, resolution: ImportConflictResolution = .keepBoth) {
|
||||
var imported = theme
|
||||
imported.isBuiltIn = false
|
||||
migrateIfNeeded(&imported)
|
||||
let conflict = allThemes.contains { $0.name == imported.name }
|
||||
if conflict {
|
||||
switch resolution {
|
||||
case .overwrite:
|
||||
try? saveUserTheme(imported)
|
||||
case .keepBoth:
|
||||
imported.name = Self.uniqueName(base: imported.name, existing: Set(allThemes.map(\.name)))
|
||||
try? saveUserTheme(imported)
|
||||
case .skip:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
try? saveUserTheme(imported)
|
||||
}
|
||||
}
|
||||
|
||||
func zoomIn() {
|
||||
zoomScale = min(zoomScale * 1.1, 5.0)
|
||||
}
|
||||
@@ -262,15 +327,17 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
|
||||
func deleteTheme(_ theme: Theme) throws {
|
||||
// Remove any user file/override regardless of built-in status (an edited
|
||||
// built-in has both a bundled copy and a user override file).
|
||||
let userFile = Self.userThemesDirectory.appendingPathComponent(Self.userThemeFilename(for: theme.name))
|
||||
if FileManager.default.fileExists(atPath: userFile.path) {
|
||||
try FileManager.default.removeItem(at: userFile)
|
||||
}
|
||||
// Hide the bundled built-in (restorable later via "Restore Built-ins").
|
||||
if theme.isBuiltIn {
|
||||
var deleted = deletedBuiltIns
|
||||
deleted.insert(theme.name)
|
||||
deletedBuiltIns = deleted
|
||||
} else {
|
||||
let userFile = Self.userThemesDirectory.appendingPathComponent(Self.userThemeFilename(for: theme.name))
|
||||
if FileManager.default.fileExists(atPath: userFile.path) {
|
||||
try FileManager.default.removeItem(at: userFile)
|
||||
}
|
||||
}
|
||||
|
||||
disabledThemes.remove(theme.name)
|
||||
@@ -338,6 +405,10 @@ class ThemeManager: ObservableObject {
|
||||
css += "\n\n/* Theme CSS */\n\(theme.customCSS)"
|
||||
}
|
||||
|
||||
if contentWidthEnabled {
|
||||
css += "\n\n#content-inner { max-width: calc(\(Int(contentWidthPx))px * var(--zoom)); margin: auto; }"
|
||||
}
|
||||
|
||||
return css
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?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>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,58 @@
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class ImageInlinerTests: XCTestCase {
|
||||
func testInlinesRelativeImageAsDataURL() {
|
||||
let html = "<p><img src=\"img/a.png\" alt=\"x\"></p>"
|
||||
let bytes = Data([0x01, 0x02, 0x03])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "img/a.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
XCTAssertFalse(out.contains("img/a.png"))
|
||||
XCTAssertTrue(out.contains("alt=\"x\""))
|
||||
}
|
||||
|
||||
func testLeavesRemoteImageUntouched() {
|
||||
let html = "<img src=\"https://example.com/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesAbsolutePathUntouched() {
|
||||
let html = "<img src=\"/Users/x/a.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in Data([0]) }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testLeavesUnresolvableRelativeImageUntouched() {
|
||||
let html = "<img src=\"missing.png\">"
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { _ in nil }
|
||||
XCTAssertEqual(out, html)
|
||||
}
|
||||
|
||||
func testContainsLocalImageTrueForRelativeSrc() {
|
||||
XCTAssertTrue(ImageInliner.containsLocalImage(in: "<img src=\"images/a.png\">"))
|
||||
}
|
||||
|
||||
func testContainsLocalImageFalseForRemoteOnly() {
|
||||
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<img src=\"https://example.com/a.png\">"))
|
||||
}
|
||||
|
||||
func testContainsLocalImageFalseForNoImages() {
|
||||
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<p>no images here</p>"))
|
||||
}
|
||||
|
||||
func testContainsLocalImageFalseForAbsolutePath() {
|
||||
XCTAssertFalse(ImageInliner.containsLocalImage(in: "<img src=\"/Users/x/a.png\">"))
|
||||
}
|
||||
|
||||
func testDecodesPercentEncodedPathBeforeResolving() {
|
||||
let html = "<img src=\"my%20pic.png\">"
|
||||
let bytes = Data([0xAA])
|
||||
let out = ImageInliner.inlineLocalImages(in: html) { path in
|
||||
path == "my pic.png" ? bytes : nil
|
||||
}
|
||||
XCTAssertTrue(out.contains("data:image/png;base64,\(bytes.base64EncodedString())"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import XCTest
|
||||
|
||||
final class TemplateScrollbarTests: XCTestCase {
|
||||
func testTemplateStylesAllScrollbarsWithThemeColors() throws {
|
||||
let template = try loadTemplate()
|
||||
let rawSourcePreRule = try cssRule("#raw-source pre", in: template)
|
||||
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-track"))
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-thumb:hover"))
|
||||
XCTAssertTrue(template.contains("::-webkit-scrollbar-corner"))
|
||||
XCTAssertTrue(template.contains("background: var(--bg)"))
|
||||
XCTAssertTrue(template.contains("var(--text)"))
|
||||
XCTAssertTrue(rawSourcePreRule.contains("border-radius: 0"))
|
||||
}
|
||||
|
||||
func testRenderedContentOwnsScrolling() throws {
|
||||
let template = try loadTemplate()
|
||||
let bodyRule = try cssRule("body", in: template)
|
||||
let contentRule = try cssRule("#content", in: template)
|
||||
let contentInnerRule = try cssRule("#content-inner", in: template)
|
||||
|
||||
XCTAssertTrue(bodyRule.contains("height: 100vh"))
|
||||
XCTAssertTrue(bodyRule.contains("overflow: hidden"))
|
||||
XCTAssertTrue(contentRule.contains("height: 100vh"))
|
||||
XCTAssertTrue(contentRule.contains("overflow-y: auto"))
|
||||
XCTAssertFalse(contentRule.contains("padding:"))
|
||||
XCTAssertTrue(contentInnerRule.contains("padding: 32px 48px"))
|
||||
XCTAssertTrue(template.contains(#"<div id="content">"#))
|
||||
XCTAssertTrue(template.contains(#"<div id="content-inner">"#))
|
||||
XCTAssertTrue(template.contains("function _contentScroller()"))
|
||||
XCTAssertTrue(template.contains("function _contentContainer()"))
|
||||
XCTAssertTrue(template.contains("content.addEventListener('scroll'"))
|
||||
XCTAssertFalse(template.contains("window.addEventListener('scroll'"))
|
||||
XCTAssertFalse(template.contains("window.scrollTo"))
|
||||
XCTAssertFalse(template.contains("window.scrollBy"))
|
||||
XCTAssertFalse(template.contains("window.scrollY"))
|
||||
XCTAssertFalse(template.contains("document.documentElement.scrollTop"))
|
||||
}
|
||||
|
||||
func testActiveTOCLinkIsKeptVisible() throws {
|
||||
let template = try loadTemplate()
|
||||
|
||||
XCTAssertTrue(template.contains("function _scrollTOCLinkIntoView(link)"))
|
||||
XCTAssertTrue(template.contains("var tocFollowPadding = 24"))
|
||||
XCTAssertTrue(template.contains("activeTop < tocTop + tocFollowPadding"))
|
||||
XCTAssertTrue(template.contains("activeBottom > tocBottom - tocFollowPadding"))
|
||||
XCTAssertTrue(template.contains("toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' })"))
|
||||
XCTAssertTrue(template.contains("_scrollTOCLinkIntoView(activeLink)"))
|
||||
XCTAssertFalse(template.contains("activeLink.scrollIntoView"))
|
||||
}
|
||||
|
||||
func testCSSHelpDocumentsScrollbarSelectors() throws {
|
||||
let cssHelp = try loadSourceFile("markdownViewr/CSSHelpView.swift")
|
||||
let appHelp = try loadSourceFile("markdownViewr/HelpView.swift")
|
||||
|
||||
XCTAssertTrue(cssHelp.contains("Custom CSS Guide"))
|
||||
XCTAssertTrue(cssHelp.contains("#content::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(cssHelp.contains("#raw-source::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(cssHelp.contains("#toc::-webkit-scrollbar-thumb"))
|
||||
XCTAssertTrue(cssHelp.contains("::-webkit-scrollbar-corner"))
|
||||
XCTAssertTrue(cssHelp.contains("#content-inner"))
|
||||
XCTAssertTrue(cssHelp.contains("nav#toc"))
|
||||
XCTAssertTrue(cssHelp.contains("div#raw-source"))
|
||||
XCTAssertFalse(cssHelp.contains("Keep readable text centered on wide screens"))
|
||||
XCTAssertFalse(cssHelp.contains("Remove heading underlines"))
|
||||
XCTAssertTrue(appHelp.contains("Open the CSS Reference"))
|
||||
XCTAssertTrue(appHelp.contains("case customCSS"))
|
||||
XCTAssertTrue(appHelp.contains("Custom CSS Reference"))
|
||||
XCTAssertTrue(appHelp.contains("scrollbar thumbs"))
|
||||
}
|
||||
|
||||
private func loadTemplate() throws -> String {
|
||||
let templateURL = try XCTUnwrap(
|
||||
Bundle.main.url(forResource: "template", withExtension: "html")
|
||||
)
|
||||
return try String(contentsOf: templateURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func loadSourceFile(_ relativePath: String) throws -> String {
|
||||
let testFileURL = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = testFileURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceURL = repoRoot.appendingPathComponent(relativePath)
|
||||
return try String(contentsOf: sourceURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func cssRule(_ selector: String, in template: String) throws -> String {
|
||||
let start = try XCTUnwrap(template.range(of: "\(selector) {"))
|
||||
let remainder = template[start.upperBound...]
|
||||
let end = try XCTUnwrap(remainder.firstIndex(of: "}"))
|
||||
return String(remainder[..<end])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class ThemeMigrationTests: XCTestCase {
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
func testDecodesCurrentVersionTheme() throws {
|
||||
let json = minimalThemeJSON(schemaVersion: Theme.currentSchemaVersion)
|
||||
let theme = try JSONDecoder().decode(Theme.self, from: json)
|
||||
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
|
||||
}
|
||||
|
||||
func testMissingSchemaVersionDefaultsToOne() throws {
|
||||
// Themes written before versioning was added have no schemaVersion field.
|
||||
let json = minimalThemeJSON(schemaVersion: nil)
|
||||
let theme = try JSONDecoder().decode(Theme.self, from: json)
|
||||
XCTAssertEqual(theme.schemaVersion, 1)
|
||||
}
|
||||
|
||||
func testUnknownFieldsAreIgnored() throws {
|
||||
// A theme from a future app version may have fields this version doesn't know about.
|
||||
// Decoding should succeed — unknown keys are silently ignored by Codable.
|
||||
var dict = minimalThemeDict(schemaVersion: Theme.currentSchemaVersion + 1)
|
||||
dict["futureField"] = "someValue"
|
||||
let json = try JSONSerialization.data(withJSONObject: dict)
|
||||
XCTAssertNoThrow(try JSONDecoder().decode(Theme.self, from: json))
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
func testMigrateNoOpAtCurrentVersion() throws {
|
||||
var theme = Theme(name: "Test")
|
||||
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
|
||||
let manager = ThemeManager()
|
||||
let versionBefore = theme.schemaVersion
|
||||
manager.migrateIfNeeded(&theme)
|
||||
XCTAssertEqual(theme.schemaVersion, versionBefore, "migrateIfNeeded should not change version for a current-version theme")
|
||||
}
|
||||
|
||||
func testNewThemeWrittenAtCurrentSchemaVersion() {
|
||||
let theme = Theme(name: "Fresh")
|
||||
XCTAssertEqual(theme.schemaVersion, Theme.currentSchemaVersion)
|
||||
}
|
||||
|
||||
func testContentWidthConstrainsInnerContentNotScrollContainer() {
|
||||
let defaults = UserDefaults.standard
|
||||
let previousEnabled = defaults.object(forKey: "contentWidthEnabled")
|
||||
let previousWidth = defaults.object(forKey: "contentWidthPx")
|
||||
defer {
|
||||
restore(previousEnabled, forKey: "contentWidthEnabled")
|
||||
restore(previousWidth, forKey: "contentWidthPx")
|
||||
}
|
||||
|
||||
let manager = ThemeManager()
|
||||
manager.contentWidthEnabled = true
|
||||
manager.contentWidthPx = 1230
|
||||
|
||||
let css = manager.generateCSS(for: Theme(name: "Content Width"))
|
||||
|
||||
XCTAssertTrue(css.contains("#content-inner { max-width: calc(1230px * var(--zoom)); margin: auto; }"))
|
||||
XCTAssertFalse(css.contains("#content { max-width:"))
|
||||
}
|
||||
|
||||
// MARK: - Round-trip
|
||||
|
||||
func testEncodesSchemaVersion() throws {
|
||||
let theme = Theme(name: "RoundTrip")
|
||||
let data = try JSONEncoder().encode(theme)
|
||||
let dict = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
let encoded = try XCTUnwrap(dict["schemaVersion"] as? Int)
|
||||
XCTAssertEqual(encoded, Theme.currentSchemaVersion)
|
||||
}
|
||||
|
||||
func testRoundTripPreservesAllFields() throws {
|
||||
let original = Theme(
|
||||
name: "Round Trip",
|
||||
colors: ThemeColors(background: "#ff0000", text: "#00ff00"),
|
||||
fonts: ThemeFonts(body: "Georgia", heading: "Helvetica", code: "Menlo"),
|
||||
sizes: ThemeSizes(baseFontSize: 16, lineHeight: 1.8),
|
||||
customCSS: "body { margin: 0; }"
|
||||
)
|
||||
let data = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(Theme.self, from: data)
|
||||
XCTAssertEqual(decoded.name, original.name)
|
||||
XCTAssertEqual(decoded.schemaVersion, original.schemaVersion)
|
||||
XCTAssertEqual(decoded.colors.background, original.colors.background)
|
||||
XCTAssertEqual(decoded.colors.text, original.colors.text)
|
||||
XCTAssertEqual(decoded.fonts.body, original.fonts.body)
|
||||
XCTAssertEqual(decoded.sizes.baseFontSize, original.sizes.baseFontSize)
|
||||
XCTAssertEqual(decoded.sizes.lineHeight, original.sizes.lineHeight)
|
||||
XCTAssertEqual(decoded.customCSS, original.customCSS)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func minimalThemeJSON(schemaVersion: Int?) -> Data {
|
||||
let dict = minimalThemeDict(schemaVersion: schemaVersion)
|
||||
return try! JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
private func minimalThemeDict(schemaVersion: Int?) -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"name": "Minimal",
|
||||
"colors": [
|
||||
"background": "#000000", "text": "#ffffff",
|
||||
"heading1": "#ffffff", "heading2": "#ffffff",
|
||||
"heading3": "#ffffff", "heading4": "#ffffff",
|
||||
"heading5": "#ffffff", "heading6": "#ffffff",
|
||||
"link": "#0000ff", "codeBackground": "#111111",
|
||||
"codeText": "#ffffff", "blockquoteBorder": "#888888",
|
||||
"blockquoteBackground": "#222222",
|
||||
"highlightBackground": "#ffff00", "highlightText": "#000000"
|
||||
],
|
||||
"fonts": ["body": "System", "heading": "System", "code": "SF Mono"],
|
||||
"sizes": [
|
||||
"baseFontSize": 14, "h1Size": 28, "h2Size": 23, "h3Size": 20,
|
||||
"h4Size": 18, "h5Size": 16, "h6Size": 15,
|
||||
"codeFontSize": 13, "lineHeight": 1.7
|
||||
],
|
||||
"customCSS": ""
|
||||
]
|
||||
if let v = schemaVersion {
|
||||
dict["schemaVersion"] = v
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
private func restore(_ value: Any?, forKey key: String) {
|
||||
if let value {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
-6
@@ -14,8 +14,11 @@ packages:
|
||||
url: https://github.com/sparkle-project/Sparkle.git
|
||||
from: "2.0.0"
|
||||
|
||||
targets:
|
||||
markdownViewr:
|
||||
# Shared base for both distribution targets. The two targets differ ONLY in their
|
||||
# Sparkle dependency (Developer ID has it, App Store must not — Sparkle's helpers
|
||||
# can't be sandboxed), the sandbox entitlements, and the MAS_BUILD flag.
|
||||
targetTemplates:
|
||||
AppBase:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
@@ -25,17 +28,37 @@ 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.1.6"
|
||||
PRODUCT_NAME: markdownViewr
|
||||
MARKETING_VERSION: "1.3.0"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ENABLE_APP_SANDBOX: "NO"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
|
||||
targets:
|
||||
markdownViewrTests:
|
||||
type: bundle.unit-test
|
||||
platform: macOS
|
||||
sources:
|
||||
- markdownViewrTests
|
||||
dependencies:
|
||||
- target: markdownViewr
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewrTests
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
|
||||
# Developer ID distribution (notarized DMG + Sparkle auto-update). `just release`.
|
||||
markdownViewr:
|
||||
templates:
|
||||
- AppBase
|
||||
dependencies:
|
||||
- package: sparkle
|
||||
product: Sparkle
|
||||
info:
|
||||
path: markdownViewr/Info.plist
|
||||
properties:
|
||||
@@ -67,3 +90,50 @@ targets:
|
||||
LSApplicationCategoryType: "public.app-category.productivity"
|
||||
SUFeedURL: "https://raw.githubusercontent.com/darinkelkhoff/markdownViewr/main/appcast.xml"
|
||||
SUPublicEDKey: "flXZnzfoR5H/JgMTtAGza8SUcUbfK4FWUPEBjxkSZyc="
|
||||
|
||||
# Mac App Store distribution (sandboxed, no Sparkle). `just release-mas`.
|
||||
# Consumes the Info.plist generated by the markdownViewr target above and strips
|
||||
# the (now-irrelevant) Sparkle feed keys from its own bundle copy.
|
||||
markdownViewr-MAS:
|
||||
templates:
|
||||
- AppBase
|
||||
settings:
|
||||
base:
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS: MAS_BUILD
|
||||
CODE_SIGN_ENTITLEMENTS: markdownViewr/markdownViewr-MAS.entitlements
|
||||
postBuildScripts:
|
||||
- name: Strip Sparkle keys from MAS Info.plist
|
||||
script: |
|
||||
PLIST="${TARGET_BUILD_DIR}/${INFOPLIST_PATH}"
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST" 2>/dev/null || true
|
||||
|
||||
schemes:
|
||||
markdownViewr:
|
||||
build:
|
||||
targets:
|
||||
markdownViewr: all
|
||||
run:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- markdownViewrTests
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
markdownViewr-MAS:
|
||||
build:
|
||||
targets:
|
||||
markdownViewr-MAS: all
|
||||
run:
|
||||
config: Debug
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
config: Debug
|
||||
archive:
|
||||
config: Release
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Show App Store Connect build + version state for markdownViewr.
|
||||
|
||||
Credential-free: the API Key ID is auto-discovered from the AuthKey_<id>.p8
|
||||
filename in ~/.appstoreconnect/private_keys/, and the issuer id is read from
|
||||
~/.appstoreconnect/issuer_id (or the ASC_ISSUER_ID env var). No secrets live in
|
||||
this (public) repo. Stdlib only — ES256 JWT is signed via openssl.
|
||||
"""
|
||||
import json, base64, time, subprocess, urllib.request, urllib.error, os, glob, sys
|
||||
|
||||
BUNDLE_ID = "com.dkelkhoff.markdownViewr"
|
||||
KEYS_DIR = os.path.expanduser("~/.appstoreconnect/private_keys")
|
||||
|
||||
key_files = glob.glob(os.path.join(KEYS_DIR, "AuthKey_*.p8"))
|
||||
if not key_files:
|
||||
sys.exit(f"No AuthKey_*.p8 in {KEYS_DIR}")
|
||||
KEY_PATH = key_files[0]
|
||||
KEY_ID = os.path.basename(KEY_PATH)[len("AuthKey_"):-len(".p8")]
|
||||
ISSUER = os.environ.get("ASC_ISSUER_ID") or \
|
||||
open(os.path.expanduser("~/.appstoreconnect/issuer_id")).read().strip()
|
||||
|
||||
def b64url(b):
|
||||
return base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
|
||||
now = int(time.time())
|
||||
seg = b64url(json.dumps({"alg": "ES256", "kid": KEY_ID, "typ": "JWT"}, separators=(',', ':')).encode()) + b"." + \
|
||||
b64url(json.dumps({"iss": ISSUER, "iat": now, "exp": now + 1200, "aud": "appstoreconnect-v1"}, separators=(',', ':')).encode())
|
||||
der = subprocess.run(["openssl", "dgst", "-sha256", "-sign", KEY_PATH], input=seg, capture_output=True).stdout
|
||||
def read_int(d, i):
|
||||
ln = d[i+1]; return d[i+2:i+2+ln], i+2+ln
|
||||
i = 2 if der[1] < 0x80 else 2 + (der[1] & 0x7f)
|
||||
r, i = read_int(der, i); s, i = read_int(der, i)
|
||||
pad = lambda v: b'\x00' * (32 - len(v.lstrip(b'\x00'))) + v.lstrip(b'\x00')
|
||||
token = (seg + b"." + b64url(pad(r) + pad(s))).decode()
|
||||
|
||||
def get(url):
|
||||
req = urllib.request.Request(url, headers={"Authorization": "Bearer " + token})
|
||||
return json.load(urllib.request.urlopen(req))
|
||||
|
||||
try:
|
||||
apps = get(f"https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]={BUNDLE_ID}")
|
||||
if not apps["data"]:
|
||||
sys.exit(f"No App Store Connect app found for bundle id {BUNDLE_ID}")
|
||||
app_id = apps["data"][0]["id"]
|
||||
print(f"App: {BUNDLE_ID} (id {app_id})\n")
|
||||
|
||||
b = get(f"https://api.appstoreconnect.apple.com/v1/builds?filter[app]={app_id}"
|
||||
"&limit=5&sort=-uploadedDate&include=preReleaseVersion,appStoreVersion")
|
||||
inc = {(x["type"], x["id"]): x for x in b.get("included", [])}
|
||||
print("=== Builds ===")
|
||||
for bd in b["data"]:
|
||||
at, rels = bd["attributes"], bd.get("relationships", {})
|
||||
pv = rels.get("preReleaseVersion", {}).get("data")
|
||||
train = inc.get((pv["type"], pv["id"]), {}).get("attributes", {}).get("version", "?") if pv else "-"
|
||||
asv = rels.get("appStoreVersion", {}).get("data")
|
||||
att = "not attached to a version"
|
||||
if asv:
|
||||
a = inc.get((asv["type"], asv["id"]), {}).get("attributes", {})
|
||||
att = f"attached to {a.get('versionString')} [{a.get('appStoreState')}]"
|
||||
enc = at.get('usesNonExemptEncryption')
|
||||
comp = {None: "NOT ANSWERED", True: "uses non-exempt encryption", False: "exempt (answered)"}.get(enc, enc)
|
||||
print(f" build {at.get('version')} train {train} state={at.get('processingState')} ({att})")
|
||||
print(f" export compliance: {comp}")
|
||||
|
||||
print("\n=== App Store versions ===")
|
||||
v = get(f"https://api.appstoreconnect.apple.com/v1/apps/{app_id}/appStoreVersions?limit=10")
|
||||
for vv in v["data"]:
|
||||
a = vv["attributes"]
|
||||
print(f" {a.get('versionString')} {a.get('platform')} [{a.get('appStoreState')}]")
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"HTTP {e.code}\n{e.read().decode()[:800]}")
|
||||
Reference in new issue
Block a user