11 KiB
App Architecture
This page explains how the major Marks runtime pieces fit together. For product-level flows, see Product workflows. For setup and verification, see Development and testing.
Targets and source layout
/project.yml is the XcodeGen project definition. It defines four targets:
Marks— the main iOS app. Sources include/Marksplus/MarksWidget/WidgetDataStore.swiftso the app can write widget data.ShareExtension— app extension with/ShareExtensionplus selected shared model/service files from/Marks(Bookmark,IngestPayload,ServerConfig,LinkdingAPI,MarksAuth,Log,PodcastRequests).MarksWidget— WidgetKit extension with/MarksWidget.MarksTests— unit-test bundle depending onMarks.
The app is Swift 6.0 and targets iOS 26.0. The main app, share extension, and widget extension share the app group group.com.magicive.marks through entitlements and runtime code.
App entrypoint and navigation shell
/Marks/MarksApp.swift contains the @main app and MainContainer.
At startup:
MarksApploads a savedServerConfigor falls back to a built-in default config.MainContainerconstructsLinkdingAPI(config:).MainContainerstoresBookmarksViewModel(api:cacheKey:)in@State, keeping the model stable across SwiftUI re-renders.MainContaineralso creates anIngestedSourceLibraryfor local source storage.- A top-level
TabViewpresents Bookmarks, Tags, Sources, Podcasts, and Search.
MainContainer also handles:
- custom deep links (
marks://bookmark?...,marks://podcast?...), - App Intent handoff through
IntentRouter.shared, - pending podcast requests queued by the share extension,
- foreground refreshes and background podcast progress persistence.
Central state: BookmarksViewModel
/Marks/Views/BookmarksViewModel.swift is @Observable @MainActor and is the core app state object. It owns:
- loaded bookmarks, errors, loading flags, pagination URL, search query, and unread filter,
- generated smart collections and enrichment progress,
- a
LinkdingAPIactor for server sync, ClaudeServicefor backend AI/podcast calls,PodcastPlayerViewModelandPodcastGenerationManager,- the disk bookmark cache URL.
Key side effects during bookmark loading and mutations:
load()optionally restores cached bookmarks from Application Support, fetches linkding bookmarks, restores local AI metadata, saves the full unfiltered cache, writes recent bookmarks toWidgetDataStore, and indexes bookmarks into Spotlight.loadMore()follows linkding'snextURL and indexes newly appended bookmarks.delete()andarchive()mutate linkding, remove local entries, and remove Spotlight entries.addBookmark()saves to linkding, inserts locally, indexes the bookmark, and starts best-effort AI enrichment.enrichAll()and backgroundstartEnrichment()call AI enrichment, persist AI metadata, and reindex enriched bookmarks.
Because one BookmarksViewModel instance is shared by all tabs, array mutations and filters have cross-tab effects.
Linkding integration
/Marks/Services/LinkdingAPI.swift is an actor wrapping linkding's REST API. It builds URLs from ServerConfig (/Marks/Models/ServerConfig.swift) and sends Authorization: Token ... headers.
Implemented operations include:
fetchBookmarks(search:offset:limit:unread:)→GET /api/bookmarks/,fetchBookmarksFromUrl(_:)for paginationnextURLs,checkBookmark(url:)→GET /api/bookmarks/check/, used by the share extension,fetchTags(limit:)→GET /api/tags/,createBookmark(_:)→POST /api/bookmarks/,updateBookmark(id:update:)→PATCH /api/bookmarks/{id}/,deleteBookmark(id:)→DELETE /api/bookmarks/{id}/,archiveBookmark(id:)→POST /api/bookmarks/{id}/archive/,verifyConnection()for configuration checks.
Bookmark, BookmarkResponse, create/update payloads, BookmarkCheck, tag response models, and SmartCollection live in /Marks/Models/Bookmark.swift.
Local source ingest
Local source ingest is separate from linkding bookmarks. It is owned by:
/Marks/Models/IngestedSource.swift— source metadata for text, PDF, and generic file imports./Marks/Services/IngestedSourceStore.swift— JSON metadata persistence, security-scoped file copying, PDFKit text extraction, plain-text extraction, and local search./Marks/Services/SourceSpotlightIndexer.swift— Spotlight indexing/removal for source title, body text, tags, kind, and original filename./Marks/Views/SourcesView.swift— Sources tab list/search, text import, file importer, QuickLook preview, delete, and podcast actions.
IngestedSourceStore writes Sources/sources.json and imported originals under Sources/files/ in Application Support. Text imports store only metadata/body text. File imports copy the selected file into app storage; PDF imports extract text with PDFDocument, while other files attempt UTF-8/ASCII extraction.
Source podcasts use IngestedSource.podcastURL (marks-source://{uuid}) as the podcast cache/index key and send up to 100,000 characters of bodyText to the backend as text.
Configuration and app-group sharing
ServerConfig contains linkding host, optional port, path, token, and HTTPS flag. Its extension stores config in two places:
UserDefaults.standardfor the main app,UserDefaults(suiteName: "group.com.magicive.marks")for extensions.
ServerConfig.loadShared() is used by the share extension, which cannot read the main app's standard defaults. Avoid changing config keys or app-group IDs without updating all entitlements, ServerConfig, WidgetDataStore, and PodcastRequests together.
Security note: /Marks/MarksApp.swift currently includes a built-in default config with a token-like value. Treat it as sensitive; do not copy it into documentation or generated output.
Backend AI, analytics, and podcasts
/Marks/Services/MarksAuth.swift registers an anonymous device with the Marks backend and caches a JWT in UserDefaults.standard. ClaudeService uses that token for backend calls:
- bookmark enrichment:
POST /v1/marks/enrich, - semantic search:
POST /v1/marks/search, - smart collections:
POST /v1/marks/collections, - podcast generation:
POST /v1/podcast/generate, - podcast polling:
GET /v1/podcast/status/{jobId}, - podcast audio download:
GET /v1/podcast/audio/{jobId}.
/Marks/Services/AnalyticsService.swift sends events through the backend path used by ClaudeService/MarksAuth.
Podcast generation accepts either a web URL or a local source pseudo-URL. ClaudeService.generatePodcast(url:title:sourceText:sourceKind:) always sends url and may also send title, text, and source_kind. Podcast audio is downloaded to Application Support under a podcasts directory using a stable hash of the article/source URL. PodcastIndex records metadata in index.json; PodcastLibrary reads that index for the Podcasts tab and widget metadata.
On-device Ask and Spotlight
The app has two AI paths:
- Backend AI through
ClaudeServicefor enrichment, semantic search, smart collections, and podcasts. - On-device RAG through
/Marks/Services/BookmarkAssistant.swiftfor the Ask UI.
BookmarkAssistant uses FoundationModels.LanguageModelSession with BookmarkSearchTool. Its instructions require answers to be grounded in bookmarks. Retrieval flows through Spotlight:
SpotlightIndexerindexes bookmarks after loads, load-more operations, add, and enrichment.SpotlightBookmarkSearch/BookmarkSearchToolsearch the index for relevant bookmarks.BookmarkEntityalso conforms to indexed App Intents concepts for system-level entity discovery.
If changing bookmark fields, keep Bookmark, BookmarkEntity, Spotlight indexing, widget payloads, and row UI in sync. If changing source fields, keep IngestedSource, IngestedSourceStore, SourceSpotlightIndexer, Sources UI, and source podcast generation in sync.
Share extension architecture
The share extension is implemented by /ShareExtension/ShareViewController.swift and /ShareExtension/ShareView.swift.
It accepts shared URL, HTML, RTF, plain-text, and text payloads, uses IngestPayloadParser to find the first bookmarkable http/https URL and preserve non-URL notes, creates a SwiftUI save card, loads shared ServerConfig, checks linkding for an existing bookmark, and lets the user edit tags, notes, read-later state, and a Create Podcast toggle.
For suggestions, it combines:
- linkding
checkBookmarkmetadata andauto_tags, - the user's existing tag vocabulary from
fetchTags(), - backend AI tag suggestions through
/ShareExtension/TagSuggester.swift.
Existing bookmarks are updated rather than duplicated. New bookmarks are created through LinkdingAPI. Podcast generation is not performed inside the extension; it queues a PodcastRequests item in the app group for the main app to process later. The same parser is used by in-app Add Bookmark text import.
Widget architecture and deep links
/MarksWidget/MarksWidgetBundle.swift registers three widgets:
RecentBookmarksWidget(systemMedium),RandomBookmarkWidget(systemSmall),RecentPodcastsWidget(systemMedium).
Widgets read only app-group JSON through /MarksWidget/WidgetDataStore.swift:
widget_bookmarks.json,widget_podcasts.json.
They do not call network APIs. Main-app writes trigger WidgetCenter.shared.reloadAllTimelines() when WidgetKit is available.
Widget links use custom URLs built by URL.marksBookmark(_:) and URL.marksPodcast(_:). MainContainer.handleDeepLink(_:) opens bookmark URLs in BrowserView or starts podcast playback and shows the player.
App Intents and Shortcuts
/Marks/Intents/MarksAppIntents.swift defines:
OpenBookmarkIntent— opens a selectedBookmarkEntityin app.SearchMarksIntent— conforms to the system search schema and routes text to the Search tab.AddBookmarkIntent— saves a URL headlessly through linkding and refreshes widget data.ShowUnreadIntent— fetches up to 10 unread bookmarks.SummarizeBookmarkIntent— calls backend AI enrichment for a selected bookmark.MarksShortcuts— registers Add Bookmark, Search, Unread, and Summarize shortcuts.
IntentRouter bridges intents that need to open UI back into MainContainer. BookmarkEntity maps between linkding Bookmark values and App Intents entities.
Recent history context
Recent git history shows major investment in ingest and podcast UX: the latest change added local text/PDF/file sources, richer URL extraction for share/add flows, source Spotlight indexing, and source-backed podcast generation. Earlier podcast and AI changes added played/unplayed queues, background podcast generation, share-sheet audio requests, podcast library/index changes, player improvements, on-device RAG, Spotlight indexing, and unified logging. Use those areas cautiously because they span app state, storage, widgets, share extension, and App Intents rather than a single view.