175 lines
11 KiB
Markdown
175 lines
11 KiB
Markdown
# App Architecture
|
|
|
|
This page explains how the major Marks runtime pieces fit together. For product-level flows, see [Product workflows](../workflows/product-workflows.md). For setup and verification, see [Development and testing](../operations/development-and-testing.md).
|
|
|
|
## 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.
|
|
|
|
## 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 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.
|