Peek Mobile#
A Tauri-based iOS app for saving and organizing URLs with tags using frecency (frequency + recency) scoring.
Overview#
Peek is a mobile bookmarking app that allows you to:
- Save URLs directly from the iOS share sheet
- Tag URLs with multiple tags
- Automatically merge tags when saving duplicate URLs
- Browse saved URLs sorted by recency
- Use frecency-scored tag suggestions with domain-affinity boost
- Sync saved URLs to an external webhook endpoint
- Edit tags on existing URLs
Tech Stack#
- Frontend: React + TypeScript + Vite
- Backend: Rust with Tauri v2
- Platform: iOS (simulator and device)
- Storage: SQLite database in iOS App Groups container (shared between app and extension)
- Database Access:
- Main app: Rust with
rusqlitecrate via Objective-C FFI bridge - Share Extension: Swift with GRDB.swift library
- Main app: Rust with
- Native Bridge: Objective-C bridge for accessing App Group container path from Rust
Architecture#
iOS Share Extension#
The app uses an iOS Share Extension with a full UI that allows:
- Immediate tagging without opening the main app
- Tag selection from frecency-sorted list
- Creating new tags on the fly
- Automatic detection and merging of duplicate URLs
- Status display showing existing tags for already-saved URLs
Frecency Algorithm#
Tags are scored using frecency (frequency + recency):
frecency_score = frequency × 10 × decay_factor
decay_factor = 1 / (1 + days_since_use / 7)
This ensures frequently used tags appear first, but decay over time if not used.
Domain-Affinity Tag Boost#
When displaying unused tags in the save/edit interfaces, tags that have been used on URLs from the same domain get a 2x frecency score multiplier. This makes relevant tags appear higher in suggestions.
Example: When saving a URL from github.com/foo/bar, any tags previously used on other GitHub URLs (e.g., github.com/bar/baz) will appear higher in the tag suggestions.
Implementation:
- Domain extraction removes
www.prefix for matching - Applied in both Share Extension (Swift) and main app edit mode (Rust/React)
URL Deduplication#
When saving a URL that already exists:
- The share extension detects the duplicate
- Shows status: "Already saved with tags: existing, tags"
- Pre-selects existing tags
- Button changes to "Update Tags"
- On save, merges new tags with existing tags (set union)
- Preserves original ID and timestamp
Webhook Sync#
The app supports syncing saved URLs to an external webhook endpoint:
- Configure webhook URL in the Settings screen
- Manual sync via "Sync All" button
- Auto-sync on save from both main app and share extension
- Daily auto-sync checks
last_synctimestamp, syncs if >24 hours - Offline detection skips webhook POST if device is offline (uses
NWPathMonitor)
Payload format:
{
"urls": [
{ "id": "uuid", "url": "https://...", "tags": ["tag1"], "saved_at": "..." }
]
}
Data Storage#
Data is stored in a SQLite database (peek.db) within the iOS App Groups container (group.com.dietrich.peek-mobile). This enables sharing between the main app and share extension with proper concurrent access via WAL mode.
Database Location:
~/Library/Developer/CoreSimulator/Devices/<DEVICE_ID>/data/Containers/Shared/AppGroup/<GROUP_UUID>/peek.db
Database Schema:
-- URLs table
CREATE TABLE urls (
id TEXT PRIMARY KEY, -- UUID
url TEXT NOT NULL UNIQUE, -- The actual URL
created_at TEXT NOT NULL, -- ISO8601 timestamp
updated_at TEXT NOT NULL, -- ISO8601 timestamp
deleted_at TEXT -- Soft delete timestamp (NULL = active)
);
-- Tags table
CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- Tag name (lowercase)
frequency INTEGER NOT NULL DEFAULT 0,
last_used TEXT NOT NULL, -- ISO8601 timestamp
frecency_score REAL NOT NULL DEFAULT 0.0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- URL-Tag junction table (many-to-many)
CREATE TABLE url_tags (
url_id TEXT NOT NULL,
tag_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (url_id, tag_id),
FOREIGN KEY (url_id) REFERENCES urls(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
-- Settings table (key-value store)
CREATE TABLE settings (
key TEXT PRIMARY KEY, -- e.g., 'webhook_url', 'last_sync'
value TEXT NOT NULL
);
Key Features:
- WAL mode for concurrent access from main app and share extension
- Soft deletes (deleted_at timestamp) for URL records
- Normalized schema with junction table for URL-tag relationships
- Indexes on frequently queried columns (url, frecency_score)
Key Files#
Share Extension#
src-tauri/gen/apple/Peek/ShareViewController.swift- Full UI share extensionShareViewController-full-ui.swift.example- Reference implementation
App Group Bridge#
src-tauri/AppGroupBridge.m- Objective-C bridge for App Groups access- Provides C functions for Rust FFI:
get_app_group_container_path()- Returns path to App Group container for SQLite databaseget_system_is_dark_mode()- Returns current system appearance (dark/light mode)
Rust Backend#
src-tauri/src/lib.rs- Tauri commands and business logic- Commands:
save_url,get_saved_urls,get_tags_by_frecency
Frontend#
src/App.tsx- React UI with saved URLs viewsrc/App.css- Mobile-optimized styling with dark mode
Configuration#
src-tauri/tauri.conf.json- Bundle ID:com.dietrich.peek-mobilesrc-tauri/gen/apple/tauri-app_iOS/tauri-app_iOS.entitlements- App Groups entitlementsrc-tauri/gen/apple/Peek/Peek.entitlements- Share extension entitlements
Development#
Prerequisites#
- Node.js and npm
- Rust and Cargo
- Xcode (for iOS development)
- Apple Developer certificate
Setup#
npm install
Running#
iOS Simulator:
npm run tauri ios dev -- "iPhone 17 Pro"
Desktop (for quick UI testing):
npm run tauri dev
Build Workflow#
Frontend assets (CSS, JS, HTML) are embedded in the Rust binary at compile time. This means:
- Changing CSS/JS requires rebuilding Rust with
cargo tauri build(NOT justcargo build) - Simply rebuilding in Xcode won't pick up frontend changes
- The library file to copy is in the
deps/subdirectory
Debug Build (Simulator):
# 1. Start Vite dev server (for hot reload during development)
npx vite --host
# 2. Build and run from Xcode with Debug scheme on simulator
# OR use the full embedded build:
npm run build
cd src-tauri
cargo tauri build --target aarch64-apple-ios-sim --debug
cp target/aarch64-apple-ios-sim/debug/deps/libpeek_save_lib.a gen/apple/Externals/arm64/Debug/libapp.a
# Then build in Xcode with Debug scheme, simulator target
Release Build (Device):
# Use the build script:
npm run build:release
# Or manually:
npm run build
cd src-tauri
cargo tauri build --target aarch64-apple-ios
cp target/aarch64-apple-ios/release/deps/libpeek_save_lib.a gen/apple/Externals/arm64/Release/libapp.a
# Then build in Xcode with Release scheme, device target
Important Notes:
- Debug uses
Externals/arm64/Debug/libapp.aand targetaarch64-apple-ios-sim - Release uses
Externals/arm64/Release/libapp.aand targetaarch64-apple-ios - Always copy from the
deps/subfolder (has embedded assets), not the root folder - Use
cargo tauri build, NOTcargo build(the latter doesn't embed frontend assets)
The Xcode preBuildScript checks if libapp.a exists and skips the Rust build if so. To force a Rust rebuild from Xcode, delete the corresponding libapp.a file.
App Icon#
The app uses Peek.icon bundle (Xcode 15+ unified icon format):
- Source:
src-tauri/gen/apple/Peek.icon/Assets/Peek clouds src.png(1232x1232) - Xcode generates all required icon sizes during build
- Do NOT recreate
Assets.xcassets/AppIcon.appiconset/- that's Tauri's default icons
Building#
Build Rust library for iOS:
cd src-tauri
./build-ios.sh
Build iOS app:
cd src-tauri/gen/apple
xcodebuild -scheme tauri-app_iOS -configuration Debug -sdk iphonesimulator -derivedDataPath build
Install on simulator:
xcrun simctl install <DEVICE_ID> "src-tauri/gen/apple/build/Build/Products/debug-iphonesimulator/Peek.app"
Bundle Identifiers#
- Main app:
com.dietrich.peek-mobile - Share extension:
com.dietrich.peek-mobile.share(must be prefixed with main app ID) - App Group:
group.com.dietrich.peek-mobile
Important: All three must match the -mobile suffix for the App Groups sharing to work.
Build Script#
The build-ios.sh script:
- Builds Rust library for both
aarch64-apple-ios-simandx86_64-apple-ios - Creates universal library with
lipo - Copies to
gen/apple/Externals/arm64/debug/libapp.a - Compiles Objective-C bridge code
Cleaning Data#
To clear all saved URLs and tags from simulator:
# Find the SQLite database
find ~/Library/Developer/CoreSimulator/Devices/<DEVICE_ID>/data/Containers/Shared/AppGroup -name "peek.db"
# Delete it (also removes WAL files)
rm "<path_to_peek.db>"*
# Or to just clear data without deleting the database:
sqlite3 "<path_to_peek.db>" "DELETE FROM url_tags; DELETE FROM urls; DELETE FROM tags;"
Share Extension in Xcode#
The share extension must be configured in Xcode:
- Target: "Peek" (Share Extension)
- Bundle ID:
com.dietrich.peek-mobile.share - Principal Class:
ShareViewController - Copy
ShareViewController-full-ui.swift.exampletosrc-tauri/gen/apple/Peek/ShareViewController.swift - Ensure entitlements include App Groups
Troubleshooting#
Stale Build Cache#
If changes to AppGroupBridge.m aren't reflected:
cd src-tauri
cargo clean
./build-ios.sh
# Rebuild in Xcode
Share Extension Not Appearing#
Check:
- Bundle ID has correct prefix:
com.dietrich.peek-mobile.share - Share extension Info.plist has correct NSExtension configuration
- App Groups entitlements match between app and extension
No Saved URLs Showing#
Verify:
- App Groups identifier matches in all three places
- Both Rust (rusqlite) and Swift (GRDB) are accessing the same database path
- Database is being created in the App Group container, not the app sandbox
- Check database contents:
sqlite3 <path_to_peek.db> "SELECT * FROM urls;"
License#
See LICENSE file.