ugly-app 0.1.619 → 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/dist/search/server/WebRetriever.d.ts +7 -0
- package/dist/search/server/WebRetriever.d.ts.map +1 -1
- package/dist/search/server/WebRetriever.js +2 -2
- package/dist/search/server/WebRetriever.js.map +1 -1
- package/dist/server/ai/providers/UglyBotWebSearchProvider.d.ts.map +1 -1
- package/dist/server/ai/providers/UglyBotWebSearchProvider.js +11 -8
- package/dist/server/ai/providers/UglyBotWebSearchProvider.js.map +1 -1
- package/dist/server/ai/types.d.ts +7 -0
- package/dist/server/ai/types.d.ts.map +1 -1
- package/dist/server/ai/types.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
- package/src/search/server/WebRetriever.ts +9 -4
- package/src/server/ai/providers/UglyBotWebSearchProvider.ts +13 -2
- package/src/server/ai/types.ts +7 -0
package/src/native/contract.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Single source of truth for the UglyNative extension surface.
|
|
2
|
+
|
|
3
|
+
// ── Shared state shapes ──────────────────────────────────────────────────────
|
|
2
4
|
export interface WorkoutLAState {
|
|
3
5
|
workoutName?: string;
|
|
4
6
|
exerciseName: string;
|
|
@@ -18,27 +20,114 @@ export interface CardioLAState {
|
|
|
18
20
|
elapsedSeconds: number;
|
|
19
21
|
isPaused: boolean;
|
|
20
22
|
}
|
|
23
|
+
export interface HostDirent {
|
|
24
|
+
name: string;
|
|
25
|
+
isDirectory: boolean;
|
|
26
|
+
isFile: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface HostStat {
|
|
29
|
+
size: number;
|
|
30
|
+
isDirectory: boolean;
|
|
31
|
+
isFile: boolean;
|
|
32
|
+
mtimeMs: number;
|
|
33
|
+
}
|
|
34
|
+
export interface UpdateStatus {
|
|
35
|
+
state: 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
|
|
36
|
+
version?: string;
|
|
37
|
+
progressPercent?: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
export type GrantState = 'granted' | 'denied' | 'scoped' | 'full' | 'prompt';
|
|
41
|
+
export interface SpawnOpts {
|
|
42
|
+
cwd?: string;
|
|
43
|
+
env?: Record<string, string>;
|
|
44
|
+
replaceEnv?: boolean;
|
|
45
|
+
detached?: boolean;
|
|
46
|
+
treeKill?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface SafeAreaInsets {
|
|
49
|
+
top: number;
|
|
50
|
+
right: number;
|
|
51
|
+
bottom: number;
|
|
52
|
+
left: number;
|
|
53
|
+
}
|
|
21
54
|
|
|
22
|
-
//
|
|
23
|
-
// `web` declares the no-shell fallback: 'noop' resolves to the result default,
|
|
24
|
-
// 'throw' rejects with NativeUnavailableError.
|
|
55
|
+
// ── Request/response channels (via invoke) ───────────────────────────────────
|
|
25
56
|
export interface NativeContract {
|
|
26
57
|
channels: {
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
'liveActivity.
|
|
34
|
-
'liveActivity.
|
|
35
|
-
'liveActivity.
|
|
36
|
-
'liveActivity.
|
|
37
|
-
'liveActivity.
|
|
58
|
+
// capture
|
|
59
|
+
'capture.screenshot': { payload: void; result: string };
|
|
60
|
+
// gps
|
|
61
|
+
'gps.start': { payload: { activityType: 'run' | 'hike' | 'bike' }; result: void };
|
|
62
|
+
'gps.stop': { payload: void; result: void };
|
|
63
|
+
// liveActivity
|
|
64
|
+
'liveActivity.startWorkout': { payload: WorkoutLAState; result: void };
|
|
65
|
+
'liveActivity.updateWorkout': { payload: Partial<WorkoutLAState>; result: void };
|
|
66
|
+
'liveActivity.endWorkout': { payload: void; result: void };
|
|
67
|
+
'liveActivity.startCardio': { payload: CardioLAState; result: void };
|
|
68
|
+
'liveActivity.updateCardio': { payload: Partial<CardioLAState>; result: void };
|
|
69
|
+
'liveActivity.endCardio': { payload: void; result: void };
|
|
70
|
+
// alarm
|
|
71
|
+
'alarm.schedule': { payload: { id: string; delaySeconds: number; title?: string; body?: string }; result: void };
|
|
72
|
+
'alarm.cancel': { payload: { id?: string }; result: void };
|
|
73
|
+
'alarm.playAudio': { payload: { id: string; base64: string; mimeType?: string }; result: void };
|
|
74
|
+
// tts
|
|
75
|
+
'tts.start': { payload: { text: string; voice?: string }; result: void };
|
|
76
|
+
'tts.pause': { payload: void; result: void };
|
|
77
|
+
'tts.resume': { payload: void; result: void };
|
|
78
|
+
'tts.stop': { payload: void; result: void };
|
|
79
|
+
// push
|
|
80
|
+
'push.register': { payload: void; result: { token: string } };
|
|
81
|
+
// clipboard
|
|
82
|
+
'clipboard.write': { payload: { text: string }; result: void };
|
|
83
|
+
'clipboard.read': { payload: void; result: { text: string } };
|
|
84
|
+
// system
|
|
85
|
+
'system.openExternal': { payload: { url: string }; result: void };
|
|
86
|
+
'system.share': { payload: { title?: string; text?: string; url?: string }; result: void };
|
|
87
|
+
// badge
|
|
88
|
+
'badge.set': { payload: { count: number }; result: void };
|
|
89
|
+
// ui
|
|
90
|
+
'ui.getSafeArea': { payload: void; result: SafeAreaInsets };
|
|
91
|
+
// fs (text ops; byte ops are facade)
|
|
92
|
+
'fs.readFile': { payload: { path: string }; result: { content: string } };
|
|
93
|
+
'fs.writeFile': { payload: { path: string; content: string }; result: void };
|
|
94
|
+
'fs.mkdir': { payload: { path: string; recursive?: boolean }; result: void };
|
|
95
|
+
'fs.rm': { payload: { path: string; recursive?: boolean; force?: boolean }; result: void };
|
|
96
|
+
'fs.rename': { payload: { from: string; to: string }; result: void };
|
|
97
|
+
'fs.readdir': { payload: { path: string }; result: { entries: HostDirent[] } };
|
|
98
|
+
'fs.stat': { payload: { path: string }; result: HostStat };
|
|
99
|
+
'fs.exists': { payload: { path: string }; result: { exists: boolean } };
|
|
100
|
+
'fs.realpath': { payload: { path: string }; result: { path: string } };
|
|
101
|
+
// secrets
|
|
102
|
+
'secrets.get': { payload: { key: string }; result: { value: string | null } };
|
|
103
|
+
'secrets.set': { payload: { key: string; value: string }; result: void };
|
|
104
|
+
'secrets.delete': { payload: { key: string }; result: void };
|
|
105
|
+
'secrets.list': { payload: void; result: { keys: string[] } };
|
|
106
|
+
// serve
|
|
107
|
+
'serve.start': { payload: { dir?: string }; result: { port: number; url: string } };
|
|
108
|
+
'serve.stop': { payload: { port: number }; result: void };
|
|
109
|
+
// workers
|
|
110
|
+
'workers.register': { payload: { path: string; boot?: boolean }; result: { id: string } };
|
|
111
|
+
'workers.unregister': { payload: { id: string }; result: void };
|
|
112
|
+
'workers.list': { payload: void; result: { ids: string[] } };
|
|
38
113
|
};
|
|
39
114
|
events: {
|
|
40
|
-
'gps.position': {
|
|
115
|
+
'gps.position': {
|
|
116
|
+
lat: number;
|
|
117
|
+
lng: number;
|
|
118
|
+
accuracy: number;
|
|
119
|
+
timestamp: number;
|
|
120
|
+
altitude?: number;
|
|
121
|
+
speed?: number;
|
|
122
|
+
course?: number;
|
|
123
|
+
};
|
|
41
124
|
'alarm.fired': { id: string };
|
|
125
|
+
'keyboard.height': { height: number };
|
|
126
|
+
'safeArea.insets': SafeAreaInsets;
|
|
127
|
+
'tts.state': { state: 'idle' | 'loading' | 'playing' | 'paused' | 'finished' };
|
|
128
|
+
'push.token': { token: string };
|
|
129
|
+
'push.notificationClick': { action: string; data: Record<string, string> };
|
|
130
|
+
'studio.update.status': UpdateStatus;
|
|
42
131
|
};
|
|
43
132
|
}
|
|
44
133
|
|
|
@@ -46,21 +135,30 @@ export type Channel = keyof NativeContract['channels'];
|
|
|
46
135
|
export type NativeEvent = keyof NativeContract['events'];
|
|
47
136
|
export type PayloadOf<C extends Channel> = NativeContract['channels'][C]['payload'];
|
|
48
137
|
export type ResultOf<C extends Channel> = NativeContract['channels'][C]['result'];
|
|
49
|
-
export type WebFallbackOf<C extends Channel> = NativeContract['channels'][C]['web'];
|
|
50
138
|
export type DataOf<E extends NativeEvent> = NativeContract['events'][E];
|
|
51
139
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
'gps
|
|
57
|
-
|
|
58
|
-
'alarm
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
140
|
+
// Top-level namespaces (permission units). Includes streaming-facade namespaces
|
|
141
|
+
// (process, net, studio) that are NOT in `channels` but ARE permission/wrapper units.
|
|
142
|
+
export type Namespace =
|
|
143
|
+
| 'capture'
|
|
144
|
+
| 'gps'
|
|
145
|
+
| 'liveActivity'
|
|
146
|
+
| 'alarm'
|
|
147
|
+
| 'tts'
|
|
148
|
+
| 'push'
|
|
149
|
+
| 'clipboard'
|
|
150
|
+
| 'system'
|
|
151
|
+
| 'badge'
|
|
152
|
+
| 'ui'
|
|
153
|
+
| 'fs'
|
|
154
|
+
| 'secrets'
|
|
155
|
+
| 'serve'
|
|
156
|
+
| 'workers'
|
|
157
|
+
| 'net'
|
|
158
|
+
| 'process'
|
|
159
|
+
| 'permissions'
|
|
160
|
+
| 'studio';
|
|
161
|
+
|
|
162
|
+
export function namespaceOf(channel: Channel): Namespace {
|
|
163
|
+
return channel.split('.')[0] as Namespace;
|
|
164
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createProcessFacade } from './process';
|
|
3
|
+
import { createNetFacade } from './net';
|
|
4
|
+
import { createStudioFacade } from './studio';
|
|
5
|
+
import { NativeUnavailableError } from '../protocol';
|
|
6
|
+
|
|
7
|
+
describe('process facade', () => {
|
|
8
|
+
it('throws NativeUnavailableError off-desktop', () => {
|
|
9
|
+
const proc = createProcessFacade(() => undefined, 'ios', async () => {});
|
|
10
|
+
expect(() => proc.spawn('ls', [])).toThrow(NativeUnavailableError);
|
|
11
|
+
});
|
|
12
|
+
it('delegates to the desktop bridge on desktop', () => {
|
|
13
|
+
const handle = {
|
|
14
|
+
onStdout() {}, onStderr() {}, onExit() {}, onError() {}, write() {}, closeStdin() {}, kill() {},
|
|
15
|
+
};
|
|
16
|
+
const bridge = { spawn: () => handle };
|
|
17
|
+
const proc = createProcessFacade(() => bridge, 'desktop', async () => {});
|
|
18
|
+
expect(proc.spawn('ls', ['-a'])).toBe(handle);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('net facade', () => {
|
|
23
|
+
it('throws on web', () => {
|
|
24
|
+
const net = createNetFacade(() => undefined, 'web', async () => {});
|
|
25
|
+
expect(() => net.connect('h', 1)).toThrow(NativeUnavailableError);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('studio facade', () => {
|
|
30
|
+
it('app.platform returns the platform; revealInFolder throws off-desktop', async () => {
|
|
31
|
+
const studio = createStudioFacade(() => undefined, 'web');
|
|
32
|
+
expect(studio.app.platform()).toBe('web');
|
|
33
|
+
await expect(studio.revealInFolder('/x')).rejects.toBeInstanceOf(NativeUnavailableError);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -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
|
+
});
|