From 02225feeb6c1f9ae27ef414039ad0252cbb46a96 Mon Sep 17 00:00:00 2001 From: heiqiu47 Date: Sat, 10 Oct 2026 07:55:31 +0800 Subject: [PATCH 1/7] fix(android): match system bars to page backgrounds --- mobile/android/assets/mobile-system-bars.js | 46 ++++++++++++++++ .../try2love/codexbridge/MainActivity.java | 11 ++-- .../codexbridge/MobileWebScripts.java | 2 +- .../try2love/codexbridge/SystemBars.java | 54 +++++++++++++++++++ mobile/tests/resources.test.cjs | 2 +- 5 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 mobile/android/assets/mobile-system-bars.js create mode 100644 mobile/android/src/io/github/try2love/codexbridge/SystemBars.java diff --git a/mobile/android/assets/mobile-system-bars.js b/mobile/android/assets/mobile-system-bars.js new file mode 100644 index 00000000..dfe006ce --- /dev/null +++ b/mobile/android/assets/mobile-system-bars.js @@ -0,0 +1,46 @@ +(() => { + if (window !== window.top || window.BridgeNativeSystemBars) return; + window.BridgeNativeSystemBars = true; + let pending = false, previous = ''; + function opaqueColor(value) { + const match = /^rgba?\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/.exec(value); + if (!match || match[4] !== undefined && Number(match[4]) !== 1) return null; + return '#' + match.slice(1, 4).map(v => Number(v).toString(16).padStart(2, '0')).join(''); + } + function colorAt(y) { + let element = document.elementFromPoint(innerWidth / 2, y); + while (element) { + const color = opaqueColor(getComputedStyle(element).backgroundColor); + if (color) return color; + element = element.parentElement; + } + return opaqueColor(getComputedStyle(document.documentElement).backgroundColor) || '#ffffff'; + } + function refresh() { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + const colors = JSON.stringify({ top: colorAt(1), bottom: colorAt(Math.max(1, innerHeight - 1)) }); + if (colors === previous) return; + previous = colors; + window.prompt('codexbridge-system-bars:__BRIDGE_CLIPBOARD_TOKEN__', colors); + }); + } + const watchDialog = dialog => observer.observe(dialog, { attributes: true, attributeFilter: ['open'] }); + const observer = new MutationObserver(records => { + for (const record of records) { + if (record.type === 'childList') { + for (const node of record.addedNodes) if (node.nodeName === 'DIALOG') watchDialog(node); + } + } + refresh(); + }); + // Watch only page containers and dialog state, keeping streamed message mutations out of this observer. + for (const element of [document.documentElement, document.body, document.getElementById('app')]) { + if (element) observer.observe(element, { attributes: true, childList: element === document.body, attributeFilter: ['class', 'style', 'data-theme', 'hidden'] }); + } + document.querySelectorAll('dialog').forEach(watchDialog); + window.addEventListener('resize', refresh); + refresh(); +})(); diff --git a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java index 076cd3cd..8b61f9da 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MainActivity.java @@ -13,7 +13,7 @@ import java.util.concurrent.*; public final class MainActivity extends Activity { - private LinearLayout root,bar;private WebView web;private String origin="";private TextView status; + private LinearLayout root,bar;private WebView web;private String origin="";private TextView status;private SystemBars systemBars; private android.content.SharedPreferences prefs;private final ExecutorService worker=Executors.newSingleThreadExecutor(); private final ExecutorService probes=Executors.newFixedThreadPool(3); private final Handler homeHandler=new Handler(Looper.getMainLooper()); @@ -71,15 +71,14 @@ void recoverConnection(String requested){ void message(String value){resetExitGesture();new AlertDialog.Builder(this).setMessage(L(value)).setPositiveButton(L("好"),null).show();} Set saved(){return new LinkedHashSet<>(prefs.getStringSet("origins",new HashSet<>()));} - @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.setFitsSystemWindows(Build.VERSION.SDK_INT<30);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen); + @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);}); if(Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::handleBack); - if(Build.VERSION.SDK_INT>=30){getWindow().setDecorFitsSystemWindows(false);screen.setOnApplyWindowInsetsListener((v,insets)->{android.graphics.Insets i=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()|WindowInsets.Type.ime());v.setPadding(i.left,i.top,i.right,i.bottom);if(downloadCard!=null)downloadCard.post(downloadCard::place);return WindowInsets.CONSUMED;});} bar=new LinearLayout(this);bar.setGravity(Gravity.CENTER_VERTICAL);bar.setPadding(dp(20),dp(4),dp(12),dp(4));root.addView(bar);header(false); downloadCard=new DownloadCard(this,screen,this::downloadAction,this::cancelDownload); status=text("",12);status.setTextColor(0xff6b7075);status.setGravity(Gravity.CENTER);root.addView(status); if(!openIntent(getIntent())){String active=prefs.getString("active","");if(saved().contains(active))connect(active+"/");else home();}monitor();root.post(this::requestStartupNotifications); } - void clear(){pendingStorage=null;if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}if(files!=null){files.onReceiveValue(null);files=null;fileView=null;}stopComputerChecks();homeStates.clear();homeNotifications.clear();resetExitGesture();generation++;if(web!=null){web.stopLoading();web.destroy();web=null;}while(root.getChildCount()>2)root.removeViewAt(2);} + void clear(){pendingStorage=null;if(routeDiscovery!=null){routeDiscovery.cancel();routeDiscovery=null;}if(files!=null){files.onReceiveValue(null);files=null;fileView=null;}stopComputerChecks();homeStates.clear();homeNotifications.clear();resetExitGesture();generation++;if(web!=null){web.stopLoading();web.destroy();web=null;}systemBars.update(0xfff5f5f3,0xfff5f5f3);while(root.getChildCount()>2)root.removeViewAt(2);} void home(){clear();header(false);status.setVisibility(View.GONE);ScrollView scroll=new ScrollView(this);scroll.setFillViewport(true);LinearLayout content=column();content.setPadding(dp(20),dp(16),dp(20),dp(24));scroll.addView(content);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1)); ImageView icon=new ImageView(this);icon.setImageResource(getResources().getIdentifier("ic_computer","drawable",getPackageName()));icon.setScaleType(ImageView.ScaleType.FIT_CENTER);LinearLayout.LayoutParams ip=new LinearLayout.LayoutParams(dp(64),dp(58));ip.gravity=Gravity.CENTER;ip.bottomMargin=dp(24);content.addView(icon,ip); TextView headline=text(L("电脑上的工作,\n带在身边。"),30);headline.setTypeface(null,android.graphics.Typeface.BOLD);headline.setGravity(Gravity.CENTER);add(headline,content,10); @@ -141,6 +140,10 @@ void checkComputers(){ }); web.setWebChromeClient(new WebChromeClient(){ @Override public boolean onJsPrompt(WebView view,String url,String prompt,String value,JsPromptResult result){ + if(prompt.equals("codexbridge-system-bars:"+clipboardToken)){ + if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin)&&value!=null&&value.length()<100){try{JSONObject colors=new JSONObject(value);String top=colors.getString("top"),bottom=colors.getString("bottom");if(top.matches("#[0-9a-fA-F]{6}")&&bottom.matches("#[0-9a-fA-F]{6}")){systemBars.update(android.graphics.Color.parseColor(top),android.graphics.Color.parseColor(bottom));result.confirm("saved");return true;}}catch(Exception ignored){}} + result.cancel();return true; + } if(prompt.equals("codexbridge-computer:"+clipboardToken)){ if(view==web&&GatewayURL.sameOrigin(url,origin)&&GatewayURL.sameOrigin(view.getUrl(),origin))result.confirm(computerName(origin));else result.cancel();return true; } diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java b/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java index 6bc08aa1..2216cb4c 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileWebScripts.java @@ -2,5 +2,5 @@ // Asset loading only; WebView navigation and prompt validation stay in MainActivity. final class MobileWebScripts { - static String appearance(android.content.res.AssetManager assets){try(java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream()){for(String asset:new String[]{"mobile-ui.js","mobile-clipboard.js","mobile-session.js"}){try(java.io.InputStream in=assets.open(asset)){byte[] data=new byte[4096];int size;while((size=in.read(data))!=-1)out.write(data,0,size);}}return out.toString("UTF-8");}catch(java.io.IOException e){throw new IllegalStateException("Missing mobile layout",e);}} + static String appearance(android.content.res.AssetManager assets){try(java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream()){for(String asset:new String[]{"mobile-ui.js","mobile-clipboard.js","mobile-session.js","mobile-system-bars.js"}){try(java.io.InputStream in=assets.open(asset)){byte[] data=new byte[4096];int size;while((size=in.read(data))!=-1)out.write(data,0,size);}}return out.toString("UTF-8");}catch(java.io.IOException e){throw new IllegalStateException("Missing mobile layout",e);}} } diff --git a/mobile/android/src/io/github/try2love/codexbridge/SystemBars.java b/mobile/android/src/io/github/try2love/codexbridge/SystemBars.java new file mode 100644 index 00000000..067f7bd0 --- /dev/null +++ b/mobile/android/src/io/github/try2love/codexbridge/SystemBars.java @@ -0,0 +1,54 @@ +package io.github.try2love.codexbridge; + +import android.app.Activity; +import android.graphics.*; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.view.*; + +final class SystemBars { + private final Window window; + private final BarBackground background=new BarBackground(); + SystemBars(Activity activity,View screen,Runnable insetsChanged){ + this.window=activity.getWindow(); + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS|WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION); + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + window.setStatusBarColor(Color.TRANSPARENT);window.setNavigationBarColor(Color.TRANSPARENT); + if(Build.VERSION.SDK_INT>=28)window.setNavigationBarDividerColor(Color.TRANSPARENT); + if(Build.VERSION.SDK_INT>=29){window.setStatusBarContrastEnforced(false);window.setNavigationBarContrastEnforced(false);} + if(Build.VERSION.SDK_INT>=30)window.setDecorFitsSystemWindows(false); + else window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE|View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN|View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION); + screen.setOnApplyWindowInsetsListener((v,insets)->{ + if(Build.VERSION.SDK_INT>=30){ + Insets i=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()|WindowInsets.Type.ime()); + v.setPadding(i.left,i.top,i.right,i.bottom); + insetsChanged.run(); + return WindowInsets.CONSUMED; + } + int left=insets.getSystemWindowInsetLeft(),top=insets.getSystemWindowInsetTop(),right=insets.getSystemWindowInsetRight(),bottom=insets.getSystemWindowInsetBottom(); + if(Build.VERSION.SDK_INT>=28&&insets.getDisplayCutout()!=null){DisplayCutout cutout=insets.getDisplayCutout();left=Math.max(left,cutout.getSafeInsetLeft());top=Math.max(top,cutout.getSafeInsetTop());right=Math.max(right,cutout.getSafeInsetRight());bottom=Math.max(bottom,cutout.getSafeInsetBottom());} + v.setPadding(left,top,right,bottom); + insetsChanged.run(); + return insets.consumeSystemWindowInsets(); + }); + // Insets are consumed here, so the native background fills the space outside the WebView. + screen.setBackground(background); + update(0xfff5f5f3,0xfff5f5f3); + screen.requestApplyInsets(); + } + void update(int top,int bottom){ + background.top=top;background.bottom=bottom;background.invalidateSelf(); + boolean lightTop=light(top),lightBottom=light(bottom); + if(Build.VERSION.SDK_INT>=30){WindowInsetsController controller=window.getInsetsController();if(controller!=null)controller.setSystemBarsAppearance((lightTop?WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS:0)|(lightBottom?WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS:0),WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS|WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS);} + else {View decor=window.getDecorView();int flags=decor.getSystemUiVisibility()&~(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR|View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);decor.setSystemUiVisibility(flags|(lightTop?View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR:0)|(lightBottom?View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR:0));} + } + private static final class BarBackground extends Drawable { + private final Paint paint=new Paint(); + private int top,bottom,alpha=255; + @Override public void draw(Canvas canvas){Rect b=getBounds();int middle=b.top+b.height()/2;paint.setColor(top);paint.setAlpha(alpha);canvas.drawRect(b.left,b.top,b.right,middle,paint);paint.setColor(bottom);paint.setAlpha(alpha);canvas.drawRect(b.left,middle,b.right,b.bottom,paint);} + @Override public void setAlpha(int alpha){this.alpha=alpha;invalidateSelf();} + @Override public void setColorFilter(ColorFilter filter){paint.setColorFilter(filter);invalidateSelf();} + @Override public int getOpacity(){return alpha==255?PixelFormat.OPAQUE:PixelFormat.TRANSLUCENT;} + } + private static boolean light(int color){return Color.red(color)*.299+Color.green(color)*.587+Color.blue(color)*.114>=128;} +} diff --git a/mobile/tests/resources.test.cjs b/mobile/tests/resources.test.cjs index 0db46e9a..bd88e539 100644 --- a/mobile/tests/resources.test.cjs +++ b/mobile/tests/resources.test.cjs @@ -6,7 +6,7 @@ const path = require('node:path'); const root = path.resolve(__dirname, '../..'); const read = value => fs.readFileSync(path.join(root, value), 'utf8'); const shared = ['mobile-ui.js', 'mobile-clipboard.js']; -const android = [...shared, 'mobile-session.js']; +const android = [...shared, 'mobile-session.js', 'mobile-system-bars.js']; function xcodeObjects() { const source = read('mobile/ios/BridgePreview.xcodeproj/project.pbxproj'); From 8a439a1eb5896f94298a6e2fd52257d19b96a5c0 Mon Sep 17 00:00:00 2001 From: heiqiu47 Date: Sat, 10 Oct 2026 09:46:24 +0800 Subject: [PATCH 2/7] fix(files): handle Windows file links --- bridge/app/service.py | 4 +-- bridge/features/workspace/files.py | 15 +++++---- site/demo-product/web/markdown.js | 3 +- tests/browser/markdown.browser.js | 6 ++++ tests/test_bridge.py | 54 ++++++++++++++++++++++++++++-- 5 files changed, 70 insertions(+), 12 deletions(-) diff --git a/bridge/app/service.py b/bridge/app/service.py index e1a95972..71ea4e14 100644 --- a/bridge/app/service.py +++ b/bridge/app/service.py @@ -1198,7 +1198,7 @@ def view(self, thread_id, attach=True, background=False, force=False): view["goalActivationIds"] = sorted(self._goal_activation_ids(thread_id)) with session.condition: artifacts = artifact_paths(session.state or {}, self.store.home) if self.host == "local" else {} - view["files"] = [{"id": k, "name": v["name"], "reference": v["reference"], "image": v["image"]} for k, v in artifacts.items()] + view["files"] = [{"id": k, "name": v["name"], "reference": v["reference"], "references": v["references"], "image": v["image"]} for k, v in artifacts.items()] self._overlay_native_goal(session, thread_id, view) self._submission_meta(session, view) view["forkedFrom"] = self._fork_origin(thread_id) @@ -1511,7 +1511,7 @@ def timeline_read(self, thread_id, mode='page', **options): {'items': media_items}, ]} artifacts = artifact_paths(file_state, self.store.home) if self.host == 'local' else {} - result['files'] = [{'id': k, 'name': v['name'], 'reference': v['reference'], 'image': v['image']} for k, v in artifacts.items()] + result['files'] = [{'id': k, 'name': v['name'], 'reference': v['reference'], 'references': v['references'], 'image': v['image']} for k, v in artifacts.items()] if mode != 'detail': self._submission_meta(session, result['meta']) result['meta']['forkedFrom'] = self._fork_origin(thread_id) diff --git a/bridge/features/workspace/files.py b/bridge/features/workspace/files.py index 17648bb2..47aaa757 100644 --- a/bridge/features/workspace/files.py +++ b/bridge/features/workspace/files.py @@ -28,6 +28,9 @@ def reference_path(value, allow_relative=False): """Resolve a local file reference without allowing remote URL access.""" if not isinstance(value, str) or not value: return None + # Chat links may prefix Windows drive paths with a URL-style slash. + if os.name == 'nt' and re.fullmatch(r'/[A-Za-z]:[\\/].*', value): + value = value[1:] try: parsed = urlsplit(value) if parsed.scheme == 'file': @@ -136,9 +139,9 @@ def artifact_paths(state, codex_home): except OSError: continue key = hashlib.sha256(str(path_identity(path)).encode()).hexdigest() - result[key] = {'path': path, 'reference': raw, 'name': path.name, - 'kind': 'directory' if path.is_dir() else 'file', - 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES} + artifact = result.setdefault(key, {'path': path, 'reference': raw, + 'references': [], 'name': path.name, 'kind': 'directory' if path.is_dir() else 'file', 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES}) + artifact['references'].append(raw) # A path can be shown once as ImageView and embedded later with another text # form (usually a plain absolute path versus file://). Only the runtime view # grants the outside-workspace exception; collect all references so Markdown @@ -158,9 +161,9 @@ def artifact_paths(state, codex_home): file_refs = sorted(ref for ref in references if urlsplit(ref).scheme == 'file') reference = plain[0] if plain else (file_refs[0] if file_refs else next(iter(references))) key = hashlib.sha256(str(path).encode()).hexdigest() - result[key] = {'path': path, 'reference': reference, 'name': path.name, - 'kind': 'directory' if path.is_dir() else 'file', - 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES} + references.update(result.get(key, {}).get('references', [])) + result[key] = {'path': path, 'reference': reference, 'references': sorted(references), 'name': path.name, + 'kind': 'directory' if path.is_dir() else 'file', 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES} return result diff --git a/site/demo-product/web/markdown.js b/site/demo-product/web/markdown.js index 935d2441..cdcebe73 100644 --- a/site/demo-product/web/markdown.js +++ b/site/demo-product/web/markdown.js @@ -9,7 +9,8 @@ window.renderMarkdown = function(node, text, files = [], fileUrl = () => '', options = {}) { const body = document.createElement('div'); body.className = 'markdown-body'; - const artifacts = new Map(files.map(file => [markdown.normalizeLink(file.reference), file])); + const artifacts = new Map(files.flatMap(file => + [file.reference, ...(file.references || [])].map(reference => [markdown.normalizeLink(reference), file]))); function target(reference) { const file = artifacts.get(reference); if (file) return {url:fileUrl(file), file}; diff --git a/tests/browser/markdown.browser.js b/tests/browser/markdown.browser.js index 55862474..83635bd2 100644 --- a/tests/browser/markdown.browser.js +++ b/tests/browser/markdown.browser.js @@ -31,6 +31,12 @@ check('workspace links keep authenticated host route', artifacts.querySelector('a')?.getAttribute('href') === '/api/sessions/test/files/report?host=' + encodeURIComponent(host)); check('local images keep authenticated host route', artifacts.querySelector('img')?.getAttribute('src') === '/api/sessions/test/files/image?host=' + encodeURIComponent(host)); + const aliases = render('[plain](D:/workspace/report.md) [slash](/D:/workspace/report.md)', + [{id:'report', reference:'D:/workspace/report.md', references:['D:/workspace/report.md', '/D:/workspace/report.md'], name:'report.md', image:false}], + file => '/api/sessions/test/files/' + file.id); + check('all references to one file remain clickable', aliases.querySelectorAll('a').length === 2 && + [...aliases.querySelectorAll('a')].every(anchor => anchor.getAttribute('href') === '/api/sessions/test/files/report')); + for (let i = 0; i <= sample.length; i++) render(sample.slice(0, i)); check('partial streaming Markdown does not throw', true); diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 4917d7e5..6e0907bd 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -648,9 +648,38 @@ def test_identity_verification_not_exposed(self): @unittest.skipUnless(os.name == 'nt', 'Windows drive path syntax') def test_reference_path_accepts_windows_drive_paths(self): from bridge.features.workspace.files import reference_path - path = reference_path('D:\\workspace\\report.txt') - self.assertTrue(path.is_absolute()) - self.assertEqual(path, PureWindowsPath('D:/workspace/report.txt')) + for reference in ('D:\\workspace\\report.txt', 'D:/workspace/report.txt', + '/D:/workspace/report.txt', '/D:\\workspace\\report.txt', + 'file:///D:/workspace/report.txt'): + with self.subTest(reference=reference): + path = reference_path(reference) + self.assertTrue(path.is_absolute()) + self.assertEqual(path, PureWindowsPath('D:/workspace/report.txt')) + + @unittest.skipUnless(os.name == 'nt', 'Windows drive path syntax') + def test_slash_prefixed_windows_artifacts_remain_in_workspace(self): + with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: + root = Path(directory) + workspace = root / 'workspace';workspace.mkdir() + outside = root / 'private.md';outside.write_text('private', encoding='utf-8') + for name in ('report.md', 'app.apk'): + allowed = workspace / name;allowed.write_bytes(b'test') + reference = '/' + allowed.as_posix() + escape = '/' + workspace.as_posix() + '/../private.md' + value = {**state(), 'cwd': str(workspace), 'turns': [{'items': [ + {'type': 'agentMessage', 'text': f'[file]({reference}) [private](/{outside.as_posix()}) [escape]({escape})'}, + ]}]} + with self.subTest(name=name): + files = artifact_paths(value, root / '.codex') + self.assertEqual(len(files), 1) + artifact = next(iter(files.values())) + self.assertEqual(artifact['path'], allowed.resolve()) + self.assertEqual(artifact['reference'], reference) + + @unittest.skipIf(os.name == 'nt', 'POSIX path syntax') + def test_slash_prefixed_drive_is_preserved_on_posix(self): + from bridge.features.workspace.files import reference_path + self.assertEqual(reference_path('/D:/workspace/report.md'), Path('/D:/workspace/report.md')) @unittest.skipUnless(os.name == 'nt', 'Windows drive path syntax') def test_windows_drive_markdown_reference_remains_in_workspace(self): @@ -675,6 +704,25 @@ def test_file_uri_image_view_links_plain_markdown_reference(self): files = artifact_paths(value, root / '.codex') self.assertEqual(len(files), 1) self.assertEqual(next(iter(files.values()))['reference'], str(image)) + self.assertEqual(set(next(iter(files.values()))['references']), {str(image), image.as_uri()}) + + def test_artifact_retains_all_references_to_same_file(self): + with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: + root = Path(directory) + for name in ('report.md', 'app.apk'): + path = root / name;path.write_bytes(b'test') + references = [str(path), path.as_uri(), str(path) + ':12'] + if os.name == 'nt': + references.extend([path.as_posix(), '/' + path.as_posix()]) + value = {**state(), 'cwd': str(root), 'turns': [{'items': [ + {'type': 'agentMessage', 'text': ' '.join(f'[file]({reference})' for reference in references)}, + ]}]} + with self.subTest(name=name): + files = artifact_paths(value, root / '.codex') + self.assertEqual(len(files), 1) + artifact = next(iter(files.values())) + self.assertEqual(set(artifact['references']), set(references)) + self.assertIn(artifact['reference'], references) def test_artifacts_only_referenced_workspace_files(self): with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: From 1cb88e1ca435b7f9a279c5869aaddb8b2dc8ed6b Mon Sep 17 00:00:00 2001 From: heiqiu47 Date: Sat, 10 Oct 2026 13:38:50 +0800 Subject: [PATCH 3/7] feat(files): preview text files --- bridge/api/assets.py | 4 +- scripts/smoke-text-preview.cjs | 134 +++++++++++++++++++++++ site/demo-product/web/markdown.js | 3 +- tests/test_bridge.py | 7 +- tests/test_resource_layout.py | 2 +- web/assets.json | 4 +- web/features/workspace/downloads.js | 2 +- web/features/workspace/text-viewer.css | 20 ++++ web/features/workspace/text-viewer.js | 145 +++++++++++++++++++++++++ web/index.html | 4 +- web/shared/i18n.js | 8 ++ web/shared/markdown.js | 4 +- 12 files changed, 327 insertions(+), 10 deletions(-) create mode 100644 scripts/smoke-text-preview.cjs create mode 100644 web/features/workspace/text-viewer.css create mode 100644 web/features/workspace/text-viewer.js diff --git a/bridge/api/assets.py b/bridge/api/assets.py index b9f0d73a..72ab1e2a 100644 --- a/bridge/api/assets.py +++ b/bridge/api/assets.py @@ -6,7 +6,9 @@ from bridge.resources import project_root -STATIC = {"/file-actions.js": ("file-actions.js", "text/javascript; charset=utf-8"), +STATIC = {"/text-viewer.js": ("features/workspace/text-viewer.js", "text/javascript; charset=utf-8"), + "/text-viewer.css": ("features/workspace/text-viewer.css", "text/css; charset=utf-8"), +"/file-actions.js": ("file-actions.js", "text/javascript; charset=utf-8"), "/host.js": ("hosts/environment.js", "text/javascript; charset=utf-8"), "/layout.js": ("layouts/viewport.js", "text/javascript; charset=utf-8"), "/permissions.js": ("permissions.js", "text/javascript; charset=utf-8"), diff --git a/scripts/smoke-text-preview.cjs b/scripts/smoke-text-preview.cjs new file mode 100644 index 00000000..690efe2d --- /dev/null +++ b/scripts/smoke-text-preview.cjs @@ -0,0 +1,134 @@ +'use strict'; +const fs = require('node:fs'), path = require('node:path'), http = require('node:http'), assert = require('node:assert/strict'); +const root = path.resolve(__dirname, '..'), out = path.join(root, '.tmp/text-preview-check'); +fs.mkdirSync(out, {recursive:true}); +const layout=JSON.parse(fs.readFileSync(path.join(root,'web/assets.json'),'utf8')); +const read = file => { + if(!file.startsWith('web/'))return fs.readFileSync(path.join(root,file),'utf8'); + const sources=layout['/'+file.slice(4)]||file.slice(4); + return [sources].flat().map(source=>fs.readFileSync(path.join(root,'web',source),'utf8')).join('\n'); +}; +const session = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; +const route = id => `/api/sessions/${session}/files/${id.repeat(64)}?host=local`; +const names = {a:'plan.md', b:'settings.json', c:'page.html', d:'binary.txt', e:'large.txt', f:'broken.txt', + 1:'notes.txt', 2:'empty.txt', 3:'delayed.md', 4:'script.py', 5:'chunked.txt', 6:'retry.txt', 7:'limit.txt'}; +const files = Object.entries(names).map(([id, name]) => ({id:id.repeat(64), reference:name, name, image:false})); +const markdown = '# Preview title\n\n**Readable document**\n\n| Name | Value |\n| --- | --- |\n| Preview | Works |\n\n```js\nconst answer = 42;\n```\n\n'; +const sources = {a:markdown, b:'{\n "name": "preview",\n "html": ""\n}', + c:'

HTML download

', d:Buffer.from([0xff, 0xfe, 0]), + e:'x'.repeat(1024 * 1024 + 1), f:'failed', 1:'中文文本\n\t缩进保留', 2:'', + 3:'# Delayed result', 4:'print("")', 5:'x'.repeat(1024 * 1024 + 1), 6:'Retry succeeded', 7:'x'.repeat(1024 * 1024)}; +const assets = ['web/host.js', 'web/vendor/markdown-it.min.js', 'web/vendor/katex/katex.min.js', 'web/vendor/texmath.js', + 'web/markdown.js', 'web/i18n.js', 'web/downloads.js', 'web/text-viewer.js']; +const styles = ['web/style.css', 'web/presentation.css', 'web/downloads.css', 'web/text-viewer.css']; +const html = '' + + '
' + + '
' + assets.map(file => ``).join('') + + ``; +const requests = []; +let retries = 0; +const server = http.createServer((req, res) => { + const url = new URL(req.url, 'http://localhost'); + if (url.pathname === '/fixture.css') { + res.writeHead(200, {'Content-Type':'text/css; charset=utf-8'}); + res.end(styles.map(read).join('\n') + '#timeline{height:220px;overflow:auto}.spacer{height:600px}'); return; + } + if (url.pathname.startsWith('/web/')) { + const file = url.pathname.slice(1); + if (!assets.includes(file)) { res.writeHead(404); res.end(); return; } + res.writeHead(200, {'Content-Type':'text/javascript; charset=utf-8'}); res.end(read(file)); return; + } + const match = /\/files\/([a-f0-9]{64})$/.exec(url.pathname); + if (!match) { + if (url.pathname === '/api/auth') { res.end(JSON.stringify({authenticated:true,csrf:'fixture'})); return; } + res.writeHead(200, {'Content-Type':'text/html', 'Set-Cookie':'preview=ok; SameSite=Strict'}); res.end(html); return; + } + requests.push({id:match[1][0], range:req.headers.range, cookie:req.headers.cookie, host:url.searchParams.get('host')}); + const id = match[1][0]; + if (id === 'f' || (id === '6' && retries++ === 0)) { res.writeHead(503); res.end(); return; } + const data = Buffer.from(sources[id]); + if (id === '2') { res.writeHead(416, {'Content-Range':'bytes */0'}); res.end(); return; } + if (id === '3') { setTimeout(() => { res.writeHead(200); res.end(data); }, 300); return; } + if (id === '5') { res.writeHead(200); res.write(data.subarray(0, 100)); res.end(data.subarray(100)); return; } + const headers = {'Content-Type':'application/octet-stream', 'Content-Disposition':`attachment; filename="${names[id]}"`, + 'Accept-Ranges':'bytes', ETag:'"fixture"', 'Content-Length':data.length}; + if (req.headers.range && data.length) { headers['Content-Range'] = `bytes 0-${data.length - 1}/${data.length}`; res.writeHead(206, headers); } + else res.writeHead(200, headers); + res.end(data); +}); + +if (process.argv.includes('--serve-only')) { + server.listen(Number(process.env.TEXT_PREVIEW_PORT || 0), '127.0.0.1', () => fs.writeFileSync(path.join(out, 'server.json'), JSON.stringify({port:server.address().port}))); +} else { + const {app, BrowserWindow} = require('electron'); + app.disableHardwareAcceleration(); + app.setPath('userData', path.join(out, 'electron-data')); + app.whenReady().then(async () => { + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const base = 'http://127.0.0.1:' + server.address().port; + const window = new BrowserWindow({show:false, width:390, height:844}); + const run = async code => { try { return await window.webContents.executeJavaScript(code); } catch (error) { throw new Error(code + ': ' + error); } }, checks = []; + const check = (ok, name) => { assert.ok(ok, name); checks.push(name); }; + const until = async code => { + const end = Date.now() + 5000; + while (Date.now() < end) { if (await run(code)) return; await new Promise(resolve => setTimeout(resolve, 20)); } + throw Error('Timed out: ' + code + ' status=' + await run("document.querySelector('.text-preview-status')?.textContent")); + }; + const click = name => run(`document.querySelector('a[href*="${name.repeat(64)}"]').click()`); + const close = async () => { await run("document.querySelector('#text-preview-dialog').close()"); await until("!document.querySelector('#text-preview-dialog').open"); await run('new Promise(resolve=>setTimeout(resolve,30))'); }; + await window.loadURL(base); + check(await run("document.querySelectorAll('[data-text-preview]').length===12"), 'Text whitelist includes MD/JSON/code and leaves HTML as download'); + const before = await run("document.querySelector('#timeline').scrollTop"); + await click('a'); await until("!!document.querySelector('#text-preview-dialog h1')"); + check(await run("document.querySelector('#text-preview-dialog h1').textContent==='Preview title' && !!document.querySelector('#text-preview-dialog table') && !!document.querySelector('#text-preview-dialog .code-copy')"), 'Markdown uses existing table and code renderer'); + check(await run("!window.injected && !document.querySelector('#text-preview-dialog script') && !document.querySelector('.download-panel:not([hidden])')"), 'Preview is safe and does not start a download'); + check(requests[0].cookie === 'preview=ok' && requests[0].host === 'local' && requests[0].range === 'bytes=0-1048576', 'Preview keeps authentication, host selection and bounded range'); + fs.writeFileSync(path.join(out, 'mobile.png'), (await window.webContents.capturePage()).toPNG()); + await run("document.documentElement.dataset.theme='dark'"); window.setSize(1100, 800); + await run('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + fs.writeFileSync(path.join(out, 'desktop-dark.png'), (await window.webContents.capturePage()).toPNG()); + await close(); check(await run("document.querySelector('#timeline').scrollTop") === before && await run("document.querySelector('#draft').value==='Keep draft'"), 'Closing keeps chat scroll and draft'); + await click('b'); await until("!!document.querySelector('.text-preview-source')"); + check(await run("document.querySelector('.text-preview-source').textContent.includes(' \"name\"') && !document.querySelector('.text-preview-content img')"), 'JSON keeps indentation and HTML remains literal text'); await close(); + await click('1'); await until("!!document.querySelector('.text-preview-source')"); + check(await run("document.querySelector('.text-preview-source').textContent==='中文文本\\n\\t缩进保留'"), 'UTF-8 text keeps tabs and line breaks'); await close(); + await click('7'); await until("document.querySelector('.text-preview-source')?.textContent.length===1048576"); + check(true, 'Exactly 1 MiB remains previewable'); await close(); + for (const [id, message] of [['d','编码'], ['e','1 MiB'], ['5','1 MiB'], ['f','重试'], ['2','为空']]) { + await click(id); await until(`document.querySelector('.text-preview-status').textContent.includes('${message}')`); + if (id === '2') { + await run("BridgeI18n.setLanguage('en')"); + check(await run("document.querySelector('.text-preview-status').textContent==='This file is empty.' && document.querySelector('.text-preview-download').textContent==='Download'"), 'Preview controls and status follow language changes'); + await run("BridgeI18n.setLanguage('zh')"); + } + check(true, 'Handles file ' + names[id]); await close(); + } + await click('6'); await until("!document.querySelector('.text-preview-retry').hidden"); + await run("document.querySelector('.text-preview-retry').click()"); await until("document.querySelector('.text-preview-source')?.textContent==='Retry succeeded'"); + check(true, 'Retry reloads a failed file'); await close(); + await click('3'); await close(); await click('b'); + await until("!!document.querySelector('.text-preview-source')"); await run('new Promise(resolve=>setTimeout(resolve,400))'); + check(await run("document.querySelector('#text-preview-title').textContent==='settings.json' && !document.querySelector('.text-preview-content h1')"), 'Closed request never overwrites a newer preview'); + await run("document.querySelector('.text-preview-download').click()"); + await until("!document.querySelector('#text-preview-dialog').open"); + await until("document.querySelector('#download-panel')?.textContent.includes('下载完成')"); + check(requests.filter(r => r.id === 'b').length >= 3, 'Download action hands file to existing transfer flow'); + // Exercise the same DOM under the mobile user agent, where native owns downloads. + await window.loadURL(base, {userAgent:'BridgeMobile/0.1-Android'}); + window.setSize(390, 844); + await run("document.documentElement.classList.add('bridge-mobile')"); + await click('a'); await until("!!document.querySelector('#text-preview-dialog h1')"); + await run('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + check(await run("document.querySelector('.text-preview-toolbar button').getBoundingClientRect().right<=innerWidth-63.5"), 'Native menu has space beside the rightmost control'); + window.setSize(320, 640); + await run("BridgeI18n.setLanguage('en');document.querySelector('#text-preview-title').textContent='A very long filename for a small phone screen.md'"); + await run('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); + check(await run("(()=>{const toolbar=document.querySelector('.text-preview-toolbar'), buttons=[...toolbar.querySelectorAll('button,a')];return toolbar.scrollWidth<=toolbar.clientWidth+1 && buttons.every(b=>{const r=b.getBoundingClientRect();return r.height>=44 && r.left>=0 && r.right<=innerWidth-63.5;});})()"), 'English controls and a long filename fit a 320px phone'); + const java = read('mobile/android/src/io/github/try2love/codexbridge/MainActivity.java'); + const back = JSON.parse('"' + java.match(/web\.evaluateJavascript\("(\(\(\)=>\{const dialog=.*?)",result/)[1] + '"'); + check(await run(back), 'Existing Android back handler closes the text preview'); + await until("!document.querySelector('#text-preview-dialog').open"); + fs.writeFileSync(path.join(out, 'checks.json'), JSON.stringify({passed:checks.length, checks}, null, 2)); + server.close(); app.exit(0); + }).catch(error => { fs.writeFileSync(path.join(out, 'checks.json'), JSON.stringify({error:String(error), stack:error.stack})); server.close(); app.exit(1); }); +} diff --git a/site/demo-product/web/markdown.js b/site/demo-product/web/markdown.js index cdcebe73..935d2441 100644 --- a/site/demo-product/web/markdown.js +++ b/site/demo-product/web/markdown.js @@ -9,8 +9,7 @@ window.renderMarkdown = function(node, text, files = [], fileUrl = () => '', options = {}) { const body = document.createElement('div'); body.className = 'markdown-body'; - const artifacts = new Map(files.flatMap(file => - [file.reference, ...(file.references || [])].map(reference => [markdown.normalizeLink(reference), file]))); + const artifacts = new Map(files.map(file => [markdown.normalizeLink(file.reference), file])); function target(reference) { const file = artifacts.get(reference); if (file) return {url:fileUrl(file), file}; diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 6e0907bd..3ec9677c 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -1111,7 +1111,7 @@ def test_page_scripts_are_served_with_same_origin_csp(self): self.assertEqual(response.status, 200) scripts = re.findall(r' @@ -43,4 +43,4 @@

更改即时生效,仅保存在当前浏览器。

- + diff --git a/web/shared/i18n.js b/web/shared/i18n.js index 61a74e71..1fd90712 100644 --- a/web/shared/i18n.js +++ b/web/shared/i18n.js @@ -2,6 +2,14 @@ // UI messages only. Never translate chat content, commands, model IDs or user input. const BridgeI18n=(()=>{ const en={ +"关闭文本预览":"Close text preview", +"正在加载文件…":"Loading file…", +"文件为空。":"This file is empty.", +"登录已失效,请重新登录后查看。":"Sign-in expired. Sign in again to view this file.", +"文件读取失败,请重试或下载。":"Could not read the file. Retry or download it.", +"文件超过 1 MiB 预览限制,请下载查看。":"File exceeds the 1 MiB preview limit. Download it to view.", +"文件编码无法预览,请下载查看。":"This file encoding cannot be previewed. Download it to view.", +"文件内容无法作为文本预览,请下载查看。":"This file cannot be previewed as text. Download it to view.", "手机访问地址(可选)":"Phone access URL (optional)", "留空使用服务器公网 IP":"Leave blank to use the server public IP", "使用域名时,需要自行将域名解析到服务器公网 IP,并配置 HTTPS 证书和反向代理;填写完整地址,例如 https://bridge.example.com。":"For a domain, point its DNS record to the server public IP and configure an HTTPS certificate and reverse proxy. Enter the full URL, such as https://bridge.example.com.", diff --git a/web/shared/markdown.js b/web/shared/markdown.js index 935d2441..bb7d3527 100644 --- a/web/shared/markdown.js +++ b/web/shared/markdown.js @@ -9,7 +9,8 @@ window.renderMarkdown = function(node, text, files = [], fileUrl = () => '', options = {}) { const body = document.createElement('div'); body.className = 'markdown-body'; - const artifacts = new Map(files.map(file => [markdown.normalizeLink(file.reference), file])); + const artifacts = new Map(files.flatMap(file => + [file.reference, ...(file.references || [])].map(reference => [markdown.normalizeLink(reference), file]))); function target(reference) { const file = artifacts.get(reference); if (file) return {url:fileUrl(file), file}; @@ -103,6 +104,7 @@ const destination = target(token.attrGet('href') || ''); element = destination ? link(destination.url, token.attrGet('title')) : document.createElement('span'); if (destination?.file?.image) { element.dataset.imagePreview = destination.url; element.dataset.imageName = destination.file.name; } + else if (destination?.file) window.BridgeTextViewer?.registerLink(element, destination.file); } else if (tags.has(token.tag)) { element = document.createElement(token.tag); if (token.tag === 'ol' && token.attrGet('start')) element.start = Number(token.attrGet('start')); From 3b67a8fc0e70ef8923207a6a1770a5c37e0f1e80 Mon Sep 17 00:00:00 2001 From: heiqiu47 Date: Sat, 10 Oct 2026 15:39:56 +0800 Subject: [PATCH 4/7] feat(files): open downloaded files --- mobile/android/AndroidManifest.xml | 2 + .../try2love/codexbridge/DownloadCard.java | 18 +++--- .../codexbridge/DownloadProvider.java | 60 +++++++++++++++++++ .../try2love/codexbridge/MainActivity.java | 21 +++++-- .../try2love/codexbridge/MobileStrings.java | 4 ++ mobile/ios/BridgePreview/App.swift | 28 +++++++-- 6 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java diff --git a/mobile/android/AndroidManifest.xml b/mobile/android/AndroidManifest.xml index d4bf66f2..8b5f227d 100644 --- a/mobile/android/AndroidManifest.xml +++ b/mobile/android/AndroidManifest.xml @@ -1,5 +1,6 @@ + @@ -7,6 +8,7 @@ + diff --git a/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java b/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java index 68a4fded..3719701a 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java +++ b/mobile/android/src/io/github/try2love/codexbridge/DownloadCard.java @@ -11,9 +11,9 @@ final class DownloadCard extends LinearLayout { private final MainActivity activity;private final FrameLayout parent; private final TextView title,details;private final ProgressBar progress;private final LinearLayout actions; - private final Button primary,cancel,toggle; + private final Button primary,open,cancel,toggle; private boolean collapsed,dragged;private float downX,downY,startX,startY;private boolean moving; - DownloadCard(MainActivity activity,FrameLayout parent,Runnable action,Runnable dismiss){ + DownloadCard(MainActivity activity,FrameLayout parent,Runnable action,Runnable openFile,Runnable dismiss){ super(activity);this.activity=activity;this.parent=parent; setOrientation(VERTICAL);setPadding(dp(12),dp(8),dp(12),dp(10)); android.graphics.drawable.GradientDrawable face=activity.background(0xfff6f9fe,16);face.setStroke(dp(1),0xffd7e1ef);setBackground(face);setElevation(dp(8)); @@ -29,7 +29,8 @@ final class DownloadCard extends LinearLayout { }}); progress=new ProgressBar(activity,null,android.R.attr.progressBarStyleHorizontal);progress.setMax(1000);progress.setProgressTintList(ColorStateList.valueOf(0xff477ccc));progress.setProgressBackgroundTintList(ColorStateList.valueOf(0xffdce6f4));addView(progress,new LinearLayout.LayoutParams(-1,dp(4))); details=activity.text("",12);details.setTextColor(0xff63738a);details.setPadding(0,dp(8),0,dp(4));addView(details,new LinearLayout.LayoutParams(-1,-2)); - actions=new LinearLayout(activity);primary=control("",action);primary.setTextColor(0xff3268b5);cancel=control("",dismiss);LinearLayout.LayoutParams first=new LinearLayout.LayoutParams(0,dp(40),1);first.setMarginEnd(dp(8));actions.addView(primary,first);actions.addView(cancel,new LinearLayout.LayoutParams(0,dp(40),1));addView(actions); + actions=new LinearLayout(activity);open=control(L("打开"),openFile);open.setVisibility(GONE);LinearLayout.LayoutParams opening=new LinearLayout.LayoutParams(0,dp(40),1);opening.setMarginEnd(dp(8));actions.addView(open,opening); + primary=control("",action);primary.setTextColor(0xff3268b5);cancel=control("",dismiss);LinearLayout.LayoutParams first=new LinearLayout.LayoutParams(0,dp(40),1);first.setMarginEnd(dp(8));actions.addView(primary,first);actions.addView(cancel,new LinearLayout.LayoutParams(0,dp(40),1));addView(actions); parent.addView(this,new FrameLayout.LayoutParams(dp(320),-2,Gravity.TOP|Gravity.LEFT));setVisibility(GONE); parent.addOnLayoutChangeListener((v,l,t,r,b,ol,ot,or,ob)->place());addOnLayoutChangeListener((v,l,t,r,b,ol,ot,or,ob)->place()); } @@ -49,20 +50,23 @@ void place(){ float bottom=Math.max(top,parent.getHeight()-parent.getPaddingBottom()-getHeight()-dp(88)); setX(dragged?Math.max(left,Math.min(right,getX())):right);setY(dragged?Math.max(top,Math.min(bottom,getY())):bottom); } - void render(ArtifactDownload.Task task,boolean saving){ + void render(ArtifactDownload.Task task,boolean saving,boolean opening){ if(task==null){setVisibility(GONE);return;} if(getVisibility()!=VISIBLE)showTask(); ArtifactDownload.State state=task.state;boolean active=state==ArtifactDownload.State.RUNNING||state==ArtifactDownload.State.READY||state==ArtifactDownload.State.PAUSING; String size=bytes(task.downloaded)+(task.total>=0?" / "+bytes(task.total):""); - String phase=state==ArtifactDownload.State.COMPLETE?L("下载完成"):state==ArtifactDownload.State.PAUSED?L("已暂停"):state==ArtifactDownload.State.FAILED?L("下载中断"):state==ArtifactDownload.State.PAUSING?L("正在暂停…"):L("正在下载…"); + String phase=state==ArtifactDownload.State.COMPLETE?L("下载完成,可打开或保存"):state==ArtifactDownload.State.PAUSED?L("已暂停"):state==ArtifactDownload.State.FAILED?L("下载中断"):state==ArtifactDownload.State.PAUSING?L("正在暂停…"):L("正在下载…"); + if(opening)phase=L("正在打开…"); + boolean busy=saving||opening; if(saving)phase=L("正在保存…"); String percent=task.total>0?String.format(Locale.ROOT,"%d%%",Math.min(100,task.downloaded*100/task.total)):state==ArtifactDownload.State.COMPLETE?"100%":""; title.setText((collapsed&&!percent.isEmpty()?percent+" · ":"")+task.name); title.setContentDescription(task.name+" · "+phase+" · "+size); progress.setIndeterminate(active&&task.total<0);progress.setProgress(task.total>0?(int)Math.min(1000,task.downloaded*1000/task.total):state==ArtifactDownload.State.COMPLETE?1000:0); details.setText(phase+" · "+size+(state==ArtifactDownload.State.RUNNING?" · "+bytes((long)task.bytesPerSecond)+"/s":"")+(state==ArtifactDownload.State.FAILED&&!task.error.isEmpty()?"\n"+L(task.error):"")); - primary.setText(L(state==ArtifactDownload.State.COMPLETE?"保存":active?"暂停":task.resumable()?"继续":"重新下载"));primary.setEnabled(!saving&&state!=ArtifactDownload.State.PAUSING&&state!=ArtifactDownload.State.READY); - cancel.setText(L(state==ArtifactDownload.State.COMPLETE?"关闭":"取消"));cancel.setEnabled(!saving); + primary.setText(L(state==ArtifactDownload.State.COMPLETE?"保存":active?"暂停":task.resumable()?"继续":"重新下载"));primary.setEnabled(!busy&&state!=ArtifactDownload.State.PAUSING&&state!=ArtifactDownload.State.READY); + cancel.setText(L(state==ArtifactDownload.State.COMPLETE?"关闭":"取消"));cancel.setEnabled(!busy); + open.setText(L("打开"));open.setVisibility(state==ArtifactDownload.State.COMPLETE?VISIBLE:GONE);open.setEnabled(!busy); setCollapsed(collapsed); } private static String bytes(long value){if(value<1024)return value+" B";if(value<1024*1024)return String.format(Locale.ROOT,"%.1f KB",value/1024d);return String.format(Locale.ROOT,"%.1f MB",value/(1024d*1024));} diff --git a/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java b/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java new file mode 100644 index 00000000..9c55ae3f --- /dev/null +++ b/mobile/android/src/io/github/try2love/codexbridge/DownloadProvider.java @@ -0,0 +1,60 @@ +package io.github.try2love.codexbridge; + +import android.content.*; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; +import java.io.*; +import java.nio.file.Files; +import java.util.*; + +/** Read-only copies keep external viewers independent of the active download. */ +public final class DownloadProvider extends ContentProvider { + private static final long RETENTION=24L*60*60*1000; + static Uri prepare(Context context,ArtifactDownload.Result result)throws IOException { + File root=new File(context.getCacheDir(),"opened-downloads"); + if(!root.isDirectory()&&!root.mkdirs())throw new IOException(); + File[] folders=root.listFiles(); + if(folders!=null)for(File folder:folders){ + if(folder.lastModified() path=uri.getPathSegments(); + if(!"content".equals(uri.getScheme())||!(getContext().getPackageName()+".downloads").equals(uri.getAuthority())||path.size()!=2||!path.get(0).matches("[a-f0-9-]{36}"))throw new FileNotFoundException(); + try{ + File folder=new File(getContext().getCacheDir(),"opened-downloads/"+path.get(0)).getCanonicalFile(); + File file=new File(folder,path.get(1)).getCanonicalFile(); + if(!folder.equals(file.getParentFile())||!file.isFile())throw new FileNotFoundException();return file; + }catch(IOException e){throw new FileNotFoundException();} + } + @Override public boolean onCreate(){return true;} + @Override public String getType(Uri uri){ + try{String name=file(uri).getName();String extension=name.substring(name.lastIndexOf('.')+1).toLowerCase(Locale.ROOT); + String type=MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);return type==null?"application/octet-stream":type; + }catch(FileNotFoundException e){return "application/octet-stream";} + } + @Override public Cursor query(Uri uri,String[] projection,String selection,String[] args,String order){ + try{File file=file(uri);String[] columns=projection==null?new String[]{OpenableColumns.DISPLAY_NAME,OpenableColumns.SIZE}:projection; + MatrixCursor cursor=new MatrixCursor(columns,1);Object[] row=new Object[columns.length]; + for(int i=0;icheckComputers(); private long homeBackAt;private Toast exitToast; private final ExecutorService downloads=Executors.newSingleThreadExecutor(); - private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private boolean savingDownload; + private FrameLayout screen;private DownloadCard downloadCard;private ArtifactDownload.Task downloadTask,choosingDownload;private boolean savingDownload,openingDownload; private ServiceConnection monitor;private ValueCallback files;private WebView fileView;private int fileGeneration;private int generation; String L(String value){return MobileStrings.text(this,value);} int dp(int n){return (int)(getResources().getDisplayMetrics().density*n);} @@ -74,7 +74,7 @@ void recoverConnection(String requested){ @Override public void onCreate(Bundle state){super.onCreate(state);prefs=getSharedPreferences("bridge",0);root=new LinearLayout(this);root.setOrientation(1);root.setBackgroundColor(0xfff5f5f3);screen=new FrameLayout(this);screen.addView(root,new FrameLayout.LayoutParams(-1,-1));setContentView(screen);systemBars=new SystemBars(this,screen,()->{if(downloadCard!=null)downloadCard.post(downloadCard::place);}); if(Build.VERSION.SDK_INT>=33)getOnBackInvokedDispatcher().registerOnBackInvokedCallback(android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT,this::handleBack); bar=new LinearLayout(this);bar.setGravity(Gravity.CENTER_VERTICAL);bar.setPadding(dp(20),dp(4),dp(12),dp(4));root.addView(bar);header(false); - downloadCard=new DownloadCard(this,screen,this::downloadAction,this::cancelDownload); + downloadCard=new DownloadCard(this,screen,this::downloadAction,this::openDownload,this::cancelDownload); status=text("",12);status.setTextColor(0xff6b7075);status.setGravity(Gravity.CENTER);root.addView(status); if(!openIntent(getIntent())){String active=prefs.getString("active","");if(saved().contains(active))connect(active+"/");else home();}monitor();root.post(this::requestStartupNotifications); } @@ -169,7 +169,7 @@ public boolean onShowFileChooser(WebView view,ValueCallback callback,File web.setDownloadListener((target,agent,disposition,mime,length)->downloadArtifact(target)); web.loadUrl(url);monitor(); }catch(Exception e){message(e.getMessage());}} - void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null);} + void renderDownload(){downloadCard.render(downloadTask,savingDownload||choosingDownload!=null,openingDownload);} void downloadArtifact(String target){ if(downloadTask!=null){downloadCard.showTask();renderDownload();message(L("已有下载任务,请先完成或取消"));return;} if(web==null||!GatewayURL.sameOrigin(web.getUrl(),origin)||!ArtifactDownload.accepts(target,origin)){message(L("此下载不是当前电脑的附件,请在浏览器中打开"));return;} @@ -182,7 +182,7 @@ void runDownload(ArtifactDownload.Task task){ task.state=ArtifactDownload.State.READY;renderDownload();downloads.execute(()->task.run(()->CookieManager.getInstance().getCookie(task.url),value->runOnUiThread(()->{if(downloadTask==value&&!isFinishing()&&!isDestroyed())renderDownload();}))); } void downloadAction(){ - ArtifactDownload.Task task=downloadTask;if(task==null||savingDownload||choosingDownload!=null)return; + ArtifactDownload.Task task=downloadTask;if(task==null||savingDownload||choosingDownload!=null||openingDownload)return; if(task.state==ArtifactDownload.State.RUNNING){task.pause();renderDownload();} else if(task.state==ArtifactDownload.State.PAUSED||task.state==ArtifactDownload.State.FAILED){ if(task.resumable())runDownload(task);else{String target=task.url,base=task.origin;cancelDownload();startDownload(target,base);} @@ -191,6 +191,19 @@ else if(task.state==ArtifactDownload.State.PAUSED||task.state==ArtifactDownload. try{startActivityForResult(save,32);}catch(Exception e){choosingDownload=null;renderDownload();message(L("没有可用的文件保存器"));} } } + void openDownload(){ + ArtifactDownload.Task task=downloadTask;if(task==null||task.state!=ArtifactDownload.State.COMPLETE||savingDownload||choosingDownload!=null||openingDownload)return; + openingDownload=true;renderDownload();downloads.execute(()->{ + Uri uri=null;try{uri=DownloadProvider.prepare(this,task.result());}catch(java.io.IOException ignored){} + final Uri target=uri;runOnUiThread(()->{ + openingDownload=false;if(isFinishing()||isDestroyed())return;renderDownload();if(task!=downloadTask)return; + if(target==null){message(L("打开失败,请重试或保存文件"));return;} + Intent view=new Intent(Intent.ACTION_VIEW).setDataAndType(target,getContentResolver().getType(target)).addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + view.setClipData(ClipData.newRawUri("",target)); + try{startActivity(view);}catch(ActivityNotFoundException e){message(L("暂无可打开此文件的应用"));}catch(Exception e){message(L("打开失败,请重试或保存文件"));} + }); + }); + } void cancelDownload(){if(downloadTask!=null){downloadTask.cancel();downloadTask=null;}renderDownload();} void saveDownload(int result,Intent data){ ArtifactDownload.Task task=choosingDownload;choosingDownload=null; diff --git a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java index e1013d51..b53ee2a5 100644 --- a/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java +++ b/mobile/android/src/io/github/try2love/codexbridge/MobileStrings.java @@ -8,6 +8,10 @@ final class MobileStrings { values.put("最近通知","Recent notifications"); values.put("需要你处理","Action requested"); values.put("已运行完毕","Completed"); + values.put("正在打开…","Opening…"); + values.put("下载完成,可打开或保存","Downloaded · Open or save"); + values.put("打开失败,请重试或保存文件","Could not open the file. Retry or save it."); + values.put("暂无可打开此文件的应用","No app is available to open this file."); values.put("未收到所选文件,请重新选择。","No selected file was received. Choose the file again."); values.put("无法读取所选文件,请从系统文件选择器重新选择。","Cannot read the selected file. Select it again using the system file picker."); diff --git a/mobile/ios/BridgePreview/App.swift b/mobile/ios/BridgePreview/App.swift index 49eace33..99594264 100644 --- a/mobile/ios/BridgePreview/App.swift +++ b/mobile/ios/BridgePreview/App.swift @@ -65,7 +65,7 @@ final class GatewayReachability: NSObject, URLSessionDataDelegate { } } -final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelegate, UIDocumentPickerDelegate { +final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelegate, UIDocumentPickerDelegate, UIDocumentInteractionControllerDelegate { private var web: WKWebView? private var origin = "" private var routeDiscovery: ConnectionDiscovery? @@ -76,6 +76,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega private var downloadState = DownloadManager.Snapshot() private let downloadPanel = DownloadPanel() private var exportPicker: UIDocumentPickerViewController? + private var openDocument: UIDocumentInteractionController? private var downloadBottom: NSLayoutConstraint? private lazy var downloads: DownloadManager = { let manager = DownloadManager() @@ -151,6 +152,7 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega } downloadPanel.onCancel = { [weak self] in self?.downloads.cancel() } downloadPanel.onSave = { [weak self] in self?.saveDownload() } + downloadPanel.onOpen = { [weak self] in self?.openDownload() } downloadBottom = downloadPanel.bottomAnchor.constraint(equalTo: page.bottomAnchor, constant: -16) let downloadWidth = downloadPanel.widthAnchor.constraint(equalToConstant: 360); downloadWidth.priority = .defaultHigh NSLayoutConstraint.activate([downloadBottom!, downloadWidth, @@ -529,6 +531,17 @@ final class BridgeController: UIViewController, WKNavigationDelegate, WKUIDelega guard let url = request.url, isArtifact(url) else { return } downloadPanel.expand(); downloads.start(url, origin: origin) } + private func openDownload() { + guard downloadState.phase == .complete, let file = downloadState.file, presentedViewController == nil else { return } + let document = UIDocumentInteractionController(url: file) + document.delegate = self; openDocument = document + // The menu transfers the document to the receiving app; the active task retains its source. + if !document.presentOpenInMenu(from: downloadPanel.bounds, in: downloadPanel, animated: true), + !document.presentPreview(animated: true) { + openDocument = nil; info(MobileStrings.text("暂无可打开此文件的应用")) + } + } + func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController { self } private func saveDownload() { guard downloadState.phase == .complete, let file = downloadState.file, presentedViewController == nil else { return } let picker = UIDocumentPickerViewController(forExporting: [file], asCopy: true); picker.delegate = self @@ -925,6 +938,7 @@ final class DownloadPanel: UIView { var onToggle: (() -> Void)? var onCancel: (() -> Void)? var onSave: (() -> Void)? + var onOpen: (() -> Void)? private let title = UILabel() private let detail = UILabel() private let status = UILabel() @@ -932,6 +946,7 @@ final class DownloadPanel: UIView { private let content = UIStackView() private let toggle = UIButton(type: .system) private let save = UIButton(type: .system) + private let open = UIButton(type: .system) private let cancel = UIButton(type: .system) private let collapse = UIButton(type: .system) private var collapsed = false @@ -957,7 +972,7 @@ final class DownloadPanel: UIView { progress.progressTintColor = tintColor; progress.trackTintColor = .tertiarySystemFill content.addArrangedSubview(detail); content.addArrangedSubview(progress); content.addArrangedSubview(status) let actions = UIStackView(); actions.axis = .horizontal; actions.spacing = 8; actions.distribution = .fillEqually; content.addArrangedSubview(actions) - for button in [toggle, save, cancel] { + for button in [toggle, open, save, cancel] { var config = UIButton.Configuration.tinted(); config.cornerStyle = .medium config.baseForegroundColor = tintColor; config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var output = incoming; output.font = .systemFont(ofSize: 14, weight: .medium); return output } button.configuration = config; button.heightAnchor.constraint(greaterThanOrEqualToConstant: 40).isActive = true; actions.addArrangedSubview(button) @@ -965,13 +980,14 @@ final class DownloadPanel: UIView { toggle.addAction(UIAction { [weak self] _ in self?.onToggle?() }, for: .touchUpInside) cancel.addAction(UIAction { [weak self] _ in self?.onCancel?() }, for: .touchUpInside) save.addAction(UIAction { [weak self] _ in self?.onSave?() }, for: .touchUpInside) + open.addAction(UIAction { [weak self] _ in self?.onOpen?() }, for: .touchUpInside) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func expand() { collapsed = false; render(state) } func render(_ state: DownloadManager.Snapshot) { self.state = state; isHidden = !state.visible; content.isHidden = collapsed let percentage = state.total.map { $0 == 0 ? 100 : min(100, Int(Double(state.received) / Double($0) * 100)) } - let phases: [DownloadManager.Phase: String] = [.connecting: "正在连接…", .downloading: "正在下载…", .paused: "已暂停", .failed: "下载未完成", .complete: "下载完成,待保存", .saved: "已保存", .idle: "", .cancelled: "已取消"] + let phases: [DownloadManager.Phase: String] = [.connecting: "正在连接…", .downloading: "正在下载…", .paused: "已暂停", .failed: "下载未完成", .complete: "下载完成,可打开或保存", .saved: "已保存", .idle: "", .cancelled: "已取消"] let phase = MobileStrings.text(phases[state.phase] ?? "") title.text = state.name + (collapsed ? " · " + (state.phase == .downloading ? percentage.map { "\($0)%" } ?? phase : phase) : "") title.accessibilityLabel = state.name @@ -986,7 +1002,8 @@ final class DownloadPanel: UIView { toggle.isHidden = ![.connecting, .downloading, .paused, .failed].contains(state.phase) toggle.configuration?.title = MobileStrings.text([.connecting, .downloading].contains(state.phase) ? "暂停" : state.canResume ? "继续下载" : "重新下载") save.isHidden = state.phase != .complete; save.configuration?.title = MobileStrings.text("保存到文件") - cancel.configuration?.title = MobileStrings.text(state.phase == .saved ? "关闭" : "取消下载") + open.isHidden = state.phase != .complete; open.configuration?.title = MobileStrings.text("打开") + cancel.configuration?.title = MobileStrings.text([.complete, .saved].contains(state.phase) ? "关闭" : "取消下载") } } @@ -1023,7 +1040,8 @@ enum MobileStrings { "正在连接…": "Connecting…", "已暂停": "Paused", "下载未完成": "Download incomplete", - "下载完成,待保存": "Downloaded · ready to save", + "下载完成,可打开或保存": "Downloaded · open or save", + "暂无可打开此文件的应用": "No app is available to open this file.", "已保存": "Saved", "已取消": "Cancelled", "展开下载": "Expand download", From 13047414f21bb85e71e96fc334635829d3fc5419 Mon Sep 17 00:00:00 2001 From: heiqiu47 Date: Sat, 10 Oct 2026 21:00:07 +0800 Subject: [PATCH 5/7] fix(files): allow file links outside workspace --- ARCHITECTURE.md | 2 +- README.md | 4 ++-- bridge/app/service.py | 6 +++--- bridge/features/workspace/files.py | 24 +++++++++++------------- tests/test_bridge.py | 30 ++++++++++++++++++------------ tests/test_download_ranges.py | 26 ++++++++++++++++++++++++++ tests/test_uploads_activity.py | 5 +++-- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0a2a844f..c4781c8b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -92,7 +92,7 @@ Windows 自动发现用户目录中的 App 运行时、常见安装目录、MSIX 账号密码采用 PBKDF2-HMAC-SHA256,登录 Cookie 使用 HttpOnly、SameSite,HTTPS 入口加 Secure。Host 和 Origin 必须在允许列表;写操作还需 CSRF 令牌。 -聊天内容、事件流、长轮询和附件接口均需要登录。文件访问仅允许当前聊天引用的本地工作目录或 visualizations 文件,并校验解析后的真实路径及大小。远端文件不映射到 本机文件系统。 +聊天内容、事件流、长轮询和附件接口均需要登录。文件访问仅允许当前聊天明确引用的本地文件,支持跨项目路径,并校验解析后的真实路径、文件类型及 50 MiB 大小上限。远端文件不映射到 本机文件系统。 ## 模块 diff --git a/README.md b/README.md index 176f8d15..b10d79fb 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,7 @@ App 设置界面通过本机进程通信管理网关;网关启停、更新及 | 连接方式 | 局域网、临时 Cloudflare 与多个固定 HTTPS 入口可并行;配置可添加、命名、启停和删除 | | 双语界面 | 电脑 App 与手机网页支持简体中文 / English 切换 | | 响应压缩 | 支持 gzip,减少大聊天通过外网传输的数据量 | -| 文件与图片 | 项目文件预览、目录定位、图片缩放与可暂停下载;普通下载阈值可实时配置 | +| 文件与图片 | 文本文件预览、跨项目文件链接、目录定位、图片缩放与可暂停下载;普通下载阈值可实时配置 | 默认继承桌面会话的模型、provider 和权限策略。手动切换模型时更新模型与推理强度;只有主动切换 Fast 开关才更新速度设置。模型是否可用取决于当前 provider。 @@ -579,7 +579,7 @@ flowchart TD - 尚未加载的聊天可查看保存历史,发送前可能需要在 App 中打开一次。 - 桌面自有服务器入口支持密码、私钥及主机指纹交互;Codex 远端聊天仍复用其已有非交互 SSH 配置,未新增 MFA 或跳板机部署支持。 - 云聊天、SSH 文件下载尚未接入。 -- 本地文件只允许访问聊天引用的工作目录及 Codex visualizations 内文件;目录外附件只显示描述。 +- 本地文件仅允许访问当前聊天明确引用的文件,支持跨项目路径;下载需要登录,并校验文件存在、类型及 50 MiB 大小上限。 - 复杂 MCP 表单、身份验证挑战和部分特殊请求需要在桌面处理。 - 保存历史的格式可能含上下文注入文本,手机排版与桌面不保证完全一致。 - 设计上复用 API、自定义 provider 和官方登录配置;实测覆盖自定义 provider,未穷举所有登录方式与服务商。 diff --git a/bridge/app/service.py b/bridge/app/service.py index 71ea4e14..7e34755b 100644 --- a/bridge/app/service.py +++ b/bridge/app/service.py @@ -1365,7 +1365,7 @@ def artifact(self, thread_id, artifact_id): with session.condition: files = artifact_paths(session.state or {}, self.store.home) if self.host == "local" else {} if artifact_id not in files: - raise KeyError("文件不属于此聊天的工作目录") + raise KeyError("文件未在此聊天中引用或已不可用") return files[artifact_id] def _annotate_upload_attachments(self, session, rows): @@ -1504,8 +1504,8 @@ def timeline_read(self, thread_id, mode='page', **options): cwd = (session.state or {}).get('cwd') media_items = [item for turn in ordered_turns(session.state or {}) for item in items_array(turn.get('items')) if item.get('type') in ('ImageView', 'imageView')] - # Resolve links in the delivered page, plus ImageView evidence needed by - # embedded model screenshots that can live outside workspace roots. + # Resolve links in the delivered page, plus runtime ImageView evidence + # retained separately from the paged message text. file_state = {'cwd': cwd, 'turns': [ {'items': [{'type': 'agentMessage', 'text': text} for text in texts]}, {'items': media_items}, diff --git a/bridge/features/workspace/files.py b/bridge/features/workspace/files.py index 47aaa757..84742392 100644 --- a/bridge/features/workspace/files.py +++ b/bridge/features/workspace/files.py @@ -1,4 +1,4 @@ -"""Serve only artifacts referenced by this chat inside its workspace/visualizations.""" +"""Serve local artifacts explicitly referenced by this chat.""" import hashlib import ntpath import os @@ -68,9 +68,8 @@ def _is_plain_reference(value): def _model_image_references(state): """Map model image references and return the trusted runtime-view identities. - ImageView rows are runtime-generated evidence and may point at desktop-side - screenshots outside a workspace. Markdown image links are model-authored - text and must not grant that broader filesystem trust. + Desktop image previews use runtime-generated ImageView evidence separately + from model-authored Markdown references. """ result = {} trusted_views = set() @@ -107,7 +106,7 @@ def referenced_model_images(state, image_views_only=False): return trusted_views if image_views_only else result -def artifact_paths(state, codex_home): +def artifact_paths(state, codex_home, *, max_file_size=50 * 1024 * 1024): roots = [Path(codex_home) / 'visualizations'] if codex_home else [] if state.get('cwd'): roots.append(Path(state['cwd'])) @@ -126,15 +125,15 @@ def artifact_paths(state, codex_home): result = {} model_images, trusted_views = _model_image_references(state) for raw in candidates: - # Model-authored Markdown and response attachments remain bound to the - # workspace/visualization roots even when they name an image suffix. value = re.sub(r':\d+$', '', raw) path = reference_path(value, allow_relative=bool(state.get('cwd'))) if not path: continue try: path = ((Path(state['cwd']) / path) if not path.is_absolute() and state.get('cwd') else path).resolve(strict=True) - if not any(root in path.parents for root in roots) or not (path.is_file() or path.is_dir()): + if (not (path.is_file() or path.is_dir()) + or (path.is_dir() and not any(root in path.parents for root in roots)) + or (path.is_file() and max_file_size is not None and path.stat().st_size > max_file_size)): continue except OSError: continue @@ -142,10 +141,8 @@ def artifact_paths(state, codex_home): artifact = result.setdefault(key, {'path': path, 'reference': raw, 'references': [], 'name': path.name, 'kind': 'directory' if path.is_dir() else 'file', 'image': path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES}) artifact['references'].append(raw) - # A path can be shown once as ImageView and embedded later with another text - # form (usually a plain absolute path versus file://). Only the runtime view - # grants the outside-workspace exception; collect all references so Markdown - # can still resolve to the same authenticated artifact ID. + # ImageView and Markdown may use different references for the same file; + # merge them so both resolve to the same authenticated artifact ID. for path, references in model_images.items(): if path not in trusted_views: continue @@ -175,4 +172,5 @@ def workspace_references(messages, cwd): root = Path(cwd).resolve() return [{'id': key, 'name': value['name'], 'reference': value['reference'], 'path': value['path'].relative_to(root).as_posix(), 'kind': value['kind'], 'image': False} - for key, value in artifact_paths(state, None).items()] + for key, value in artifact_paths(state, None, max_file_size=None).items() + if root in value['path'].parents] diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3ec9677c..f3cbfa7f 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -657,7 +657,7 @@ def test_reference_path_accepts_windows_drive_paths(self): self.assertEqual(path, PureWindowsPath('D:/workspace/report.txt')) @unittest.skipUnless(os.name == 'nt', 'Windows drive path syntax') - def test_slash_prefixed_windows_artifacts_remain_in_workspace(self): + def test_slash_prefixed_windows_artifacts_support_cross_project_paths(self): with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: root = Path(directory) workspace = root / 'workspace';workspace.mkdir() @@ -671,10 +671,10 @@ def test_slash_prefixed_windows_artifacts_remain_in_workspace(self): ]}]} with self.subTest(name=name): files = artifact_paths(value, root / '.codex') - self.assertEqual(len(files), 1) - artifact = next(iter(files.values())) - self.assertEqual(artifact['path'], allowed.resolve()) - self.assertEqual(artifact['reference'], reference) + by_path = {item['path']: item for item in files.values()} + self.assertEqual(set(by_path), {allowed.resolve(), outside.resolve()}) + self.assertEqual(by_path[allowed.resolve()]['reference'], reference) + self.assertEqual(set(by_path[outside.resolve()]['references']), {'/' + outside.as_posix(), escape}) @unittest.skipIf(os.name == 'nt', 'POSIX path syntax') def test_slash_prefixed_drive_is_preserved_on_posix(self): @@ -682,7 +682,7 @@ def test_slash_prefixed_drive_is_preserved_on_posix(self): self.assertEqual(reference_path('/D:/workspace/report.md'), Path('/D:/workspace/report.md')) @unittest.skipUnless(os.name == 'nt', 'Windows drive path syntax') - def test_windows_drive_markdown_reference_remains_in_workspace(self): + def test_windows_drive_markdown_reference_supports_cross_project_paths(self): with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: root = Path(directory) workspace = root / 'workspace';workspace.mkdir() @@ -691,7 +691,7 @@ def test_windows_drive_markdown_reference_remains_in_workspace(self): value = state();value['cwd'] = str(workspace) value['turns'] = [{'items': [{'type': 'agentMessage', 'text': f'[report]({allowed}) [private]({outside})'}]}] files = artifact_paths(value, root / '.codex') - self.assertEqual([item['name'] for item in files.values()], ['report.txt']) + self.assertEqual({item['name'] for item in files.values()}, {'report.txt', 'private.txt'}) def test_file_uri_image_view_links_plain_markdown_reference(self): with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: @@ -724,7 +724,7 @@ def test_artifact_retains_all_references_to_same_file(self): self.assertEqual(set(artifact['references']), set(references)) self.assertIn(artifact['reference'], references) - def test_artifacts_only_referenced_workspace_files(self): + def test_artifacts_include_only_referenced_files_across_projects(self): with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: root = Path(directory) workspace = root / 'workspace' @@ -733,13 +733,18 @@ def test_artifacts_only_referenced_workspace_files(self): allowed.write_text('report') outside = root / 'private.txt' outside.write_text('private') + unreferenced = root / 'unreferenced.txt';unreferenced.write_text('private') + missing = root / 'missing.txt' + oversized = root / 'large.apk' + with oversized.open('wb') as stream: stream.truncate(50 * 1024 * 1024 + 1) value = state() value['cwd'] = str(workspace) - value['turns'] = [{'items': [{'type': 'agentMessage', 'text': f'[report]({allowed}) [private]({outside})'}]}] + references = [allowed, outside, missing, oversized, workspace] + value['turns'] = [{'items': [{'type': 'agentMessage', 'text': ' '.join(f'[file]({path})' for path in references)}]}] files = artifact_paths(value, root / '.codex') - self.assertEqual([v['name'] for v in files.values()], ['report.txt']) + self.assertEqual({v['path'] for v in files.values()}, {allowed.resolve(), outside.resolve()}) - def test_artifact_symlink_cannot_escape_workspace(self): + def test_artifact_symlink_resolves_referenced_cross_project_file(self): with tempfile.TemporaryDirectory(dir=str(ROOT / '.tmp')) as directory: root = Path(directory) workspace = root / 'workspace' @@ -755,7 +760,8 @@ def test_artifact_symlink_cannot_escape_workspace(self): raise value = {**state(), 'cwd': str(workspace), 'turns': [ {'items': [{'type': 'agentMessage', 'text': f'[link]({link})'}]}]} - self.assertEqual(artifact_paths(value, root / '.codex'), {}) + files = artifact_paths(value, root / '.codex') + self.assertEqual([item['path'] for item in files.values()], [outside.resolve()]) def test_file_approval_includes_actual_pending_diff(self): value = state() diff --git a/tests/test_download_ranges.py b/tests/test_download_ranges.py index 054d6dee..b27f9e81 100644 --- a/tests/test_download_ranges.py +++ b/tests/test_download_ranges.py @@ -9,6 +9,7 @@ import unittest from unittest.mock import patch +from bridge.features.workspace.files import path_identity from bridge.clients.codex.remote import RemoteStore from bridge.app.service import Bridge from bridge.features.workspace.workspace import Workspace, MAX_TRANSFER, operate @@ -165,6 +166,31 @@ def test_auth_host_path_symlink_and_live_size_limits_remain_enforced(self): with self.file.open('wb') as stream: stream.truncate(61 * 1024 * 1024) self.assertEqual(self.get({**auth, 'Range': 'bytes=0-9'}, artifact)[0], 400) + def test_cross_project_apk_download_requires_chat_reference_and_login(self): + workspace = self.root / 'AstrBot_Plugin'; workspace.mkdir() + project = self.root / 'codex-mobile-bridge'; project.mkdir() + apk = project / 'Bridge Dev.apk'; apk.write_bytes(b'APK fixture bytes') + identifier = hashlib.sha256(str(path_identity(apk)).encode()).hexdigest() + url = '/api/sessions/' + support.THREAD + '/files/' + identifier + session = SimpleNamespace(condition=support.threading.Condition(), state={'cwd': str(workspace), 'turns': []}) + bridge = SimpleNamespace(host='local', store=SimpleNamespace(home=self.root / '.codex'), + session=lambda thread, attach=False: session) + self.server.bridge.artifact = lambda thread, key: Bridge.artifact(bridge, thread, key) + self.assertEqual(self.get({}, url)[0], 401) + auth = self.login() + self.assertEqual(self.get(auth, url)[0], 404) + references = [str(apk), apk.as_uri()] + if os.name == 'nt': references.append('/' + apk.as_posix()) + for reference in references: + with self.subTest(reference=reference): + session.state['turns'] = [{'items': [{'type': 'agentMessage', 'text': f'[APK](<{reference}>)'}]}] + status, headers, data = self.get(auth, url) + self.assertEqual((status, data), (200, apk.read_bytes())) + self.assertIn('Bridge', headers['Content-Disposition']) + self.assertEqual(self.get({**auth, 'Range': 'bytes=0-2'}, url)[::2], (206, b'APK')) + session.state['turns'] = [] + self.assertEqual(self.get(auth, url)[0], 404) + def test_artifact_parent_symlink_swap_cannot_escape(self): folder = self.root / 'folder'; folder.mkdir() outside = self.root / 'outside'; outside.mkdir() diff --git a/tests/test_uploads_activity.py b/tests/test_uploads_activity.py index 3c6fbb1d..0e9a6e9c 100644 --- a/tests/test_uploads_activity.py +++ b/tests/test_uploads_activity.py @@ -229,7 +229,7 @@ def test_model_images_embed_and_imageview_activity_render_securely(self): self.assertEqual(Path(resolved['path']), image.resolve()) self.assertTrue(resolved['image']) - def test_model_markdown_cannot_leave_roots_but_imageview_can(self): + def test_cross_project_markdown_artifacts_and_runtime_image_previews(self): import tempfile from bridge.features.workspace.files import artifact_paths with tempfile.TemporaryDirectory(dir=self.root.parent) as directory: @@ -239,7 +239,8 @@ def test_model_markdown_cannot_leave_roots_but_imageview_can(self): codex_home = self.root / 'codex-home';codex_home.mkdir(exist_ok=True) markdown = {'cwd': str(workspace), 'turns': [{'items': [ {'type': 'agentMessage', 'text': f'![outside]({outside})'}]}]} - self.assertEqual(artifact_paths(markdown, codex_home), {}) + markdown_rows = artifact_paths(markdown, codex_home) + self.assertEqual([row['path'] for row in markdown_rows.values()], [outside.resolve()]) evidence = {'cwd': str(workspace), 'turns': [{'items': [ {'type': 'agentMessage', 'text': f'![outside]({outside})'}, {'type': 'ImageView', 'path': outside.as_uri()}]}]} From d99d8e0b325196bf84fe320fd28e6dc9cdf12536 Mon Sep 17 00:00:00 2001 From: heiqiu47 Date: Sat, 10 Oct 2026 23:01:51 +0800 Subject: [PATCH 6/7] feat(desktop): add Windows login startup --- README.md | 2 +- README_EN.md | 2 +- desktop/index.html | 2 +- desktop/login-startup.cjs | 47 ++++++++++++++++++++++++++ desktop/main.cjs | 23 +++++++++---- desktop/shell/renderer.js | 10 +++--- scripts/smoke-windows.cjs | 31 +++++++++++++++-- tests/desktop.test.cjs | 71 +++++++++++++++++++++++++++++++++++++-- web/shared/i18n.js | 5 +++ 9 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 desktop/login-startup.cjs diff --git a/README.md b/README.md index b10d79fb..d2b71f96 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ v2.0.0 在线查询订阅周期日期,显示剩余时间和具体日期;接 已有命令行部署时,在“应用与维护”选择原来的 `.local` 目录,可识别并管理已经运行的网关。运行期间端口、网络和程序路径不可修改;停止后才能调整。关闭 App 窗口会保留网关进程,点击“停止”才会停止手机访问。密码变更在下次启动生效;新版网关会自动读取通知配置变更。 -Windows 关闭窗口会收起到系统托盘;双击托盘或再次启动 App 可恢复同一窗口。托盘可打开或复制手机地址(优先 HTTPS,其次局域网),也可选择“停止网关并退出”或“退出控制面板(保留网关)”。默认数据保存在当前用户的 App 数据目录,卸载不自动删除配置与凭据。手动更新、移动解压版或卸载前,请先停止网关并退出控制面板,避免后台运行时仍占用程序文件。“打开 App 自动启动”不等于 Windows 开机启动。 +Windows 关闭窗口会收起到系统托盘;双击托盘或再次启动 App 可恢复同一窗口。托盘可打开或复制手机地址(优先 HTTPS,其次局域网),也可选择“停止网关并退出”或“退出控制面板(保留网关)”。默认数据保存在当前用户的 App 数据目录,卸载不自动删除配置与凭据。手动更新、移动解压版或卸载前,请先停止网关并退出控制面板,避免后台运行时仍占用程序文件。Windows 安装版和源码开发版可在“应用与维护”的“启动与数据目录”开启“登录 Windows 后自动启动”,保存后在登录时收起到托盘,并同时开启“打开 App 时自动启动网关”。关闭登录自启后保留网关启动偏好;手动打开 App 显示控制面板。电脑完成 Windows 登录并保持联网后,手机可连接网关。 右上角可切换 **简体中文 / English**,与 Mac 新界面位置一致。首次启动按系统语言选择(英文系统使用英文,其他语言回退简体中文),之后记住手动选择,并保留旧 Windows 版已保存的语言偏好。切换立即更新桌面页面、连接配置、状态提示和 Windows 托盘,不重启网关、不丢弃未保存配置。桌面偏好保存在 App 用户目录的 `language.json`。手机网页使用同一翻译表,但语言可独立选择并记忆;原始运行日志和聊天内容不翻译,未知系统错误保留原始诊断信息。 diff --git a/README_EN.md b/README_EN.md index ccf4cc19..b6ef6199 100644 --- a/README_EN.md +++ b/README_EN.md @@ -140,7 +140,7 @@ Use a Release package above for everyday use. The desktop app manages the gatewa Download links are in the quick start above. Development artifacts are available from successful **GitHub Actions → Desktop builds** runs, or you can build locally as described below. -On Windows, closing the window hides it to the tray; launching again restores it. The tray offers separate actions to stop the gateway and quit, or quit only the controller. Stop the gateway and exit before manually upgrading or moving the app. Select Chinese or English at the top right; existing Windows language preferences are retained, and tray labels follow the selection. Phone language is independent. Uninstalling does not automatically remove gateway settings or credentials. +On Windows, closing the window hides it to the tray; launching again restores it. Packaged and source Windows apps offer **Start automatically when signing in to Windows** under **App & maintenance → Startup and data directory**. Saving this option also enables automatic gateway startup. Login launches stay in the tray; manual launches show the control panel. Turning off login startup keeps the gateway startup preference. Source launches preserve the selected Python and data directory in a local development configuration. The tray offers separate actions to stop the gateway and quit, or quit only the controller. Stop the gateway and exit before manually upgrading or moving the app. Select Chinese or English at the top right; existing Windows language preferences are retained, and tray labels follow the selection. Phone language is independent. Uninstalling does not automatically remove gateway settings or credentials. The App provides: diff --git a/desktop/index.html b/desktop/index.html index c6a674d9..f4358174 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -35,7 +35,7 @@

手动填写的跳转地址优先;留空自动选择网关入口。局域网链接需要手机在同一网络。临时域名变化后,旧通知的链接可能失效。

已关注聊天

在手机端添加监控;在这里管理完成通知或删除监控。删除监控不会删除会话。

- +