Files
linkding-ios/openwiki/architecture/app-architecture.md
Krishna Kumar f7735431a8
All checks were successful
CI / build-and-deploy (push) Successful in 16s
Update OpenWiki for source ingest
2026-07-02 01:20:18 -05:00

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 /Marks plus /MarksWidget/WidgetDataStore.swift so the app can write widget data.
  • ShareExtension — app extension with /ShareExtension plus selected shared model/service files from /Marks (Bookmark, IngestPayload, ServerConfig, LinkdingAPI, MarksAuth, Log, PodcastRequests).
  • MarksWidget — WidgetKit extension with /MarksWidget.
  • MarksTests — unit-test bundle depending on Marks.

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:

  1. MarksApp loads a saved ServerConfig or falls back to a built-in default config.
  2. MainContainer constructs LinkdingAPI(config:).
  3. MainContainer stores BookmarksViewModel(api:cacheKey:) in @State, keeping the model stable across SwiftUI re-renders.
  4. MainContainer also creates an IngestedSourceLibrary for local source storage.
  5. A top-level TabView presents 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 LinkdingAPI actor for server sync,
  • ClaudeService for backend AI/podcast calls,
  • PodcastPlayerViewModel and PodcastGenerationManager,
  • 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 to WidgetDataStore, and indexes bookmarks into Spotlight.
  • loadMore() follows linkding's next URL and indexes newly appended bookmarks.
  • delete() and archive() 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 background startEnrichment() 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 pagination next URLs,
  • 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.standard for 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:

  1. Backend AI through ClaudeService for enrichment, semantic search, smart collections, and podcasts.
  2. On-device RAG through /Marks/Services/BookmarkAssistant.swift for the Ask UI.

BookmarkAssistant uses FoundationModels.LanguageModelSession with BookmarkSearchTool. Its instructions require answers to be grounded in bookmarks. Retrieval flows through Spotlight:

  • SpotlightIndexer indexes bookmarks after loads, load-more operations, add, and enrichment.
  • SpotlightBookmarkSearch/BookmarkSearchTool search the index for relevant bookmarks.
  • BookmarkEntity also 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 checkBookmark metadata and auto_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.

/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 selected BookmarkEntity in 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.