ugly-app 0.1.676 → 0.1.678

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.676";
1
+ export declare const CLI_VERSION = "0.1.678";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.676";
2
+ export const CLI_VERSION = "0.1.678";
3
3
  //# sourceMappingURL=version.js.map
@@ -15,4 +15,10 @@ export type { BrowseFacade } from './facades/browse.js';
15
15
  export type { McpFacade } from './facades/mcp.js';
16
16
  export type { TerminalFacade } from './facades/terminal.js';
17
17
  export { MCP_CATALOG } from './mcp-catalog.js';
18
+ export { createProxyClient } from './proxyClient.js';
19
+ export type { ProxyClient, ProxyClientOpts, ProxySocket, HostInfo, RTCFactory, } from './proxyClient.js';
20
+ export { isDesktopChannel, withProxy, dispatchNativeEvent, DESKTOP_NAMESPACES } from './proxy.js';
21
+ export type { WithProxyOpts } from './proxy.js';
22
+ export { fpTag, verifyFpTag, parseFingerprint, mskSigner } from './proxyCrypto.js';
23
+ export type { ProxyRole, ProxySigner } from './proxyCrypto.js';
18
24
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/native/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,OAAO,EACP,WAAW,EACX,SAAS,EACT,QAAQ,EACR,MAAM,EACN,SAAS,EACT,cAAc,EACd,aAAa,EACb,UAAU,EACV,QAAQ,EACR,YAAY,EACZ,UAAU,EACV,SAAS,EACT,WAAW,EACX,aAAa,EACb,aAAa,EACb,cAAc,EACd,aAAa,EACb,aAAa,EACb,QAAQ,EACR,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,eAAe,EACf,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtF,YAAY,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC/D,YAAY,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACtE,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/native/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,OAAO,EACP,WAAW,EACX,SAAS,EACT,QAAQ,EACR,MAAM,EACN,SAAS,EACT,cAAc,EACd,aAAa,EACb,UAAU,EACV,QAAQ,EACR,YAAY,EACZ,UAAU,EACV,SAAS,EACT,WAAW,EACX,aAAa,EACb,aAAa,EACb,cAAc,EACd,aAAa,EACb,aAAa,EACb,QAAQ,EACR,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,eAAe,EACf,aAAa,EACb,WAAW,EACX,kBAAkB,EAClB,aAAa,EACb,eAAe,GAChB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,YAAY,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAC7D,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC/E,YAAY,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACtF,YAAY,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC/D,YAAY,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAC1D,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACtE,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAG/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,YAAY,EACV,WAAW,EACX,eAAe,EACf,WAAW,EACX,QAAQ,EACR,UAAU,GACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAClG,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACnF,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC"}
@@ -4,4 +4,8 @@ export { NativeUnavailableError, PermissionDeniedError } from './protocol.js';
4
4
  export { installUglyNative } from './install.js';
5
5
  export { native, permissions, nativePlatform, isNativeAvailable } from './wrapper.js';
6
6
  export { MCP_CATALOG } from './mcp-catalog.js';
7
+ // ── Ugly Proxy (remote UglyNative over WebRTC) ──
8
+ export { createProxyClient } from './proxyClient.js';
9
+ export { isDesktopChannel, withProxy, dispatchNativeEvent, DESKTOP_NAMESPACES } from './proxy.js';
10
+ export { fpTag, verifyFpTag, parseFingerprint, mskSigner } from './proxyCrypto.js';
7
11
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/native/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE/E,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAStF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/native/index.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAE5C,OAAO,EAAE,gBAAgB,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE/E,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAStF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAE/C,mDAAmD;AACnD,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAQrD,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAElG,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,27 @@
1
+ import type { UglyNative } from './protocol.js';
2
+ import type { ProxyClient } from './proxyClient.js';
3
+ /** Namespaces that require a desktop host (RCE-grade / filesystem / long-running tasks). */
4
+ export declare const DESKTOP_NAMESPACES: ReadonlySet<string>;
5
+ export declare function isDesktopChannel(channel: string): boolean;
6
+ export interface WithProxyOpts {
7
+ client: ProxyClient;
8
+ /**
9
+ * Whether a given channel should tunnel to the host. Default: desktop-cap channels go
10
+ * to the proxy when it's connected. On a real desktop (full local impl) pass a predicate
11
+ * that returns false so everything stays local.
12
+ */
13
+ shouldProxy?: (channel: string) => boolean;
14
+ /** True on platforms with no local desktop-cap implementation (mobile/web). */
15
+ desktopCapsAreRemoteOnly?: boolean;
16
+ }
17
+ /**
18
+ * Wrap a base UglyNative so desktop-capability calls route through the proxy. Events from
19
+ * the host must be fed back into the base event bus by wiring the proxy client's `onEvent`
20
+ * to a `dispatchNativeEvent` (below) — then `base.subscribe` delivers them unchanged.
21
+ */
22
+ export declare function withProxy(base: UglyNative, opts: WithProxyOpts): UglyNative;
23
+ /** Dispatch a proxy event onto the same 'uglyNative' CustomEvent bus base.subscribe reads. */
24
+ export declare function dispatchNativeEvent(win: {
25
+ dispatchEvent(e: unknown): boolean;
26
+ }, event: string, data: unknown): void;
27
+ //# sourceMappingURL=proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/native/proxy.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,4FAA4F;AAC5F,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAYjD,CAAC;AAEH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAGzD;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,WAAW,CAAC;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC;IAC3C,+EAA+E;IAC/E,wBAAwB,CAAC,EAAE,OAAO,CAAC;CACpC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,aAAa,GAAG,UAAU,CAc3E;AAED,8FAA8F;AAC9F,wBAAgB,mBAAmB,CACjC,GAAG,EAAE;IAAE,aAAa,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,CAAA;CAAE,EAC3C,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,OAAO,GACZ,IAAI,CAON"}
@@ -0,0 +1,49 @@
1
+ import { NativeUnavailableError } from './protocol.js';
2
+ /** Namespaces that require a desktop host (RCE-grade / filesystem / long-running tasks). */
3
+ export const DESKTOP_NAMESPACES = new Set([
4
+ 'fs',
5
+ 'process',
6
+ 'net',
7
+ 'serve',
8
+ 'workers',
9
+ 'terminal',
10
+ 'sandbox',
11
+ 'mcp',
12
+ 'secrets',
13
+ 'browse',
14
+ 'task',
15
+ ]);
16
+ export function isDesktopChannel(channel) {
17
+ const ns = channel.includes('.') ? channel.slice(0, channel.indexOf('.')) : channel;
18
+ return DESKTOP_NAMESPACES.has(ns);
19
+ }
20
+ /**
21
+ * Wrap a base UglyNative so desktop-capability calls route through the proxy. Events from
22
+ * the host must be fed back into the base event bus by wiring the proxy client's `onEvent`
23
+ * to a `dispatchNativeEvent` (below) — then `base.subscribe` delivers them unchanged.
24
+ */
25
+ export function withProxy(base, opts) {
26
+ const shouldProxy = opts.shouldProxy ?? ((ch) => isDesktopChannel(ch) && opts.client.isOpen());
27
+ return {
28
+ platform: base.platform,
29
+ invoke: ((channel, payload) => {
30
+ if (shouldProxy(channel))
31
+ return opts.client.invoke(channel, payload);
32
+ if (opts.desktopCapsAreRemoteOnly && isDesktopChannel(channel)) {
33
+ // No host selected and no local impl — surface a clear, actionable error.
34
+ return Promise.reject(new NativeUnavailableError(channel));
35
+ }
36
+ return base.invoke(channel, payload);
37
+ }),
38
+ subscribe: base.subscribe,
39
+ };
40
+ }
41
+ /** Dispatch a proxy event onto the same 'uglyNative' CustomEvent bus base.subscribe reads. */
42
+ export function dispatchNativeEvent(win, event, data) {
43
+ const detail = { event, data };
44
+ const ev = typeof CustomEvent === 'function'
45
+ ? new CustomEvent('uglyNative', { detail })
46
+ : { type: 'uglyNative', detail };
47
+ win.dispatchEvent(ev);
48
+ }
49
+ //# sourceMappingURL=proxy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy.js","sourceRoot":"","sources":["../../src/native/proxy.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AAGvD,4FAA4F;AAC5F,MAAM,CAAC,MAAM,kBAAkB,GAAwB,IAAI,GAAG,CAAC;IAC7D,IAAI;IACJ,SAAS;IACT,KAAK;IACL,OAAO;IACP,SAAS;IACT,UAAU;IACV,SAAS;IACT,KAAK;IACL,SAAS;IACT,QAAQ;IACR,MAAM;CACP,CAAC,CAAC;AAEH,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC9C,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IACpF,OAAO,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;AACpC,CAAC;AAcD;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,IAAgB,EAAE,IAAmB;IAC7D,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,CAAC,CAAC,EAAU,EAAE,EAAE,CAAC,gBAAgB,CAAC,EAAE,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;IACvG,OAAO;QACL,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,MAAM,EAAE,CAAC,CAAC,OAAe,EAAE,OAAgB,EAAE,EAAE;YAC7C,IAAI,WAAW,CAAC,OAAO,CAAC;gBAAE,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YACtE,IAAI,IAAI,CAAC,wBAAwB,IAAI,gBAAgB,CAAC,OAAO,CAAC,EAAE,CAAC;gBAC/D,0EAA0E;gBAC1E,OAAO,OAAO,CAAC,MAAM,CAAC,IAAI,sBAAsB,CAAC,OAAO,CAAC,CAAC,CAAC;YAC7D,CAAC;YACD,OAAO,IAAI,CAAC,MAAM,CAAC,OAAgB,EAAE,OAAgB,CAAC,CAAC;QACzD,CAAC,CAAyB;QAC1B,SAAS,EAAE,IAAI,CAAC,SAAS;KAC1B,CAAC;AACJ,CAAC;AAED,8FAA8F;AAC9F,MAAM,UAAU,mBAAmB,CACjC,GAA2C,EAC3C,KAAa,EACb,IAAa;IAEb,MAAM,MAAM,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;IAC/B,MAAM,EAAE,GACN,OAAO,WAAW,KAAK,UAAU;QAC/B,CAAC,CAAC,IAAI,WAAW,CAAC,YAAY,EAAE,EAAE,MAAM,EAAE,CAAC;QAC3C,CAAC,CAAE,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAuB,CAAC;IAC3D,GAAG,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC;AACxB,CAAC"}
@@ -0,0 +1,41 @@
1
+ import { type ProxySigner } from './proxyCrypto.js';
2
+ /** Subset of ugly-app's client Socket the proxy needs (emit/on are runtime-generic). */
3
+ export interface ProxySocket {
4
+ emit(type: string, data: object): void;
5
+ on(event: string, handler: (data: unknown) => void): () => void;
6
+ /** Request/response (used for presence:query). */
7
+ send?(type: string, data: object, timeout?: number): Promise<unknown>;
8
+ }
9
+ export type RTCFactory = (config: RTCConfiguration) => RTCPeerConnection;
10
+ export interface HostInfo {
11
+ deviceId: string;
12
+ label: string;
13
+ isHost: boolean;
14
+ lastSeen: number;
15
+ }
16
+ export interface ProxyClientOpts {
17
+ socket: ProxySocket;
18
+ /** This device's id (so listHosts can exclude self). */
19
+ selfDeviceId: string;
20
+ /** When set, sign our offer fingerprint + verify the host's answer fingerprint. */
21
+ signer?: ProxySigner;
22
+ iceServers?: RTCIceServer[];
23
+ rtcFactory?: RTCFactory;
24
+ /** evt frames from the host (UglyNative subscribe events, task deltas) land here. */
25
+ onEvent?: (event: string, data: unknown) => void;
26
+ requestTimeoutMs?: number;
27
+ openTimeoutMs?: number;
28
+ }
29
+ export interface ProxyClient {
30
+ /** Query the worker for this user's online desktop hosts. */
31
+ listHosts(): Promise<HostInfo[]>;
32
+ /** Establish (or re-establish) the DataChannel to a host. Resolves when open. */
33
+ connect(hostDeviceId: string): Promise<void>;
34
+ /** Tunnel one UglyNative invoke over the DataChannel. */
35
+ invoke(channel: string, payload: unknown): Promise<unknown>;
36
+ isOpen(): boolean;
37
+ hostDeviceId(): string | null;
38
+ close(): void;
39
+ }
40
+ export declare function createProxyClient(opts: ProxyClientOpts): ProxyClient;
41
+ //# sourceMappingURL=proxyClient.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxyClient.d.ts","sourceRoot":"","sources":["../../src/native/proxyClient.ts"],"names":[],"mappings":"AAKA,OAAO,EAAoB,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEtE,wFAAwF;AACxF,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAChE,kDAAkD;IAClD,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACvE;AAED,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,gBAAgB,KAAK,iBAAiB,CAAC;AAEzE,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,WAAW,CAAC;IACpB,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,qFAAqF;IACrF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACjD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,6DAA6D;IAC7D,SAAS,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IACjC,iFAAiF;IACjF,OAAO,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,yDAAyD;IACzD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5D,MAAM,IAAI,OAAO,CAAC;IAClB,YAAY,IAAI,MAAM,GAAG,IAAI,CAAC;IAC9B,KAAK,IAAI,IAAI,CAAC;CACf;AAYD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,WAAW,CAsHpE"}
@@ -0,0 +1,145 @@
1
+ // Client side of the Ugly Proxy: from a phone/web (or a desktop acting as a client), open
2
+ // a WebRTC DataChannel to a chosen desktop host and tunnel UglyNative calls over it (the
3
+ // "dumb pipe"). Signaling rides the app Socket's rtc:* messages; DTLS gives e2e transport
4
+ // encryption; the MSK-bound fingerprint check (proxyCrypto) authenticates the host.
5
+ // RTCPeerConnection + Socket are injectable so the framing/correlation is unit-testable.
6
+ import { parseFingerprint } from './proxyCrypto.js';
7
+ function rec(d) {
8
+ return (d ?? {});
9
+ }
10
+ export function createProxyClient(opts) {
11
+ const rtcFactory = opts.rtcFactory ?? ((cfg) => new RTCPeerConnection(cfg));
12
+ const reqTimeout = opts.requestTimeoutMs ?? 30_000;
13
+ const openTimeout = opts.openTimeoutMs ?? 15_000;
14
+ let pc = null;
15
+ let dc = null;
16
+ let host = null;
17
+ let seq = 0;
18
+ const pending = new Map();
19
+ const offs = [];
20
+ function cleanup() {
21
+ for (const off of offs) {
22
+ try {
23
+ off();
24
+ }
25
+ catch { /* noop */ }
26
+ }
27
+ offs.length = 0;
28
+ for (const p of pending.values()) {
29
+ if (p.timer)
30
+ clearTimeout(p.timer);
31
+ p.reject(new Error('proxy connection closed'));
32
+ }
33
+ pending.clear();
34
+ try {
35
+ dc?.close();
36
+ }
37
+ catch { /* noop */ }
38
+ try {
39
+ pc?.close();
40
+ }
41
+ catch { /* noop */ }
42
+ dc = null;
43
+ pc = null;
44
+ }
45
+ function onDcMessage(raw) {
46
+ let f;
47
+ try {
48
+ f = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw));
49
+ }
50
+ catch {
51
+ return;
52
+ }
53
+ if (f.t === 'res' && f.id !== undefined) {
54
+ const p = pending.get(f.id);
55
+ if (!p)
56
+ return;
57
+ pending.delete(f.id);
58
+ if (p.timer)
59
+ clearTimeout(p.timer);
60
+ if (f.ok)
61
+ p.resolve(f.result);
62
+ else
63
+ p.reject(new Error(f.error ?? 'proxy error'));
64
+ }
65
+ else if (f.t === 'evt' && f.event) {
66
+ opts.onEvent?.(f.event, f.data);
67
+ }
68
+ }
69
+ async function listHosts() {
70
+ if (!opts.socket.send)
71
+ throw new Error('socket lacks request support for presence:query');
72
+ const res = rec(await opts.socket.send('presence:query', {}));
73
+ const devices = res['devices'] ?? [];
74
+ return devices.filter((d) => d.isHost && d.deviceId !== opts.selfDeviceId);
75
+ }
76
+ async function connect(hostDeviceId) {
77
+ cleanup();
78
+ host = hostDeviceId;
79
+ const conn = rtcFactory({ iceServers: opts.iceServers ?? [] });
80
+ pc = conn;
81
+ const channel = conn.createDataChannel('proxy');
82
+ dc = channel;
83
+ conn.onicecandidate = (e) => {
84
+ if (e.candidate)
85
+ opts.socket.emit('rtc:ice', { to: hostDeviceId, candidate: e.candidate.toJSON() });
86
+ };
87
+ channel.onmessage = (e) => onDcMessage(e.data);
88
+ offs.push(opts.socket.on('rtc:answer', (d) => { void onAnswer(rec(d)); }));
89
+ offs.push(opts.socket.on('rtc:ice', (d) => { void onRemoteIce(rec(d)); }));
90
+ offs.push(opts.socket.on('rtc:error', (d) => { if (rec(d)['from'] === host)
91
+ cleanup(); }));
92
+ const opened = new Promise((resolve, reject) => {
93
+ const timer = setTimeout(() => reject(new Error('proxy DataChannel open timeout')), openTimeout);
94
+ channel.onopen = () => { clearTimeout(timer); resolve(); };
95
+ });
96
+ const offer = await conn.createOffer();
97
+ await conn.setLocalDescription(offer);
98
+ const sdp = conn.localDescription?.sdp ?? offer.sdp ?? '';
99
+ const tag = opts.signer ? await opts.signer.sign('client', parseFingerprint(sdp) ?? '') : undefined;
100
+ opts.socket.emit('rtc:offer', { to: hostDeviceId, sdp, fpTag: tag });
101
+ await opened;
102
+ }
103
+ async function onAnswer(d) {
104
+ if (!pc || d['from'] !== host)
105
+ return;
106
+ const sdp = String(d['sdp'] ?? '');
107
+ if (opts.signer) {
108
+ const ok = await opts.signer.verify('host', parseFingerprint(sdp) ?? '', String(d['fpTag'] ?? ''));
109
+ if (!ok) {
110
+ cleanup();
111
+ return;
112
+ }
113
+ }
114
+ await pc.setRemoteDescription({ type: 'answer', sdp });
115
+ }
116
+ async function onRemoteIce(d) {
117
+ if (!pc || d['from'] !== host)
118
+ return;
119
+ try {
120
+ await pc.addIceCandidate(d['candidate']);
121
+ }
122
+ catch { /* benign */ }
123
+ }
124
+ function invoke(channel, payload) {
125
+ if (!dc || dc.readyState !== 'open')
126
+ return Promise.reject(new Error('proxy not connected'));
127
+ const id = String(seq++);
128
+ return new Promise((resolve, reject) => {
129
+ const timer = reqTimeout > 0
130
+ ? setTimeout(() => { pending.delete(id); reject(new Error(`proxy invoke timed out: ${channel}`)); }, reqTimeout)
131
+ : null;
132
+ pending.set(id, { resolve, reject, timer });
133
+ dc.send(JSON.stringify({ t: 'req', id, channel, payload }));
134
+ });
135
+ }
136
+ return {
137
+ listHosts,
138
+ connect,
139
+ invoke,
140
+ isOpen: () => dc?.readyState === 'open',
141
+ hostDeviceId: () => host,
142
+ close: cleanup,
143
+ };
144
+ }
145
+ //# sourceMappingURL=proxyClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxyClient.js","sourceRoot":"","sources":["../../src/native/proxyClient.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export type ProxyRole = 'client' | 'host';
2
+ /** Extract + normalize the DTLS fingerprint from an SDP blob ("sha-256 AB:CD:.."). */
3
+ export declare function parseFingerprint(sdp: string): string | null;
4
+ export declare function fpTag(msk: Uint8Array, role: ProxyRole, fingerprint: string): Promise<string>;
5
+ export declare function verifyFpTag(msk: Uint8Array, role: ProxyRole, fingerprint: string | null, tag: string | undefined): Promise<boolean>;
6
+ /** Convenience signer bound to an MSK — what proxyClient expects when MSK is in-JS. */
7
+ export interface ProxySigner {
8
+ sign(role: ProxyRole, fingerprint: string): Promise<string>;
9
+ verify(role: ProxyRole, fingerprint: string, tag: string): Promise<boolean>;
10
+ }
11
+ export declare function mskSigner(msk: Uint8Array): ProxySigner;
12
+ //# sourceMappingURL=proxyCrypto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxyCrypto.d.ts","sourceRoot":"","sources":["../../src/native/proxyCrypto.ts"],"names":[],"mappings":"AAQA,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAC;AA0B1C,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG3D;AAED,wBAAsB,KAAK,CAAC,GAAG,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAIlG;AAED,wBAAsB,WAAW,CAC/B,GAAG,EAAE,UAAU,EACf,IAAI,EAAE,SAAS,EACf,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,uFAAuF;AACvF,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5D,MAAM,CAAC,IAAI,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC7E;AAED,wBAAgB,SAAS,CAAC,GAAG,EAAE,UAAU,GAAG,WAAW,CAKtD"}
@@ -0,0 +1,53 @@
1
+ // Client-side fingerprint auth for the Ugly Proxy. Binds the WebRTC DTLS fingerprint to
2
+ // the per-user MSK (from the cookie-sync system) so the signaling relay can't MITM the
3
+ // handshake. Implemented with WebCrypto (HKDF + HMAC-SHA256) — no extra dependency, and
4
+ // byte-compatible with the host side's @noble implementation (same RFC 5869 / RFC 2104).
5
+ const INFO = 'ugly-native-proxy-v1';
6
+ const enc = new TextEncoder();
7
+ function toHex(buf) {
8
+ const b = new Uint8Array(buf);
9
+ let s = '';
10
+ for (let i = 0; i < b.length; i++)
11
+ s += b[i].toString(16).padStart(2, '0');
12
+ return s;
13
+ }
14
+ function subtle() {
15
+ const c = globalThis.crypto;
16
+ if (!c?.subtle)
17
+ throw new Error('[proxyCrypto] WebCrypto SubtleCrypto unavailable');
18
+ return c.subtle;
19
+ }
20
+ /** HKDF-SHA256(MSK) → 32-byte proxy-auth key, imported as an HMAC signing key. */
21
+ async function deriveHmacKey(msk) {
22
+ const base = await subtle().importKey('raw', msk, 'HKDF', false, ['deriveBits']);
23
+ const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode(INFO) }, base, 256);
24
+ return subtle().importKey('raw', bits, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
25
+ }
26
+ /** Extract + normalize the DTLS fingerprint from an SDP blob ("sha-256 AB:CD:.."). */
27
+ export function parseFingerprint(sdp) {
28
+ const m = /a=fingerprint:(\S+)\s+([0-9A-Fa-f:]+)/.exec(sdp || '');
29
+ return m ? `${m[1].toLowerCase()} ${m[2].toUpperCase()}` : null;
30
+ }
31
+ export async function fpTag(msk, role, fingerprint) {
32
+ const key = await deriveHmacKey(msk);
33
+ const sig = await subtle().sign('HMAC', key, enc.encode(`${role}|${fingerprint}`));
34
+ return toHex(sig);
35
+ }
36
+ export async function verifyFpTag(msk, role, fingerprint, tag) {
37
+ if (!fingerprint || !tag)
38
+ return false;
39
+ const expected = await fpTag(msk, role, fingerprint);
40
+ if (expected.length !== tag.length)
41
+ return false;
42
+ let diff = 0;
43
+ for (let i = 0; i < expected.length; i++)
44
+ diff |= expected.charCodeAt(i) ^ tag.charCodeAt(i);
45
+ return diff === 0;
46
+ }
47
+ export function mskSigner(msk) {
48
+ return {
49
+ sign: (role, fp) => fpTag(msk, role, fp),
50
+ verify: (role, fp, tag) => verifyFpTag(msk, role, fp, tag),
51
+ };
52
+ }
53
+ //# sourceMappingURL=proxyCrypto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxyCrypto.js","sourceRoot":"","sources":["../../src/native/proxyCrypto.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-app",
3
- "version": "0.1.676",
3
+ "version": "0.1.678",
4
4
  "type": "module",
5
5
  "comment:files": "Allowlist what ships to npm. dist = runtime; src = sourcemap targets (dist/*.js.map reference ../../src/); templates = CLI scaffold. Everything else at repo root (.pgdata local Postgres, coverage, assets/icons sources, test/, test-results/) is excluded by omission. The !negations strip the scaffold's installed deps + cruft (templates/node_modules is 200MB+ and must never ship). package.json/README/LICENSE ship automatically.",
6
6
  "files": [
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.676";
2
+ export const CLI_VERSION = "0.1.678";
@@ -48,3 +48,17 @@ export type { BrowseFacade } from './facades/browse.js';
48
48
  export type { McpFacade } from './facades/mcp.js';
49
49
  export type { TerminalFacade } from './facades/terminal.js';
50
50
  export { MCP_CATALOG } from './mcp-catalog.js';
51
+
52
+ // ── Ugly Proxy (remote UglyNative over WebRTC) ──
53
+ export { createProxyClient } from './proxyClient.js';
54
+ export type {
55
+ ProxyClient,
56
+ ProxyClientOpts,
57
+ ProxySocket,
58
+ HostInfo,
59
+ RTCFactory,
60
+ } from './proxyClient.js';
61
+ export { isDesktopChannel, withProxy, dispatchNativeEvent, DESKTOP_NAMESPACES } from './proxy.js';
62
+ export type { WithProxyOpts } from './proxy.js';
63
+ export { fpTag, verifyFpTag, parseFingerprint, mskSigner } from './proxyCrypto.js';
64
+ export type { ProxyRole, ProxySigner } from './proxyCrypto.js';
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { isDesktopChannel, withProxy } from './proxy.js';
3
+ import { NativeUnavailableError } from './protocol.js';
4
+ import type { UglyNative } from './protocol.js';
5
+ import type { ProxyClient } from './proxyClient.js';
6
+
7
+ function fakeClient(open: boolean): ProxyClient {
8
+ return {
9
+ listHosts: async () => [],
10
+ connect: async () => {},
11
+ invoke: vi.fn(async () => ({ viaProxy: true })),
12
+ isOpen: () => open,
13
+ hostDeviceId: () => (open ? 'h' : null),
14
+ close: () => {},
15
+ };
16
+ }
17
+
18
+ function fakeBase(): UglyNative {
19
+ return {
20
+ platform: 'ios',
21
+ invoke: vi.fn(async () => ({ viaLocal: true })) as unknown as UglyNative['invoke'],
22
+ subscribe: vi.fn(() => () => {}) as unknown as UglyNative['subscribe'],
23
+ };
24
+ }
25
+
26
+ describe('isDesktopChannel', () => {
27
+ it('classifies desktop capabilities', () => {
28
+ for (const ch of ['fs.readFile', 'process.spawn', 'net.connect', 'serve.start', 'workers.register', 'terminal.open', 'task.start', 'mcp.call']) {
29
+ expect(isDesktopChannel(ch)).toBe(true);
30
+ }
31
+ });
32
+ it('classifies device capabilities as local', () => {
33
+ for (const ch of ['clipboard.writeText', 'gps.start', 'tts.start', 'push.register', 'alarm.schedule', 'liveActivity.startWorkout']) {
34
+ expect(isDesktopChannel(ch)).toBe(false);
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('withProxy', () => {
40
+ it('routes desktop-cap calls to the proxy when connected', async () => {
41
+ const base = fakeBase();
42
+ const client = fakeClient(true);
43
+ const n = withProxy(base, { client });
44
+ const r = await n.invoke('fs.readFile' as never, { path: '/x' } as never);
45
+ expect(r).toEqual({ viaProxy: true });
46
+ expect(client.invoke).toHaveBeenCalledWith('fs.readFile', { path: '/x' });
47
+ expect(base.invoke).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it('keeps device-cap calls local', async () => {
51
+ const base = fakeBase();
52
+ const client = fakeClient(true);
53
+ const n = withProxy(base, { client });
54
+ const r = await n.invoke('clipboard.writeText' as never, { text: 'hi' } as never);
55
+ expect(r).toEqual({ viaLocal: true });
56
+ expect(client.invoke).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('errors on desktop-cap with no host when remote-only', async () => {
60
+ const base = fakeBase();
61
+ const client = fakeClient(false);
62
+ const n = withProxy(base, { client, desktopCapsAreRemoteOnly: true });
63
+ await expect(n.invoke('fs.readFile' as never, {} as never)).rejects.toBeInstanceOf(NativeUnavailableError);
64
+ expect(base.invoke).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('falls through desktop-cap to local when not remote-only and proxy idle (desktop host)', async () => {
68
+ const base = fakeBase();
69
+ const client = fakeClient(false);
70
+ const n = withProxy(base, { client });
71
+ const r = await n.invoke('fs.readFile' as never, {} as never);
72
+ expect(r).toEqual({ viaLocal: true });
73
+ });
74
+
75
+ it('passes subscribe through to the base', () => {
76
+ const base = fakeBase();
77
+ const n = withProxy(base, { client: fakeClient(true) });
78
+ n.subscribe('gps.position' as never, () => {});
79
+ expect(base.subscribe).toHaveBeenCalled();
80
+ });
81
+ });
@@ -0,0 +1,75 @@
1
+ // Routes UglyNative calls between the local transport and the Ugly Proxy. Desktop-grade
2
+ // capabilities (fs/process/net/serve/workers/terminal/sandbox/mcp/secrets/browse/task)
3
+ // have NO local implementation on mobile/web — they tunnel to the selected desktop host.
4
+ // Device capabilities (clipboard/push/gps/tts/...) always stay local. Additive: wrap a
5
+ // base UglyNative with `withProxy(...)`; the core install.ts is untouched.
6
+ import type { UglyNative } from './protocol.js';
7
+ import { NativeUnavailableError } from './protocol.js';
8
+ import type { ProxyClient } from './proxyClient.js';
9
+
10
+ /** Namespaces that require a desktop host (RCE-grade / filesystem / long-running tasks). */
11
+ export const DESKTOP_NAMESPACES: ReadonlySet<string> = new Set([
12
+ 'fs',
13
+ 'process',
14
+ 'net',
15
+ 'serve',
16
+ 'workers',
17
+ 'terminal',
18
+ 'sandbox',
19
+ 'mcp',
20
+ 'secrets',
21
+ 'browse',
22
+ 'task',
23
+ ]);
24
+
25
+ export function isDesktopChannel(channel: string): boolean {
26
+ const ns = channel.includes('.') ? channel.slice(0, channel.indexOf('.')) : channel;
27
+ return DESKTOP_NAMESPACES.has(ns);
28
+ }
29
+
30
+ export interface WithProxyOpts {
31
+ client: ProxyClient;
32
+ /**
33
+ * Whether a given channel should tunnel to the host. Default: desktop-cap channels go
34
+ * to the proxy when it's connected. On a real desktop (full local impl) pass a predicate
35
+ * that returns false so everything stays local.
36
+ */
37
+ shouldProxy?: (channel: string) => boolean;
38
+ /** True on platforms with no local desktop-cap implementation (mobile/web). */
39
+ desktopCapsAreRemoteOnly?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Wrap a base UglyNative so desktop-capability calls route through the proxy. Events from
44
+ * the host must be fed back into the base event bus by wiring the proxy client's `onEvent`
45
+ * to a `dispatchNativeEvent` (below) — then `base.subscribe` delivers them unchanged.
46
+ */
47
+ export function withProxy(base: UglyNative, opts: WithProxyOpts): UglyNative {
48
+ const shouldProxy = opts.shouldProxy ?? ((ch: string) => isDesktopChannel(ch) && opts.client.isOpen());
49
+ return {
50
+ platform: base.platform,
51
+ invoke: ((channel: string, payload: unknown) => {
52
+ if (shouldProxy(channel)) return opts.client.invoke(channel, payload);
53
+ if (opts.desktopCapsAreRemoteOnly && isDesktopChannel(channel)) {
54
+ // No host selected and no local impl — surface a clear, actionable error.
55
+ return Promise.reject(new NativeUnavailableError(channel));
56
+ }
57
+ return base.invoke(channel as never, payload as never);
58
+ }) as UglyNative['invoke'],
59
+ subscribe: base.subscribe,
60
+ };
61
+ }
62
+
63
+ /** Dispatch a proxy event onto the same 'uglyNative' CustomEvent bus base.subscribe reads. */
64
+ export function dispatchNativeEvent(
65
+ win: { dispatchEvent(e: unknown): boolean },
66
+ event: string,
67
+ data: unknown,
68
+ ): void {
69
+ const detail = { event, data };
70
+ const ev =
71
+ typeof CustomEvent === 'function'
72
+ ? new CustomEvent('uglyNative', { detail })
73
+ : ({ type: 'uglyNative', detail } as unknown as Event);
74
+ win.dispatchEvent(ev);
75
+ }
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createProxyClient, type ProxySocket, type RTCFactory } from './proxyClient.js';
3
+ import type { ProxySigner } from './proxyCrypto.js';
4
+
5
+ const OFFER_SDP = 'v=0\r\na=fingerprint:sha-256 AA:BB\r\n';
6
+ const ANSWER_SDP = 'v=0\r\na=fingerprint:sha-256 CC:DD\r\n';
7
+ const flush = () => new Promise((r) => setTimeout(r, 0));
8
+
9
+ class FakeDC {
10
+ readyState: RTCDataChannelState = 'connecting';
11
+ onopen: (() => void) | null = null;
12
+ onmessage: ((e: MessageEvent) => void) | null = null;
13
+ onclose: (() => void) | null = null;
14
+ sent: string[] = [];
15
+ send(d: string): void { this.sent.push(d); }
16
+ close(): void { this.readyState = 'closed'; }
17
+ open(): void { this.readyState = 'open'; this.onopen?.(); }
18
+ deliver(obj: unknown): void { this.onmessage?.({ data: JSON.stringify(obj) } as MessageEvent); }
19
+ }
20
+
21
+ class FakePC {
22
+ onicecandidate: ((e: RTCPeerConnectionIceEvent) => void) | null = null;
23
+ localDescription: RTCSessionDescription | null = null;
24
+ remote: RTCSessionDescriptionInit | null = null;
25
+ added: RTCIceCandidateInit[] = [];
26
+ dc = new FakeDC();
27
+ closed = false;
28
+ createDataChannel(): RTCDataChannel { return this.dc as unknown as RTCDataChannel; }
29
+ async createOffer(): Promise<RTCSessionDescriptionInit> { return { type: 'offer', sdp: OFFER_SDP }; }
30
+ async setLocalDescription(d: RTCSessionDescriptionInit): Promise<void> {
31
+ this.localDescription = { type: d.type, sdp: d.sdp } as RTCSessionDescription;
32
+ }
33
+ async setRemoteDescription(d: RTCSessionDescriptionInit): Promise<void> { this.remote = d; }
34
+ async addIceCandidate(c: RTCIceCandidateInit): Promise<void> { this.added.push(c); }
35
+ close(): void { this.closed = true; }
36
+ emitIce(candidate: unknown): void { this.onicecandidate?.({ candidate } as RTCPeerConnectionIceEvent); }
37
+ }
38
+
39
+ function fakeSocket() {
40
+ const handlers = new Map<string, Set<(d: unknown) => void>>();
41
+ const emitted: Array<{ type: string; data: unknown }> = [];
42
+ let sendImpl: ((type: string, data: object) => Promise<unknown>) | undefined;
43
+ const socket: ProxySocket = {
44
+ emit: (type, data) => emitted.push({ type, data }),
45
+ on: (e, h) => { let s = handlers.get(e); if (!s) { s = new Set(); handlers.set(e, s); } s.add(h); return () => s!.delete(h); },
46
+ send: (type, data) => (sendImpl ? sendImpl(type, data) : Promise.reject(new Error('no send'))),
47
+ };
48
+ return {
49
+ socket,
50
+ emitted,
51
+ recv: (type: string, data: unknown) => handlers.get(type)?.forEach((h) => h(data)),
52
+ setSend: (fn: (type: string, data: object) => Promise<unknown>) => { sendImpl = fn; },
53
+ };
54
+ }
55
+
56
+ const okSigner: ProxySigner = { sign: async () => 'CTAG', verify: async () => true };
57
+
58
+ function setup(signer?: ProxySigner, openTimeoutMs = 1000) {
59
+ const { socket, emitted, recv, setSend } = fakeSocket();
60
+ let pc!: FakePC;
61
+ const rtcFactory: RTCFactory = () => { pc = new FakePC(); return pc as unknown as RTCPeerConnection; };
62
+ const onEvent = vi.fn();
63
+ const client = createProxyClient({ socket, selfDeviceId: 'me', signer, rtcFactory, onEvent, openTimeoutMs });
64
+ return { client, emitted, recv, setSend, onEvent, getPc: () => pc };
65
+ }
66
+
67
+ describe('createProxyClient', () => {
68
+ it('connect emits a signed rtc:offer and resolves once the DataChannel opens', async () => {
69
+ const { client, emitted, recv, getPc } = setup(okSigner);
70
+ const p = client.connect('hostX');
71
+ await flush();
72
+ expect(emitted).toContainEqual({ type: 'rtc:offer', data: { to: 'hostX', sdp: OFFER_SDP, fpTag: 'CTAG' } });
73
+
74
+ recv('rtc:answer', { from: 'hostX', sdp: ANSWER_SDP, fpTag: 'HTAG' });
75
+ await flush();
76
+ expect(getPc().remote).toEqual({ type: 'answer', sdp: ANSWER_SDP });
77
+
78
+ getPc().dc.open();
79
+ await p;
80
+ expect(client.isOpen()).toBe(true);
81
+ expect(client.hostDeviceId()).toBe('hostX');
82
+ client.close();
83
+ });
84
+
85
+ it('tunnels invoke as a req frame and resolves on the matching res', async () => {
86
+ const { client, recv, getPc } = setup(okSigner);
87
+ const p = client.connect('hostX');
88
+ await flush();
89
+ recv('rtc:answer', { from: 'hostX', sdp: ANSWER_SDP, fpTag: 'H' });
90
+ getPc().dc.open();
91
+ await p;
92
+
93
+ const r = client.invoke('fs.readFile', { path: '/x' });
94
+ const sent = JSON.parse(getPc().dc.sent[0]);
95
+ expect(sent).toMatchObject({ t: 'req', channel: 'fs.readFile', payload: { path: '/x' } });
96
+ getPc().dc.deliver({ t: 'res', id: sent.id, ok: true, result: { content: 'hi' } });
97
+ await expect(r).resolves.toEqual({ content: 'hi' });
98
+ client.close();
99
+ });
100
+
101
+ it('rejects invoke when the host returns res ok:false', async () => {
102
+ const { client, recv, getPc } = setup(okSigner);
103
+ const p = client.connect('hostX');
104
+ await flush(); recv('rtc:answer', { from: 'hostX', sdp: ANSWER_SDP }); getPc().dc.open(); await p;
105
+ const r = client.invoke('process.spawn', {});
106
+ const sent = JSON.parse(getPc().dc.sent[0]);
107
+ getPc().dc.deliver({ t: 'res', id: sent.id, ok: false, error: 'denied' });
108
+ await expect(r).rejects.toThrow('denied');
109
+ client.close();
110
+ });
111
+
112
+ it('routes evt frames to onEvent', async () => {
113
+ const { client, recv, getPc, onEvent } = setup(okSigner);
114
+ const p = client.connect('hostX');
115
+ await flush(); recv('rtc:answer', { from: 'hostX', sdp: ANSWER_SDP }); getPc().dc.open(); await p;
116
+ getPc().dc.deliver({ t: 'evt', event: 'task.event:t1', data: { delta: 'x' } });
117
+ expect(onEvent).toHaveBeenCalledWith('task.event:t1', { delta: 'x' });
118
+ client.close();
119
+ });
120
+
121
+ it('forwards local ICE candidates addressed to the host', async () => {
122
+ const { client, emitted, getPc } = setup(okSigner);
123
+ const p = client.connect('hostX');
124
+ await flush();
125
+ getPc().emitIce({ toJSON: () => ({ candidate: 'cand' }) });
126
+ expect(emitted).toContainEqual({ type: 'rtc:ice', data: { to: 'hostX', candidate: { candidate: 'cand' } } });
127
+ getPc().dc.open(); await p; client.close();
128
+ });
129
+
130
+ it('rejects a host answer whose fingerprint fails MSK verification', async () => {
131
+ const badSigner: ProxySigner = { sign: async () => 'CTAG', verify: async () => false };
132
+ const { client, recv, getPc } = setup(badSigner, 80);
133
+ const p = client.connect('hostX');
134
+ await flush();
135
+ recv('rtc:answer', { from: 'hostX', sdp: ANSWER_SDP, fpTag: 'forged' });
136
+ await flush();
137
+ expect(getPc().remote).toBeNull(); // never accepted
138
+ await expect(p).rejects.toThrow(/timeout/); // cleaned up; never opened
139
+ });
140
+
141
+ it('listHosts filters to other devices that are hosts', async () => {
142
+ const { client, setSend } = setup();
143
+ setSend(async () => ({ devices: [
144
+ { deviceId: 'me', label: 'mine', isHost: true, lastSeen: 1 },
145
+ { deviceId: 'h1', label: 'Desk', isHost: true, lastSeen: 2 },
146
+ { deviceId: 'c1', label: 'Phone', isHost: false, lastSeen: 3 },
147
+ ] }));
148
+ expect(await client.listHosts()).toEqual([{ deviceId: 'h1', label: 'Desk', isHost: true, lastSeen: 2 }]);
149
+ });
150
+ });
@@ -0,0 +1,179 @@
1
+ // Client side of the Ugly Proxy: from a phone/web (or a desktop acting as a client), open
2
+ // a WebRTC DataChannel to a chosen desktop host and tunnel UglyNative calls over it (the
3
+ // "dumb pipe"). Signaling rides the app Socket's rtc:* messages; DTLS gives e2e transport
4
+ // encryption; the MSK-bound fingerprint check (proxyCrypto) authenticates the host.
5
+ // RTCPeerConnection + Socket are injectable so the framing/correlation is unit-testable.
6
+ import { parseFingerprint, type ProxySigner } from './proxyCrypto.js';
7
+
8
+ /** Subset of ugly-app's client Socket the proxy needs (emit/on are runtime-generic). */
9
+ export interface ProxySocket {
10
+ emit(type: string, data: object): void;
11
+ on(event: string, handler: (data: unknown) => void): () => void;
12
+ /** Request/response (used for presence:query). */
13
+ send?(type: string, data: object, timeout?: number): Promise<unknown>;
14
+ }
15
+
16
+ export type RTCFactory = (config: RTCConfiguration) => RTCPeerConnection;
17
+
18
+ export interface HostInfo {
19
+ deviceId: string;
20
+ label: string;
21
+ isHost: boolean;
22
+ lastSeen: number;
23
+ }
24
+
25
+ export interface ProxyClientOpts {
26
+ socket: ProxySocket;
27
+ /** This device's id (so listHosts can exclude self). */
28
+ selfDeviceId: string;
29
+ /** When set, sign our offer fingerprint + verify the host's answer fingerprint. */
30
+ signer?: ProxySigner;
31
+ iceServers?: RTCIceServer[];
32
+ rtcFactory?: RTCFactory;
33
+ /** evt frames from the host (UglyNative subscribe events, task deltas) land here. */
34
+ onEvent?: (event: string, data: unknown) => void;
35
+ requestTimeoutMs?: number;
36
+ openTimeoutMs?: number;
37
+ }
38
+
39
+ export interface ProxyClient {
40
+ /** Query the worker for this user's online desktop hosts. */
41
+ listHosts(): Promise<HostInfo[]>;
42
+ /** Establish (or re-establish) the DataChannel to a host. Resolves when open. */
43
+ connect(hostDeviceId: string): Promise<void>;
44
+ /** Tunnel one UglyNative invoke over the DataChannel. */
45
+ invoke(channel: string, payload: unknown): Promise<unknown>;
46
+ isOpen(): boolean;
47
+ hostDeviceId(): string | null;
48
+ close(): void;
49
+ }
50
+
51
+ interface Pending {
52
+ resolve(v: unknown): void;
53
+ reject(e: Error): void;
54
+ timer: ReturnType<typeof setTimeout> | null;
55
+ }
56
+
57
+ function rec(d: unknown): Record<string, unknown> {
58
+ return (d ?? {}) as Record<string, unknown>;
59
+ }
60
+
61
+ export function createProxyClient(opts: ProxyClientOpts): ProxyClient {
62
+ const rtcFactory = opts.rtcFactory ?? ((cfg: RTCConfiguration) => new RTCPeerConnection(cfg));
63
+ const reqTimeout = opts.requestTimeoutMs ?? 30_000;
64
+ const openTimeout = opts.openTimeoutMs ?? 15_000;
65
+
66
+ let pc: RTCPeerConnection | null = null;
67
+ let dc: RTCDataChannel | null = null;
68
+ let host: string | null = null;
69
+ let seq = 0;
70
+ const pending = new Map<string, Pending>();
71
+ const offs: Array<() => void> = [];
72
+
73
+ function cleanup(): void {
74
+ for (const off of offs) { try { off(); } catch { /* noop */ } }
75
+ offs.length = 0;
76
+ for (const p of pending.values()) {
77
+ if (p.timer) clearTimeout(p.timer);
78
+ p.reject(new Error('proxy connection closed'));
79
+ }
80
+ pending.clear();
81
+ try { dc?.close(); } catch { /* noop */ }
82
+ try { pc?.close(); } catch { /* noop */ }
83
+ dc = null;
84
+ pc = null;
85
+ }
86
+
87
+ function onDcMessage(raw: unknown): void {
88
+ let f: { t?: string; id?: string; ok?: boolean; result?: unknown; error?: string; event?: string; data?: unknown };
89
+ try {
90
+ f = JSON.parse(typeof raw === 'string' ? raw : new TextDecoder().decode(raw as ArrayBuffer));
91
+ } catch {
92
+ return;
93
+ }
94
+ if (f.t === 'res' && f.id !== undefined) {
95
+ const p = pending.get(f.id);
96
+ if (!p) return;
97
+ pending.delete(f.id);
98
+ if (p.timer) clearTimeout(p.timer);
99
+ if (f.ok) p.resolve(f.result);
100
+ else p.reject(new Error(f.error ?? 'proxy error'));
101
+ } else if (f.t === 'evt' && f.event) {
102
+ opts.onEvent?.(f.event, f.data);
103
+ }
104
+ }
105
+
106
+ async function listHosts(): Promise<HostInfo[]> {
107
+ if (!opts.socket.send) throw new Error('socket lacks request support for presence:query');
108
+ const res = rec(await opts.socket.send('presence:query', {}));
109
+ const devices = (res['devices'] as HostInfo[] | undefined) ?? [];
110
+ return devices.filter((d) => d.isHost && d.deviceId !== opts.selfDeviceId);
111
+ }
112
+
113
+ async function connect(hostDeviceId: string): Promise<void> {
114
+ cleanup();
115
+ host = hostDeviceId;
116
+ const conn = rtcFactory({ iceServers: opts.iceServers ?? [] });
117
+ pc = conn;
118
+ const channel = conn.createDataChannel('proxy');
119
+ dc = channel;
120
+
121
+ conn.onicecandidate = (e) => {
122
+ if (e.candidate) opts.socket.emit('rtc:ice', { to: hostDeviceId, candidate: e.candidate.toJSON() });
123
+ };
124
+ channel.onmessage = (e: MessageEvent) => onDcMessage(e.data);
125
+
126
+ offs.push(opts.socket.on('rtc:answer', (d) => { void onAnswer(rec(d)); }));
127
+ offs.push(opts.socket.on('rtc:ice', (d) => { void onRemoteIce(rec(d)); }));
128
+ offs.push(opts.socket.on('rtc:error', (d) => { if (rec(d)['from'] === host) cleanup(); }));
129
+
130
+ const opened = new Promise<void>((resolve, reject) => {
131
+ const timer = setTimeout(() => reject(new Error('proxy DataChannel open timeout')), openTimeout);
132
+ channel.onopen = () => { clearTimeout(timer); resolve(); };
133
+ });
134
+
135
+ const offer = await conn.createOffer();
136
+ await conn.setLocalDescription(offer);
137
+ const sdp = conn.localDescription?.sdp ?? offer.sdp ?? '';
138
+ const tag = opts.signer ? await opts.signer.sign('client', parseFingerprint(sdp) ?? '') : undefined;
139
+ opts.socket.emit('rtc:offer', { to: hostDeviceId, sdp, fpTag: tag });
140
+
141
+ await opened;
142
+ }
143
+
144
+ async function onAnswer(d: Record<string, unknown>): Promise<void> {
145
+ if (!pc || d['from'] !== host) return;
146
+ const sdp = String(d['sdp'] ?? '');
147
+ if (opts.signer) {
148
+ const ok = await opts.signer.verify('host', parseFingerprint(sdp) ?? '', String(d['fpTag'] ?? ''));
149
+ if (!ok) { cleanup(); return; }
150
+ }
151
+ await pc.setRemoteDescription({ type: 'answer', sdp });
152
+ }
153
+
154
+ async function onRemoteIce(d: Record<string, unknown>): Promise<void> {
155
+ if (!pc || d['from'] !== host) return;
156
+ try { await pc.addIceCandidate(d['candidate'] as RTCIceCandidateInit); } catch { /* benign */ }
157
+ }
158
+
159
+ function invoke(channel: string, payload: unknown): Promise<unknown> {
160
+ if (!dc || dc.readyState !== 'open') return Promise.reject(new Error('proxy not connected'));
161
+ const id = String(seq++);
162
+ return new Promise<unknown>((resolve, reject) => {
163
+ const timer = reqTimeout > 0
164
+ ? setTimeout(() => { pending.delete(id); reject(new Error(`proxy invoke timed out: ${channel}`)); }, reqTimeout)
165
+ : null;
166
+ pending.set(id, { resolve, reject, timer });
167
+ dc!.send(JSON.stringify({ t: 'req', id, channel, payload }));
168
+ });
169
+ }
170
+
171
+ return {
172
+ listHosts,
173
+ connect,
174
+ invoke,
175
+ isOpen: () => dc?.readyState === 'open',
176
+ hostDeviceId: () => host,
177
+ close: cleanup,
178
+ };
179
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { fpTag, verifyFpTag, parseFingerprint, mskSigner } from './proxyCrypto.js';
3
+
4
+ const MSK = new Uint8Array(32).fill(7);
5
+ const SDP = [
6
+ 'v=0',
7
+ 'a=fingerprint:sha-256 11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00',
8
+ ].join('\r\n');
9
+
10
+ describe('proxyCrypto (WebCrypto)', () => {
11
+ it('parses the DTLS fingerprint', () => {
12
+ expect(parseFingerprint(SDP)).toMatch(/^sha-256 11:22:/);
13
+ });
14
+
15
+ it('produces a stable, role-bound tag that verifies', async () => {
16
+ const fp = parseFingerprint(SDP)!;
17
+ const tag = await fpTag(MSK, 'client', fp);
18
+ expect(tag).toHaveLength(64); // hex SHA-256
19
+ expect(await fpTag(MSK, 'client', fp)).toBe(tag); // deterministic
20
+ expect(await verifyFpTag(MSK, 'client', fp, tag)).toBe(true);
21
+ });
22
+
23
+ it('rejects swapped fingerprint, wrong role, wrong MSK, missing tag', async () => {
24
+ const fp = parseFingerprint(SDP)!;
25
+ const tag = await fpTag(MSK, 'client', fp);
26
+ expect(await verifyFpTag(MSK, 'client', fp.replace('11:22', 'AA:BB'), tag)).toBe(false);
27
+ expect(await verifyFpTag(MSK, 'host', fp, tag)).toBe(false);
28
+ expect(await verifyFpTag(new Uint8Array(32).fill(9), 'client', fp, tag)).toBe(false);
29
+ expect(await verifyFpTag(MSK, 'client', fp, undefined)).toBe(false);
30
+ });
31
+
32
+ it('mskSigner wraps sign/verify', async () => {
33
+ const s = mskSigner(MSK);
34
+ const fp = parseFingerprint(SDP)!;
35
+ const tag = await s.sign('client', fp);
36
+ expect(await s.verify('client', fp, tag)).toBe(true);
37
+ });
38
+ });
@@ -0,0 +1,72 @@
1
+ // Client-side fingerprint auth for the Ugly Proxy. Binds the WebRTC DTLS fingerprint to
2
+ // the per-user MSK (from the cookie-sync system) so the signaling relay can't MITM the
3
+ // handshake. Implemented with WebCrypto (HKDF + HMAC-SHA256) — no extra dependency, and
4
+ // byte-compatible with the host side's @noble implementation (same RFC 5869 / RFC 2104).
5
+
6
+ const INFO = 'ugly-native-proxy-v1';
7
+ const enc = new TextEncoder();
8
+
9
+ export type ProxyRole = 'client' | 'host';
10
+
11
+ function toHex(buf: ArrayBuffer): string {
12
+ const b = new Uint8Array(buf);
13
+ let s = '';
14
+ for (let i = 0; i < b.length; i++) s += b[i]!.toString(16).padStart(2, '0');
15
+ return s;
16
+ }
17
+
18
+ function subtle(): SubtleCrypto {
19
+ const c = (globalThis as { crypto?: Crypto }).crypto;
20
+ if (!c?.subtle) throw new Error('[proxyCrypto] WebCrypto SubtleCrypto unavailable');
21
+ return c.subtle;
22
+ }
23
+
24
+ /** HKDF-SHA256(MSK) → 32-byte proxy-auth key, imported as an HMAC signing key. */
25
+ async function deriveHmacKey(msk: Uint8Array): Promise<CryptoKey> {
26
+ const base = await subtle().importKey('raw', msk as BufferSource, 'HKDF', false, ['deriveBits']);
27
+ const bits = await subtle().deriveBits(
28
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode(INFO) },
29
+ base,
30
+ 256,
31
+ );
32
+ return subtle().importKey('raw', bits, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
33
+ }
34
+
35
+ /** Extract + normalize the DTLS fingerprint from an SDP blob ("sha-256 AB:CD:.."). */
36
+ export function parseFingerprint(sdp: string): string | null {
37
+ const m = /a=fingerprint:(\S+)\s+([0-9A-Fa-f:]+)/.exec(sdp || '');
38
+ return m ? `${m[1].toLowerCase()} ${m[2].toUpperCase()}` : null;
39
+ }
40
+
41
+ export async function fpTag(msk: Uint8Array, role: ProxyRole, fingerprint: string): Promise<string> {
42
+ const key = await deriveHmacKey(msk);
43
+ const sig = await subtle().sign('HMAC', key, enc.encode(`${role}|${fingerprint}`));
44
+ return toHex(sig);
45
+ }
46
+
47
+ export async function verifyFpTag(
48
+ msk: Uint8Array,
49
+ role: ProxyRole,
50
+ fingerprint: string | null,
51
+ tag: string | undefined,
52
+ ): Promise<boolean> {
53
+ if (!fingerprint || !tag) return false;
54
+ const expected = await fpTag(msk, role, fingerprint);
55
+ if (expected.length !== tag.length) return false;
56
+ let diff = 0;
57
+ for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ tag.charCodeAt(i);
58
+ return diff === 0;
59
+ }
60
+
61
+ /** Convenience signer bound to an MSK — what proxyClient expects when MSK is in-JS. */
62
+ export interface ProxySigner {
63
+ sign(role: ProxyRole, fingerprint: string): Promise<string>;
64
+ verify(role: ProxyRole, fingerprint: string, tag: string): Promise<boolean>;
65
+ }
66
+
67
+ export function mskSigner(msk: Uint8Array): ProxySigner {
68
+ return {
69
+ sign: (role, fp) => fpTag(msk, role, fp),
70
+ verify: (role, fp, tag) => verifyFpTag(msk, role, fp, tag),
71
+ };
72
+ }