ugly-app 0.1.620 → 0.1.621
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.
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/native/contract-meta.d.ts +7 -0
- package/dist/native/contract-meta.d.ts.map +1 -0
- package/dist/native/contract-meta.js +105 -0
- package/dist/native/contract-meta.js.map +1 -0
- package/dist/native/contract.d.ts +254 -24
- package/dist/native/contract.d.ts.map +1 -1
- package/dist/native/contract.js +4 -15
- package/dist/native/contract.js.map +1 -1
- package/dist/native/facades/net.d.ts +26 -0
- package/dist/native/facades/net.d.ts.map +1 -0
- package/dist/native/facades/net.js +22 -0
- package/dist/native/facades/net.js.map +1 -0
- package/dist/native/facades/process.d.ts +20 -0
- package/dist/native/facades/process.d.ts.map +1 -0
- package/dist/native/facades/process.js +15 -0
- package/dist/native/facades/process.js.map +1 -0
- package/dist/native/facades/studio.d.ts +47 -0
- package/dist/native/facades/studio.d.ts.map +1 -0
- package/dist/native/facades/studio.js +38 -0
- package/dist/native/facades/studio.js.map +1 -0
- package/dist/native/index.d.ts +10 -3
- package/dist/native/index.d.ts.map +1 -1
- package/dist/native/index.js +4 -2
- package/dist/native/index.js.map +1 -1
- package/dist/native/install.d.ts.map +1 -1
- package/dist/native/install.js +10 -8
- package/dist/native/install.js.map +1 -1
- package/dist/native/permissions.d.ts +12 -0
- package/dist/native/permissions.d.ts.map +1 -0
- package/dist/native/permissions.js +47 -0
- package/dist/native/permissions.js.map +1 -0
- package/dist/native/protocol.d.ts +5 -1
- package/dist/native/protocol.d.ts.map +1 -1
- package/dist/native/protocol.js +8 -0
- package/dist/native/protocol.js.map +1 -1
- package/dist/native/web-adapter.d.ts +5 -0
- package/dist/native/web-adapter.d.ts.map +1 -0
- package/dist/native/web-adapter.js +111 -0
- package/dist/native/web-adapter.js.map +1 -0
- package/dist/native/wrapper.d.ts +80 -6
- package/dist/native/wrapper.d.ts.map +1 -1
- package/dist/native/wrapper.js +149 -12
- package/dist/native/wrapper.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/version.ts +1 -1
- package/src/native/contract-meta.test.ts +23 -0
- package/src/native/contract-meta.ts +114 -0
- package/src/native/contract.test.ts +13 -7
- package/src/native/contract.ts +130 -32
- package/src/native/facades/facades.test.ts +35 -0
- package/src/native/facades/net.ts +50 -0
- package/src/native/facades/process.ts +37 -0
- package/src/native/facades/studio.ts +73 -0
- package/src/native/index.ts +24 -3
- package/src/native/install.test.ts +8 -0
- package/src/native/install.ts +12 -8
- package/src/native/permissions.test.ts +42 -0
- package/src/native/permissions.ts +53 -0
- package/src/native/protocol.test.ts +15 -0
- package/src/native/protocol.ts +10 -1
- package/src/native/web-adapter.test.ts +27 -0
- package/src/native/web-adapter.ts +118 -0
- package/src/native/wrapper.test.ts +22 -0
- package/src/native/wrapper.ts +163 -13
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NativeUnavailableError } from '../protocol.js';
|
|
2
|
+
import type { Platform } from '../contract-meta.js';
|
|
3
|
+
|
|
4
|
+
export interface UglySocket {
|
|
5
|
+
onConnect(cb: () => void): void;
|
|
6
|
+
onData(cb: (chunk: Uint8Array) => void): void;
|
|
7
|
+
onClose(cb: () => void): void;
|
|
8
|
+
onError(cb: (err: string) => void): void;
|
|
9
|
+
write(data: Uint8Array | string): void;
|
|
10
|
+
end(): void;
|
|
11
|
+
close(): void;
|
|
12
|
+
}
|
|
13
|
+
export interface UglyServer {
|
|
14
|
+
port: number;
|
|
15
|
+
onConnection(cb: (s: UglySocket) => void): void;
|
|
16
|
+
close(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface NetBridge {
|
|
20
|
+
connect(host: string, port: number): UglySocket;
|
|
21
|
+
listen(port?: number): Promise<UglyServer>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NetFacade {
|
|
25
|
+
connect(host: string, port: number): UglySocket;
|
|
26
|
+
listen(port?: number): Promise<UglyServer>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createNetFacade(
|
|
30
|
+
getBridge: () => NetBridge | undefined,
|
|
31
|
+
platform: Platform,
|
|
32
|
+
ensure: () => Promise<void>,
|
|
33
|
+
): NetFacade {
|
|
34
|
+
const guard = (): NetBridge => {
|
|
35
|
+
if (platform === 'web') throw new NativeUnavailableError('net.connect');
|
|
36
|
+
const b = getBridge();
|
|
37
|
+
if (!b) throw new NativeUnavailableError('net.connect');
|
|
38
|
+
return b;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
connect(host, port) {
|
|
42
|
+
void ensure();
|
|
43
|
+
return guard().connect(host, port);
|
|
44
|
+
},
|
|
45
|
+
listen(port) {
|
|
46
|
+
void ensure();
|
|
47
|
+
return guard().listen(port);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NativeUnavailableError } from '../protocol.js';
|
|
2
|
+
import type { Platform } from '../contract-meta.js';
|
|
3
|
+
import type { SpawnOpts } from '../contract.js';
|
|
4
|
+
|
|
5
|
+
export interface UglyProcess {
|
|
6
|
+
onStdout(cb: (chunk: string) => void): void;
|
|
7
|
+
onStderr(cb: (chunk: string) => void): void;
|
|
8
|
+
onExit(cb: (code: number | null) => void): void;
|
|
9
|
+
onError(cb: (err: string) => void): void;
|
|
10
|
+
write(data: string): void;
|
|
11
|
+
closeStdin(): void;
|
|
12
|
+
kill(signal?: string): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProcessBridge {
|
|
16
|
+
spawn(cmd: string, args: string[], opts?: SpawnOpts): UglyProcess;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProcessFacade {
|
|
20
|
+
spawn(cmd: string, args: string[], opts?: SpawnOpts): UglyProcess;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createProcessFacade(
|
|
24
|
+
getBridge: () => ProcessBridge | undefined,
|
|
25
|
+
platform: Platform,
|
|
26
|
+
ensure: () => Promise<void>,
|
|
27
|
+
): ProcessFacade {
|
|
28
|
+
return {
|
|
29
|
+
spawn(cmd, args, opts) {
|
|
30
|
+
if (platform !== 'desktop') throw new NativeUnavailableError('process.spawn');
|
|
31
|
+
void ensure(); // fire the permission check; the desktop daemon also enforces
|
|
32
|
+
const bridge = getBridge();
|
|
33
|
+
if (!bridge) throw new NativeUnavailableError('process.spawn');
|
|
34
|
+
return bridge.spawn(cmd, args, opts);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { NativeUnavailableError } from '../protocol.js';
|
|
2
|
+
import type { NativePlatform } from '../protocol.js';
|
|
3
|
+
import type { Platform } from '../contract-meta.js';
|
|
4
|
+
import type { UpdateStatus } from '../contract.js';
|
|
5
|
+
|
|
6
|
+
export interface StudioFacade {
|
|
7
|
+
window: {
|
|
8
|
+
minimize(): Promise<void>;
|
|
9
|
+
maximizeToggle(): Promise<boolean>;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
isMaximized(): Promise<boolean>;
|
|
12
|
+
};
|
|
13
|
+
fullscreen: { toggle(): Promise<boolean>; is(): Promise<boolean> };
|
|
14
|
+
zoom: { get(): Promise<number>; set(level: number): Promise<void> };
|
|
15
|
+
update: {
|
|
16
|
+
check(): Promise<UpdateStatus>;
|
|
17
|
+
download(): Promise<UpdateStatus>;
|
|
18
|
+
install(): Promise<void>;
|
|
19
|
+
getStatus(): Promise<UpdateStatus>;
|
|
20
|
+
};
|
|
21
|
+
dialog: { openDirectory(): Promise<string | null> };
|
|
22
|
+
revealInFolder(path: string): Promise<void>;
|
|
23
|
+
app: { version(): Promise<string>; platform(): NativePlatform };
|
|
24
|
+
openProjectInNewWindow(path: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type StudioBridge = Partial<{
|
|
28
|
+
window: StudioFacade['window'];
|
|
29
|
+
fullscreen: StudioFacade['fullscreen'];
|
|
30
|
+
zoom: StudioFacade['zoom'];
|
|
31
|
+
update: StudioFacade['update'];
|
|
32
|
+
dialog: StudioFacade['dialog'];
|
|
33
|
+
revealInFolder: (p: string) => Promise<void>;
|
|
34
|
+
getAppVersion: () => Promise<string>;
|
|
35
|
+
openProjectInNewWindow: (p: string) => Promise<void>;
|
|
36
|
+
}>;
|
|
37
|
+
|
|
38
|
+
export function createStudioFacade(getBridge: () => StudioBridge | undefined, platform: Platform): StudioFacade {
|
|
39
|
+
const b = () => getBridge();
|
|
40
|
+
const noop = async (): Promise<void> => {};
|
|
41
|
+
const unavailable = (ch: string): never => {
|
|
42
|
+
throw new NativeUnavailableError(ch);
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
window: {
|
|
46
|
+
minimize: async () => (b()?.window ? b()!.window!.minimize() : noop()),
|
|
47
|
+
maximizeToggle: async () => (b()?.window ? b()!.window!.maximizeToggle() : false),
|
|
48
|
+
close: async () => (b()?.window ? b()!.window!.close() : noop()),
|
|
49
|
+
isMaximized: async () => (b()?.window ? b()!.window!.isMaximized() : false),
|
|
50
|
+
},
|
|
51
|
+
fullscreen: {
|
|
52
|
+
toggle: async () => (b()?.fullscreen ? b()!.fullscreen!.toggle() : false),
|
|
53
|
+
is: async () => (b()?.fullscreen ? b()!.fullscreen!.is() : false),
|
|
54
|
+
},
|
|
55
|
+
zoom: {
|
|
56
|
+
get: async () => (b()?.zoom ? b()!.zoom!.get() : 0),
|
|
57
|
+
set: async (l) => (b()?.zoom ? b()!.zoom!.set(l) : noop()),
|
|
58
|
+
},
|
|
59
|
+
update: {
|
|
60
|
+
check: async () => (b()?.update ? b()!.update!.check() : unavailable('studio.update.check')),
|
|
61
|
+
download: async () => (b()?.update ? b()!.update!.download() : unavailable('studio.update.download')),
|
|
62
|
+
install: async () => (b()?.update ? b()!.update!.install() : unavailable('studio.update.install')),
|
|
63
|
+
getStatus: async () => (b()?.update ? b()!.update!.getStatus() : unavailable('studio.update.getStatus')),
|
|
64
|
+
},
|
|
65
|
+
dialog: { openDirectory: async () => (b()?.dialog ? b()!.dialog!.openDirectory() : null) },
|
|
66
|
+
revealInFolder: async (p) => (b()?.revealInFolder ? b()!.revealInFolder!(p) : unavailable('studio.revealInFolder')),
|
|
67
|
+
app: {
|
|
68
|
+
version: async () => (b()?.getAppVersion ? b()!.getAppVersion!() : ''),
|
|
69
|
+
platform: () => platform as NativePlatform,
|
|
70
|
+
},
|
|
71
|
+
openProjectInNewWindow: async (p) => (b()?.openProjectInNewWindow ? b()!.openProjectInNewWindow!(p) : noop()),
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/native/index.ts
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
export type {
|
|
2
|
-
NativeContract,
|
|
2
|
+
NativeContract,
|
|
3
|
+
Channel,
|
|
4
|
+
NativeEvent,
|
|
5
|
+
PayloadOf,
|
|
6
|
+
ResultOf,
|
|
7
|
+
DataOf,
|
|
8
|
+
Namespace,
|
|
9
|
+
WorkoutLAState,
|
|
10
|
+
CardioLAState,
|
|
11
|
+
HostDirent,
|
|
12
|
+
HostStat,
|
|
13
|
+
UpdateStatus,
|
|
14
|
+
GrantState,
|
|
15
|
+
SpawnOpts,
|
|
16
|
+
SafeAreaInsets,
|
|
3
17
|
} from './contract.js';
|
|
18
|
+
export { namespaceOf } from './contract.js';
|
|
19
|
+
export type { Platform, Fallback } from './contract-meta.js';
|
|
20
|
+
export { CHANNEL_FALLBACK, NAMESPACES, fallbackFor } from './contract-meta.js';
|
|
4
21
|
export type { NativePlatform, UglyNative } from './protocol.js';
|
|
5
|
-
export { NativeUnavailableError } from './protocol.js';
|
|
6
|
-
export { native, nativePlatform, isNativeAvailable } from './wrapper.js';
|
|
22
|
+
export { NativeUnavailableError, PermissionDeniedError } from './protocol.js';
|
|
7
23
|
export { installUglyNative } from './install.js';
|
|
24
|
+
export { native, permissions, nativePlatform, isNativeAvailable } from './wrapper.js';
|
|
25
|
+
export type { PermissionsFacade } from './permissions.js';
|
|
26
|
+
export type { UglyProcess } from './facades/process.js';
|
|
27
|
+
export type { UglySocket, UglyServer } from './facades/net.js';
|
|
28
|
+
export type { StudioFacade } from './facades/studio.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
1
2
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
3
|
import { buildUglyNative } from './install';
|
|
3
4
|
|
|
@@ -56,6 +57,13 @@ describe('buildUglyNative', () => {
|
|
|
56
57
|
await expect(p).resolves.toBeNull();
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
it('web platform routes clipboard.read through the web-adapter', async () => {
|
|
61
|
+
(navigator as unknown as { clipboard: unknown }).clipboard = { readText: async () => 'zz' };
|
|
62
|
+
const n = buildUglyNative(fakeWin({}));
|
|
63
|
+
expect(n.platform).toBe('web');
|
|
64
|
+
expect(await n.invoke('clipboard.read', undefined)).toEqual({ text: 'zz' });
|
|
65
|
+
});
|
|
66
|
+
|
|
59
67
|
it('delivers events to subscribers', () => {
|
|
60
68
|
const win = fakeWin({});
|
|
61
69
|
const n = buildUglyNative(win);
|
package/src/native/install.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Channel, NativeEvent, PayloadOf, ResultOf, DataOf } from './contract.js';
|
|
2
|
-
import { CHANNEL_WEB_FALLBACK } from './contract.js';
|
|
3
2
|
import type { UglyNative, UglyNativeWindow } from './protocol.js';
|
|
4
|
-
import {
|
|
3
|
+
import { webInvoke } from './web-adapter.js';
|
|
5
4
|
|
|
6
5
|
type WinLike = UglyNativeWindow & {
|
|
7
6
|
addEventListener(type: string, cb: (e: unknown) => void): void;
|
|
@@ -10,6 +9,16 @@ type WinLike = UglyNativeWindow & {
|
|
|
10
9
|
};
|
|
11
10
|
|
|
12
11
|
export function buildUglyNative(win: WinLike): UglyNative {
|
|
12
|
+
// Native→web event bus: dispatch a uniform CustomEvent the subscriber reads.
|
|
13
|
+
const emit = <E extends NativeEvent>(event: E, data: DataOf<E>): void => {
|
|
14
|
+
const detail = { event, data };
|
|
15
|
+
const ev =
|
|
16
|
+
typeof CustomEvent === 'function'
|
|
17
|
+
? new CustomEvent('uglyNative', { detail })
|
|
18
|
+
: ({ type: 'uglyNative', detail } as unknown as Event);
|
|
19
|
+
win.dispatchEvent(ev);
|
|
20
|
+
};
|
|
21
|
+
|
|
13
22
|
// ── invoke transport (priority: desktop -> ios -> android -> web) ──
|
|
14
23
|
let platform: UglyNative['platform'];
|
|
15
24
|
let rawInvoke: (channel: string, payload: unknown) => Promise<unknown>;
|
|
@@ -44,12 +53,7 @@ export function buildUglyNative(win: WinLike): UglyNative {
|
|
|
44
53
|
});
|
|
45
54
|
} else {
|
|
46
55
|
platform = 'web';
|
|
47
|
-
rawInvoke = (channel) =>
|
|
48
|
-
if (CHANNEL_WEB_FALLBACK[channel as Channel] === 'throw') {
|
|
49
|
-
return Promise.reject(new NativeUnavailableError(channel));
|
|
50
|
-
}
|
|
51
|
-
return Promise.resolve(undefined);
|
|
52
|
-
};
|
|
56
|
+
rawInvoke = (channel, payload) => webInvoke(channel as Channel, payload, emit);
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
// ── events: single 'uglyNative' CustomEvent carrying {event, data} ──
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createPermissions } from './permissions';
|
|
3
|
+
import { PermissionDeniedError } from './protocol';
|
|
4
|
+
|
|
5
|
+
// A fake transport that models grants accumulating via permissions.request.
|
|
6
|
+
function fakeInvoke() {
|
|
7
|
+
const granted: Record<string, string> = {};
|
|
8
|
+
return vi.fn(async (channel: string, payload: unknown) => {
|
|
9
|
+
if (channel === 'permissions.request') {
|
|
10
|
+
const grants = (payload as { grants: Record<string, string | boolean> }).grants;
|
|
11
|
+
for (const [k, v] of Object.entries(grants)) granted[k] = v === true ? 'granted' : String(v);
|
|
12
|
+
return { granted: { ...granted } };
|
|
13
|
+
}
|
|
14
|
+
if (channel === 'permissions.query') return { granted: { ...granted } };
|
|
15
|
+
if (channel === 'permissions.revoke') {
|
|
16
|
+
delete granted[(payload as { namespace: string }).namespace];
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('permissions facade', () => {
|
|
24
|
+
it('ensure throws for ungranted daemon namespace, passes after grant', async () => {
|
|
25
|
+
const invoke = fakeInvoke();
|
|
26
|
+
const perms = createPermissions(invoke, 'desktop');
|
|
27
|
+
await expect(perms.ensure('fs')).rejects.toBeInstanceOf(PermissionDeniedError);
|
|
28
|
+
await perms.request({ fs: 'scoped' });
|
|
29
|
+
await expect(perms.ensure('fs')).resolves.toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
it('ensure is a no-op for app namespaces (gps)', async () => {
|
|
32
|
+
const invoke = fakeInvoke();
|
|
33
|
+
const perms = createPermissions(invoke, 'ios');
|
|
34
|
+
await expect(perms.ensure('gps')).resolves.toBeUndefined();
|
|
35
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
it('daemon namespace on web always throws', async () => {
|
|
38
|
+
const invoke = fakeInvoke();
|
|
39
|
+
const perms = createPermissions(invoke, 'web');
|
|
40
|
+
await expect(perms.ensure('secrets')).rejects.toBeInstanceOf(PermissionDeniedError);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Namespace, GrantState } from './contract.js';
|
|
2
|
+
import type { Platform } from './contract-meta.js';
|
|
3
|
+
import { PermissionDeniedError } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
// Namespaces gated by explicit grants (daemon capabilities). OS-backed and app
|
|
6
|
+
// namespaces are not pre-gated here (the OS prompts at call time).
|
|
7
|
+
const GATED: ReadonlySet<Namespace> = new Set<Namespace>([
|
|
8
|
+
'fs',
|
|
9
|
+
'secrets',
|
|
10
|
+
'serve',
|
|
11
|
+
'workers',
|
|
12
|
+
'net',
|
|
13
|
+
'process',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
type Invoke = (channel: string, payload: unknown) => Promise<unknown>;
|
|
17
|
+
|
|
18
|
+
export interface PermissionsFacade {
|
|
19
|
+
request(grants: Partial<Record<Namespace, GrantState | boolean>>): Promise<Record<string, GrantState>>;
|
|
20
|
+
query(): Promise<Record<string, GrantState>>;
|
|
21
|
+
revoke(namespace: Namespace): Promise<void>;
|
|
22
|
+
ensure(namespace: Namespace): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createPermissions(invoke: Invoke, platform: Platform): PermissionsFacade {
|
|
26
|
+
const cache = new Map<Namespace, GrantState>();
|
|
27
|
+
const facade: PermissionsFacade = {
|
|
28
|
+
async request(grants) {
|
|
29
|
+
const res = (await invoke('permissions.request', { grants })) as { granted: Record<string, GrantState> };
|
|
30
|
+
for (const [k, v] of Object.entries(res.granted)) cache.set(k as Namespace, v);
|
|
31
|
+
return res.granted;
|
|
32
|
+
},
|
|
33
|
+
async query() {
|
|
34
|
+
const res = (await invoke('permissions.query', undefined)) as { granted: Record<string, GrantState> };
|
|
35
|
+
for (const [k, v] of Object.entries(res.granted)) cache.set(k as Namespace, v);
|
|
36
|
+
return res.granted;
|
|
37
|
+
},
|
|
38
|
+
async revoke(namespace) {
|
|
39
|
+
await invoke('permissions.revoke', { namespace });
|
|
40
|
+
cache.delete(namespace);
|
|
41
|
+
},
|
|
42
|
+
async ensure(namespace) {
|
|
43
|
+
if (!GATED.has(namespace)) return;
|
|
44
|
+
if (platform === 'web') throw new PermissionDeniedError(namespace);
|
|
45
|
+
const cached = cache.get(namespace);
|
|
46
|
+
if (cached && cached !== 'denied' && cached !== 'prompt') return;
|
|
47
|
+
const granted = await facade.query();
|
|
48
|
+
const state = granted[namespace];
|
|
49
|
+
if (!state || state === 'denied' || state === 'prompt') throw new PermissionDeniedError(namespace);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
return facade;
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PermissionDeniedError, NativeUnavailableError } from './protocol';
|
|
3
|
+
|
|
4
|
+
describe('protocol errors', () => {
|
|
5
|
+
it('PermissionDeniedError carries the namespace', () => {
|
|
6
|
+
const e = new PermissionDeniedError('fs');
|
|
7
|
+
expect(e).toBeInstanceOf(Error);
|
|
8
|
+
expect(e.namespace).toBe('fs');
|
|
9
|
+
expect(e.message).toContain('fs');
|
|
10
|
+
});
|
|
11
|
+
it('NativeUnavailableError carries the channel', () => {
|
|
12
|
+
const e = new NativeUnavailableError('process.spawn');
|
|
13
|
+
expect(e.message).toContain('process.spawn');
|
|
14
|
+
});
|
|
15
|
+
});
|
package/src/native/protocol.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Channel, NativeEvent, PayloadOf, ResultOf, DataOf } from './contract.js';
|
|
1
|
+
import type { Channel, NativeEvent, PayloadOf, ResultOf, DataOf, Namespace } from './contract.js';
|
|
2
2
|
|
|
3
3
|
export type NativePlatform = 'ios' | 'android' | 'desktop' | 'web';
|
|
4
4
|
|
|
@@ -9,6 +9,15 @@ export class NativeUnavailableError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export class PermissionDeniedError extends Error {
|
|
13
|
+
readonly namespace: Namespace;
|
|
14
|
+
constructor(namespace: Namespace) {
|
|
15
|
+
super(`uglyNative.${namespace}.* requires the "${namespace}" permission (not granted)`);
|
|
16
|
+
this.name = 'PermissionDeniedError';
|
|
17
|
+
this.namespace = namespace;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export interface UglyNative {
|
|
13
22
|
readonly platform: NativePlatform;
|
|
14
23
|
invoke<C extends Channel>(channel: C, payload: PayloadOf<C>): Promise<ResultOf<C>>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { webInvoke } from './web-adapter';
|
|
4
|
+
import { NativeUnavailableError } from './protocol';
|
|
5
|
+
|
|
6
|
+
describe('web-adapter', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
(navigator as unknown as { clipboard: unknown }).clipboard = {
|
|
9
|
+
writeText: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
readText: vi.fn().mockResolvedValue('hi'),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
it('clipboard.write delegates to navigator.clipboard.writeText', async () => {
|
|
14
|
+
await webInvoke('clipboard.write', { text: 'yo' }, () => {});
|
|
15
|
+
expect((navigator.clipboard as { writeText: ReturnType<typeof vi.fn> }).writeText).toHaveBeenCalledWith('yo');
|
|
16
|
+
});
|
|
17
|
+
it('clipboard.read returns navigator.clipboard.readText', async () => {
|
|
18
|
+
const r = await webInvoke('clipboard.read', undefined, () => {});
|
|
19
|
+
expect(r).toEqual({ text: 'hi' });
|
|
20
|
+
});
|
|
21
|
+
it('fs.readFile throws NativeUnavailableError on web', async () => {
|
|
22
|
+
await expect(webInvoke('fs.readFile', { path: '/x' }, () => {})).rejects.toBeInstanceOf(NativeUnavailableError);
|
|
23
|
+
});
|
|
24
|
+
it('liveActivity.endWorkout is a silent noop-ish resolve (no throw)', async () => {
|
|
25
|
+
await expect(webInvoke('liveActivity.endWorkout', undefined, () => {})).resolves.toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Channel, NativeEvent, DataOf, SafeAreaInsets } from './contract.js';
|
|
2
|
+
import { fallbackFor } from './contract-meta.js';
|
|
3
|
+
import { NativeUnavailableError } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
type Emit = <E extends NativeEvent>(event: E, data: DataOf<E>) => void;
|
|
6
|
+
|
|
7
|
+
// In-tab alarm timers (web fallback only; cleared on tab close).
|
|
8
|
+
const alarmTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
9
|
+
let gpsWatchId: number | null = null;
|
|
10
|
+
let webAudio: HTMLAudioElement | null = null;
|
|
11
|
+
|
|
12
|
+
function readSafeArea(): SafeAreaInsets {
|
|
13
|
+
const cs = getComputedStyle(document.documentElement);
|
|
14
|
+
const px = (v: string): number => parseInt(v || '0', 10) || 0;
|
|
15
|
+
return {
|
|
16
|
+
top: px(cs.getPropertyValue('--sat')),
|
|
17
|
+
right: 0,
|
|
18
|
+
bottom: 0,
|
|
19
|
+
left: 0,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function webInvoke(channel: Channel, payload: unknown, emit: Emit): Promise<unknown> {
|
|
24
|
+
switch (channel) {
|
|
25
|
+
case 'clipboard.write':
|
|
26
|
+
await navigator.clipboard.writeText((payload as { text: string }).text);
|
|
27
|
+
return undefined;
|
|
28
|
+
case 'clipboard.read':
|
|
29
|
+
return { text: await navigator.clipboard.readText() };
|
|
30
|
+
case 'system.openExternal':
|
|
31
|
+
window.open((payload as { url: string }).url, '_blank', 'noopener');
|
|
32
|
+
return undefined;
|
|
33
|
+
case 'system.share':
|
|
34
|
+
if (navigator.share) await navigator.share(payload as ShareData);
|
|
35
|
+
return undefined;
|
|
36
|
+
case 'badge.set': {
|
|
37
|
+
const n = (payload as { count: number }).count;
|
|
38
|
+
const navAny = navigator as unknown as {
|
|
39
|
+
setAppBadge?: (n?: number) => Promise<void>;
|
|
40
|
+
clearAppBadge?: () => Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
if (n > 0) await navAny.setAppBadge?.(n);
|
|
43
|
+
else await navAny.clearAppBadge?.();
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
case 'ui.getSafeArea':
|
|
47
|
+
return readSafeArea();
|
|
48
|
+
case 'gps.start': {
|
|
49
|
+
if (gpsWatchId != null) navigator.geolocation.clearWatch(gpsWatchId);
|
|
50
|
+
gpsWatchId = navigator.geolocation.watchPosition(
|
|
51
|
+
(pos) =>
|
|
52
|
+
emit('gps.position', {
|
|
53
|
+
lat: pos.coords.latitude,
|
|
54
|
+
lng: pos.coords.longitude,
|
|
55
|
+
accuracy: pos.coords.accuracy,
|
|
56
|
+
timestamp: pos.timestamp,
|
|
57
|
+
altitude: pos.coords.altitude ?? undefined,
|
|
58
|
+
speed: pos.coords.speed ?? undefined,
|
|
59
|
+
course: pos.coords.heading ?? undefined,
|
|
60
|
+
}),
|
|
61
|
+
() => {},
|
|
62
|
+
{ enableHighAccuracy: true },
|
|
63
|
+
);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
case 'gps.stop':
|
|
67
|
+
if (gpsWatchId != null) {
|
|
68
|
+
navigator.geolocation.clearWatch(gpsWatchId);
|
|
69
|
+
gpsWatchId = null;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
case 'alarm.schedule': {
|
|
73
|
+
const p = payload as { id: string; delaySeconds: number; title?: string; body?: string };
|
|
74
|
+
const existing = alarmTimers.get(p.id);
|
|
75
|
+
if (existing) clearTimeout(existing);
|
|
76
|
+
alarmTimers.set(
|
|
77
|
+
p.id,
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
alarmTimers.delete(p.id);
|
|
80
|
+
if ('Notification' in window && Notification.permission === 'granted') {
|
|
81
|
+
new Notification(p.title ?? 'Alarm', { body: p.body });
|
|
82
|
+
}
|
|
83
|
+
emit('alarm.fired', { id: p.id });
|
|
84
|
+
}, p.delaySeconds * 1000),
|
|
85
|
+
);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
case 'alarm.cancel': {
|
|
89
|
+
const id = (payload as { id?: string }).id;
|
|
90
|
+
if (id) {
|
|
91
|
+
const t = alarmTimers.get(id);
|
|
92
|
+
if (t) clearTimeout(t);
|
|
93
|
+
alarmTimers.delete(id);
|
|
94
|
+
} else {
|
|
95
|
+
for (const t of alarmTimers.values()) clearTimeout(t);
|
|
96
|
+
alarmTimers.clear();
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
case 'alarm.playAudio': {
|
|
101
|
+
const p = payload as { base64: string; mimeType?: string };
|
|
102
|
+
webAudio = new Audio(`data:${p.mimeType ?? 'audio/wav'};base64,${p.base64}`);
|
|
103
|
+
await webAudio.play().catch(() => {});
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
case 'capture.screenshot': {
|
|
107
|
+
// Best-effort capture requires a user-gesture-driven display capture; declared
|
|
108
|
+
// web-capable but gated by a gesture, so treat as unavailable until wired by a caller.
|
|
109
|
+
throw new NativeUnavailableError(channel);
|
|
110
|
+
}
|
|
111
|
+
default: {
|
|
112
|
+
const fb = fallbackFor(channel, 'web');
|
|
113
|
+
if (fb === 'throw') throw new NativeUnavailableError(channel);
|
|
114
|
+
// 'noop' / 'web'-but-unimplemented (liveActivity, tts, push, …) resolve silently.
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -30,4 +30,26 @@ describe('native wrapper', () => {
|
|
|
30
30
|
native.gps.onPosition(cb);
|
|
31
31
|
expect(subscribe).toHaveBeenCalledWith('gps.position', cb);
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
it('capture/clipboard (non-gated) invoke the right channels', async () => {
|
|
35
|
+
const { invoke } = installFakeNative();
|
|
36
|
+
const { native } = await import('./wrapper');
|
|
37
|
+
await native.capture.screenshot();
|
|
38
|
+
await native.clipboard.write({ text: 'a' });
|
|
39
|
+
expect(invoke).toHaveBeenCalledWith('capture.screenshot', undefined);
|
|
40
|
+
expect(invoke).toHaveBeenCalledWith('clipboard.write', { text: 'a' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('fs.readFile ensures the fs permission then invokes', async () => {
|
|
44
|
+
const granted: Record<string, string> = { fs: 'full' };
|
|
45
|
+
const invoke = vi.fn(async (channel: string) => {
|
|
46
|
+
if (channel === 'permissions.query') return { granted: { ...granted } };
|
|
47
|
+
if (channel === 'fs.readFile') return { content: 'hello' };
|
|
48
|
+
return undefined;
|
|
49
|
+
});
|
|
50
|
+
(globalThis as unknown as { UglyNative: unknown }).UglyNative = { platform: 'desktop', invoke, subscribe: () => () => {} };
|
|
51
|
+
const { native } = await import('./wrapper');
|
|
52
|
+
expect(await native.fs.readFile('/x')).toBe('hello');
|
|
53
|
+
expect(invoke).toHaveBeenCalledWith('fs.readFile', { path: '/x' });
|
|
54
|
+
});
|
|
33
55
|
});
|