@@ -70,30 +70,54 @@ export const PROTOCOL_VERSION: string = pkg.version;
7070/**
7171 * True iff a remote peer reporting `remote` speaks a wire-compatible
7272 * schema set with this build's PROTOCOL_VERSION. Compatible means
73- * `remote` satisfies a caret range over PROTOCOL_VERSION :
73+ * "same breaking line", per the bump policy above :
7474 *
75- * - `^0.5.7` → compatible with `0.5.x` (any patch), not `0.6.0`
76- * - `^1.2.3` → compatible with `1.x.y` (any minor or patch ≥ 1.2.3)
75+ * - during 0.x the minor is the breaking axis → same `0.minor` line
76+ * (`0.5.1` ↔ `0.5.9` compatible in both directions, `0.6.0` not)
77+ * - ≥1.0 the major is the breaking axis → same major
78+ * - pre-release on either side → exact match only (mid-development
79+ * schemas churn; two builds are only known-compatible if identical)
7780 *
78- * This is npm's standard caret semantics — same rule npm uses to decide
79- * which versions satisfy a `^x.y.z` dependency. The semver package
80- * handles pre-release tags / build metadata / edge cases correctly,
81- * which a hand-rolled major-comparison would miss.
81+ * Deliberately NOT npm caret semantics. `satisfies(remote, ^local)` is
82+ * asymmetric — it rejects a remote with a LOWER patch on the same
83+ * breaking line, but the bump policy says patch bumps are additive and
84+ * old peers harmlessly ignore them. Symmetric same-line comparison is
85+ * the faithful implementation, and it guarantees the daemon (which
86+ * checks `hello` first) always rejects before iOS gets a chance to —
87+ * iOS's `server_info` re-check is pure drift defense.
8288 *
8389 * Returns `false` on unparseable input (defensive — we'd rather refuse
8490 * than blunder on with an unknown version that might or might not be
8591 * compatible).
8692 */
8793export function isProtocolCompatible ( remote : string ) : boolean {
88- if ( ! semver . valid ( remote ) || ! semver . valid ( PROTOCOL_VERSION ) ) return false ;
89- return semver . satisfies ( remote , `^${ PROTOCOL_VERSION } ` , {
90- // includePrerelease: false — pre-release versions don't satisfy
91- // a caret range over a stable version. This is npm's default and
92- // it's the right call: a 1.2.0-beta.1 daemon shouldn't be
93- // considered compatible with a 1.0.0 client (the beta may have
94- // incompatible schemas mid-development).
95- includePrerelease : false ,
96- } ) ;
94+ const r = semver . parse ( remote ) ;
95+ const local = semver . parse ( PROTOCOL_VERSION ) ;
96+ if ( ! r || ! local ) return false ;
97+ if ( r . prerelease . length > 0 || local . prerelease . length > 0 ) {
98+ return semver . eq ( r , local ) ;
99+ }
100+ if ( r . major !== local . major ) return false ;
101+ return local . major === 0 ? r . minor === local . minor : true ;
102+ }
103+
104+ /**
105+ * Which side is outdated, given the version an INCOMPATIBLE remote
106+ * reported. Only meaningful after `isProtocolCompatible(remote)`
107+ * returned false — incompatible versions are never equal, so the
108+ * semver-lower side is the one that needs updating.
109+ *
110+ * Returns from the caller's perspective: `"remote"` → the peer is
111+ * older (iOS calling this → tell the user to update the Mac app),
112+ * `"local"` → this build is older. Returns `null` when the input is
113+ * unparseable or equal to ours (a peer that rejected us while
114+ * reporting our own version is misbehaving, not outdated) — callers
115+ * fall back to direction-neutral "update both" copy.
116+ */
117+ export function outdatedSide ( remote : string ) : "local" | "remote" | null {
118+ if ( ! semver . valid ( remote ) || ! semver . valid ( PROTOCOL_VERSION ) ) return null ;
119+ if ( semver . eq ( remote , PROTOCOL_VERSION ) ) return null ;
120+ return semver . lt ( remote , PROTOCOL_VERSION ) ? "remote" : "local" ;
97121}
98122
99123// ─── Pair offer ────────────────────────────────────────────────────────────
@@ -1320,15 +1344,23 @@ export const errorFrame = z.object({
13201344 "not_a_directory" ,
13211345 ] ) ,
13221346 message : z . string ( ) ,
1347+ /** Sender's PROTOCOL_VERSION. Set on `incompatible_protocol` VERSION-
1348+ * mismatch rejections so the receiver can run `outdatedSide()` and
1349+ * name the side that needs updating. Deliberately absent on the
1350+ * other `incompatible_protocol` use (frame-before-hello, a protocol-
1351+ * misuse rejection where versions may well be EQUAL and direction is
1352+ * meaningless) and on all other codes. */
1353+ protocolVersion : z . string ( ) . optional ( ) ,
13231354} ) ;
13241355
13251356// ─── Hello / server_info (wire-version handshake on DataChannel open) ─────
13261357//
13271358// iOS sends `hello` immediately after DC.open carrying its PROTOCOL_VERSION.
13281359// Daemon checks compatibility via `isProtocolCompatible` and responds with
1329- // its own `server_info` (or with `error{code:"incompatible_protocol"}` +
1330- // DC close on mismatch). iOS treats `server_info` as the "ready" signal —
1331- // application commands may only flow after it arrives.
1360+ // its own `server_info` (or with `error{code:"incompatible_protocol",
1361+ // protocolVersion}` + DC close on mismatch — the version lets iOS name
1362+ // which side is outdated via `outdatedSide`). iOS treats `server_info` as
1363+ // the "ready" signal — application commands may only flow after it arrives.
13321364//
13331365// Single field exchanged each way. There's no separate "app release
13341366// version" / "daemon release version" — the protocol package owns the
0 commit comments