diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 604a1218..ab876e7d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -228,6 +228,30 @@ jobs: fi # --- MacOS Build --- + - name: Import Apple Developer ID certificate + if: matrix.platform == 'macos-latest' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + echo -n "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -lut 21600 build.keychain + security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -1) + if [ -z "$identity" ]; then + echo 'No Developer ID Application signing identity was imported.' >&2 + exit 1 + fi + echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" + rm certificate.p12 + - name: Build MacOS (Universal) if: matrix.platform == 'macos-latest' env: @@ -235,6 +259,10 @@ jobs: NODE_OPTIONS: "--max-old-space-size=8192" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} run: npm run tauri build -- --target universal-apple-darwin - name: Upload MacOS Artifacts diff --git a/RELEASING.md b/RELEASING.md index ac07ede7..f4f50849 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -25,9 +25,21 @@ In the GitHub repo settings → Secrets and variables → Actions → New reposi |---------------------------------------|----------------------------------------------------| | `TAURI_SIGNING_PRIVATE_KEY` | full content of `~/.tauri/markpad-updater.key` | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | the password you set in step 1 | +| `APPLE_CERTIFICATE` | base64-encoded Developer ID Application `.p12` | +| `APPLE_CERTIFICATE_PASSWORD` | password used when exporting that `.p12` | +| `KEYCHAIN_PASSWORD` | temporary GitHub Actions keychain password | +| `APPLE_ID` | Apple ID used for notarization | +| `APPLE_PASSWORD` | app-specific password for that Apple ID | +| `APPLE_TEAM_ID` | Apple Developer team ID | The build workflow reads both at signing time on macOS, Windows, and Linux runners. +### 3. Configure macOS code signing and notarization + +The release workflow imports a `Developer ID Application` certificate on the macOS runner, signs the universal bundle, then asks Apple to notarize and staple it. Create the certificate in the Apple Developer portal, export it from Keychain Access as a password-protected `.p12`, encode it with `base64 -i certificate.p12`, and store the resulting single-line value in `APPLE_CERTIFICATE`. + +Use an app-specific Apple ID password for `APPLE_PASSWORD`. The certificate, Apple ID, and team ID must belong to the same Developer Program team. A release intentionally fails at the macOS build step if the certificate cannot be imported or no `Developer ID Application` identity is found; publishing an ad-hoc-signed macOS bundle would break persistent TCC folder grants. + ### 3. Send the public key content Send the **single-line content** of `~/.tauri/markpad-updater.key.pub` (no comments, no header lines) to the developer who'll commit it to `src-tauri/tauri.conf.json` under `plugins.updater.pubkey`. Until that placeholder is replaced, auto-update is inert — the app surfaces a clean error state instead of contacting the update server. @@ -92,6 +104,5 @@ Mention this clearly in the release notes for the first auto-update-capable vers ## Out of scope (not handled by this workflow) -- **Apple Developer ID code-signing & notarization** — `.app` bundles are unsigned. macOS may show a Gatekeeper warning on first launch. Minisign verification by the updater is independent of Apple code-signing. - **Windows Authenticode signing** — neither the portable `.exe` nor the `*-setup.exe` NSIS installer is signed with a code-signing certificate. Users may see a SmartScreen warning. Minisign verification by the updater is independent. - **Retroactive signing** of older releases. diff --git a/scripts/documentLoadFailure.test.ts b/scripts/documentLoadFailure.test.ts new file mode 100644 index 00000000..8fc579d0 --- /dev/null +++ b/scripts/documentLoadFailure.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const session = readFileSync('src/lib/sessions/documentSession.svelte.ts', 'utf8'); + +test('a transient missing-file read error preserves the open tab', () => { + assert.doesNotMatch(session, /tabManager\.closeTab/); + assert.match(session, /options\.onError\('Error loading file', error\);/); +}); diff --git a/scripts/macosReleaseSigning.test.ts b/scripts/macosReleaseSigning.test.ts new file mode 100644 index 00000000..29c4ff2e --- /dev/null +++ b/scripts/macosReleaseSigning.test.ts @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const workflow = readFileSync('.github/workflows/build.yml', 'utf8'); + +test('the release workflow imports a Developer ID certificate before building macOS', () => { + assert.match(workflow, /name: Import Apple Developer ID certificate/); + assert.match(workflow, /APPLE_CERTIFICATE: \$\{\{ secrets\.APPLE_CERTIFICATE \}\}/); + assert.match(workflow, /security import certificate\.p12/); + assert.match(workflow, /APPLE_SIGNING_IDENTITY/); +}); + +test('the macOS release build receives notarization credentials', () => { + const macosBuild = workflow.slice(workflow.indexOf('name: Build MacOS (Universal)')); + + assert.match(macosBuild, /APPLE_ID: \$\{\{ secrets\.APPLE_ID \}\}/); + assert.match(macosBuild, /APPLE_PASSWORD: \$\{\{ secrets\.APPLE_PASSWORD \}\}/); + assert.match(macosBuild, /APPLE_TEAM_ID: \$\{\{ secrets\.APPLE_TEAM_ID \}\}/); +}); diff --git a/scripts/reloadOpenToolbar.test.ts b/scripts/reloadOpenToolbar.test.ts index b7830656..194660df 100644 --- a/scripts/reloadOpenToolbar.test.ts +++ b/scripts/reloadOpenToolbar.test.ts @@ -24,10 +24,10 @@ test('preview-level open shortcut handles Ctrl/Cmd+O outside Monaco without doub assert.match(markdownViewer, /cmdOrCtrl[\s\S]*key === 'o'[\s\S]*selectFile\(\)/); }); -test('editor toolbar delegates to Monaco actions and exposes expected Markdown commands', () => { +test('editor toolbar forwards Monaco actions and optional payloads', () => { assert.match(markdownViewer, /import EditorToolbar from '\.\/components\/EditorToolbar\.svelte'/); - assert.match(markdownViewer, / editorPane\?\.runEditorAction\(actionId\)\}/); - assert.match(editor, /export function runEditorAction\(actionId: string\)/); + assert.match(markdownViewer, / editorPane\?\.runEditorAction\(actionId, payload\)\}/); + assert.match(editor, /export function runEditorAction\(actionId: string, payload\?: any\)/); for (const actionId of [ 'fmt-bold', diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index f1b8d7d6..15d8673e 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -1172,9 +1172,13 @@

{t('settings.toolbarsSettings', settings.language)}

-
-
-

{t('settings.applicationToolbar', settings.language)}

+
+ + + {t('settings.applicationToolbar', settings.language)} + +
+
{/each} +
-
- -
-
-

{t('settings.editorToolbar', settings.language)}

+ + +
+ + + {t('settings.editorToolbar', settings.language)} + +
+
{/each} +
-
+ {:else if activeCategory === 'files'}
@@ -1712,10 +1722,21 @@ display: none; } - .toolbar-section { - display: flex; - flex-direction: column; - gap: 8px; + .toolbar-settings-chevron { + width: 7px; + height: 7px; + border-right: 1.5px solid var(--color-fg-muted); + border-bottom: 1.5px solid var(--color-fg-muted); + transform: rotate(-45deg); + transition: transform 0.12s ease; + } + + .toolbar-settings[open] .toolbar-settings-chevron { + transform: rotate(45deg); + } + + .toolbar-settings-body { + padding-bottom: 12px; } .toolbar-section-header { @@ -1725,15 +1746,6 @@ padding-bottom: 2px; } - .toolbar-section-header h3 { - margin: 0; - font-size: 12px; - font-weight: 600; - color: var(--color-fg-muted); - text-transform: uppercase; - letter-spacing: 0.5px; - } - .toolbar-settings-list { display: flex; flex-direction: column; diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts index 4817f096..591d2c81 100644 --- a/src/lib/sessions/documentSession.svelte.ts +++ b/src/lib/sessions/documentSession.svelte.ts @@ -142,11 +142,10 @@ export function createDocumentSession(options: DocumentSessionOptions) { if (filePath) options.saveRecentFile(filePath); } catch (error) { console.error('Error loading file:', error); - const errorText = String(error); - if (errorText.includes('The system cannot find the file specified') || errorText.includes('No such file or directory')) { - options.deleteRecentFile(filePath); - if (tabManager.activeTab?.path === filePath) tabManager.closeTab(tabManager.activeTab.id); - } else options.onError('Error loading file', error); + // A watcher can observe a transient gap while another process replaces + // the file. Keep the already-open buffer and recent-file entry instead + // of closing the tab and discarding the user's recovery path. + options.onError('Error loading file', error); } }