Skip to content

feat(lan): experimental LAN web server for iOS Safari playback#2262

Open
0Chencc wants to merge 21 commits into
Predidit:mainfrom
0Chencc:feat/lan-web-server
Open

feat(lan): experimental LAN web server for iOS Safari playback#2262
0Chencc wants to merge 21 commits into
Predidit:mainfrom
0Chencc:feat/lan-web-server

Conversation

@0Chencc

@0Chencc 0Chencc commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an experimental LAN HTTP server that lets iOS users watch anime via Safari on the same network, bypassing the need for self-signed iOS certificates.

The desktop Kazumi client (macOS/Windows/Linux) hosts a lightweight web service. An iPhone/iPad on the same LAN opens the URL in Safari and gets a full browsing + playback experience powered by the desktop's plugin system and video proxy.

Key features

Web client (pure HTML/CSS/JS, no Flutter Web)

  • Material 3 visual system with real-time theme sync from desktop via /api/theme
  • 4-tab home: Popular (tag filter + infinite scroll), Timeline (season picker + sort/filter), Collect (5-tab grouping), My (history)
  • Bangumi detail page: hero with blurred cover background, info card (air date / rating / rank), 6-state collect button, 5 tabs (概览/吐槽/角色/评论/制作人员)
  • Search: 3 sorts (match/rating/heat), 2 filters (hide watched/abandoned), 10-item history
  • Source picker: Plugin TabBar with concurrent queries + status dots + alias/manual search fallback
  • Episodes page: 选集/评论 2 tabs, road (playback line) switcher
  • Player: HLS via native Safari / hls.js fallback, prev/next episode, playback rate, aspect ratio, canvas danmaku overlay, progress sync
  • Responsive: NavigationRail (desktop) ↔ BottomNavigationBar (mobile)

Server infrastructure

  • shelf + shelf_router HTTP server with persistent port (random first run, user-editable, conflict fallback)
  • Video proxy: Referer/UA injection, M3U8 rewriting, HLS ad filtering (same algorithm as desktop's ffmpeg hls_ad_filter)
  • Bangumi mirror support: respects enableBangumiProxy setting for popular/timeline endpoints
  • mDNS/Bonjour broadcast via bonsoir for network discovery
  • Access log + error handler middleware for diagnostics
  • Graceful error handling: retry on transient failures, user-friendly messages instead of raw exceptions

Settings page

  • Enable/disable toggle with auto-start on app launch
  • Editable port (persisted, 1024-65535 range validation)
  • LAN address list with copy-to-clipboard
  • Bonjour broadcast status
  • Firewall guidance for Windows users

Files changed

  • lib/lan/ — entire new directory (server, proxy, web client, mDNS, theme export)
  • lib/pages/settings/lan/ — settings UI
  • lib/pages/init_page.dart — auto-start on launch
  • lib/pages/my/my_page.dart — settings entry point (desktop only)
  • lib/services/storage/settings_keys.dartlanServerEnable, lanServerPort

Test plan

  • Enable LAN server in settings → verify port shown, address list populated
  • Open URL in iOS Safari on same network → home page loads with theme
  • Browse popular/timeline/collect/search → data loads, navigation works
  • Open bangumi detail → hero background renders, collect button works
  • Start playback → video proxy works, HLS plays, danmaku overlays
  • Switch episodes → prev/next buttons work, progress syncs
  • Disable server → port released, web client unreachable
  • Restart app → server auto-starts on same port

0Chencc added 21 commits May 13, 2026 17:07
Add the bare bones for an opt-in HTTP server that other devices on the
LAN can reach. v0 only stands up the lifecycle plumbing: a shelf-based
server, a mobx controller wired into the existing Modular tree, an
entry under "我的 → 实验性功能", and an auto-start hook in InitPage that
honours the new lanServerEnable setting. The server currently exposes
nothing beyond `/` and `/healthz` — the plugin API, video proxy, HTML
client and mDNS broadcast will land in subsequent slices.
LanServer now serves the three endpoints a future web client needs to
browse the catalogue:

- GET /api/plugins        — list installed plugins
- GET /api/search         — proxy Plugin.queryBangumi with proper
                            captcha / no-result / failure surfacing
- GET /api/episodes       — proxy Plugin.querychapterRoads

The controller is now constructed with a lazy PluginsController
provider so we don't poke at Modular before its DI graph is ready. The
"实验性功能" entry in 我的 is now gated behind Utils.isDesktop() — mobile
builds have no use for it and we don't want it to look like an Android
toggle.

Video resolution + the HTML client + mDNS are still ahead.
Promote shelf to a direct dependency and add shelf_router + its
transitive http_methods. No version changes to anything previously
locked.
A remote browser can't run the WebView scraping pipeline or speak the
plugin's Referer/Cookie game, so the LAN server now stands in for both:

- LanSourceResolver wraps WebViewVideoSourceProvider behind a Lock so
  parallel /api/resolve calls don't trample each other's `_resolveId`.

- /api/resolve drives the resolver, mints a ProxySession describing the
  upstream URL + the plugin's Referer / UA, hands back a random token,
  and returns the local play URL the client should hit.

- ProxySessionStore keeps tokens in memory with a 2h TTL. Tokens are
  16 bytes from Random.secure, URL-safe base64 encoded.

- VideoProxyHandler streams /proxy/<token> and /proxy/<token>/<sub>
  through HttpClient with Referer/UA injected and Range passed through.
  Hop-by-hop headers and content-encoding are stripped on the way back
  so shelf doesn't double-encode.

- M3u8Rewriter rewrites the resource URIs inside m3u8 playlists —
  EXTINF segments, EXT-X-STREAM-INF children, plus the URI="..."
  attribute on EXT-X-KEY / EXT-X-MAP — so segments keep flowing
  through us instead of escaping out to the origin and hitting CORS
  or Referer checks in the browser.

CORS middleware is now permissive (Access-Control-Allow-Origin: *) and
exposes Content-Range so browser video elements can seek.
A single-file, no-CDN, dark-themed SPA that lets an iOS Safari user
(or any other browser on the LAN) drive the same search → episodes →
play flow the desktop client offers, but without the native binary.

- hash-router with three views: search, episode grid, player
- <video playsinline webkit-playsinline> so iOS doesn't force full-screen
  takeover; relies on iOS Safari's native HLS support for the m3u8
  output of the proxy
- last-used plugin and keyword cached in localStorage so reopens land
  somewhere useful
- safe-area-inset paddings + apple-mobile-web-app meta for sane home-
  screen-add behaviour on iOS

No danmaku, no resume, no collection — that's deliberately out of scope
for the MVP slice.
…(v1d)

Add bonsoir-backed DNS-SD broadcast so Kazumi shows up under "Network"
in the Finder, in iOS's network discovery sheet, in avahi-browse, etc.
The broadcast is best-effort — if it fails (most commonly on a Windows
host without Bonjour Print Services / iTunes), the HTTP server keeps
running and we surface a neutral message in the settings page instead
of an error.

The settings page now also offers a `http://<hostname>.local:<port>`
shortcut on macOS / Linux, where the OS's own mDNS responder already
publishes the hostname. We deliberately don't claim this works on
Windows — the user will need to access the LAN IP there.

Locked plugin registrants regenerated for macOS / Windows targets to
pick up bonsoir's native side.

This closes out the v1 scope: a remote browser can search, pick an
episode and play, and surface-level Bonjour discovery is in place.
Rewrites the served HTML/CSS to look like the desktop app instead of a
generic dark page:

- A proper M3-style colour token set (surface / surface-container-* /
  primary / on-primary / outline / outline-variant ...) with a dark
  branch that activates via [data-theme="dark"] or via
  prefers-color-scheme when [data-theme="auto"].
- /api/theme exposes the host's themeMode (system / light / dark) and
  primaryColor (parsed out of the SettingBoxKey.themeColor ARGB string,
  default Material green). Web client fetches this on boot and applies
  it before the first render so there's no theme flash.
- /assets/<file> route serves binary assets out of rootBundle; the
  client uses it to load MiSans for type that matches the desktop app.
- The shell is no longer a single scroll page — there's a sticky app
  bar with a contextual back button, an M3 search bar with a
  pill-shaped submit, episode chips that highlight in primary-container
  on press, and tonal buttons for secondary actions.

No functional change yet — the views are still search → episodes →
player, and HLS still relies on browser-native support (Safari only).
hls.js fallback is the next slice.
Ship hls.js (1.5.20, ~415KB minified) as an asset and lazy-load it from
the player view only when the browser doesn't speak HLS natively. Safari
continues to use the OS-level HLS pipeline and never pays the JS cost.

- /api/resolve now also returns streamType ("hls" | "mp4" | "unknown")
  inferred from the original URL extension. The client uses it together
  with video.canPlayType to decide whether to attach hls.js or just set
  video.src.
- ensureHlsLoaded() injects <script src="/assets/hls.min.js"> at most
  once per session. The Hls instance is created with enableWorker, has
  basic recovery for NETWORK_ERROR / MEDIA_ERROR, and is destroyed when
  the route leaves /play.
- player-meta now labels the path it took (HLS 原生 / HLS hls.js / MP4 /
  直链), which makes "why doesn't it play" reports much easier.

This unblocks Android Chrome, desktop Chrome / Edge / Firefox.
Switch the web home from a plugin-first quick lane to the same
bangumi-first flow the desktop client uses:

- New endpoints proxy the existing BangumiApi:
  /api/bangumi/search, /api/bangumi/{id},
  /api/bangumi/{id}/characters | /comments | /staff. Characters are
  pre-sorted 主角 → 配角 → 客串 just like InfoPage does.

- The home view is now a Bangumi search bar that renders BangumiItem
  cards (cover / titles / score / air date / summary clamp) and links
  into a detail page.

- /bangumi?id=X view: hero header with a blurred-cover backdrop and a
  proper cover card, score + stars + rank + vote count, tags chips,
  and the same four tabs the desktop app shows (简介 / 角色 / 制作 /
  吐槽). Tab bodies lazy-load on first activation.

- A floating "开始观看" button opens a modal sheet that re-uses the
  /api/search plugin path: pick a rule, see plugin hits (pre-filled
  with the Bangumi 中文名), tap one to jump into the existing
  /episodes → /play flow. The bangumi id is carried as `bid` in the
  hash so v3c can attach watch history to it.

- Modal sheet, bangumi cards, hero, tabs, chips, character/staff/
  comment cards, and FAB are all new M3-style components.

Plugin-first search is retained internally as pluginSearchOnce() —
nothing else uses it as a primary view anymore.
GET /api/danmaku?bangumiId=X&episode=N walks the same bgm-id → dandan-id
→ comments chain as the desktop client (DanmakuApi). The desktop's
Color values are encoded back to RGB ints, and the original [BiliBili]
/ [Gamer] / [DanDan] source string is carried through so the client can
filter later.

The player view now wraps <video> in a .player-wrap container that hosts
a DanmakuLayer canvas overlay. The layer:

- Sorts incoming comments by time and uses a binary-search seek
  detector so jumping the timeline doesn't dump a backlog of stale
  comments onto the screen
- Allocates lanes for rolling / top / bottom comments and drops a
  comment when no lane is free (matches canvas_danmaku's behaviour)
- Pauses emission while video.paused, throttles itself to the actual
  frame rate via requestAnimationFrame deltas
- Persists config (enabled / fontSize / opacity / scroll-duration) in
  localStorage so settings survive page reloads

A collapsible <details> panel below the player exposes those four knobs
plus a comment-count badge. Danmaku only loads when both `bid` and
`episode` are present in the route — the episode grid was updated to
forward both, source picker already carries `bid` from the bangumi
detail page.

Carrying bid + episode also sets up v3c's watch-history sync.
The web client now reads from and writes to the same GStorage that
desktop Kazumi uses, so a session you started in one place keeps going
in the other.

Backend:
- GET /api/history / list      — single record / all histories
- POST /api/history/progress   — upserts a Progress, honours
                                  privateMode by going through
                                  IHistoryRepository directly
- DELETE /api/history          — drop a record
- GET /api/collect / list      — current CollectType for a bangumi /
                                  every collectible (optional ?type
                                  filter)
- PUT /api/collect             — set CollectType 1..5, type=0 deletes
- DELETE /api/collect          — explicit delete

_resolveBangumiItem first looks in the existing history, then in the
collectibles box, and only hits BangumiApi when the user has never
touched this bangumi locally — so /api/collect PUT doesn't burn a
network call on the common path.

Frontend:
- Detail page sprouts a "+ 收藏 / 已在看 / 已想看 ..." button below the
  hero. Tapping it opens a sheet with the five CollectType options +
  "取消收藏". State is fetched on entry.
- Player view's resolve flow now, when bid + episode + plugin are all
  present, asks /api/history, seeks to progresses[episode] if the saved
  position is past five seconds, then fires an interval every five
  seconds that POSTs the current time. It also flushes on pause and on
  pagehide so we don't lose the last few seconds when you swipe back.
- /episodes carries `road` (0-indexed) alongside `episode`, so we
  match the desktop's Progress(road, episode, ms) shape exactly.
Re-shape the web home page to match the desktop app's bottom-tab
structure. /home now accepts a ?tab= parameter and renders one of:

- **推荐**: keeps the Bangumi search bar at the top so the entry point
  doesn't move, then renders the BangumiApi trending list as a poster
  grid below.
- **时间表**: 7-day chips with the current weekday pre-selected
  (Mon=1 to match desktop's Calendar layout); tapping a chip switches
  the grid.
- **追番**: groups the user's CollectedBangumi by CollectType (在看 /
  想看 / 搁置 / 看过 / 抛弃) — same labels as CollectType.label.
- **我的**: the History list (sorted desc by lastWatchTime), each row
  showing cover, last-watched episode name + date, and the plugin that
  served the source. Tapping a row jumps to the bangumi detail page.

Backend gets two thin wrappers:
- GET /api/popular?offset=&limit= → BangumiApi.getBangumiTrendsList
- GET /api/timeline                → BangumiApi.getCalendar, 7 days

The bottom tab bar is a fixed glass bar (backdrop-filter blur) with
safe-area-inset padding. A .bottom-spacer keeps content from being
clipped by it.

This closes v3 — the web client now mirrors the desktop client's main
surfaces end-to-end.
Reworks the web UI from the ground up so it actually looks like Kazumi
instead of a generic dark page:

Layout
- Drops the sticky top app bar.
- Introduces a left NavigationRail (84px) that mirrors the desktop's
  menu: a green circular search button on top, four primary tabs
  (推荐 / 时间表 / 追番 / 我的), and a settings entry at the bottom.
  The active item gets a tonal pill background.
- On narrow viewports (<600px) the rail collapses into a sticky bottom
  NavigationBar so phones stay usable.
- Detail / episodes / player views render an inline back-button +
  page-title pair inside .content instead of relying on the removed
  app bar.

Colour
- Dark palette is now near-black (--surface #060708) to match the
  desktop screenshot, with the multi-level surface-container ladder
  toned down. outline-variant is nearly invisible by default so cards
  don't carry borders.

Cards
- Bangumi search result rows, episode tiles, history rows, character /
  staff / comment cards and the danmaku panel all drop their borders.
  They show a quiet hover/active state on surface-container instead.
- Poster grid renders only cover + 2-line title (no score / no air
  date overlay) at 7:10 aspect with 12px corners, three across on
  mobile.
- Detail page summary turns into prose (no card chrome); chips stay
  pill-shaped but borderless.

Search
- The Bangumi search bar is no longer pinned to 推荐. Tapping the green
  search button in the rail opens a dedicated /search view with the
  input auto-focused.

FAB
- 56dp filled square FAB with the host primary colour, lifted above
  the bottom nav on narrow screens.

The four-tab content (popular / timeline / collect / my) shows a 28px
weighted page title (with optional ▾ chevron on 推荐 to match the
desktop's tag-picker affordance). No functional changes — danmaku,
progress sync, collect management, and the resolve / proxy chain are
all untouched.
Stop hand-rolling Material 3 tokens in the web client. The web visual
system is now driven by the same ColorScheme / textTheme the desktop
app uses, pushed live over SSE.

Server side
- lib/bean/settings/effective_color_scheme_notifier.dart: a global
  ValueNotifier<EffectiveColorScheme?> that exposes whichever
  ColorScheme app_widget.dart's DynamicColorBuilder ended up choosing
  (which is the same one Flutter's MaterialApp.router will render).
- lib/app_widget.dart: in the DynamicColorBuilder.builder body, push
  the resolved light + dark colorSchemes into the notifier (microtask
  defers it past the current build so we don't fire listeners during
  build).
- lib/lan/theme_export.dart: buildScheme()/exportColorTokens()/
  exportTypographyTokens(). Goes through ColorScheme.fromSeed (same
  call Flutter makes internally) instead of poking at
  material_color_utilities, so SDK upgrades won't drift. OLED enhance
  matches Utils.oledDarkTheme: only surface/onSurface/onPrimary/
  onSecondary get overridden in dark mode.
- lib/lan/lan_server.dart:
  * /api/theme upgraded to v2 schema. Keeps the v1 fields
    (themeMode/primaryColor/useDynamicColor) for compat; adds
    oledEnhance + schemes.{light,dark} (33 hex fields each, mapped
    1:1 from ColorScheme) + typography (15 M3 type roles with size /
    weight / letterSpacing / height).
  * /api/theme/stream is a new SSE endpoint. Listens on
    GStorage.setting.watch() filtered to theme-related keys, and on
    effectiveColorSchemeNotifier. CORS middleware short-circuits this
    path so shelf doesn't buffer the stream.
  * Initial Color hex parsing now uses Color.r/g/b (Flutter 3.27+)
    to stay current with the deprecation of Color.value.

Web side
- lib/lan/web_index_html.dart shrinks to a thin shell that interpolates
  three new constants from lib/lan/web/:
    * web_styles.dart       — all CSS (the old <style> block)
    * web_app_script.dart   — router / views / theme / nav rail
    * web_player_script.dart — DanmakuLayer / HLS / progress reporting
  Cleaner diffs going forward — CSS changes won't touch JS files.
- applyTheme() now rebuilds a <style id="theme-tokens"> element that
  defines :root[data-theme="light"] / [dark] / [auto] blocks with
  every ColorScheme field as a CSS variable (camelCase → kebab-case).
  Typography roles emit --display-large-size / -weight / -letter-
  spacing / -height etc. They're declared now but consumed in v5b.
- EventSource("/api/theme/stream") drives applyTheme() live, with a
  5s reconnect on error (handles iOS Safari background tab freezes
  and server restarts).

No visible UX change in this commit — CSS still references the same
variable names it did in v4. v5b will rewrite DOM/CSS to consume the
new tokens.
The web client now consumes the ColorScheme + textTheme v5a started
streaming, and the DOM/CSS layout mirrors the desktop client's
NavigationRail / Card / SliverAppBar widget tree instead of a
generic dark page.

Layout (matches lib/pages/menu/menu.dart exactly)
- body / .nav-rail background: surfaceContainer
- .content background: primaryContainer + top-left/bottom-left
  16px radius, which is the M3 NavigationRail-vs-content visual
  separation the desktop app draws via ClipRRect.
- On portrait < 600px the rail collapses into a bottom NavigationBar,
  search drops out of the bar (also matching menu.dart's
  bottomMenuWidget), .content stretches edge-to-edge with no radius.

NavigationRail (M3 spec, mirrors desktop destinations)
- 56dp circular FAB on top using primaryContainer / on-primaryContainer,
  search SVG icon.
- 4 destinations bottom-aligned (groupAlignment: 1.0 from menu.dart).
- Each destination has an inline pill `nav-indicator` (56x32 dp);
  active gets secondaryContainer fill, inactive shows just the icon.
- Filled / outlined icon pairing for the 4 destinations
  (home / timeline / favorite / settings), driven by the new
  Material Symbols SVG path table embedded in the script.
- labelType: selected — label only renders for the active item, just
  like NavigationRailLabelType.selected on desktop.

Tokens
- All colour CSS now resolves through CSS variables that applyTheme()
  (v5a) populates from the server's ColorScheme.fromSeed output —
  not hand-tuned approximations.
- Typography: --display-large-{size,weight,letter-spacing,height}
  through --label-small-* are declared with M3 defaults and rebuilt
  every time applyTheme runs. Selectors across the page now refer to
  the right role (page-title → headline-medium, .item → body-large,
  .ep → label-large, .chip → label-small, .modal-title → title-large,
  etc).

State layer
- Drops the "swap background colour" hack in favour of M3-spec
  semi-transparent overlays: every interactive surface has a
  ::before pseudo-element whose opacity flips between 0 → 0.08 (hover)
  → 0.12 (pressed). Variables `--state-{hover,focus,pressed,dragged}-
  opacity` expose the values centrally.

Poster card (mirrors BangumiCardV)
- Aspect 0.65, 12dp image corner, title in titleSmall with
  letterSpacing 0.3, padding fromLTRB(5,3,5,1) — these are the exact
  Card values in lib/bean/card/bangumi_card.dart.
- Grid columns: 3 / 5 / 6 across the 600 / 840 breakpoints — matches
  popular_page.dart's contentGrid.

Bits and pieces
- ICONS map exposes the SVG paths we needed (Material Symbols set),
  so pageHeader back, search submit, and the play-arrow on the
  bangumi detail FAB all use real Material icons instead of unicode
  arrows.
- The detail page "开始观看" FAB becomes an M3 Extended FAB with
  the play-arrow leading icon.

Player / danmaku / progress / collect / hls.js logic untouched —
v5b is pure visual.
Misread menu.dart in v5b. The Container(color: primaryContainer) on
line 174 wraps a ClipRRect → PageView → RouterOutlet → PopularPage's
own Scaffold, and that Scaffold's backgroundColor (unset, so defaults
to colorScheme.surface) covers the entire visible area.
primaryContainer is only ever painted in the tiny corner triangle
between the rounded clip and the rectangular Container — effectively
invisible.

So the real visible "content background" on desktop is surface, not
primaryContainer. Adjust the web .content rule to match. Drop the
16dp left corners along with it (they had no effect once the colour
went away).

Other layers (nav-rail, hero gradient end, on-primary-container text
colour) stay as they were until each gets its own audit pass.
UI:
- Detail page hero: banner overlay, title-first layout, 6-state CollectButton
- Timeline: season picker (时间机器), weekday TabBar, sort/filter sheet, horizontal cards
- Source picker: Plugin TabBar with concurrent queries + status dots + alias/manual search
- Episodes page: 选集/评论 2 tabs, road switcher, per-episode comments
- Player: prev/next, playback rate, aspect, reload, back overlay
- Popular: tag dropdown, infinite scroll, return-to-top FAB
- Collect: 5 tabs (在看/想看/搁置/看过/抛弃) with counts
- Search: 3 sorts, 2 filters, 10-item history

Backend:
- /api/bangumi/<id>/episodes + /api/bangumi/<id>/episode-comments
- /api/timeline?season=YYYY-Q (4x20 accumulated)
- /api/popular?tag=... (delegates to getBangumiList)
- HLS proxy runs media playlists through M3u8AdFilter (mirrors ffmpeg hls_ad_filter)

Infra:
- Persistent server port (random first run, user-editable, conflict fallback)
- Remove SSE theme stream → visibilitychange lazy refresh (SSE on Windows +
  dart:io stalls subsequent short requests, causing ERR_EMPTY_RESPONSE)
- Add access log + error handler middleware, /favicon.ico 204 shim
Flutter 3.44 stopped having package:flutter/material.dart re-export the
cupertino library, so CupertinoPageTransitionsBuilder (used by
pageTransitionsTheme2024) no longer resolves through material alone.

Add an explicit `import 'package:flutter/cupertino.dart';` to fix the
Windows build error:
  lib/utils/constants.dart(33,29): error G311314CC: Method not found:
  'CupertinoPageTransitionsBuilder'.
Adapt LAN web slice to main's services/ migration & typed settings registry:

- services/ migration: lan/* now imports from services/{logging,storage,
  video_source} instead of utils/* and providers/video/* (renames carried
  by main; only paths changed, semantics preserved)
- SettingBoxKey replaced by SettingsKeys typed registry
  (services/storage/settings_keys.dart); register lanServerEnable and
  lanServerPort under SettingGroup.misc
- LanServerController & LanServer switch from raw GStorage.setting.get/put
  to GStorage.getSetting/putSetting wrappers
- WebViewVideoSourceProvider -> WebViewVideoSourceService (LanSourceResolver)
- Utils.getRandomUA() -> top-level getRandomUA() (utils/http_headers.dart)
- _danmakuToJson: Danmaku -> DanmakuEntry (modules/danmaku rename)
- Dedupe duplicate cupertino import in constants.dart (auto-merge artifact
  from both branches independently adding the same Flutter 3.44 fix)
- init_page _lanServerInit: migrate setting.get to GStorage.getSetting
main's utils -> services migration removed Utils.isDesktop but left
my_page.dart referencing the deleted Utils class and utils/utils.dart.
Point it at the top-level isDesktop() in utils/device.dart.
- _handlePopular / _handleTimeline: respect enableBangumiProxy setting,
  delegate to getBangumiMirrorPopularSubjects / getBangumiMirrorSeasonCalendar
  when mirror is enabled (aligns with PopularController / TimelineController)
- _handleBangumiDetail: add _retryNullable (3 attempts, 400ms gap) for
  transient mirror timeouts; change 404 "not found" to 502 "上游暂时不可用"
- _handleBangumiComments / _handleEpisodeComments: catch mirror 401 gracefully,
  return 502 with user-friendly message instead of leaking NetworkException
- Web frontend: add retryBlock() helper for centered error + retry button;
  apply to detail load failure, tucao tab, and episode comments tab
final decoded = utf8.decode(base64Url.decode(subPath));
// 简单校验是 http(s) URL
final uri = Uri.parse(decoded);
if (!uri.hasScheme || (uri.scheme != 'http' && uri.scheme != 'https')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: SSRF / open-proxy: the proxied sub-URL is only validated to have an http/https scheme, with no restriction on the target host.

A client that holds any session token can request /proxy/<token>/<base64url(arbitrary-url)> and the server will fetch any http(s) URL — including loopback (http://127.0.0.1:port/...), link-local metadata (http://169.254.169.254/...) and other RFC1918 hosts on the machine's network. Because the server binds to InternetAddress.anyIPv4 with wildcard CORS and no authentication, any device on the LAN effectively gets a general-purpose proxy into the host's internal network.

Consider restricting the decoded sub-URL to the same host/origin as session.originalUrl (or an allowlist), and rejecting loopback/link-local/private target addresses.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if (persistPreference) {
await GStorage.putSetting(SettingsKeys.lanServerEnable, true);
}
} catch (e, st) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Inconsistent state / resource leak on the failure path. After the HTTP server (and possibly the mDNS broadcaster) are already started at lines 62-69, any throw from GStorage.putSetting (line 65 or 71) or refreshAddresses lands here and resets isRunning = false / port = null without calling _server.stop() / _mdns.stop().

The server keeps listening and mDNS keeps broadcasting while the controller reports stopped, and a later stop() returns early because of the if (!isRunning) return; guard (line 106) — so the user can never cleanly shut it down. The catch block should tear down _server and _mdns before clearing the flags.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if (!hadAny) {
// 对齐应用端:吐槽拉取失败只在 tab 内显示软状态 + 重试,不暴露
// 内部异常文本。镜像模式下吐槽接口常返 401,重试按钮让用户自行再试。
list.append(retryBlock("吐槽获取失败,请重试", () => renderTabBody()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The 吐槽 retry button calls renderTabBody() with no argument, but renderTabBody(key) (line 1602) dispatches on key. With key === undefined none of the branches match, so it only clears tabBody.innerHTML and renders nothing — clicking “重试” blanks the whole tab area instead of reloading the comments. Pass the current tab key:

Suggested change
list.append(retryBlock("吐槽获取失败,请重试", () => renderTabBody()));
list.append(retryBlock("吐槽获取失败,请重试", () => renderTabBody("tucao")));

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jun 24, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 3
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
lib/lan/proxy/video_proxy_handler.dart 290 SSRF / open-proxy: proxied sub-URL validated only by scheme, no host restriction; LAN-exposed unauthenticated server can be used to reach loopback/internal hosts.
lib/lan/lan_server_controller.dart 73 start() failure path resets isRunning/port without stopping the already-running server/mDNS, leaking the listener and making stop() a no-op.
lib/lan/web/web_app_script.dart 1734 吐槽 retry button calls renderTabBody() with no key, blanking the tab instead of reloading comments.

Notes / Assumptions

  • This is a large, mostly-new feature (LAN web server). The server binds to all IPv4 interfaces with wildcard CORS and no authentication, and exposes state-mutating endpoints (history/collect). This appears intentional for the LAN use case but materially widens the attack surface — the SSRF finding above is the most actionable consequence.
  • Lower-confidence hardening items not posted inline: unbounded proxy session map (no size cap, only TTL purge) in proxy_session_store.dart; potential pagehide listener accumulation across SPA episode navigation in web_player_script.dart; undisposed TextEditingController in lan_server_settings_page.dart; non-atomic re-entrancy guard allowing a double-start race in lan_server_controller.dart.
  • Generated/vendored files (*.g.dart, pubspec.lock, hls.min.js, plugin registrants) were not deeply reviewed.

Fix these issues in Kilo Cloud

Files Reviewed (key files)
  • lib/lan/lan_server.dart - 0 issues (wildcard CORS + no auth noted)
  • lib/lan/proxy/video_proxy_handler.dart - 1 issue
  • lib/lan/proxy/m3u8_rewriter.dart - 0 issues
  • lib/lan/proxy/proxy_session_store.dart - 0 issues (suggestion: no size cap)
  • lib/lan/lan_server_controller.dart - 1 issue
  • lib/lan/lan_mdns_broadcaster.dart - 0 issues
  • lib/lan/source_resolver.dart - 0 issues
  • lib/lan/theme_export.dart - 0 issues
  • lib/lan/web/web_app_script.dart - 1 issue
  • lib/lan/web/web_player_script.dart - 0 issues
  • lib/lan/web/web_styles.dart / lib/lan/web_index_html.dart - 0 issues
  • lib/pages/**, lib/services/storage/settings_keys.dart, lib/utils/constants.dart - 0 issues

Reviewed by claude-4.8-opus-20260528 · Input: 3.8K · Output: 18.1K · Cached: 1.6M

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant