Repository navigation
Conversation
个人配置: - app.json 改用自己的 bundle identifier - package.json 由 expo prebuild 自动更新 ios/android 脚本 relay 修复(将单独整理成上游 PR): - 配对设备的 target 授权范围不再被可用列表放大 - 客户端 SSE 中断时释放上游连接 - 修正测试脚手架失效的连接计数器并补回归测试
Remote access is Tailscale's job now. The relay runs on the same host as the
backends it fronts, binds loopback only, and never sees a tunnel, so everything
that existed to enrol a remote machine and dial it back is dead weight.
Removed:
- clients/: 38 files of machine-side launchers, supervisors and FRP tunnel
plumbing, superseded by the host deployment script
- relay machine enrolment: the OAuth device-code flow, /api/machine/*, the
console's Machines and Pending sections, and the frp-ssh transport payload
- relay/deploy/deploy-relay.sh and docs/windows-relay-oauth-spec.md
Kept, because "machine" means two different things here: in the app it is the
user-facing name for a relay target, and that vocabulary stays. machine-status-
monitor.mjs probes each declared target and is unrelated to enrolment; it stays
wired to the target registry, though nothing reads its cache until the multi-
backend work lands.
State files written by the tunnel-era relay carry a machines array. Rather than
reject them, validateState drops the field and migrates to version 3, so an
existing deployment keeps its owner credential and paired devices on upgrade.
Tests: 53 -> 50. Five machine-enrolment cases in pairing-store and one machine
OAuth case in passkey-pairing were removed; three new cases replace them, each
verified to fail against the pre-deletion tree: the enrolment routes now 404,
a tunnel-era state file upgrades without losing devices, and an unknown state
version is still rejected rather than silently treated as a fresh install.
…value The suite has been red on this branch since the iOS bundle identifier was set to a personal one: the test still pinned the upstream placeholder. Every deployment signs with its own id, so pinning any single value reddens the suite for whoever changes it next. Assert a well-formed reverse-DNS identifier instead, which still fails on an empty or single-segment value.
Running the real relay shows these paths answer 401, not 404: once the pairing controller stops claiming a route, the request falls through to the relay's own bearer check. The 404 in this test is the fixture's fallback for an unclaimed route, which is a statement about the controller rather than about deployed behaviour. Name and comment now say so.
… a proxy
Brings up the backends, the relay, and HTTPS on the tailnet in one command.
There is no reverse proxy and no certificate to renew: tailscaled terminates
TLS for the host's own *.ts.net name, which also means nothing listens on a
public interface. Measured on this machine, `tailscale serve` streams SSE
without buffering -- one event per second arrives at 1.001s intervals, 11ms
behind a direct connection -- so the proxy it replaces was only adding a hop.
Two modes. production installs launchd jobs and supervises the backends;
`--managed-backend name:port[:profile]` registers each one as its own relay
target, which is how a client gets a switchable "machine" per profile. shadow
installs nothing and runs beside a deployment that is already serving devices,
registering its backend with --target instead of taking it over, so a new build
can be exercised without touching the live one.
Three rules the scripts enforce rather than document:
- no path is hardcoded. The Tailscale CLI lives inside the app bundle, and
/usr/bin/git may be an xcode-select shim that fails; both are probed.
- ProcessType never reaches a plist. Background puts a job in a throttled
band where a Node service needs over 90s to bind its port.
- verification goes through the tailnet name, never 127.0.0.1, because the
loopback path skips both TLS termination and the proxy hop.
Renders launchd jobs from a module with tests rather than a heredoc, so the
ProcessType ban and the supervision keys are asserted; one test round-trips
through plutil to prove macOS can parse what we write.
Verified by installing, killing the relay to confirm launchd restarts it, and
uninstalling to exit 0 with no residue.
The relay answered its own OPTIONS preflight but forwarded the upstream response verbatim, so a client with a real origin cleared the preflight and was then blocked from reading the body. Measured against a live backend: the preflight carried access-control-allow-origin, the GET that followed carried nothing. From the client side that looks like a hang rather than a policy error, which is an expensive thing to debug from inside a renderer. Supply the header only when the backend did not, so a backend started with `opencode serve --cors` keeps its own policy instead of being overridden. The desktop shell loads from its own scheme and is the first client this affects; phones were unaffected because a native fetch enforces no origin.
…nding layer The window loads the same React bundle the phone runs. `expo export --platform web` produces a plain static site, so components, store, themes and business logic are shared rather than reimplemented; the shell only does the three things a page cannot do for itself. Serves the bundle from a registered `cockpit://` scheme rather than file://, whose null origin the relay's CORS policy cannot name and which disables the storage APIs the UI uses. Keeps the renderer sandboxed with node integration off, and exposes three named functions across the bridge instead of a channel the page can send anything down. The keybinding layer is the substantive part. A terminal owns every key and a window does not: tab moves focus, ctrl+w closes the window, ctrl+p prints, and the leader itself -- ctrl+x -- is Cut inside a text field. 44 chords are claimed before their default action runs; ctrl+v is deliberately not among them because opencode's own table marks paste as non-preventing. Same-chord-different-meaning needed a context model. Taking the table at face value produced 13 collisions, e.g. ctrl+d as exit, delete-session and delete-stash at once. Splitting each dialog into its own context and naming the six list-scoped actions whose names do not reveal their scope brings it down to three, each irreducible: the upstream table binds <leader>q twice, and up/down are cursor movement or history depending on where the cursor sits, which is a runtime fact. A test pins that set, so a fourth cannot appear quietly. Conflicts are reported rather than resolved by parse order, because the losing action becomes unreachable and nobody would know. Verified by running it: the window paints, renders the real UI, and reports no page errors. The smoke mode asserts on errors the preload actually collects, not on an array nothing writes to. Not yet wired: the page receives the dispatched actions but does not consume them, and the first screen is still the bundle's own Hosts view rather than a purpose-built host-and-profile picker.
The desktop shell resolves a key against opencode's table and sends back an action name like session_compact. The app speaks a different vocabulary -- command-palette ids, entrypoints, and a few things only the session screen can do -- so without a translation the shell would claim the key and nothing would happen, which is worse than leaving it unbound. An action absent from the table resolves to `unsupported` rather than being dropped, so a key that does nothing can say so instead of looking broken. The mapping is checked rather than trusted: a test walks every target and fails if it names a command or entrypoint that does not exist. A typo there compiles cleanly and then silently no-ops at the one moment a user is watching. Verified by planting one: a single misspelling turns three tests red. Two further tests assert the reverse direction, that no palette action or entrypoint has been left without a key. The bridge itself is inert when the host object is absent, so the phone build needs no platform branch at the call sites, and a throwing handler is reported instead of tearing down the subscription -- one bad action should not leave every later key dead.
… nobody can Three outcomes: the store does it now, a screen that registered a handler does it, or nobody can yet. The third is the reason this exists. A key that silently does nothing is indistinguishable from a broken keyboard, so an action no one claims is reported with why -- no session open, no screen mounted, or not in the mapping at all. Session-scoped actions are refused before they reach a store method that would throw on a missing session, which is what pressing escape on the host list used to do. One test walks the lookup tables against the capability table: a name present in one and missing from the other would otherwise only surface as a crash at the moment a key is pressed.
Completes the chain the shell started. A screen registry holds the actions only a mounted screen can perform, a performer calls the store method or the screen handler, and a hook mounts the whole thing once at the root. The hook is thin because translation, routing and performing are pure modules with their own tests; it only subscribes while the app is mounted and reads the store at press time rather than closing over a stale snapshot. Nothing is allowed to vanish. An action with no open session, no mounted screen, or no mapping reports why, because a key that silently does nothing is indistinguishable from a broken keyboard. Async store actions that reject are surfaced rather than left as unhandled rejections. The registry keeps the newer screen's handler when navigating between sessions: the second screen mounts before the first unmounts, so a stale dispose must not remove the live handler. Verified by pressing a real key in a real window, not by asserting on a mock. The smoke run sends Escape through Electron's input pipeline and diffs the page before and after; one line appears, "interrupt needs an open session", which exists only in the new performer. Its presence proves every link fired: key interception, keymap resolution, IPC, translation, routing, the store write and the re-render. The successful-mutation branch is unit-tested but was not the path this observable exercised.
…keys to resolve against Two pieces, found in that order. The transcript list is inverted: the newest message sits at native offset 0 and older content at larger offsets, so page-up moves toward older by *increasing* the offset. Getting that backwards is silent, so the arithmetic is a tested module rather than inline in the component, with clamping at both ends and a guard for metrics that have not been measured yet. The component advances its own record of the offset immediately after issuing a scroll, because key repeat outruns the native scroll event and reading the stale value would make every press after the first a no-op. Then the measurement that mattered: pressing PageUp in the real window changed nothing, and the reason was not the scrolling. Keys are resolved against the surface the user is on, and nothing had ever told the shell what that was, so every key resolved as 'global' -- where no transcript, prompt or diff binding exists. Every context-scoped binding in the table was unreachable. Screens now declare their surface for as long as they are mounted, and the value is replayed to a shell bridge that attaches later, since the two mount in either order. Verified that the previously working path still works: Escape in a real window still reports "interrupt needs an open session" end to end. PageUp moving a live transcript is covered by unit tests and by a measured resolution table -- it resolves to nothing in 'global' and to messages_page_up in 'messages' -- but has not been watched against real content, which needs a paired host in the shell.
A host can run several backends side by side, one per profile, and the relay exposes each as its own target. The store already discovers them with their reachability, so this only shapes what the screen shows and what it refuses. Two decisions live here rather than in a component. A host with no machine stays visible with the reason it has none, because hiding it looks like the host was never added, and the two kinds of nothing -- answered but authorized nothing versus never answered -- read very differently to someone debugging. And a host running more than one machine is flagged, because every machine on a host shares one session database: the same session appears under each of them, so choosing a machine picks which process runs the next prompt, not which sessions exist. That is the one thing this screen has to say out loud rather than let a user discover by seeing their session listed twice. The screen component and its routing are not here yet; this is the part that can be tested without one.
A laptop reaches several backends on one host, and which one runs the next prompt is a choice worth making before a session is on screen. The desktop shell now loads this route at launch; the phone keeps its existing tabs, where the sessions screen already offers a per-machine filter and a first screen would be one more thing to dismiss. The screen opens straight through when a host has exactly one reachable machine, because a picker with a single option is a dialog, not a choice. When a host runs more than one it says that they share a session database, so seeing the same session under each machine reads as expected rather than as a bug. Two defects that only a real window showed, both now fixed: the route rendered with a stray "devices" header because it was never registered in the stack, and the empty state printed its reason twice, once as a subtitle and once in the body. Verified after the fix by launching the shell and reading what the window actually painted.
Upstream refused both on purpose: a phone was a monitor, not a development machine. A laptop reaching the same host is a development machine, so the two hardcoded refusals are replaced by real calls. The API layer already had both; only the store said no. A created session carries the machine it was created on. Without that it would be routed to whichever backend answered first, which on a host running several profiles is not the one the user picked. startNewSessionPrompt no longer states something untrue. It clears a stale error banner so the screen offering the choices opens clean; creation itself is createSession. The two tests asserting the refusal are replaced by three asserting the capability, including that a refused creation reports itself and returns no ref rather than handing back a reference to nothing. Directory and machine are selectable at creation. Agent and model are not yet: they are chosen per session after it opens, and wiring them into the creation call needs the picker UI that does not exist yet.
The data was already there: the store fetches a session's diff when it opens and keeps it current from the event stream. Only the screen was still rendering a hardcoded sample, with a subtitle that said so. The page is addressed by route rather than reading whichever session happens to be active, so a diff opened from one session cannot show another's changes. With no session in the route it keeps the fixture, which is what the copy-QA page and its existing test rely on, and it now distinguishes "this session has no changes" from "this is the sample" instead of showing sample changes for an empty session. The session screen claims the diff entrypoint, so the desktop keybinding that resolves to it has somewhere to go. Two things this turned up. The screen's test never mocked expo-router or the store, so pulling either into the module graph broke the suite at import time before a single test ran. And SessionRef requires a machine id, which a route parameter cannot promise, so the absent case is now explicit rather than passed through as undefined.
Machine, working directory, agent and model go in, but they do not leave together. Creating a session carries the machine and the directory; the agent and the model are applied to the session once it exists, because that is how the backend models them. So this returns both halves, and a caller that uses only the first silently drops what the user picked. Agents and models come from the chosen machine's execution contract. A machine whose contract has not arrived can still be chosen but can offer neither list, and saying so beats rendering two empty dropdowns that look like a machine with no agents. The configured default model is a bare model id while a picker key carries its provider, so the default is matched on the id. Comparing the two strings would never match and the default would quietly become whatever sorted first. A choice the machine does not offer is refused by name rather than dropped, and a blank directory falls back to the machine's own rather than sending an empty string for the backend to interpret. The screen is not here yet; this is the part that can be tested without one.
Collects the four choices and then does the two-step the backend requires: create carries the machine and directory, and the agent and model are applied to the session once it exists. Getting only the first half right would look like the pickers did nothing. The agent and model lists come from the machine's execution contract, which is cached per machine and directory rather than per session, so a new session can read one an earlier session already fetched. When none is cached the screen says the machine has not reported its agents and that the session will start on its defaults, instead of rendering empty lists that read as "this machine has none". session_new no longer routes to the store. Creating a session needs four choices that only a screen can collect, so the key now opens that screen; the store keeps the creation call itself. The desktop's first screen claims the key. Two things this turned up. Importing the store into a screen test pulls React Native into the graph and fails on a missing __DEV__ global before any test runs, so the scope-key helper is mirrored in the mock instead. And only machines that are actually answering are offered, since creating a session on an unreachable one fails after the user has made four choices rather than before.
Three gaps and one measurement. The new-session key only worked on the screen that claimed it, which made it dead everywhere else. Opening a screen is an app-level concern, so the root claims it now and the key works from any surface. On a fresh install nothing has ever cached a machine's execution contract, so the agent and model lists would have stayed empty forever and the four-choice form was really a two-choice one. Fetching a machine's agents and providers is not a session operation, so the store now asks the machine directly and caches the result under the same key a session would have used. The desktop app can be installed: one command builds the renderer, packages a .app, and then checks the artefact rather than the exit code -- an Info.plist macOS cannot parse produces a bundle that fails to launch with no useful message. Verified by running the packaged binary, not the dev build: it paints the machine picker with no page errors. Unsigned, so the first launch needs right-click then Open. And the question that has been open since the multi-profile design was written: two backends on one session database. Measured in an isolated data directory that never opens the real one. Started together, the second dies with "database is locked" and never binds a port. Started after the first is listening, both run, and fifteen rounds of concurrent writes produced no lock contention in either log with the database in WAL mode. So it works, but they must not initialise at the same time -- the deploy script already starts them one at a time, while launchd at boot does not, and relies on KeepAlive restarting the loser ten seconds later. That last part is inference, not measurement, and says so in the docs. Two traps found along the way, both recorded: macOS /tmp resolves to /private/tmp and the server stores the resolved path, so a query with the unresolved one matches zero sessions rather than erroring; and session listing is scoped by the server's own working directory, which a directory query does not change.
The smoke could only send one chord, so every leader binding -- twenty-eight of them, including the one that creates a session -- was unverified in a real window. A comma now separates chords in a sequence. Pressing ctrl+x then n moves the window from the machine picker to the new session screen, which proves five things at once that unit tests cannot: the leader chord is taken before the text field treats it as Cut, the pending state survives between two key events, the sequence resolves to session_new, the action reaches the claim at the root rather than dying on whichever screen is mounted, and the screen it opens actually renders.
The relay normalises a missing allowedDirectories to an empty list, and an empty list forbids every directory. The deploy script omitted the field, so every fresh host produced an owner client that answered directory-scoped requests with 403 directory_forbidden. Writing null, which means any directory, fixes it. Found by running the app's live end-to-end test against a real deployment: it connected, discovered the machines, and then failed on the first scoped call. The deploy script's own verification had passed throughout, because it only ever made unscoped requests. It now asks for a directory explicitly, so this class of failure surfaces at deploy time instead of on a user's first real call. The check is known to fail on the old configuration: that is exactly the 403 the end-to-end run produced before the fix.
A hiddenInset title bar floats the window buttons over the page, and the page starts at the top, so the app title rendered underneath them. The strip is reserved in the shell rather than in the shared UI, because the phone renders that same UI and the space would be dead margin there. The smoke now reads the computed padding back and fails if it is missing. The injection is a side effect with no return value to check, so without this a failed injection would look exactly like a successful one.
The deploy script never generated a bootstrap key, so the pairing console could not register an owner and the QR flow was unreachable. Pasting a 64-character token by hand was the only way to add a device, which is not a flow anyone would use on a phone. The key is generated once and preserved across redeploys, and the script prints a one-time setup link. The secret travels in the URL fragment, which browsers do not send to the server, so it never reaches an access log; the relay stops accepting it the moment an owner passkey exists. Verified against the running relay rather than by reading the code: the correct key returns a WebAuthn ceremony whose relying-party id is the tailnet hostname, and a wrong key of the same length is refused with 403.
…nnot drift Hiding the title bar and reserving room for the window buttons are one decision, but they lived in two places: a style on the window and a CSS injection fifty lines away. Shipping the first without the second is exactly what put the traffic lights on top of the app header. Both now come from one function, and a test asserts the pairing on every platform rather than only the one this machine runs: the inset is required exactly when the title bar is hidden, the CSS exists exactly when the inset is required, and an unrecognised platform falls back to a real system title bar rather than to buttons floating over the content. Windows still has no real-machine check. It takes the system title bar, which draws above the page instead of over it, so the overlap cannot occur by construction -- but that is reasoning, and the test only guards the decision, not the rendering.
The pairing endpoints were same-origin by construction: no allow-origin header, Cross-Origin-Resource-Policy set to same-origin, and a preflight handler covering exactly one route. That holds for the console, which the relay serves itself, and fails for every separate client -- including the packaged desktop app, which loads from its own scheme. An Authorization header is not CORS-safelisted, so even a GET is preflighted; the single-route handler left the rest unreachable. The preflight now covers /api/*, and JSON responses carry allow-origin with a cross-origin resource policy. A wildcard origin cannot be combined with credentials by any browser, so this does not expose the console's cookie session to another origin. Found by driving the real UI in a browser against a live relay: six console errors on /api/pairing/me. After the fix the same call returns a readable 401, which is correct -- a static token client is not a paired device -- rather than being blocked before the answer arrives.
The first screen only read cached relay targets and nothing ever populated them on a cold start, so a healthy relay was reported as "no machine authorized". The hosts tab worked because tapping a host runs the check; the new screen had no equivalent. Set the active connection and refresh once per connection whose targets are unknown, and say so on screen while it runs. Also replace the titlebar smoke assertion, which measured the wrong thing. It read the computed padding on body, which proves the injected CSS parsed but not that any content moved. Measure the viewport position of the topmost visible text instead, and record the root elements' positioning so a future regression is diagnosable from the artifact.
Two places replied to OPTIONS. The pairing module sees the request first, and its /api/ branch spelled out a narrower list of its own: it allowed only Authorization and Content-Type, and dropped PATCH. It therefore shadowed the proxy's correct answer for every API route. The desktop app sends X-OpenCode-Target and X-OpenCode-Directory, so the browser blocked its requests before they were sent and the app could only report "Failed to fetch". Nothing else noticed: curl does not enforce CORS, and the phone loads a page the relay itself serves, which is same-origin and never preflights. Move the policy into lib/cors.mjs and have both callers use it. Responses now also expose X-Next-Cursor, without which a cross-origin reader sees null and the paged session list stops after one page. The test that was missing reads the client's own source and fails if a custom header it uses is neither allowed nor exposed.
The packaging script was macOS only: the platform flag, the bundle layout, the plist lint and the du call all assumed an .app, so there was no way to produce a Windows build at all. What differs between the targets now lives in package-target.mjs, and the script takes --platform. Cross-building win32 from the Mac host works and needs no Wine. Both icon containers are derived on every build from the phone app's existing artwork, which is the icon its author agreed we could use. One source image means macOS and Windows cannot end up on different art. The .ico is assembled here because the format is a header followed by whole PNG files, and pulling in an image library to write sixteen bytes of it would be the larger cost. The smoke also stops measuring the wrong things. It now asserts where the topmost visible text actually landed rather than that a padding rule parsed, and it makes that assertion on every platform: macOS must clear the floating window buttons, and everywhere else the same inset would be dead margin. It captures the page again once the network has settled, because first paint showed a spinner and reading it as the finished screen reported a healthy app as broken.
An empty list was always explained as "this relay authorized no machine for this device", which reads as a permissions problem. The usual cause is a request that failed with a message the screen never showed, and the wrong explanation sent this investigation toward relay permissions while the real error sat unread in the store. Pass the host's sync error into the model and show it.
Three of its checks could not report a problem, and one of them had already been read as evidence. The page's error and action lists were handed to the renderer through contextBridge, which deep-clones what it copies. The page therefore held a dead snapshot: every later push landed in the preload's world and was invisible in the one the check read. The console-error assertion had been passing against an array that could never fill. Both lists are exposed as functions now, which the bridge proxies live. Synthetic key presses named their modifiers the way a binding spells them, and sendInputEvent ignores a name it does not know. "ctrl" was such a name, so the modifier vanished and the press landed as a bare letter; a smoke that pressed nothing of the sort still reported the screen had changed. Names are translated now, and an unrecognised one fails the run instead of being dropped. Adds a click step, because screens reachable only by pointer cannot otherwise be keyboard-tested, and records which actions the shell delivered, because "the key never resolved" and "it resolved and nothing handled it" look identical from outside and need opposite fixes.
…killing the live stack The machine picker is the first screen and sits outside the tabs, so when the only paired host cannot be reached there was nothing on it that led anywhere: no way to add a relay, edit its address, or remove it. It carries a Manage hosts control now, present whether or not anything is reachable. Separately, uninstall-macos.sh selected launchd jobs by label prefix regardless of mode. Shadow installs no launchd job at all, so a shadow teardown matched the production relay instead: it unloaded the live job and deleted its plist, taking the host off the air. Shadow now touches nothing launchd knows about, and the test asserts the teardown plan through the script's own dry run.
The native project is untracked, so the two constraints that make it build are invisible: a path without non-ASCII characters, because CocoaPods reads the Podfile through Ruby and fails on the first non-Latin byte, and a post_install that lifts pods whose declared deployment target predates what Xcode accepts. Both were found the hard way.
A free Apple ID signs an app for seven days and an installed copy cannot be extended, so every renewal is a full rebuild and reinstall. Doing that by hand meant remembering the team, the scheme, the configuration, and which device id to pass; getting one wrong produced either a build against the wrong signing identity or a silent no-op. The script finds the connected phone by the shape of its UDID rather than by the wording of devicectl's state column, which differs depending on how the phone is attached. It reads the expiry out of the profile the build actually embedded and refuses to install something that is already expiring: a stale profile on disk otherwise produces an app that stops launching within hours.
…or a path Creating a session meant typing an absolute path, which on a phone means recalling one exactly. "/~" looks like the obvious shorthand and the relay rejects it, because it is not an absolute path; the app then showed the bare token directory_forbidden, which reads as a demand for a directory rather than as a statement that the one given is not usable. The machine already knows where its sessions live. Those directories are now offered one tap each, busiest first, with the machine's own default at the top, and the field says what leaving it empty does. The relay's rejection tokens are translated where they surface.
…stination A locked or half-attached iPhone still appears in devicectl's list, marked unavailable. Taking its id regardless handed xcodebuild a destination it could not match, and the error it printed talked about destination specifiers and listed every simulator on the machine, which says nothing about the phone being locked. The device is now chosen from the physical entries that are not unavailable, and an attached-but-unusable phone is reported as exactly that.
The README opened in English with a template's description of itself. It now says in one paragraph what the thing does for the person reading it, and names the project it started from: BB-84C's opencode-mobile-solution, whose relay and phone client this fork builds on, and whose app icon it uses with permission.
The empty state said the relay backend cannot look up past edits. That was
wrong, and it was wrong because the probe behind it asked the wrong path: the
client reaches this endpoint at /session/{id}/diff, while the probe used
/api/session/{id}/diff, which no route serves and which the backend answers
with its web app's index page, at status 200. The same mistake made session
deletion look broken when it works.
The endpoint answers with JSON. Why a given session has no recorded changes is
not something this screen knows, so it no longer guesses.
Running bare `opencode` on the host starts its own process with its own session database, so nothing it does is visible from the phone. This attaches to the backend already running on 4096, which is the one the phone and every paired client talk to, so all of them see one session list. The script was referenced in deploy-notes as living in ~/.local/bin and had never been in the repository, so cloning the repo did not give you the command the notes told you to run. Credentials are read from backend.env into the environment rather than passed on the command line, so they stay out of ps and shell history. It also supplies --dir when the caller omits it: the backend partitions sessions by working directory, and without it the list belongs to the service process's own directory rather than the project you are in.
A client running Shadowrocket or Clash builds its own utun and takes over the default route, so packets bound for the tailnet are swallowed before Tailscale sees them. The symptom is a connection that times out rather than one that is refused, and none of it is visible from the host, which reports every service healthy. Three causes present identically. The document separates them with one command, because the fixes have nothing in common: a VPN stealing the route is a client rule change, an address collision can only be fixed on the router, and a blocked data plane is a NAT setting. Two findings are recorded because they cost real time. Shadowrocket's tun-excluded-routes reads like "leave this range alone" but means "hand this range to the physical NIC", and the global route it installs outranks the scoped route Tailscale relies on, so it makes things worse rather than better. And curl ignores the client's rules entirely because it reads proxy environment variables, which produces a GUI that works while every command line request hangs.
A turn that fails server-side is reported as an event, not as a failed request: the machine emits session.error and carries on. The app handled message, status, question and todo events and silently dropped that one, so an unavailable model produced no reply, no error, and no change on screen. Measured against the live host: prompting with a model that does not exist returns 204 and then emits session.error twice, carrying "Model not found: <provider>/<model>". The event now lands in the session's error channel and clears the busy status, so the spinner stops. The error text is read defensively, because a shape this app does not recognise still has to say something rather than nothing. That channel also rendered under a fixed "Transcript refresh warning" label while every writer puts a self-describing sentence into it, so a model error read as a transcript problem. The label is gone.
…h it The friend's repo investigated an iOS 27 launch crash reported against a TestFlight build from July. The root cause is not established there — no termination report was available — and their own device opened the app fine after updating. What they prepared is compatibility rather than a proven fix: Expo 57.0.23 with aligned native modules, and expo-build-properties with ios.enableSceneSupport, because Apple requires the scene lifecycle for anything built against the iOS 27 SDK. Merging the whole of upstream rather than cherry-picking the two iOS lines costs one conflict and keeps the fork close enough to sync again later. The conflict was complementary: both sides had added an event handler at the same place, ours for session.error and theirs for permission.asked, so both stay. Our bundle identifier stays com.skylerhu.opencodemobile, which is what makes the next install an upgrade rather than a second app with empty storage. Version moves to 1.1.0: upstream's own app.json still says 1.0.0, and a separate number says plainly that this line carries their work plus ours.
Counted from the diff rather than recalled: 41 files, +3598/-50, 27 of them new and none removed. Almost all of it is a desktop keyboard layer the phone never loads; what a phone user actually sees extra is the machine picker, the new session screen, the command palette, and four additions inside the session menu. Records the three deliberate divergences, with the reason for each: our bundle identifier, which is what keeps an install an upgrade; our version line; and the dropped VPS-era client directories.
createSession defaulted title to the literal 'New Session' and always sent it. The OpenCode core auto-names a session only while its title matches its own placeholder (^(New session - |Child session - )<ISO8601 with ms>Z$), so every session opened from the app stayed unnamed, with no error and no log line. Send title only when the caller chose one; an explicit title is still sent and still wins.
Tests assert the request body: no title sends {}, an explicit title is kept, the session handed back still carries the server placeholder, and the missing-directory guard still throws. Against the pre-fix client, two of the four fail.
npm test on the merge result: 436 passed, 4 skipped. typecheck: the 2 pre-existing errors in pairing.test.ts, unchanged.
Needs an app rebuild and reinstall to reach a phone. Sessions already titled 'New Session' are not renamed retroactively.
|
新增一个提交 One new commit, |
The paperclip only typed "@" into the prompt. Its reference list shows agents until a query is typed, so pressing it looked like "mention an agent", and nothing on this surface could send a photo or a PDF. The paperclip now picks images and PDFs (Photos or Files on a phone, the file dialog on the desktop) and sends each one as a file part with a data URL next to the text. Images up to 5 MB, PDFs up to 20 MB, five per message; the type is read from the bytes, not from the picker's label. A model that declares it cannot read the kind attached is refused before anything is sent, and an attachment on a shell line or slash command is refused rather than dropped. The old reference behaviour keeps working behind an @ button. The transcript shows an attached file as its name. The phone's transcript cache keeps the name and drops the inline bytes, so one photo cannot spend the bounded cache that protects launch memory. Verified against the running backend through the relay: claude-sonnet-5 read a two-colour PNG and a code word from a PDF sent this way, and in a browser the picker, chip, send and remove steps all worked. app tests 460 passed, 4 skipped; typecheck has only the two existing pairing.test.ts errors. Native: adds expo-image-picker and expo-document-picker, so the iOS app must be rebuilt; app.json carries the photo-library permission text.
|
新增提交 New commit |
Special patch, 2026-10-01, docs only. When a client has no IPv6 and the tailnet runs over DERP relays (e.g. cross-border traffic is blocked), Shadowrocket's DIRECT rules are not enough: browsers and the Electron client go through the system proxy first, Shadowrocket's own direct socket cannot reach the tailnet, and the request fails with a 503. The new section at the end of clients/README.md adds *.ts.net and 100.64.0.0/10 to skip-proxy, and shows how to test the proxied path with curl (env -u NO_PROXY + -x), since plain curl honours NO_PROXY and always looks fine. Both steps are only needed in the no-IPv6 case; with IPv6 direct connectivity the three existing DIRECT rules suffice.
…sign it so alerts reach Notification Center Ctrl+C / Ctrl+A on Windows and Cmd+A on macOS are no longer claimed by the TUI keymap, and a right-click menu offers copy and select all. Closing the window hides it to the tray or menu bar; the tray, the Dock, a second launch and a notification click all reopen it, and a crashed renderer is rebuilt. The app starts at login by default, with a tray toggle. Packaged macOS builds are re-signed ad hoc under their own bundle id, since a build signed as "Electron" is never registered for notifications.
…ssions everywhere On iOS a whole message now renders as one UITextView (react-native-uitextview), so a selection can start and end anywhere inside it, across paragraphs, lists and code blocks. Select text opens the whole loaded conversation, scrolled to the message, for selections that span messages; the message card keeps a permanent ... button instead of a long press. Permission and question requests are kept per directory, so a parent session shows its subagents' requests and can answer them. A global event stream feeds a waiting badge on the session list, an in-app banner, and desktop notifications that open the session.
|
Pushed three commits to
Tests: |
Windows only shows a toast for an app with a Start Menu shortcut carrying its AppUserModelID and toast activator. The packaged build has no installer, so it sets both ids at startup and writes its own shortcut.
|
One more commit, |
Replies render $...$, \(...\), $$...$$ and \[...\] as formulas with KaTeX, the engine the HTML reports use, while prices such as $5 stay text. The desktop and web build typeset inline; on iOS a message containing math is rendered in a WebView, with KaTeX's fonts inlined because such a page cannot read font files from the app bundle, and Select text uses one too when the conversation has math. Copying a selection that contains math puts the TeX source on the clipboard with line breaks intact. The desktop renderer build now places KaTeX's fonts beside its stylesheet and fails if any referenced font is missing. App version 1.2.0, desktop shell 0.2.0. New dependencies: katex 0.19.0 and react-native-webview 13.16.1 (a native module).
|
Released 1.2.0 ( |
… red
The session's todo list (GET /session/{id}/todo, live through todo.updated) shows as a bar at the top of the session with progress and the task in progress, expanding to the whole list; open by default on the desktop, folded on the phone, toggled by the TUI's <leader>b or the session menu.
A failed turn is not output: the server stores { name, data: { message } } on the assistant message and emits session.error, and neither reached the screen, so work stopped with no reason shown. The message error now renders as a red box in the transcript (also when an old session is opened), an error with no message (a missing model, say) shows above the prompt until dismissed or a new turn starts, a retrying status says why, and Stop shows only as Interrupted. The global watch also follows session.error, so a failing subagent or background session marks its card Stopped, raises a desktop notification and a phone banner. Server stack traces are dropped from the text.
Packaging now signs the macOS bundle in a temporary copy: under iCloud the bundle is re-tagged between clearing and signing, which failed the build intermittently. App 2.0.0, desktop shell 1.0.0.
|
Released 2.0.0 ( |
The shell renders the same UI as the phone, so a separate number (1.0.0 against the app's 2.0.0) only made the two look like different releases.
The English README, docs/architecture.md and relay/README.md still described the VPS relay, the SSH/frp tunnel, machine enrollment and the clients/ launchers, all removed when this fork moved to a single host on the tailnet. They now describe the host deployment, tailscale serve, the desktop client, the event streams the 2.0 features rely on, and how to build each part from source, since no public binaries are published. The product spec's transport diagram is updated and its broken links to the UX spec are fixed.

This PR brings the
opencode-cockpitline back to your repository for review. It is opened so you can read the full history and diff before we decide how to merge the two projects. Please do not treat it as a request to mergemainas-is; the last section lists what I think should and should not come across.hairen-org/opencode-mobile-solutionmain@6e79fa4(tagv2.0.0isd9fcf71; the two commits after it renumber the desktop shell and update the docs) (a real fork of this repo)main@fa368d2, which is also the merge base. 60 commits ahead, 0 behind, history kept as it happened. The one exception is98f6b72, which squashes our own two-commit PR for the session auto-naming fix described below.iOS 27: a data-preserving upgrade no longer crashes
docs/ios27-crash-investigation.mdsays the root cause of the iOS 27 launch crash is not confirmed because no termination report is available, and that the next confirmation step is a build with the cache fix, tested by the original reporter while keeping existing data.I ran that step on my own phone:
7dd3aa7com.skylerhu.opencodemobile) and same signing team. Not a fresh install. All existing app data was kept.This confirms the symptom went away under the exact condition your doc asked for. It does not identify the mechanism: I have no crash report from before the upgrade, so I cannot say the cache was the cause. One caveat on comparability: my phone ran this line's bundle, not your TestFlight bundle
app.opencodemobile.client, so this is the same test condition on a different install, not the original reporter's device.Sessions opened from the app are named again (added 2026-09-24,
98f6b72)createSessioninapp/src/opencode/client.tsdefaulted the title to the literalNew Sessionand always sent it. The OpenCode core names a session automatically only while its title still matches its own placeholder,^(New session - |Child session - )<ISO 8601 with milliseconds>$, so every session created from the app kept that literal until someone renamed it by hand. No error, no log line. Your code has the same default, so this applies to your app as well.The fix sends
titleonly when the caller chose one. Four tests assert the request body; two of them fail against the old client. It reaches a phone only after a rebuild and reinstall, and sessions already titledNew Sessionare not renamed retroactively.The paperclip attaches a real file now (added 2026-09-29,
23324dc)In your
mainthe paperclip in the composer only inserts@, and the reference list it opens shows agents until you type a query, so it reads as "mention an agent". Nothing could send a photo or a PDF.It now opens Photos or Files on the phone and the file dialog on the desktop, and sends each pick as a
filepart with a data URL next to the text, which the prompt endpoint already accepts. Images up to 5 MB, PDFs up to 20 MB, five per message; the type is read from the file's bytes. A model whose declared capabilities exclude that kind is refused before sending. The old reference behaviour moved to its own@button. Sent files show in the transcript by name, and the phone's transcript cache drops the inline bytes so a photo cannot use up the bounded cache.Checked through the relay against a live backend: a model read a two-colour PNG and a code word from a PDF sent this way; in a browser the pick, chip, send and remove steps worked. This adds two native modules (
expo-image-picker,expo-document-picker), so it needs a native rebuild, plusNSPhotoLibraryUsageDescription, which theapp.jsonplugin entry supplies.Copy anything, answer subagent permissions, get alerted (added 2026-10-05,
ff7cf90,0c83cd1,ee57e9c,29e07d6)Three problems from daily use, all fixed on both the phone and the desktop app.
Partial text selection. On iOS a React Native
Textwithselectableonly offers Copy for its whole string, and every paragraph and code block was its own view, so nobody could copy a few words or a span across paragraphs. Each message now renders as one UITextView through@bsky.app/react-native-uitextview2.7.1 (Bluesky's library, tested by its authors against RN 0.86).markdown-spans.tsflattens the markdown into styled runs: block structure becomes newlines, list markers and quote bars; bold, italic, code, links and headings stay as attributes. A selection can start and end anywhere inside a message. For selections across messages, the...menu'sSelect textopens the whole loaded conversation as one text, scrolled to that message. The card no longer opens its menu on long press, which belongs to selection now; a permanent...button does it. On the desktop, WindowsCtrl+C/Ctrl+Aand macOSCmd+Awere claimed by the TUI keymap (exit, clear input, line start); the shell now leaves them to the system and adds a right-click menu with Copy and Select All. This adds a native module, so it needs a native rebuild.Subagent permission requests were invisible. The store kept only the open session's requests, and a subagent asks in its own child session, so the parent sat blocked with nothing on screen. Requests are now kept per directory. The parent session shows its subagents' requests labelled
From subagent · <title>and can answer them in place; only the session's own requests lock its input. Questions were never affected, since subagents cannot ask.Alerts. A watch on
/global/event(it receives events from every directory) feeds aN waiting for youbadge on the session list, an in-app banner on the phone that opens the session, and on the desktop a system notification that does the same. The desktop app now stays in the tray or menu bar when its window closes, starts at login by default (a tray toggle turns it off), and rebuilds its window if the renderer dies, so an alert never points at a window that will not open. macOS packages are re-signed ad hoc under their bundle id, because a build signed asElectronis never registered with Notification Center and every alert it sends is dropped. On Windows a toast only appears for an app with a Start Menu shortcut carrying its AppUserModelID; the build has no installer, so it writes that shortcut itself on first launch.Checked live: a child session with an
askrule for bash raised a real permission request. The desktop app showed the banner, the badge and the card in the parent session,Allow oncecleared it on the server and the command ran. On the desktop a real mouse drag selected 1,094 characters across message cards. Partial selection on the phone was confirmed by hand on an iPhone 16. Not verified: clicking a system notification, since this Mac was in Do Not Disturb.A todo board, and failed turns shown in red (2.0.0,
d9fcf71)Todo board. The TUI shows the main agent's todo list in its sidebar, which is how you see a long task's progress and plan. The app already loaded todos (
GET /session/{id}/todo,todo.updated) and never displayed them. They now show as a bar at the top of the session, with done/total and the task in progress, that expands to the full list ([✓] [•] [ ] [✗]). It is open by default on the desktop and folded on the phone, and the TUI's<leader>b(sidebar toggle) or the session menu flips it.Failed turns. A failed turn is not output. The server writes
{ name, data: { message } }to the assistant message'sinfo.errorand emitssession.error. The app showed neither, so a session that stopped on, for example,Internal Server Error: No available Claude accounts support the requested modellooked as if it had stalled for no reason. Now:retrystatus says why it is retrying.Interrupted.session.error, so a failing subagent or unopened session marks its list cardStopped, raises a desktop notification, and shows a phone banner.Checked live on the desktop app against this machine's backend:
0/3 → 1/3as it worked;session.error(unknown model) appeared within 3 s in the open session, and on the list card and banner when the session was not open;No available Claude accountserror showed it in red.Not yet verified on the phone.
Also in this release: macOS packaging now signs the bundle in a temporary copy. Under iCloud the bundle folder was re-tagged between clearing and signing, which failed the build intermittently. App version 2.0.0; the desktop shell is numbered 2.0.0 too (
31070c4), since it renders the same UI.After the release,
31070c4numbers the desktop shell 2.0.0 like the app, and6e79fa4brings the README,docs/architecture.mdandrelay/README.mdup to the tailnet setup and these clients. It also says how to build each part from source, since no public binaries are published.Formulas render as math, and copy back as TeX (1.2.0,
974e76b)Most of my questions to OpenCode are mathematical, and answers given in the chat showed raw
$...$LaTeX. Replies now render$...$,\(...\),$$...$$and\[...\]with KaTeX, the engine HTML reports use. A single$follows the Pandoc rule, so "$5 and $10" stays text; math inside code is left alone; a formula KaTeX cannot parse shows its source in red instead of blanking the message. Copying a selection that contains math puts the TeX source on the clipboard with line breaks intact (KaTeX's own copy-tex extension drops paragraph breaks in a React Native Web page, so this is a small replacement,math-copy.ts).MathView.web.tsx). The renderer build places KaTeX's fonts beside its stylesheet, since Metro copies the CSS but not the fonts it references, and fails if any referenced font is missing.react-native-webview13.16.1, a native module), sized from the height the page reports. Messages without math keep the native selectable text. KaTeX's fonts are inlined in a generated module (npm run build:katex-cssafter a katex upgrade) because a page loaded from an HTML string cannot reliably read font files from the app bundle.Select textuses a WebView too when the conversation has math.Checked: on the desktop app, five formulas typeset with KaTeX fonts loaded, and a real Cmd+C put the TeX source on the system clipboard. The iOS page was checked in WebKit (rendering, height reporting, copy) and then by hand on an iPhone.
This commit also sets the app version to 1.2.0 and the desktop shell to 0.2.0.
The largest difference: VPS and tunnel transport removed, replaced by tailnet direct access
This is the part that needs the most attention, because merging it as-is would remove a deployment path you support today.
Removed (commit
d50656c):clients/macos/(19 files) andclients/windows/(19 files): machine-side launchers, supervisors, and the FRP tunnel plumbing. About 9,100 lines./api/machine/*, the console's Machines and Pending sections, and thefrp-sshtransport payload.relay/lib/pairing-store.mjsandrelay/lib/passkey-pairing.mjseach shrink by roughly 300 lines.relay/deploy/deploy-relay.shanddocs/windows-relay-oauth-spec.md.Replaced by:
host/(10 new files):deploy-macos.shinstalls the backend and relay as launchd jobs on one Mac and publishes the relay withtailscale serve, then prints the one-time owner passkey registration step. Alongside it:uninstall-macos.sh,renew-ios-app.sh(rebuild and re-sign the iPhone app), andopencode-shared(attaches a localopencodeto the running backend instead of starting a second one).*.ts.netname. No public listener, no port forwarding, no tunnel to maintain.docs/architecture.mdsection 2 is rewritten to match.Why: I only run backends on the machine the relay lives on, and Tailscale already solves remote reach and certificates. The enrollment and tunnel code became unreachable in that topology, so I deleted it rather than carry it untested.
Upgrade path for existing relays: a state file written by the tunnel-era relay carries a
machinesarray.validateStatedrops that field and migrates to version 3, keeping the owner credential and paired devices. An unknown state version is still rejected. The enrollment routes now return 404. Three new tests cover these cases, each verified to fail against the pre-deletion tree.What you would lose by merging this part: Windows machines reached over FRP, the VPS relay topology, and remote machine enrollment. The word "machine" stays in the app, where it now means a relay target.
Three deliberate differences
ios.bundleIdentifieriscom.skylerhu.opencodemobile(yours iscom.example.opencodemobile). This is what made my install an upgrade instead of a new app, and I am keeping it on this line. Commit80e1d9echanged the app test to assert the identifier's shape rather than one fixed value, so either value passes. On merge you would keep your own.app.jsonstill says 1.0.0 (your 1.0.1 and 1.0.3 are TestFlight build numbers that were never written back). The separate number marks builds that include this line's additions.Everything else in the diff
app/src/ux/desktop-*), a machine picker as the first screen, a new-session screen, command palette, notice toast, delete-session with its own confirmation, a Top button in the transcript, attachments, and the 1.2 / 2.0 work described above: any-range selection, subagent permissions, attention badge and notifications, typeset math, the todo board and red error boxes. None of your features were removed; the one changed interaction is that the message menu opens from a permanent...button instead of a long press, which now belongs to text selection. Full list:docs/differences-from-upstream.md.desktop/host/relay/deploy-notes/docs/,README.md,.gitignore,clients/README.mddocs/architecture.mdrewritten for the single-host tailnet setup (the English half had still described the VPS relay, the frp tunnel, machine enrollment and theclients/launchers), a Chinese introduction with credit to you,docs/differences-from-upstream.md, a note on why clients behind a third-party VPN cannot reach the tailnet, and fixed links between the two product specs.Relay changes beyond the enrollment removal, which
docs/differences-from-upstream.mddoes not cover because it only describesapp/:relay/lib/cors.mjs, commita01752e). The pairing module and the proxy both answered preflights and had drifted apart. The narrower one shadowed the other and blocked the desktop app'sX-OpenCode-Target/X-OpenCode-Directoryrequests. The proxy now also addsAccess-Control-Allow-Origin: *andAccess-Control-Expose-Headersto forwarded responses when the backend does not set them, because without exposed headers the paged session list readX-Next-Cursoras null and silently showed one page. Your preflight already answered with*. What is new is that forwarded response bodies become readable cross-origin. Auth is a bearer header rather than a cookie, so a page still needs a token, but this matters more in your publicly reverse-proxied topology than in mine.526b61fwith a different patch shape. If you merge fix(relay): release aborted SSE streams and keep pairing scope bounded #2 first, expect overlap inrelay/lib/proxy.mjsandrelay/lib/pairing-store.mjs.Branches in the fork:
mainis the one under review.fix/relay-stream-teardownis the PR #2 branch.upstream-mainis an old pointer to yourd89d493and can be ignored.Tests
Run on
25d7da4, withappanddesktopre-run ond9fcf71:app:npx vitest run, 517 passed, 4 skipped ond9fcf71(460 on23324dc). The only type errors are two existing ones insrc/opencode/pairing.test.ts.relay:npm test, 56 passed (not re-run; nothing since23324dctouchesrelay/)desktop:npm test, 44 passed ond9fcf71; a Windows x64 package cross-builds from the Mac. Notifications on a real Windows machine are not verified yet.host:node --test test/plist.test.mjs, 7 passed. I did not runtest/uninstall-scope.test.mjson this machine because it invokesuninstall-macos.shand this Mac hosts a live deployment.A secret scan over the full history of all three pushed branches found no real credentials. The 64-character hex strings are SHA-256 checksums of release artifacts. Credential-looking strings in tests are fixtures.
中文速览:两边差在哪,合并时要注意什么
两边的手机端基本一致。你的功能我一个没删,iOS 27 相关的改动(Expo 57、scene 生命周期、会话缓存上限、权限请求卡片)我全部合进来了。我多加的东西有两块:一是只在桌面端加载的键盘操作层,外加 Electron 桌面壳;二是选机器、新建会话、命令面板、删除会话这几个界面。
差异最大的是部署方式。你那边走 VPS 中继加 FRP/SSH 隧道,远端机器通过 OAuth 注册进来。我这边把中继和后端放在同一台 Mac 上,中继只监听本机回环地址,手机走 Tailscale 内网直连,所以
clients/macos、clients/windows、relay/deploy和中继里的机器注册流程都被我删了,换成host/deploy-macos.sh。旧状态文件升级时会丢掉machines字段,已配对的设备和主人凭证会保留。iOS 27 闪退:iPhone 16,iOS 27,1.1.0 覆盖安装,同 bundle id、同签名团队,是原地升级,旧数据都在。升级后不再闪退,冷启动和从后台切回都正常。这是你文档里缺的那一步确认。它只能证明症状消失了,根因还没法定,因为我手上没有升级前的崩溃报告。
会话自动命名:app 新建会话时一直把标题写成
New Session发出去,而内核只给还带着占位标题的会话自动起名,所以从 app 开的会话永远叫New Session。你那边的createSession也是同样的默认值。最新提交98f6b72改成不指定标题时就不发,让服务端自己命名。要重新构建装到手机上才生效,已经叫New Session的旧会话不会被补名。附件:你原版里输入框右下角的回形针其实只是插入一个
@,点开先列出的是席位角色,所以看起来像“@角色”,并不能发文件。23324dc把它改成真附件:手机上选相册或文件,桌面上弹文件对话框,图片和 PDF 随消息直接发给模型;原来的 @ 引用挪到单独的@按钮。新增了两个原生模块,要重新构建原生工程。复制、子 agent 权限和提醒(2026-10-05,
ff7cf90、0c83cd1、ee57e9c、29e07d6):Text只能整段复制,所以每条消息改成用 Bluesky 的react-native-uitextview渲染成一整块,消息里任意几个字、跨段落、跨代码块都能选。要跨多条消息选,就用「...」→「Select text」,它会打开整段对话并停在当前消息。长按留给选字,消息菜单改由常驻的「...」按钮打开。桌面端不再拦截 Windows 的Ctrl+C/Ctrl+A和 Mac 的Cmd+A,右键菜单可以复制、全选。新增了原生模块,要重新构建原生工程。公式(1.2.0,
974e76b):回答里的$...$、\(...\)、$$...$$、\[...\]现在用 KaTeX 排成公式,和 HTML 报告同一个引擎;「$5 和 $10」这类金额不会被当成公式。复制选区时,里面的公式会变回$...$源码,换行保留。桌面端直接排版;iOS 上只有带公式的消息改用 WebView 显示(新增原生模块react-native-webview),字体内嵌在生成的样式里。版本号改为 App 1.2.0、桌面壳 0.2.0。todo 看板与报错(2.0.0,
d9fcf71):info.error的错误和session.error事件,现在都显示成红色报错框。文档(
6e79fa4):README 的英文部分、docs/architecture.md和relay/README.md原来还在介绍 VPS 中继、frp 隧道、机器注册和clients/启动器,这些早已删掉,现在改成单主机 + Tailscale 的实际架构,并写明各部分怎么从源码编译。桌面分机的版本号也和 App 统一成 2.0.0(31070c4)。合并风险和建议:
main。一合进去,你的 Windows/FRP 路线、VPS 拓扑和远端机器注册就全没了。我建议分三块处理:app/、desktop/和中继修复可以先合;传输层到底是保留隧道、只用 Tailscale 还是两条都支持,我们当面定;host/可以作为「单机 + Tailscale」的可选部署方式放进来。app.json里的 bundle id 和版本号请保留你自己的。我这边的com.skylerhu.opencodemobile不能改回去,改了我手机上就变成装一个新应用,已配对的主机和令牌会被清空。deploy-notes/是我个人机器的部署记录,里面写着我的 tailnet 域名,不适合进你的仓库,合并时建议排除。Access-Control-Allow-Origin: *。在我这种只有 tailnet 能访问的环境里问题不大;在你那种中继公开在反向代理后面的环境里,建议先评估。proxy.mjs和pairing-store.mjs上重叠,需要手动对一下。