Icon:
- Mark now fills ~85% of the squircle (was 80%) so the goose reads at
the same visual weight as first-party dock icons. Apple's template
specifies 7.5-10% padding inside the squircle; 7.5% lands closer to
the average first-party icon.
- Baked a drop shadow into the icon PNG: 8px offset, 18px Gaussian
blur at ~28% black, rendered under the white squircle on its own
layer so the blur doesn't leak into the mark. First-party macOS
icons include this shadow in the PNG — the dock doesn't add one at
render time. Separable Gaussian (2x 1D passes) keeps startup fast.
Screen header consistency (match Messages pattern):
- Bookmarks, Drafts, Search, Reads, Highlights: titles switched from
headlineMedium to titleMedium.
- All five header rows now pad horizontal = 12.dp, vertical = 8.dp
(matching ConversationListPane's "Messages" header).
- Removed the 16.dp outer wrapper padding from Bookmarks and the 16.dp
bottom padding from Reads header — screens now sit edge-to-edge.
- DeckColumnContainer's 12.dp outer padding around column content
removed for the same reason SinglePaneLayout's was: creates a
`#F5F5F7` frame around every screen that reads as inconsistent
with Messages (and every native desktop app).
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Previous impl filled the entire 1024x1024 canvas with the squircle.
Apple's macOS app icon template leaves ~100px of transparent margin
around a centered 824x824 squircle (content area is 80.5% of the
canvas width) — which is the size the dock normalizes to when laying
out next to first-party apps. Filling the whole canvas made Amethyst
render ~20% larger than its neighbors in the dock and Cmd+Tab.
Now matches Apple's reference geometry:
- Canvas: 1024x1024 (transparent margins of 100px)
- Squircle: 824x824 centered
- Corner radius: ~185px (22.45% of the squircle, per HIG)
- Mark: 10% padding inside the squircle
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
SinglePaneLayout wrapped the main content area in `.padding(12.dp)`,
leaving a visible strip of the window background around every screen.
On macOS with the theming updates this strip became very noticeable:
conversation list, chat content, and the NavigationRail no longer
shared a single edge, and you could see `#F5F5F7` framing the whole
content block.
Native desktop apps don't frame content like that — padding is added
inside cards / lists / dialogs, not around the window content area.
Removing the wrapper padding lets the Messages two-column layout
(surfaceContainer sidebar + surface chat) run edge-to-edge.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
macOS Big Sur+ mandates the squircle icon template for dock / Cmd+Tab —
bare transparent logos stick out next to first-party apps. PlatformAppIcon
wraps the source logo in a 1024x1024 white squircle with 10% padding at
runtime, only when the host OS is macOS (PlatformInfo.host, not .current
— the override is for in-app theming, the dock is drawn by the real OS).
No extra image file: Java2D renders the squircle from the existing
icon.png so Linux / Windows paths stay untouched (they still get the
raw transparent logo, which is the right call for GNOME and matches
Windows 11 taskbar rendering).
Apple's true shape is a superellipse; Java2D lacks a primitive for it,
so RoundRectangle2D with 22.5% corner radius is the practical approx
(invisible difference at dock icon sizes). Applied to both
java.awt.Taskbar.iconImage (dock) and Window(icon=) (title-bar thumb).
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
The conversation list (left) was rendering on surface (white) while the
chat content (right) was inheriting the window background — backwards
from how Messages.app, Slack, Telegram, Discord lay out a two-pane
chat: secondary surface on the list side, primary (white in light
mode) on the content side.
- ConversationListPane root now paints surfaceContainer.
- Unselected ConversationCard goes transparent so the pane's
surfaceContainer shows through; selected / focused tints re-derived
from primary and onSurface so they still read on the new bg.
- Right-side chat Box now explicitly paints surface.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Window(icon = ...) only sets the in-title-bar proxy icon — it does NOT
drive the Cmd+Tab app switcher on macOS. That reads java.awt.Taskbar's
iconImage, which was never set, so gradle-run sessions showed the
generic Java coffee-cup square.
Load the PNG via ImageIO (preserves alpha), then set Taskbar.iconImage
before the application{} block starts. Guarded by isTaskbarSupported +
Feature.ICON_IMAGE so the call is a no-op on platforms without it.
Applies on all OSes that support the Taskbar API — macOS gets the dock
icon, GNOME/KDE get the task-switcher thumbnail, Windows gets the
taskbar icon.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Two separate readability fixes:
1. macOS light-mode primary was poor contrast on the gray background.
- Background was #ECECEC (Apple's title-bar chrome gray), now #F5F5F7
(Apple's secondarySystemBackgroundColor — used for main content).
- Surface container tones shifted accordingly so the sidebar
(surfaceContainer) is still visibly grayer than the content.
- Accents with high luminance (Apple Yellow, Graphite) now darken to
a readable shade in light mode via a luminance clamp that preserves
hue — saturated blue is untouched, but yellow pulls toward amber
so the primary color stays visible on near-white surfaces.
2. Window icon was the generic Java cup during `./gradlew :desktopApp:run`
because the Window composable had no `icon` param. The 100x100 icon
in resources also looked blocky in the dock. Now:
- icon.png replaced with the 512x512 transparent logo from
fastlane/metadata/android/en-US/images/icon.png.
- Window(icon = ...) loads it via Skia (non-deprecated path), shown
in the macOS dock / Windows taskbar / GNOME & KDE task switchers.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Previous approach reserved a 28dp strip across the whole top of the
window for traffic lights — wasting the area right of the lights that
Slack / Notion / Safari-style apps reuse for app content.
Now the sidebar / NavigationRail extends to the top of the window
(their surfaceContainer covers the traffic lights on the left), and
the main content area (deck columns / single-pane content) also
extends to the top since it's to the right of the lights.
- DeckSidebar: top padding now 8dp + titleBarInsetTop so icons clear
the traffic lights.
- SinglePaneLayout's NavigationRail: uses its `header` slot to hold a
Spacer of titleBarInsetTop, same effect.
- Removed the full-width title bar strip from MainContent.
- SinglePaneLayout NavigationRail container color switched from
surfaceVariant to surfaceContainer to match the deck sidebar and
the Material 3 sidebar convention.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Adds three override hooks so a single-OS developer can preview every
platform's theming without VMs:
AMETHYST_PLATFORM=GNOME ./gradlew :desktopApp:run
AMETHYST_APPEARANCE=light ./gradlew :desktopApp:run
AMETHYST_ACCENT=#3584E4 ./gradlew :desktopApp:run
Each override accepts either an env var or a `-Damethyst.<key>=<value>`
JVM property (forwarded from the gradle invocation via build.gradle.kts).
- PlatformInfo: `amethyst.platform` accepts MACOS, WINDOWS, GNOME, KDE,
LINUX_OTHER, UNKNOWN. Adds a `host` accessor for code that needs the
real underlying OS (for a future fallback hook).
- PlatformAppearance: `amethyst.appearance` accepts light/dark.
- PlatformAccent: `amethyst.accent` accepts `#RRGGBB`, `RRGGBB`, or any
libadwaita accent name (blue, teal, green, yellow, orange, red, pink,
purple, slate).
Window chrome stays native to the host OS — overriding the platform
swaps in-app theming only, not the AWT title bar — so on a Mac you
still see traffic lights even when previewing the GNOME theme.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Replace the hard-coded Material3 dark theme with a per-OS theme that
follows the host system's appearance, accent color, fonts, and rounding
language. Adds a `desktop/platform/` module with:
- PlatformInfo: detects macOS, Windows, GNOME, KDE, other Linux via
XDG_CURRENT_DESKTOP / DESKTOP_SESSION.
- PlatformAppearance: reads OS dark/light preference (defaults on macOS,
gsettings on GNOME, kreadconfig on KDE, registry on Windows) and
refreshes on window focus.
- PlatformAccent: pulls the user's accent color from each OS (named
AppleAccentColor, gsettings accent-color, kdeglobals AccentColor,
DWM AccentColor) and falls back to Amethyst purple.
- PlatformFonts: resolves the OS's preferred UI font via Skia's
FontMgr (SF Pro Text on macOS, Segoe UI Variable on Win 11,
Cantarell/Adwaita Sans on GNOME, Noto Sans on KDE).
- PlatformShapes / PlatformTypography / PlatformColorScheme: per-OS
rounding (macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI
4/8/8), letter-spacing tightening for SF Pro / Adwaita, and surface
tones from each OS's reference palette.
macOS gets native chrome treatment: `apple.laf.useScreenMenuBar`
routes the MenuBar to the system menu bar; `apple.awt.transparentTitleBar`
+ `apple.awt.fullWindowContent` lets the deck/sidebar extend under the
traffic lights; a 28dp top strip in MainContent reserves space so
content doesn't underlap them.
DeckSidebar bumps from 48dp to 56dp (desktop density) and switches its
background to the Material3 `surfaceContainer` token, which our new
per-OS schemes drive to the right tone for each platform.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.
Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:
- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
so the measured TextLayoutResult is reused across tint variations
Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.
The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).
A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).
https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:
desktop/service/upload/DesktopBlossomAuth.kt → commons/service/upload/BlossomAuth.kt
desktop/service/upload/DesktopBlossomClient.kt → commons/service/upload/BlossomClient.kt
desktop/service/upload/DesktopMediaCompressor.kt → commons/service/upload/MediaCompressor.kt
desktop/service/upload/DesktopMediaMetadata.kt → commons/service/upload/MediaMetadata.kt
desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt
API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
to avoid colliding with the `MediaMetadata` data class in the same
package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
Callers pass an `OkHttpClient` (or use the default one-shot client);
desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
commons' classpath has a nullable `Response.body`, so explicitly
null-check it.
Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
works on the public `width`/`height` properties — replace with
`?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
MediaCompressor's EXIF stripping).
Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).
Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
Replace the `material-icons-extended` library with Google's Material Symbols
variable font, rendered via a new `commons/icons/symbols/` package. Weight is
set to 100 (Thin) via a single tunable default (MaterialSymbolsDefaults.WEIGHT),
so we can re-tune line thickness globally in one line without regenerating
assets.
Key changes:
- commons/composeResources/font/material_symbols_outlined.ttf: Material Symbols
Outlined variable font (all 3600+ glyphs, 4 design axes: wght/FILL/GRAD/opsz).
- commons/icons/symbols/: MaterialSymbol data class, 219 codepoint constants,
variable-font FontFamily helper, Canvas-backed Icon composable (overload that
accepts MaterialSymbol and supports auto-mirror for RTL), and a Painter
wrapper for use with Image / AsyncImage.
- Bulk-rewrote 250+ call sites across amethyst/, commons/, desktopApp/:
Icons.{Default,Outlined,Filled,Rounded,AutoMirrored.*}.X → MaterialSymbols.X
or MaterialSymbols.AutoMirrored.X. `imageVector =` renamed to `symbol =`
inside Icon() calls; Image(imageVector = MS.X) converted to
Image(painter = rememberMaterialSymbolPainter(MS.X)).
- Dropped material-icons-extended dependency from commons, desktopApp,
amethyst build files.
Hand-drawn custom icons (Bookmark, Like, Reply, Zap, chess pieces, robohash)
remain as ImageVector and are untouched.
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
Three call sites did the same two-layer peel —
kind:1059 → (nip44 decrypt) → kind:13 sealed rumor → (nip44 decrypt) → rumor
Extracted as `GiftWrapEvent.unwrapAndUnsealOrNull(signer)` in
commons/relayClient/nip17Dm/, and collapsed:
- commons/marmot/MarmotIngest.kt — was an inline `when` switch; now a
one-liner. Behaviour unchanged (still passes through if the inner
isn't a seal, matching the previous defensive `else -> inner` branch).
- desktopApp/Main.kt — dropped the nested unwrap/unseal block in the
kind:1059 case of the LocalCache dispatcher.
- cli/DmCommands.kt — replaced the manual chain in `decryptChatMessages`.
Android's DecryptAndIndexProcessor keeps its two separate handlers
(processNewGiftWrap / processNewSealedRumor) because the LocalCache
pipeline re-enters on each peel — that's a different shape and not
touched.
No behaviour change on any platform.
- Persist relay list events (kinds 10050/10007/10006) as JSON to
java.util.prefs.Preferences with per-account key isolation
- Load persisted relay configs on startup before bootstrap subscription
- Validate loaded events (kind + pubkey check), 8KB guard on writes
- Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays
- Fix FeedScreen relay count: shows feed relay count, not all connected
- Per-screen relay picker dialogs: Dns icon on Feed and Search screens
opens AlertDialog wrapping existing editors (Nip65RelayEditor,
SearchRelayEditor) — no new composable files
- Fix created_at dedup: use >= for replaceable event semantics
- Fix setters: use TimeUtils.now() not Long.MAX_VALUE
- Add consumePublishedEvent() for local immediate update after publish
- Remove stale "not loaded" warnings from Search/Blocked editors
- Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any>
- Fix picker LaunchedEffect(Unit) to not overwrite user edits
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.
- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
small placeholderModel(thumbhash, blurhash) helper centralising the
"prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
calculator decodes the bitmap / video thumbnail once and runs both
encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
Replace tint-only selection with Surface background + primaryContainer
color so the selected icon is clearly visible in the workspace editor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Shorten "Single Pane" to "Single" in SegmentedButton to prevent wrapping
- Add 8dp left margin before checkmark in workspace card
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PinnedNavBarState now takes WorkspaceManager reference. Pin/unpin
actions update the active workspace's singlePaneScreens list,
making sidebar customization and workspace editing the same action.
- PinnedNavBarState.syncToWorkspace() updates active workspace on pin/unpin
- PinnedNavBarState.loadFromWorkspace() loads from active workspace
- Remove separate DesktopPreferences.pinnedNavItems persistence
- Workspace is the single source of truth for nav bar screens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change Workspace.singlePaneScreen (single string) to
singlePaneScreens (list of typeKey strings). First screen is
the default. On workspace switch, loads the screen list into
PinnedNavBarState and navigates to the first screen.
- Add SinglePaneScreensEditor to workspace editor dialog
- Add PinnedNavBarState.loadFromList() for workspace-driven nav
- Backward compat: load old "singlePaneScreen" format as single-item list
- Cmd+Shift+S captures current deck columns as singlePaneScreens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove consumed flag (stale on re-open, double-fire is harmless)
- Fix keyboard nav for WORKSPACES tab (was broken, only worked for screens)
- Reset selectedIndex on tab switch
- Fix delete active workspace → reload columns for new active workspace
- Extract DeckColumnType.param() extension to eliminate 3x duplication
- Simplify moveSelection to handle all modes (tabs + unified search)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add two-tab App Drawer (Screens/Workspaces), workspace cards with CRUD,
editor dialog with icon picker and column configuration, unified search
across screens and workspaces, layout mode auto-switching, and
Cmd+Shift+S to save current layout as workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add workspace presets for different usage modes. Each workspace stores
layout mode + column configuration. Switching destroys current layout
and rebuilds from saved config (no background state).
- Add Workspace data model with WorkspaceColumn
- Add WorkspaceManager with save/load/switch/add/delete
- Add WorkspaceIcons registry for Material icon resolution
- Add WorkspaceBar to AppDrawer footer with workspace chips
- Add DeckState.loadFromWorkspace() for column rebuilding
- Add workspaces persistence to DesktopPreferences
- Default workspace: "Social" (Home + Notifications + DMs)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add PinnedNavBarState to manage user-customizable sidebar items.
Replace hardcoded navItems in SinglePaneLayout with pinnedScreens
from state, persisted as CSV to DesktopPreferences. Right-click
context menu on App Drawer items to pin/unpin from sidebar.
- Add PinnedNavBarState with pin/unpin/move/save/load
- Add pinnedNavItems to DesktopPreferences (CSV persistence)
- Update SinglePaneLayout to use pinnedScreens from state
- Update AppDrawer with isPinned indicator and context menu
- Remove NavItem data class and hardcoded navItems list
- HomeFeed and Settings are always pinned (non-removable)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace AddColumnDialog with full-screen App Drawer overlay (Cmd+K).
Screens organized by category (Social, Long-Form, Discovery, Identity,
Play) with instant search, keyboard navigation (arrows + Enter), and
open-column indicators in Deck mode. Works in both Single Pane and
Deck layout modes.
- Add AppDrawer.kt with ScreenCategory, LAUNCHABLE_SCREENS registry
- Add SinglePaneState for hoisted single-pane navigation
- Wire Cmd+K shortcut, redirect Cmd+T to drawer
- Add "More" button to SinglePaneLayout nav rail
- Delete AddColumnDialog.kt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Complete Tor toggle UX:
- key(appRestartKey) wraps App — full Compose tree rebuild on toggle
- TorManager at Window level — survives rebuild, no stop/start collision
- Tor bootstrap gate at top of App — blocks ALL creation (clients, relays,
Coil, subscriptions) until Tor proxy is ready. Zero clearnet during bootstrap.
- Coil ImageLoader reinitialized after setInstance on each rebuild
- Connection pool evicted on rebuild
- DesktopTorManager retries start up to 3x (handles previous daemon stopping)
- Settings reload from prefs on rebuild (fixes stale toggle display)
- Confirmation dialog on mode change in both TorSettingsSection and Dialog
Known: 2-4 stale Coil CDN connections may persist briefly after toggle
(image loads, not relay traffic — close after OkHttp 5min keep-alive).
Relay WebSocket connections are fully through Tor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When user changes Tor settings, shows "Restart Required" confirmation
dialog. On confirm: saves prefs, triggers appRestartKey++ which forces
Compose to unmount/remount entire App tree.
Key behaviors:
- Window stays open, user stays logged in (AccountManager outside App)
- Column layout preserved (DeckState outside App)
- DisposableEffect stops old Tor daemon before new one starts
- TorSettingsSection mode change → confirmation dialog
- TorSettingsDialog Save → confirmation dialog
- Relays, subscriptions, caches all recreated fresh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove the LaunchedEffect/scope.launch reconnect code that didn't
work (relay subscriptions lost after disconnect+connect cycle).
Tor toggle will use app rebuild via key() instead (next commit).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Relay reconnect was only triggered when Tor transitioned to Active.
When toggling OFF, relays stayed on the dead SOCKS proxy and never
reconnected over clearnet — feed went blank.
Now triggers reconnect on both Active and Off transitions, so relays
switch between proxy and direct connections when toggling Tor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Port Android's OkHttpFactory pattern to desktop — creates a custom
Fetcher.Factory<Uri> that calls DesktopHttpClient.currentClient() per
image request, routing all image loads through the SOCKS proxy when
Tor is active.
Avoids the OkHttpNetworkFetcher.factory() import issue by directly
constructing NetworkFetcher with the Tor-aware Call.Factory, matching
the proven Android implementation.
This closes the last network leak — ALL egress paths now route through
Tor when enabled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SECURITY: With Tor ON, all HTTP traffic now routes through SOCKS proxy.
Previously only relay WebSocket connections used Tor; 8 other egress
paths created bare OkHttpClient() instances bypassing Tor entirely.
Fail-closed behavior:
- When Tor expected but bootstrapping → dead SOCKS proxy on port 1
(requests fail instead of leaking IP)
- lateinit var crashes on misconfiguration (loud failure vs silent leak)
Leak sites fixed (all use DesktopHttpClient.currentClient()):
- AnimatedGifImage: GIF fetch
- SaveMediaAction: media downloads
- EncryptedMediaService: NIP-17 DM media
- ServerHealthCheck: Blossom server probes
- NoteActions: zap/lightning LNURL resolution
- DesktopBlossomClient: media uploads
- AccountManager: NIP-46 bunker relay connections
- Coil image loader: TODO (OkHttpNetworkFetcher import issue)
Also:
- Relay reconnect on Tor Active (prevents stale clearnet connections)
- isTorExpected() for fail-closed logic
- Tests updated for new torTypeProvider parameter
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shield was getting pushed off screen when RelayHealthIndicator ("x s ago")
appeared above it. Moved to very last position (after BunkerHeartbeat)
so it's always visible at the bottom edge.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Shield icon was only in DeckSidebar (deck mode) but missing from
SinglePaneLayout's NavigationRail. Added between RelayHealthIndicator
and BunkerHeartbeatIndicator. Clicking navigates to Settings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
UX improvements based on manual testing feedback:
- Default TorType changed from OFF to INTERNAL (Tor on by default)
- Default preset changed to Full Privacy (all routing via Tor)
- Shield icon always visible in sidebar (not hidden when Off)
- Shield icon clickable — opens Settings
- TorSettingsDialog Cancel/Save buttons sticky at bottom (don't scroll away)
- HorizontalDivider above buttons for visual separation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 6: Polish and wiring.
Per-relay routing:
- TorRelayEvaluation wired into DesktopHttpClient with actual settings
- shouldUseTorForRelay uses real evaluation instead of { false }
- Settings changes propagate to torTypeFlow/externalPortFlow reactively
CompositionLocal for Tor state:
- LocalTorState provides TorState (status, settings, onSettingsChanged)
- Avoids threading Tor params through every composable layer
- DeckColumnContainer reads from LocalTorState for RelaySettingsScreen
.onion relay badge:
- RelayStatusCard shows "Requires Tor" (red) when Tor is off
- Shows "via Tor" (green) when Tor is active
- Only on .onion relay URLs
Shutdown hook:
- activeTorManager.stopSync() in existing shutdown hook
- Ensures no orphaned Tor processes on app exit
- Set via volatile var from App composable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 5: Desktop settings UI for Tor.
TorStatusIndicator:
- Shield icon in sidebar footer (gray/yellow/green/red)
- Tooltip shows status (no port number for security)
- Only visible when Tor is not Off
TorSettingsSection:
- Inline in settings screen between Media Server and Dev Settings
- Mode selector (Off/Internal/External) with segmented buttons
- External SOCKS port input with validation (1-65535)
- "Advanced..." button opens full dialog
TorSettingsDialog:
- Full DialogWindow with preset selector (radio buttons)
- 4 relay routing toggles + 7 content routing toggles
- Preset auto-detection (whichPreset)
- Save/Cancel with DesktopTorPreferences persistence
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add complete UI layer for Marmot Protocol MLS group messaging:
State models (commons/commonMain/):
- MarmotGroupChatroom: tracks decrypted inner messages per group
- MarmotGroupList: manages all group chatrooms with change notifications
- MarmotGroupFeedFilter: feed filter for group message feeds
- MarmotGroupFeedViewModel: ViewModel exposing group messages
Account integration:
- Add marmotGroupList to IAccount interface and implementations
- GroupEventHandler indexes inner events into MarmotGroupList
- AccountViewModel gains sendMarmotGroupMessage, createMarmotGroup,
publishMarmotKeyPackage helper methods
Android screens (amethyst/):
- MarmotGroupListScreen: lists all groups with last message preview
- MarmotGroupChatScreen: group conversation with message composer
- MarmotGroupChatView: message feed + simple text composer
- CreateGroupDialog: create new MLS group with display name
- AddMemberDialog: add member by npub/hex (KeyPackage fetch TBD)
Navigation & integration:
- Route.MarmotGroupList and Route.MarmotGroupChat routes
- Wired into AppNavigation nav graph
- "MLS Groups" button added to ChannelFabColumn on Messages screen
- Marmot group newest messages appear in ChatroomListKnownFeedFilter
https://claude.ai/code/session_0194SxKfAU61PY92eqP4cCXM
Adds an ephemeral variant of GiftWrap for transient encrypted messages
that relays don't need to persist. EphemeralGiftWrapEvent extends
GiftWrapEvent so all existing processing paths (decryption, routing,
notifications, call signaling) automatically handle it via type
hierarchy. Relay subscription filters are updated to request both kinds.
https://claude.ai/code/session_0157X96G6HLTzYxkdX9pyTSJ
Rename the existing BookmarkListEvent (kind 30001) to OldBookmarkListEvent
and create a new BookmarkListEvent with kind 10003 as per the updated spec.
- OldBookmarkListEvent (kind 30001): Kept as-is for backward compatibility,
displayed as "Old Bookmarks" in the Bookmark Lists screen
- BookmarkListEvent (kind 10003): New replaceable event, all new bookmark
operations (save/check/remove) use this kind
- Both kinds are displayed as separate items in the Bookmark Lists screen
- Old Bookmarks screen includes a "Move All to New Bookmarks" button that
migrates public bookmarks to public and private to private
- Created PrivateReplaceableTagArrayEvent base class for replaceable events
with encrypted private tags (kind 10003 is in the 10000-19999 range)
- Updated all relay filters, search queries, and profile views to fetch
both kinds
- Updated Android and Desktop modules
https://claude.ai/code/session_01U9sjQHQMVVHxYiesoXjqop