diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 761f780..3fdc80a 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -49,7 +49,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "app" -version = "1.0.0" +version = "1.0.2" dependencies = [ "platform-dirs", "regex", diff --git a/app/src/screens/Editor.tsx b/app/src/screens/Editor.tsx index 3080180..edde4af 100644 --- a/app/src/screens/Editor.tsx +++ b/app/src/screens/Editor.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown' import type { Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' +import { openUrl } from '@tauri-apps/plugin-opener' import { NoteApiError, noteDiscard, noteRead, noteSave } from '../api/notes' import { toggleTodo } from '../api/todos' import { workspaceActions, useWorkspaceStore } from '../stores/workspaceStore' @@ -11,6 +12,7 @@ import { Toast } from '../components/Toast' import { TodoRow } from '../components/TodoRow' import { workspaceUpdateNoteTags, workspaceGetAllTags } from '../api/workspace' import { extractMarkdownTodos, normalizePreviewTodoLines, toggleMarkdownTodoInBody, type MarkdownTodo } from '../utils/markdownTodo' +import { shouldOpenMarkdownLinkExternally } from '../utils/markdownLink' import { noteColorSlot } from '../utils/noteColor' import { isTypingTarget } from '../utils/keyboard' @@ -391,6 +393,25 @@ export function Editor(props: EditorProps) { const markdownComponents = useMemo( () => ({ + a: ({ href, children, onClick, ...props }) => ( + { + onClick?.(event) + if (event.defaultPrevented || !href || !shouldOpenMarkdownLinkExternally(href)) { + return + } + + event.preventDefault() + void openUrl(href).catch((error) => { + console.error('Failed to open markdown link externally:', error) + }) + }} + > + {children} + + ), li: ({ node, children, className, ...props }) => { const startLine = node?.position?.start?.line const todo = typeof startLine === 'number' ? previewTodosByLine.get(startLine - 1) : undefined diff --git a/app/src/styles.css b/app/src/styles.css index 5bf1fab..01bfa62 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -92,6 +92,7 @@ --color-slot-number-bg: oklch(from var(--color-text) l c h / 0.08); --color-slot-number-border: oklch(from var(--color-text) l c h / 0.14); --color-chip-bg: oklch(from var(--color-text) l c h / 0.10); + --color-unchecked-entry-bg: var(--color-chip-bg); --color-autocomplete-bg: oklch(0.99 0.01 285); --color-autocomplete-border: oklch(from var(--color-text) l c h / 0.14); --color-autocomplete-hover: oklch(from var(--color-text) l c h / 0.08); @@ -233,6 +234,16 @@ body { line-height: var(--line-height-base); } +::-moz-selection { + background: var(--color-text); + color: var(--color-slot-1); +} + +::selection { + background: var(--color-text); + color: var(--color-slot-1); +} + /* ── App shell ──────────────────────────────────────────────────────── */ .appRoot { @@ -1069,7 +1080,7 @@ body { } .editorMarkdown th { - background: var(--color-surface-strong); + background: var(--color-unchecked-entry-bg); } .editorMarkdown hr { @@ -1451,7 +1462,7 @@ body { display: flex; align-items: center; justify-content: center; - background: var(--color-chip-bg); + background: var(--color-unchecked-entry-bg); } .todoText { diff --git a/app/src/utils/markdownLink.test.ts b/app/src/utils/markdownLink.test.ts new file mode 100644 index 0000000..aa402c9 --- /dev/null +++ b/app/src/utils/markdownLink.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { shouldOpenMarkdownLinkExternally } from './markdownLink' + +describe('shouldOpenMarkdownLinkExternally', () => { + it('opens https links externally', () => { + expect(shouldOpenMarkdownLinkExternally('https://example.com')).toBe(true) + }) + + it('opens http links externally', () => { + expect(shouldOpenMarkdownLinkExternally('http://example.com')).toBe(true) + }) + + it('opens mailto links externally', () => { + expect(shouldOpenMarkdownLinkExternally('mailto:hello@example.com')).toBe(true) + }) + + it('opens tel links externally', () => { + expect(shouldOpenMarkdownLinkExternally('tel:+49123456789')).toBe(true) + }) + + it('keeps fragment links inside the preview', () => { + expect(shouldOpenMarkdownLinkExternally('#fn1')).toBe(false) + }) + + it('keeps relative links inside the app', () => { + expect(shouldOpenMarkdownLinkExternally('docs/page.md')).toBe(false) + }) + + it('returns false for empty or invalid href values', () => { + expect(shouldOpenMarkdownLinkExternally('')).toBe(false) + expect(shouldOpenMarkdownLinkExternally(' ')).toBe(false) + expect(shouldOpenMarkdownLinkExternally('https://')).toBe(false) + expect(shouldOpenMarkdownLinkExternally(undefined)).toBe(false) + }) +}) diff --git a/app/src/utils/markdownLink.ts b/app/src/utils/markdownLink.ts new file mode 100644 index 0000000..6848ee0 --- /dev/null +++ b/app/src/utils/markdownLink.ts @@ -0,0 +1,13 @@ +const EXTERNAL_MARKDOWN_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']) + +export function shouldOpenMarkdownLinkExternally(href: string | null | undefined): boolean { + const value = href?.trim() + if (!value || value.startsWith('#')) return false + + try { + const url = new URL(value) + return EXTERNAL_MARKDOWN_PROTOCOLS.has(url.protocol) + } catch { + return false + } +} diff --git a/build-app.sh b/build-app.sh index f7e9760..462e576 100755 --- a/build-app.sh +++ b/build-app.sh @@ -72,6 +72,15 @@ if ((${#TAURI_ARGS[@]} > 0)); then mode="$2" shift 2 + export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" + export DEVELOPER_DIR="$(xcode-select -p)" + export CC="$(xcrun --sdk macosx -f clang)" + export CXX="$(xcrun --sdk macosx -f clang++)" + export AR="$(xcrun --sdk macosx -f ar)" + export RANLIB="$(xcrun --sdk macosx -f ranlib)" + export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER="$CC" + export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER="$CC" + cd "$app_dir" pnpm install @@ -93,6 +102,15 @@ else app_dir="$1" mode="$2" + export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" + export DEVELOPER_DIR="$(xcode-select -p)" + export CC="$(xcrun --sdk macosx -f clang)" + export CXX="$(xcrun --sdk macosx -f clang++)" + export AR="$(xcrun --sdk macosx -f ar)" + export RANLIB="$(xcrun --sdk macosx -f ranlib)" + export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER="$CC" + export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER="$CC" + cd "$app_dir" pnpm install @@ -107,3 +125,13 @@ else echo "Build artifacts: $out_dir" ' bash "$APP_DIR" "$BUILD_MODE" fi + +if [[ "$(uname -s)" == "Darwin" ]]; then + if [[ "$BUILD_MODE" = "release" ]]; then + APP_BUNDLE="$APP_DIR/src-tauri/target/release/bundle/macos/Ponder.app" + else + APP_BUNDLE="$APP_DIR/src-tauri/target/debug/bundle/macos/Ponder.app" + fi + + "$ROOT_DIR/scripts/macos-vendor-dylibs.sh" "$APP_BUNDLE" +fi diff --git a/docs/developer/BUILD_AND_RELEASE.md b/docs/developer/BUILD_AND_RELEASE.md index 4e34db2..5c746b3 100644 --- a/docs/developer/BUILD_AND_RELEASE.md +++ b/docs/developer/BUILD_AND_RELEASE.md @@ -33,6 +33,12 @@ From repository root: ./build-app.sh ``` +On macOS, the build flow prefers Apple toolchain linkers inside the Nix shell +to avoid introducing `/nix/store` runtime dependencies. As a safeguard, the +build script also vendors any remaining external non-system dylibs into the +generated `.app` bundle and fails the build if absolute library paths still +point outside the bundle. + Release build: ```bash diff --git a/flake.nix b/flake.nix index 7e07895..0b453da 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,12 @@ # macOS SDK aus Xcode CLT verfügbar machen export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" export DEVELOPER_DIR="$(xcode-select -p)" + export CC="$(xcrun --sdk macosx -f clang)" + export CXX="$(xcrun --sdk macosx -f clang++)" + export AR="$(xcrun --sdk macosx -f ar)" + export RANLIB="$(xcrun --sdk macosx -f ranlib)" + export CARGO_TARGET_AARCH64_APPLE_DARWIN_LINKER="$CC" + export CARGO_TARGET_X86_64_APPLE_DARWIN_LINKER="$CC" # Komfort export RUST_BACKTRACE=1 diff --git a/scripts/macos-vendor-dylibs.sh b/scripts/macos-vendor-dylibs.sh new file mode 100755 index 0000000..61baf47 --- /dev/null +++ b/scripts/macos-vendor-dylibs.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env zsh +set -euo pipefail +setopt KSH_ARRAYS + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 /path/to/App.app" >&2 + exit 1 +fi + +APP_BUNDLE=$1 + +if [[ ! -d "$APP_BUNDLE" ]]; then + echo "Error: app bundle not found at $APP_BUNDLE" >&2 + exit 1 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Skipping dylib vendoring outside macOS." + exit 0 +fi + +APP_BUNDLE=$(cd "$APP_BUNDLE" && pwd) +CONTENTS_DIR="$APP_BUNDLE/Contents" +MACOS_DIR="$CONTENTS_DIR/MacOS" +FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" + +mkdir -p "$FRAMEWORKS_DIR" + +main_executable="" +while IFS= read -r candidate; do + if [[ -f "$candidate" && -x "$candidate" ]]; then + main_executable=$candidate + break + fi +done < <(find "$MACOS_DIR" -maxdepth 1 -type f | sort) + +if [[ -z "$main_executable" ]]; then + echo "Error: could not find app executable in $MACOS_DIR" >&2 + exit 1 +fi + +typeset -A seen_sources=() +typeset -A bundled_names=() +typeset -A queued=() +typeset -a worklist=() + +is_system_dependency() { + local dep=$1 + [[ "$dep" == @* ]] && return 0 + [[ "$dep" == /System/* ]] && return 0 + [[ "$dep" == /usr/lib/* ]] && return 0 + return 1 +} + +is_within_bundle() { + local dep=$1 + [[ "$dep" == "$APP_BUNDLE"/* ]] +} + +bundle_name_for_path() { + basename "$1" +} + +queue_binary() { + local binary=$1 + if [[ -n "${queued[$binary]:-}" ]]; then + return + fi + queued[$binary]=1 + worklist+=("$binary") +} + +copy_dependency() { + local source_path=$1 + local bundle_name + local dest_path + + if ! [[ -f "$source_path" ]]; then + echo "Error: dependency not found at $source_path" >&2 + exit 1 + fi + + bundle_name=$(bundle_name_for_path "$source_path") + dest_path="$FRAMEWORKS_DIR/$bundle_name" + + if [[ -n "${seen_sources[$source_path]:-}" ]]; then + echo "$dest_path" + return + fi + + if [[ -n "${bundled_names[$bundle_name]:-}" && "${bundled_names[$bundle_name]}" != "$source_path" ]]; then + echo "Error: dependency name collision for $bundle_name" >&2 + echo " existing: ${bundled_names[$bundle_name]}" >&2 + echo " new: $source_path" >&2 + exit 1 + fi + + cp "$source_path" "$dest_path" + chmod u+w "$dest_path" + install_name_tool -id "@rpath/$bundle_name" "$dest_path" + + seen_sources[$source_path]=$dest_path + bundled_names[$bundle_name]=$source_path + + queue_binary "$dest_path" + echo "$dest_path" +} + +list_dependencies() { + local binary=$1 + otool -L "$binary" | tail -n +2 | awk '{print $1}' +} + +rewrite_binary_dependencies() { + local binary=$1 + local binary_dir + local dep + local rewritten_path + local bundle_name + + binary_dir=$(dirname "$binary") + + while IFS= read -r dep; do + [[ -z "$dep" ]] && continue + + if is_system_dependency "$dep" || is_within_bundle "$dep"; then + continue + fi + + if [[ "$dep" != /* ]]; then + echo "Error: unsupported dependency path '$dep' in $binary" >&2 + exit 1 + fi + + copy_dependency "$dep" >/dev/null + bundle_name=$(bundle_name_for_path "$dep") + + if [[ "$binary_dir" == "$FRAMEWORKS_DIR" ]]; then + rewritten_path="@loader_path/$bundle_name" + else + rewritten_path="@executable_path/../Frameworks/$bundle_name" + fi + + install_name_tool -change "$dep" "$rewritten_path" "$binary" + done < <(list_dependencies "$binary") +} + +queue_binary "$main_executable" + +index=0 +while (( index < ${#worklist[@]} )); do + rewrite_binary_dependencies "${worklist[$index]}" + ((index += 1)) +done + +while IFS= read -r vendored_binary; do + [[ -z "$vendored_binary" ]] && continue + codesign --force --sign - --timestamp=none "$vendored_binary" +done < <(find "$FRAMEWORKS_DIR" -type f | sort) + +codesign --force --sign - --timestamp=none --deep "$APP_BUNDLE" + +violations=() +while IFS= read -r binary; do + while IFS= read -r dep; do + [[ -z "$dep" ]] && continue + if is_system_dependency "$dep"; then + continue + fi + if is_within_bundle "$dep"; then + continue + fi + violations+=("$binary -> $dep") + done < <(list_dependencies "$binary") +done < <(find "$MACOS_DIR" "$FRAMEWORKS_DIR" -type f | sort) + +if (( ${#violations[@]} > 0 )); then + echo "Error: app bundle still references external dynamic libraries:" >&2 + printf ' %s\n' "${violations[@]}" >&2 + exit 1 +fi + +codesign --verify --deep --strict "$APP_BUNDLE" +echo "Vendored external dylibs into $FRAMEWORKS_DIR"