Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
219aed6
feat(mobile): HTML 渲染态透传同目录资源,多文件产物不再缺图缺样式
zqchris Aug 3, 2026
9db6885
fix: address review — 资源改内联 data URI + CSP 断网络出口,取件补 SSH 上下文
zqchris Aug 3, 2026
f8171fd
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
3aa8bad
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
b236dd7
fix: address review — CSP 前置到任何作者内容之前,SVG fragment 回填保留
zqchris Aug 3, 2026
4267245
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
02a9b0e
fix: address review — 整页内联加总量预算,防不可信产物撑爆内存
zqchris Aug 3, 2026
2e0a3df
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
1d1e994
fix: address review — 资源取件后回收 OSS 对象,失败路径同样回收
zqchris Aug 3, 2026
fa4ed6b
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
11babf8
fix: address review — 预览零出网信道、惰性文本不占配额、OSS 失败窗口回收、提示分开两类
zqchris Aug 3, 2026
eff3e49
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
4d598a3
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
9cc0804
fix: address review — 模板体不占配额、下载前判上限、预算按回填倍数计费
zqchris Aug 3, 2026
d44452d
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
db46ead
fix: address review — 未闭合标记不再掩到文末;iframe/embed 不取回
zqchris Aug 3, 2026
7cdf3b0
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
f077b88
fix: address review — ossKey 在回包校验之前就交给调用方(零字节文件也会遗留对象)
zqchris Aug 3, 2026
64127cb
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
c7293b3
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
982363a
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
21a11d7
fix: address review — 删掉掩码层、收 style 属性 url()、解码字符引用、SSH 白名单同步、提示改口径
zqchris Aug 3, 2026
f44d871
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
ae008af
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
1f28d14
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
d9d92f2
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
7b86300
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
7a388a1
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
132e34e
Merge branch 'mobile-html-preview' into mobile-html-resource-passthrough
zqchris Aug 3, 2026
584e784
fix(mobile): 资源扫描跳过 RAWTEXT 内容,并修四处有界的识别缺陷
zqchris Aug 3, 2026
2111c1c
fix(mobile): image 白名单补 src —— HTML 里 <image> 是 <img> 的废弃别名
zqchris Aug 3, 2026
44837f4
perf(mobile): 跳过区间判定改二分,消掉自己引入的 O(n·m) DoS 面
zqchris Aug 3, 2026
1067881
fix(mobile): style 体也跳过标签扫描;Windows 路径判定两处统一到一份实现
zqchris Aug 3, 2026
330d2e5
test(mobile): 钉住两次 RAWTEXT span 扫描不可合并成一次+filter
zqchris Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 111 additions & 10 deletions apps/mobile/app/files/preview/[sessionId].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* OSS 导出原图就绪后无缝换源(不出 loading 态,规则 7);其它 = 占位 + 下载。
* markdown 与 HTML 额外有「渲染 / 源码」双态,默认渲染:两者都只用已读到的那份文本
* (不为渲染多走一遍 OSS 导出),载体分别是 MarkdownFileReader 与 HtmlFileReader。
* HTML 再多一步同目录资源透传:页面引用的相对资源经 media:fetch 逐个取回后回填
* (htmlLocalResources + useHtmlLocalResources),自包含页面零请求直接过。
*
* absPath 单文件模式(route 参 absPath,与 relPath 互斥):聊天 chip 指向
* workdir 外文件时进入。file-browser 的 relPath 通道(listDir / readFile /
Expand Down Expand Up @@ -51,7 +53,15 @@ import { decodeGzipBase64Text, mergePathIntoComposerDraft, shareMimeForFileName
import { appendQuote, truncateQuoteText } from '@/session/chatQuoteStore';
import { getCachedPreviewText, storeCachedPreviewText } from '@/session/fileBrowserCache';
import { exportRemoteFileToUrl } from '@/session/fileBrowserExport';
import type { RemoteMediaSshContext } from '@/session/fileBrowserGallery';
import { HtmlFileReader } from '@/session/HtmlFileReader';
import {
HTML_RESOURCE_LIMIT,
HTML_RESOURCE_MAX_BYTES,
htmlBaseDirOf,
type HtmlResourceFetchTarget,
} from '@/session/htmlLocalResources';
import { useHtmlLocalResources } from '@/session/useHtmlLocalResources';
import { MarkdownFileReader } from '@/session/MarkdownFileReader';
import { RemoteMediaPlayerWebView } from '@/session/mediaPlayerWebView';
import {
Expand All @@ -66,7 +76,7 @@ import { ImageLightbox } from '@/session/ImageLightbox';
import { buildMediaPayload } from '@/session/messagePayload';
import type { MobileMessageGalleryImage } from '@/session/messageGallery';
import type { MobileRemoteMediaPresignResult } from '@/session/remoteMedia';
import { downloadRemoteMediaShareTemp } from '@/session/remoteMediaDiskCacheExpo';
import { downloadRemoteMediaAsDataUri, downloadRemoteMediaShareTemp } from '@/session/remoteMediaDiskCacheExpo';
import { remoteSessionStore, useRemoteSessions } from '@/session/remoteSessionStore';
import type { RemoteSession } from '@/session/types';
import { fontWeight, lineHeight, monoFont, useTheme, useThemedStyles, type ThemeColors } from '@/theme';
Expand Down Expand Up @@ -323,6 +333,45 @@ export default function RemoteFilePreviewScreen() {
[deviceId, maker, openLink, presignGet, singleAbsPath, workdir],
);

// SSH 远程工作区的取件上下文:三项必须同时给(被控端 parseSshMediaOrigin 会按
// sessionId 反查会话库逐项比对);本机会话为 null,取件走被控桌面本机路径。
const sshMediaContext = useMemo((): RemoteMediaSshContext | null => {
const remoteHostId = session?.remoteHostId?.trim();
if (!remoteHostId || !sessionId || !workdir) return null;
return { sessionId, remoteHostId, workdir };
}, [session?.remoteHostId, sessionId, workdir]);

/**
Comment thread
greptile-apps[bot] marked this conversation as resolved.
* 任意被控端绝对路径 → **`data:` URI**(HTML 渲染态取同目录资源用)。
*
* 与 exportToUrl 的区别:后者只服务「当前这个文件」(workdir 内走两段式导出、
* absPath 模式走单一路径取件);资源透传要取的是**页面引用的其它路径**,所以
* 统一走 media:fetch 的绝对路径通道 —— 它对 workdir 内外一视同仁,一条路径一条码。
* 路径合法性(必须是 HTML 所在目录子树内的相对引用)已在 htmlLocalResources
* 里 fail-closed 判定,这里不再重复。
*
* SSH 会话必须带上 sshMediaContext,否则被控端会把 absPath 当本机路径解析
* (review P2:取件必失败,同名路径还会读到错误来源)。
*/
const fetchResourceDataUri = useCallback(
async (target: HtmlResourceFetchTarget): Promise<string> => {
const url = await fetchRemoteAbsFileToUrl(
Comment thread
zqchris marked this conversation as resolved.
Outdated
{ maker, deviceId, openLink, presignGet },
target.absPath,
sshMediaContext,
);
// 预签名地址只在这里用一次:下载完即转成 data: URI,**绝不回填进页面**
// (页面里的脚本能读 DOM,凭证进 DOM 等于交给不可信文档,review P1)。
const dataUri = await downloadRemoteMediaAsDataUri(
url,
target.mimeType,
HTML_RESOURCE_MAX_BYTES,
);
Comment thread
zqchris marked this conversation as resolved.
Outdated
return dataUri ?? '';
},
[deviceId, maker, openLink, presignGet, sshMediaContext],
);

// 文本预览读文件也走瞬断重试 + openLink(与列表/搜索/导出同一路径),
// relay 短暂重连不再把预览页打成「读取失败」。
// absPath 单文件模式走 text-file:read-preview(被控端绝对路径文本通道),
Expand Down Expand Up @@ -436,12 +485,14 @@ export default function RemoteFilePreviewScreen() {
renderItem={({ item, index }) => (
<View style={{ width: pageWidth }}>
<FilePreviewPage
absolutePathOf={absolutePathOf}
active={Math.abs(index - pageIndex) <= 1}
// HTML 渲染态只在真正可见的当前页挂载可执行 WebView(review P1):
// active 含相邻页(文本预取要它),但相邻页提前挂 WebView 会让用户还没
// 滑到的文件里的脚本 / 计时器 / 网络请求先跑起来,滑走后还继续跑。
visible={index === pageIndex}
exportToUrl={exportToUrl}
fetchResourceDataUri={fetchResourceDataUri}
item={item}
maker={maker}
onDownload={() => void downloadAndShare(item)}
Expand Down Expand Up @@ -545,8 +596,10 @@ function PreviewNav({
}

function FilePreviewPage({
absolutePathOf,
active,
exportToUrl,
fetchResourceDataUri,
item,
maker,
onDownload,
Expand All @@ -558,8 +611,10 @@ function FilePreviewPage({
visible,
workdir,
}: {
absolutePathOf(relPath: string): string;
active: boolean;
exportToUrl(relPath: string, mtimeMs: number): Promise<string>;
fetchResourceDataUri(target: HtmlResourceFetchTarget): Promise<string>;
item: FileBrowserGridItem;
maker: Pick<MobileMakerTransport, 'fileBrowser'>;
onDownload(): void;
Expand Down Expand Up @@ -594,7 +649,20 @@ function FilePreviewPage({
return <AvPreviewPage active={active} exportToUrl={exportToUrl} item={item} kind={avKind} onDownload={onDownload} workdir={workdir} />;
}
if (item.thumb === 'doc') {
return <TextPreviewPage active={active} item={item} onDownload={onDownload} onQuoteSelection={onQuoteSelection} readTextFile={readTextFile} targetLine={targetLine} visible={visible} workdir={workdir} />;
return (
<TextPreviewPage
absolutePathOf={absolutePathOf}
active={active}
fetchResourceDataUri={fetchResourceDataUri}
item={item}
onDownload={onDownload}
onQuoteSelection={onQuoteSelection}
readTextFile={readTextFile}
targetLine={targetLine}
visible={visible}
workdir={workdir}
/>
);
}
return <UnsupportedPage item={item} onDownload={onDownload} reason={t('files.preview.unsupportedType')} />;
}
Expand Down Expand Up @@ -744,7 +812,9 @@ function PdfPreviewPage({
* markdown / HTML(richTextKindOf)多一层「渲染 / 源码」切换,渲染态复用同一份已读文本。
*/
function TextPreviewPage({
absolutePathOf,
active,
fetchResourceDataUri,
item,
onDownload,
onQuoteSelection,
Expand All @@ -753,7 +823,11 @@ function TextPreviewPage({
visible,
workdir,
}: {
/** item.relPath → 被控端绝对路径(HTML 资源透传要据此定位同目录)。 */
absolutePathOf(relPath: string): string;
active: boolean;
/** 页面引用的资源 → `data:` URI(签名地址不进页面,见屏级 fetchResourceDataUri)。 */
fetchResourceDataUri(target: HtmlResourceFetchTarget): Promise<string>;
item: FileBrowserGridItem;
onDownload(): void;
/** chat-text-quote:markdown 渲染态的选中引用回调(源码态暂不支持,见 PR 说明)。 */
Expand Down Expand Up @@ -840,6 +914,23 @@ function TextPreviewPage({
};
}, [active, cacheable, item.relPath, richKind, readTextFile, t, workdir]);

// HTML 资源透传:页面引用的同目录资源取回后回填,自包含页面零请求直接过。
// hook 必须在下面的早返回之前无条件调用 —— 未就绪时传空串,内部即刻短路。
const htmlSource = richKind === 'html' && state.status === 'ready' ? (state.content ?? '') : '';
const htmlBaseDir = useMemo(
() => (htmlSource ? htmlBaseDirOf(absolutePathOf(item.relPath)) : ''),
Comment thread
zqchris marked this conversation as resolved.
[absolutePathOf, htmlSource, item.relPath],
);
const htmlResources = useHtmlLocalResources(htmlSource, htmlBaseDir, fetchResourceDataUri);
Comment thread
zqchris marked this conversation as resolved.
const resourceNotices = [
htmlResources.failed > 0
? t('files.preview.htmlResourcesMissing', { count: htmlResources.failed })
: null,
htmlResources.skipped > 0
? t('files.preview.htmlResourcesTruncated', { limit: HTML_RESOURCE_LIMIT })
: null,
Comment thread
zqchris marked this conversation as resolved.
Outdated
Comment thread
zqchris marked this conversation as resolved.
].filter((line): line is string => line !== null);

if (state.status === 'loading') {
return (
<View style={styles.centerFill}>
Expand Down Expand Up @@ -885,20 +976,30 @@ function TextPreviewPage({
))}
</View>
) : null}
{showRendered && richKind === 'html' && resourceNotices.length > 0 ? (
<View style={styles.truncBar} testID="filePreview.htmlResourceNotice">
<Info color={colors.textSecondary} size={iconSize.sm} strokeWidth={iconStroke.regular} />
<Text style={styles.truncText}>{resourceNotices.join(' · ')}</Text>
</View>
) : null}
{showRendered ? (
richKind === 'html' ? (
// HTML 生成物:已读到的文本直接进 WebView(不走 OSS 导出),同目录相对资源
// 取不到是已知边界,见 HtmlFileReader 头注。
//
// **只在真正可见的当前页挂载**(review P1):HTML 里的脚本是可执行的不可信
// 内容,相邻预取页提前挂 WebView 会让用户还没打开的文件里的脚本 / 计时器 /
// 网络请求先跑起来。离开当前页即卸载 —— 卸载 WebView 是停掉这些东西最彻底
// 的方式(比 injectJavaScript 去逐个 clearInterval 可靠)。文本预取不受影响,
// 所以滑回来时无需重新取件。
visible ? (
<HtmlFileReader html={state.content ?? ''} testID="filePreview.htmlRendered" />
) : (
// 的方式(比 injectJavaScript 去逐个 clearInterval 可靠)。文本预取与资源
// 取件都不受影响,所以滑回来时无需重新取。
!visible ? (
<View style={styles.centerFill} testID="filePreview.htmlOffscreen" />
) : htmlResources.loading ? (
// 取件期间不先渲染破图再热替换 —— 那会让 WebView 重载、页面闪一下。
<View style={styles.centerFill} testID="filePreview.htmlResourceLoading">
<ActivityIndicator color={colors.textTertiary} />
<Text style={styles.hintText}>{t('files.preview.fetchingHtmlResources')}</Text>
</View>
) : (
// HTML 生成物:已读到的文本 + 内联好的同目录资源进 WebView。
<HtmlFileReader html={htmlResources.html} testID="filePreview.htmlRendered" />
)
) : (
<MarkdownFileReader markdown={state.content ?? ''} onQuoteSelection={onQuoteSelection} targetLine={targetLine} testID="filePreview.markdownRendered" />
Expand Down
29 changes: 24 additions & 5 deletions apps/mobile/src/__tests__/filePreviewPagerWiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const htmlReaderSource = readFileSync(

describe('HTML 渲染态的 WebView 约束', () => {
it('显式给 baseUrl,不吃两端默认值不一致(Android 空串会吞掉页内锚点)', () => {
expect(htmlReaderSource).toContain("source={{ baseUrl: 'about:blank', html }}");
expect(htmlReaderSource).toContain("source={{ baseUrl: 'about:blank', html: guardedHtml }}");
});

it('回调是唯一导航决策点:originWhitelist 不得收窄', () => {
Expand Down Expand Up @@ -71,22 +71,41 @@ describe('HTML 渲染态的 WebView 约束', () => {
// 相邻预取页(active)不得提前挂 WebView:里面的脚本 / 计时器 / 网络请求会在
// 用户还没打开该文件时就跑起来,滑走后还继续跑(review P1)。
expect(source).toContain('visible={index === pageIndex}');
expect(source).toContain('visible ? (\n <HtmlFileReader');
expect(source).toContain('!visible ? (');
expect(source).toContain('<HtmlFileReader html={htmlResources.html}');
expect(source).toContain('testID="filePreview.htmlOffscreen"');
// 挂载门必须是 visible 而不是 active。
expect(source).not.toMatch(/active\s*\?\s*\(\s*<HtmlFileReader/);
});
});

describe('HTML 生成物的渲染态接线', () => {
it('渲染态复用已读文本,不为 HTML 另走一遍 OSS 导出', () => {
// 取件通道保持一条:richKind 非空时才留原文,渲染态直接把它喂 HtmlFileReader
it('文档正文复用已读文本,不为 HTML 另走一遍 OSS 两段式导出', () => {
// 取件通道保持一条:richKind 非空时才留原文,渲染态用的就是它(经资源回填)
expect(source).toContain('content: richKind ? content : undefined');
expect(source).toContain("<HtmlFileReader html={state.content ?? ''}");
expect(source).toContain('<HtmlFileReader html={htmlResources.html}');
// HTML 不得混进 exportToUrl 那条(图片 / PDF / 音视频 / 下载共用的)导出链路。
expect(source).not.toMatch(/HtmlFileReader[^>]*exportToUrl/);
});

it('同目录资源走 media:fetch 绝对路径通道,不复用 exportToUrl', () => {
// exportToUrl 只服务「当前这个文件」;资源要取的是页面引用的其它路径。
expect(source).toContain('useHtmlLocalResources(htmlSource, htmlBaseDir, fetchResourceDataUri)');
// 精确取回调体判定(不用邻近匹配:props 列表里两个名字相邻会误报)。
const body = /const fetchResourceDataUri = useCallback\(([\s\S]*?)\n \);/.exec(source);
expect(body, '未找到 fetchResourceDataUri 实现').not.toBeNull();
expect(body![1]).toContain('fetchRemoteAbsFileToUrl(');
expect(body![1]).toContain('downloadRemoteMediaAsDataUri(');
// exportToUrl 只服务「当前这个文件」,资源要取的是页面引用的其它路径。
expect(body![1]).not.toContain('exportToUrl');
});

it('资源被跳过 / 取不到时如实提示,不静默截断', () => {
expect(source).toContain("t('files.preview.htmlResourcesMissing'");
expect(source).toContain("t('files.preview.htmlResourcesTruncated'");
expect(source).toContain('testID="filePreview.htmlResourceNotice"');
});

it('markdown 与 HTML 共用同一套双态机(不再是 markdown 专用)', () => {
expect(source).toContain("const richKind = richTextKindOf(item.name)");
expect(source).toContain("useState<'rendered' | 'source'>(richKind ? 'rendered' : 'source')");
Expand Down
Loading
Loading